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:
2026-10-08 14:59:01 +02:00
parent d2e31789a7
commit 50d3ffd1dd
104 changed files with 7209 additions and 33 deletions

View File

@@ -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,
};