feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and put tickets in them from the ticket page. The Sprints tab follows the one under way with its progress and a burndown in tickets or points against the ideal line. Closing a sprint sends what was left to the next one or back to the backlog; the board can be filtered by sprint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,14 @@ import {
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string;
|
||||
values: number[];
|
||||
/** null: no value there (the line breaks; e.g. days still to come). */
|
||||
values: (number | null)[];
|
||||
/** CSS colour (a `--ts-viz-*` token). */
|
||||
color: string;
|
||||
/** Wash the area under a line (~10%); ignored when stacked. */
|
||||
area?: boolean;
|
||||
/** Dashed line, for references such as an ideal pace. */
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
export interface ChartMarker {
|
||||
@@ -136,6 +139,7 @@ function niceTicks(max: number): number[] {
|
||||
@if (shape.line) {
|
||||
<path
|
||||
[class]="stacked() ? 'ts-chart__gap' : 'ts-chart__line'"
|
||||
[class.ts-chart__line--dashed]="shape.dashed"
|
||||
[attr.d]="shape.line"
|
||||
[style.stroke]="stacked() ? null : shape.color"
|
||||
/>
|
||||
@@ -183,7 +187,7 @@ function niceTicks(max: number): number[] {
|
||||
class="ts-chart__tooltip-key"
|
||||
[style.--key]="row.color"
|
||||
></span>
|
||||
<strong>{{ row.value }}</strong> {{ row.label }}
|
||||
<strong>{{ row.value ?? '—' }}</strong> {{ row.label }}
|
||||
</p>
|
||||
}
|
||||
@if (tip.total !== null) {
|
||||
@@ -211,7 +215,7 @@ function niceTicks(max: number): number[] {
|
||||
<tr>
|
||||
<th scope="row">{{ label }}</th>
|
||||
@for (s of series(); track s.label) {
|
||||
<td>{{ s.values[i] }}</td>
|
||||
<td>{{ s.values[i] ?? '—' }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
@@ -282,6 +286,10 @@ function niceTicks(max: number): number[] {
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.ts-chart__line--dashed {
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.ts-chart__wash {
|
||||
fill-opacity: 0.1;
|
||||
}
|
||||
@@ -431,13 +439,25 @@ export class SeriesChartComponent {
|
||||
let base = new Array(this.count()).fill(0) as number[];
|
||||
return this.series().map((s) => {
|
||||
const lower = this.stacked() ? base : base.map(() => 0);
|
||||
const upper = s.values.map((v, i) => (this.stacked() ? lower[i] : 0) + v);
|
||||
if (this.stacked()) base = upper;
|
||||
// Stacked bands have no gaps: a missing value adds nothing.
|
||||
const upper: (number | null)[] = s.values.map((v, i) =>
|
||||
v === null && !this.stacked()
|
||||
? null
|
||||
: (this.stacked() ? lower[i] : 0) + (v ?? 0),
|
||||
);
|
||||
if (this.stacked()) base = upper as number[];
|
||||
return { ...s, lower, upper };
|
||||
});
|
||||
});
|
||||
protected readonly ticks = computed(() =>
|
||||
niceTicks(Math.max(0, ...this.bands().flatMap((b) => b.upper))),
|
||||
niceTicks(
|
||||
Math.max(
|
||||
0,
|
||||
...this.bands().flatMap((b) =>
|
||||
b.upper.filter((v): v is number => v !== null),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
private readonly max = computed(() => last(this.ticks()) || 1);
|
||||
|
||||
@@ -482,10 +502,19 @@ export class SeriesChartComponent {
|
||||
|
||||
protected readonly shapes = computed(() =>
|
||||
this.bands().map((band) => {
|
||||
const points = band.upper.map((v, i) => `${this.x(i)},${this.y(v)}`);
|
||||
const line = points.length ? `M${points.join('L')}` : '';
|
||||
const gaps = band.upper.some((v) => v === null);
|
||||
// A gap starts a new stroke ("M") where the values come back.
|
||||
const line = band.upper
|
||||
.map((v, i) =>
|
||||
v === null
|
||||
? null
|
||||
: `${i === 0 || band.upper[i - 1] === null ? 'M' : 'L'}${this.x(i)},${this.y(v)}`,
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
const points = band.upper.map((v, i) => `${this.x(i)},${this.y(v ?? 0)}`);
|
||||
let fill: string | null = null;
|
||||
if (this.stacked() || band.area) {
|
||||
if (!gaps && (this.stacked() || band.area)) {
|
||||
const floor = band.lower
|
||||
.map((v, i) => `${this.x(i)},${this.y(v)}`)
|
||||
.reverse();
|
||||
@@ -493,7 +522,13 @@ export class SeriesChartComponent {
|
||||
? `M${points.join('L')}L${floor.join('L')}Z`
|
||||
: null;
|
||||
}
|
||||
return { label: band.label, color: band.color, line, fill };
|
||||
return {
|
||||
label: band.label,
|
||||
color: band.color,
|
||||
dashed: !!band.dashed,
|
||||
line,
|
||||
fill,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -501,10 +536,10 @@ export class SeriesChartComponent {
|
||||
protected readonly endLabels = computed(() => {
|
||||
if (this.stacked() || this.count() === 0) return [];
|
||||
const labels = this.bands()
|
||||
.map((band) => ({
|
||||
text: String(last(band.upper) ?? 0),
|
||||
y: this.y(last(band.upper) ?? 0),
|
||||
}))
|
||||
.map((band) => {
|
||||
const value = last(band.upper.filter((v) => v !== null)) ?? 0;
|
||||
return { text: String(value), y: this.y(value) };
|
||||
})
|
||||
.sort((a, b) => a.y - b.y);
|
||||
const apart = labels.every(
|
||||
(l, i) => i === 0 || l.y - labels[i - 1].y >= 14,
|
||||
@@ -515,11 +550,13 @@ export class SeriesChartComponent {
|
||||
protected readonly dots = computed(() => {
|
||||
const index = this.hover();
|
||||
if (index === null) return [];
|
||||
return this.bands().map((band) => ({
|
||||
label: band.label,
|
||||
color: band.color,
|
||||
y: this.y(band.upper[index] ?? 0),
|
||||
}));
|
||||
return this.bands()
|
||||
.filter((band) => band.upper[index] !== null)
|
||||
.map((band) => ({
|
||||
label: band.label,
|
||||
color: band.color,
|
||||
y: this.y(band.upper[index] ?? 0),
|
||||
}));
|
||||
});
|
||||
|
||||
protected readonly tooltip = computed(() => {
|
||||
@@ -534,7 +571,9 @@ export class SeriesChartComponent {
|
||||
return {
|
||||
title: this.fullLabels()[index],
|
||||
rows,
|
||||
total: this.stacked() ? rows.reduce((sum, r) => sum + r.value, 0) : null,
|
||||
total: this.stacked()
|
||||
? rows.reduce((sum, r) => sum + (r.value ?? 0), 0)
|
||||
: null,
|
||||
x,
|
||||
left: x > this.width() * 0.6,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user