Browse Source

add burndown (burnup?) graphs.

master
Gisle Aune 2 years ago
parent
commit
b322383d3d
  1. 177
      frontend/package-lock.json
  2. 1
      frontend/package.json
  3. 131
      frontend/src/lib/components/common/BurndownChart.svelte
  4. 114
      frontend/src/lib/components/common/layercake/AxisX.svelte
  5. 101
      frontend/src/lib/components/common/layercake/AxisY.svelte
  6. 27
      frontend/src/lib/components/common/layercake/Line.svelte
  7. 7
      frontend/src/lib/components/scope/SprintBody.svelte
  8. 5
      frontend/src/lib/models/sprint.ts
  9. 11
      usecases/sprints/burndown_test.go

177
frontend/package-lock.json

@ -23,6 +23,7 @@
"cookie": "^0.5.0",
"fa-svelte": "^3.1.0",
"insane": "^2.6.2",
"layercake": "^7.1.0",
"marked": "^4.0.17",
"node-sass": "^7.0.1",
"sass": "^1.52.3",
@ -11020,6 +11021,88 @@
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==",
"dev": true
},
"node_modules/d3-array": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
"dev": true,
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dev": true,
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==",
"dev": true,
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dev": true,
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@ -14065,6 +14148,15 @@
"he": "0.5.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@ -15071,6 +15163,15 @@
"node": ">=6"
}
},
"node_modules/layercake": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/layercake/-/layercake-7.1.0.tgz",
"integrity": "sha512-DV6FsmG0c5a2UyB15QBKBhgxhUW7E1615UTiTQGs2cnfSHPdgo8PfhAdX/E6AZwYhN9oLECpINBhEg3WNlIKjw==",
"dev": true,
"dependencies": {
"d3-scale": "^4.0.2"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@ -31207,6 +31308,67 @@
}
}
},
"d3-array": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.0.tgz",
"integrity": "sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g==",
"dev": true,
"requires": {
"internmap": "1 - 2"
}
},
"d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true
},
"d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"dev": true
},
"d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"requires": {
"d3-color": "1 - 3"
}
},
"d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dev": true,
"requires": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
}
},
"d3-time": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz",
"integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==",
"dev": true,
"requires": {
"d3-array": "2 - 3"
}
},
"d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dev": true,
"requires": {
"d3-time": "1 - 3"
}
},
"dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@ -33524,6 +33686,12 @@
"he": "0.5.0"
}
},
"internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"dev": true
},
"invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@ -34350,6 +34518,15 @@
"integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==",
"dev": true
},
"layercake": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/layercake/-/layercake-7.1.0.tgz",
"integrity": "sha512-DV6FsmG0c5a2UyB15QBKBhgxhUW7E1615UTiTQGs2cnfSHPdgo8PfhAdX/E6AZwYhN9oLECpINBhEg3WNlIKjw==",
"dev": true,
"requires": {
"d3-scale": "^4.0.2"
}
},
"lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",

1
frontend/package.json

@ -26,6 +26,7 @@
"cookie": "^0.5.0",
"fa-svelte": "^3.1.0",
"insane": "^2.6.2",
"layercake": "^7.1.0",
"marked": "^4.0.17",
"node-sass": "^7.0.1",
"sass": "^1.52.3",

131
frontend/src/lib/components/common/BurndownChart.svelte

@ -0,0 +1,131 @@
<script lang="ts" context="module">
const MILESTONE_COLORS = [
"#f4b083",
"#d8dce4",
"#ffd966",
"#84f5ff",
"#8ef88e",
];
</script>
<script lang="ts">
import { LayerCake, Svg } from 'layercake';
import Line from './layercake/Line.svelte';
import AxisY from './layercake/AxisY.svelte';
import type { SprintBurndownDataPoint } from '$lib/models/sprint';
import { getTimeContext } from '../contexts/TimeContext.svelte';
import AxisX from './layercake/AxisX.svelte';
const {now} = getTimeContext();
export let data: SprintBurndownDataPoint[]
export let from: Date | string | number
export let to: Date | string | number
export let milestone: number = 10000;
export let green: boolean = false;
let after = false;
let today = 0;
let lineColor = "#777777";
let points: {x: number, y: number}[] = [];
let milestones: number[] = [];
let milestoneIndex = 0;
let fromTime: number
let toTime: number
let timeSplits: number[] = [];
$: fromTime = new Date(from).getTime();
$: toTime = new Date(to).getTime();
$: after = toTime < $now.getTime();
$: today = Math.floor($now.getTime() / 86400000) * 86400000;
$: {
const dataMap = {}
dataMap[fromTime] = 0;
if (after) {
dataMap[toTime] = data?.[data?.length - 1]?.value || 0;
} else {
dataMap[today] = data?.[data?.length - 1]?.value || 0;
}
timeSplits = [];
if (toTime - fromTime > (86400000 * 350)) {
let current = new Date(fromTime);
current.setUTCDate(1);
while (current.getTime() < toTime) {
timeSplits.push(current.getTime());
current.setUTCMonth(current.getUTCMonth() + 1);
}
} else if (toTime - fromTime > (86400000 * 7)) {
for (let x = fromTime; x < toTime; x += 86400000 * 7) {
timeSplits.push(x)
}
} else {
for (let x = fromTime; x < toTime; x += 86400000) {
timeSplits.push(x)
}
}
let last = 0;
for (const {date, value} of data) {
const currentX = new Date(date+"T00:00:00Z").getTime();
const prevX = currentX - 86400000;
if (dataMap[prevX] == null) {
dataMap[prevX] = last;
}
dataMap[currentX] = value;
last = value;
}
milestones = [];
milestoneIndex = 0;
for (let n = milestone; n < last; n += milestone) {
milestones.push(n);
milestoneIndex += 1;
if (milestoneIndex == MILESTONE_COLORS.length - 1) {
break
}
}
if (green) {
lineColor = "#78ff78";
} else {
lineColor = MILESTONE_COLORS[milestoneIndex];
}
points = Object.keys(dataMap).map(k => ({x: parseInt(k), y: dataMap[k]})).sort((a,b) => a.x - b.x);
};
</script>
<div class="chart-container">
<LayerCake
padding={{ top: 0, right: 5, bottom: 0, left: 5 }}
x="x"
y="y"
yNice={8}
xDomain={[fromTime, toTime]}
yDomain={[0, milestones.length == 0 ? milestone : null]}
data={points}
>
<Svg>
<AxisX
ticks={timeSplits}
formatTick={_t => ""}
/>
<AxisY
ticks={milestones}
formatTick={_tick => ""}
gridlines={true}
/>
<Line stroke={lineColor} />
</Svg>
</LayerCake>
</div>
<style lang="sass">
.chart-container
width: 100%
height: 30px
</style>

114
frontend/src/lib/components/common/layercake/AxisX.svelte

@ -0,0 +1,114 @@
<!--
@component
Generates an SVG x-axis. This component is also configured to detect if your x-scale is an ordinal scale. If so, it will place the markers in the middle of the bandwidth.
-->
<script>
import { getContext } from 'svelte';
const { width, height, xScale, yRange } = getContext('LayerCake');
/** @type {Boolean} [gridlines=true] - Extend lines from the ticks into the chart space */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Boolean} [baseline=false] – Show a solid line at the bottom. */
export let baseline = false;
/** @type {Boolean} [snapTicks=false] - Instead of centering the text on the first and the last items, align them to the edges of the chart. */
export let snapTicks = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|Function} [ticks] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. If nothing, it uses the default ticks supplied by the D3 function. */
export let ticks = undefined;
/** @type {Number} [xTick=0] - TK */
export let xTick = 0;
/** @type {Number} [yTick=16] - The distance from the baseline to place each tick value. */
export let yTick = 16;
$: isBandwidth = typeof $xScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$xScale.domain() :
typeof ticks === 'function' ?
ticks($xScale.ticks()) :
$xScale.ticks(ticks);
function textAnchor(i) {
if (snapTicks === true) {
if (i === 0) {
return 'start';
}
if (i === tickVals.length - 1) {
return 'end';
}
}
return 'middle';
}
</script>
<g class="axis x-axis" class:snapTicks>
{#each tickVals as tick, i (tick)}
<g class="tick tick-{i}" transform="translate({$xScale(tick)},{Math.max(...$yRange)})">
{#if gridlines !== false}
<line class="gridline" y1={$height * -1} y2="0" x1="0" x2="0" />
{/if}
{#if tickMarks === true}
<line
class="tick-mark"
y1={0}
y2={6}
x1={xTick || isBandwidth ? $xScale.bandwidth() / 2 : 0}
x2={xTick || isBandwidth ? $xScale.bandwidth() / 2 : 0}
/>
{/if}
<text
x={xTick || isBandwidth ? $xScale.bandwidth() / 2 : 0}
y={8}
dx=""
dy=""
font-size="0.66em"
text-anchor={textAnchor(i)}>{formatTick(tick)}</text
>
</g>
{/each}
{#if baseline === true}
<line class="baseline" y1={$height + 0.5} y2={$height + 0.5} x1="0" x2={$width} />
{/if}
</g>
<style lang="scss">
@import "../../../css/colors.sass";
.tick {
font-size: 0.725em;
font-weight: 200;
}
line,
.tick line {
stroke: $color-entry2;
stroke-dasharray: 1;
}
.tick text {
fill: $color-entry5;
}
.tick .tick-mark,
.baseline {
stroke-dasharray: 0;
}
/* This looks slightly better */
.axis.snapTicks .tick:last-child text {
transform: translateX(3px);
}
.axis.snapTicks .tick.tick-0 text {
transform: translateX(-3px);
}
</style>

101
frontend/src/lib/components/common/layercake/AxisY.svelte

@ -0,0 +1,101 @@
<!--
@component
Generates an HTML y-axis.
-->
<script>
import { getContext } from 'svelte';
const { padding, xRange, yScale } = getContext('LayerCake');
/** @type {Boolean} [gridlines=true] - Extend lines from the ticks into the chart space */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|Function} [ticks=4] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. */
export let ticks = 4;
/** @type {Number} [xTick=0] - How far over to position the text marker. */
export let xTick = 0;
/** @type {Number} [yTick=0] - How far up and down to position the text marker. */
export let yTick = 0;
/** @type {Number} [dxTick=0] - Any optional value passed to the `dx` attribute on the text marker and tick mark (if visible). This is ignored on the text marker if your scale is ordinal. */
export let dxTick = 0;
/** @type {Number} [dyTick=-4] - Any optional value passed to the `dy` attribute on the text marker and tick mark (if visible). This is ignored on the text marker if your scale is ordinal. */
export let dyTick = -4;
/** @type {String} [textAnchor='start'] The CSS `text-anchor` passed to the label. This is automatically set to "end" if the scale has a bandwidth method, like in ordinal scales. */
export let textAnchor = 'start';
$: isBandwidth = typeof $yScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$yScale.domain() :
typeof ticks === 'function' ?
ticks($yScale.ticks()) :
$yScale.ticks(ticks);
</script>
<g class='axis y-axis' transform='translate({-$padding.left}, 0)'>
{#each tickVals as tick (tick)}
<g class='tick tick-{tick}' transform='translate({$xRange[0] + (isBandwidth ? $padding.left : 0)}, {$yScale(tick)})'>
{#if gridlines !== false}
<line
class="gridline"
x1={5}
x2='98.75%'
y1={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
y2={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
></line>
{/if}
{#if tickMarks === true}
<line
class='tick-mark'
x1='0'
x2='{isBandwidth ? -6 : 6}'
y1={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
y2={yTick + (isBandwidth ? ($yScale.bandwidth() / 2) : 0)}
></line>
{/if}
<text
x='{xTick}'
y='{yTick + (isBandwidth ? $yScale.bandwidth() / 2 : 0)}'
dx='{isBandwidth ? -9 : dxTick}'
dy='{isBandwidth ? 4 : dyTick}'
style="text-anchor:{isBandwidth ? 'end' : textAnchor};"
>{formatTick(tick)}</text>
</g>
{/each}
</g>
<style lang="scss">
@import "../../../css/colors.sass";
.tick {
font-size: .725em;
font-weight: 200;
}
.tick line {
stroke: $color-entry2;
}
.tick .gridline {
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
.tick.tick-0 line {
stroke-dasharray: 0;
}
</style>

27
frontend/src/lib/components/common/layercake/Line.svelte

@ -0,0 +1,27 @@
<!--
@component
Generates an SVG area shape using the `area` function from [d3-shape](https://github.com/d3/d3-shape).
-->
<script>
import { getContext } from 'svelte';
const { data, xGet, yGet } = getContext('LayerCake');
/** @type {String} [stroke='#ab00d6'] - The shape's fill color. This is technically optional because it comes with a default value but you'll likely want to replace it with your own color. */
export let stroke = '#ab00d6';
$: path = 'M' + $data
.map(d => $xGet(d) + ',' + $yGet(d))
.join('L');
</script>
<path class='path-line' d='{path}' {stroke}></path>
<style>
.path-line {
fill: none;
stroke-linejoin: round;
stroke-linecap: round;
stroke-width: 1;
}
</style>

7
frontend/src/lib/components/scope/SprintBody.svelte

@ -3,6 +3,7 @@
import { SprintKind } from "$lib/models/sprint";
import Amount from "../common/Amount.svelte";
import AmountRow from "../common/AmountRow.svelte";
import BurndownChart from "../common/BurndownChart.svelte";
import LabeledProgress from "../common/LabeledProgress.svelte";
import LabeledProgressRow from "../common/LabeledProgressRow.svelte";
import Markdown from "../common/Markdown.svelte";
@ -33,6 +34,12 @@
{/each}
</LabeledProgressRow>
{/if}
{#if sprint.kind !== SprintKind.Items && sprint.aggregateBurndown != null && sprint.aggregateBurndown.length > 0}
<BurndownChart data={sprint.aggregateBurndown} from={sprint.fromTime} to={sprint.toTime} milestone={sprint.aggregateRequired} />
{/if}
{#if sprint.kind === SprintKind.Items && sprint.itemBurndown != null && sprint.itemBurndown.length > 0}
<BurndownChart green data={sprint.itemBurndown} from={sprint.fromTime} to={sprint.toTime} milestone={sprint.itemsRequired} />
{/if}
{#if sprint.kind === SprintKind.Items}
{#each (sprint.items||[]) as item (item.id)}
{#if !compact || !item.acquiredTime}

5
frontend/src/lib/models/sprint.ts

@ -19,17 +19,18 @@ export default interface Sprint {
aggregateRequired: number
aggregateAcquired: number
aggregatePlanned: number
aggregateBurndown: SprintBurndownDataPoint[]
aggregateBurndown?: SprintBurndownDataPoint[]
itemsAcquired?: number
itemsRequired?: number
itemBurndown?: SprintBurndownDataPoint[]
partIds: number[]
items: Item[]
progress: StatProgress[]
}
interface SprintBurndownDataPoint {
export interface SprintBurndownDataPoint {
date: string
value: number
}

11
usecases/sprints/burndown_test.go

@ -61,6 +61,17 @@ func TestBurndownGenerator_Add(t *testing.T) {
{Date: d(21), Value: 55},
})
// Add one more on an existing date
bg.Add(td(5, 17, 32), 3)
assert.Equal(t, bg.Points, []BurndownDataPoint{
{Date: d(1), Value: 1},
{Date: d(2), Value: 16},
{Date: d(3), Value: 20},
{Date: d(4), Value: 23},
{Date: d(5), Value: 36},
{Date: d(7), Value: 38},
{Date: d(21), Value: 58},
})
}
func d(dd int) models.Date {

Loading…
Cancel
Save