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

@@ -137,6 +137,12 @@ const NOBODY = '__nobody__';
[options]="priorityFilterOptions"
[(value)]="priorityFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Sprint"
[options]="sprintFilterOptions()"
[(value)]="sprintFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Release"
@@ -335,6 +341,8 @@ export class BoardPage {
readonly release = input<string | undefined>();
/** `?assignee=` (from the tracking page's team list), bound by the router. */
readonly assignee = input<string | undefined>();
/** `?sprint=` (from the sprints page), bound by the router. */
readonly sprint = input<string | undefined>();
protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade);
@@ -353,6 +361,16 @@ export class BoardPage {
protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly area = signal<string | null>(ALL);
protected readonly typeFilter = signal<string | null>(ALL);
protected readonly sprintFilter = signal<string | null>(ALL);
protected readonly sprintFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => [
{ value: ALL, label: 'Todos los sprints' },
...this.project.openSprints().map((s) => ({
value: s.id,
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
})),
]);
protected readonly priorityFilter = signal<string | null>(ALL);
/** What is typed; `search` follows it once typing pauses. */
protected readonly searchInput = signal('');
@@ -364,6 +382,7 @@ export class BoardPage {
!!(
this.assigneeFilter() ||
this.releaseFilter() ||
this.sprintFilter() ||
this.area() ||
this.typeFilter() ||
this.priorityFilter() ||
@@ -485,6 +504,10 @@ export class BoardPage {
const assignee = this.assignee();
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
});
effect(() => {
const sprint = this.sprint();
if (sprint) untracked(() => this.sprintFilter.set(sprint));
});
effect((onCleanup) => {
const text = this.searchInput().trim();
const timer = setTimeout(() => this.search.set(text), 300);
@@ -505,6 +528,7 @@ export class BoardPage {
priorities: priority ? [priority] : undefined,
labels: this.labels().length ? this.labels() : undefined,
search: this.search() || undefined,
sprintId: this.sprintFilter() || undefined,
};
if (!projectId) return;
untracked(() =>
@@ -569,6 +593,7 @@ export class BoardPage {
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
);
this.releaseFilter.set(filter.releaseId ?? ALL);
this.sprintFilter.set(ALL);
this.area.set(filter.area ?? ALL);
this.typeFilter.set(filter.types?.[0] ?? ALL);
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
@@ -581,6 +606,7 @@ export class BoardPage {
for (const filter of [
this.assigneeFilter,
this.releaseFilter,
this.sprintFilter,
this.area,
this.typeFilter,
this.priorityFilter,

View File

@@ -594,6 +594,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>
<pi-label>Release</pi-label>
</pi-select-field>
<pi-select-field
[options]="sprintOptions()"
[disabled]="!project.canContribute() || sprintLocked()"
[value]="issue.sprint?.id ?? ''"
(valueChange)="facade.setSprint($event || null)"
>
<pi-label>Sprint</pi-label>
</pi-select-field>
<pi-select-field
[options]="typeOptions()"
[disabled]="!project.canContribute()"
@@ -768,6 +776,30 @@ export class IssuePage {
.map((r) => ({ value: r.id, label: r.name })),
],
);
/** Open sprints, plus the ticket's own even if it closed since. */
protected readonly sprintOptions = computed<PiSelectFieldOption<string>[]>(
() => {
const current = this.facade.issue()?.sprint;
const open = this.project.openSprints();
return [
{ value: NONE, label: 'Backlog (sin sprint)' },
...open.map((s) => ({
value: s.id,
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
})),
...(current && !open.some((s) => s.id === current.id)
? [{ value: current.id, label: `${current.name} · cerrado` }]
: []),
];
},
);
/** A closed sprint keeps its tickets. */
protected readonly sprintLocked = computed(() => {
const current = this.facade.issue()?.sprint;
return (
!!current && !this.project.openSprints().some((s) => s.id === current.id)
);
});
protected readonly priorityOptions = options(PRIORITY);
/** Active values, plus the ticket's own even if archived since. */
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(

View File

@@ -86,6 +86,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
<nav class="ts-tabs" aria-label="Secciones del proyecto">
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>

View File

@@ -0,0 +1,571 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiDateRangeFieldComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiProgressBarComponent,
PiSegmentedControlComponent,
PiSelectFieldComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { Sprint, SprintStatus } from '@task-stream/web/tracker/domain';
import { ChartSeries, last, SeriesChartComponent } from '../ui/series-chart';
const DAY = 86_400_000;
const BACKLOG = '';
/** `YYYY-MM-DD` of a local date (what the date picker gives). */
const isoDay = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const localDate = (day: string) => new Date(`${day}T00:00:00`);
const label = (day: string, month: 'short' | 'long' = 'short') =>
localDate(day).toLocaleDateString('es', { day: 'numeric', month });
const STATUS: Record<SprintStatus, string> = {
[SprintStatus.ACTIVE]: 'En curso',
[SprintStatus.PLANNED]: 'Planificado',
[SprintStatus.CLOSED]: 'Cerrado',
};
/** /p/:key/sprints — plan the iterations, follow the one under way, close it. */
@Component({
selector: 'ts-sprints-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
SeriesChartComponent,
PiActionButtonComponent,
PiDateRangeFieldComponent,
PiEmptyStateComponent,
PiLabelDirective,
PiProgressBarComponent,
PiSegmentedControlComponent,
PiSelectFieldComponent,
PiTextareaComponent,
PiTextFieldComponent,
],
template: `
<div class="ts-section-header">
<p class="ts-muted">
Iteraciones de unas semanas. Mete tickets desde su ficha; al cerrar un
sprint, lo que quede pasa al siguiente o vuelve al backlog.
</p>
@if (project.canManage()) {
<pi-action-button
color="primary"
text="Planificar sprint"
(execute)="openCreate()"
/>
}
</div>
@if (focus(); as sprint) {
<section
class="ts-sprint ts-sprint--focus ts-panel"
[attr.data-status]="sprint.status"
aria-labelledby="ts-sprint-focus"
>
<header class="ts-sprint__head">
<h2 id="ts-sprint-focus">{{ sprint.name }}</h2>
<span class="ts-pill" [attr.data-status]="sprint.status">{{
statusLabel(sprint)
}}</span>
<span class="ts-muted">{{ when(sprint) }}</span>
</header>
@if (sprint.goal) {
<p class="ts-sprint__goal">🎯 {{ sprint.goal }}</p>
}
<pi-progress-bar
[label]="progressLabel(sprint)"
[value]="sprint.progress.percent"
/>
@if (project.burndown()?.sprintId === sprint.id) {
<div class="ts-sprint__chart">
<pi-segmented-control
ariaLabel="Medir en"
[options]="units"
[(value)]="unit"
/>
<ts-series-chart
axisLabel="Día"
[labels]="burndownLabels()"
[longLabels]="burndownLongLabels()"
[series]="burndownSeries()"
[marker]="todayMarker()"
[summary]="burndownSummary()"
/>
</div>
}
<div class="ts-sprint__actions">
<a
class="ts-plain-link"
routerLink="../board"
[queryParams]="{ sprint: sprint.id }"
>Ver en el tablero →</a
>
<span class="ts-spacer"></span>
@if (project.canManage()) {
@switch (sprint.status) {
@case ('ACTIVE') {
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Editar"
(execute)="openEdit(sprint)"
/>
<pi-action-button
size="small"
color="primary"
text="Cerrar sprint"
(execute)="openClose(sprint)"
/>
}
@case ('PLANNED') {
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Editar"
(execute)="openEdit(sprint)"
/>
<pi-action-button
size="small"
color="primary"
text="Empezar"
[disabled]="!!project.activeSprint()"
(execute)="project.startSprint(sprint.id)"
/>
}
}
}
</div>
</section>
}
@if (planned().length) {
<h2 class="ts-sprints__title">Planificados</h2>
<ul class="ts-sprints">
@for (sprint of planned(); track sprint.id) {
<li class="ts-sprint ts-panel" [class.is-focus]="isFocus(sprint)">
<button
type="button"
class="ts-sprint__pick"
(click)="show(sprint)"
>
<strong>{{ sprint.name }}</strong>
<span class="ts-muted">{{ when(sprint) }}</span>
<span class="ts-mono"
>{{ sprint.progress.total }} tickets ·
{{ sprint.progress.storyPoints }} pts</span
>
</button>
@if (project.canManage()) {
<span class="ts-sprint__row-actions">
<pi-action-button
size="small"
color="grey"
appearance="ghost"
text="Borrar"
(execute)="project.deleteSprint(sprint.id)"
/>
<pi-action-button
size="small"
color="primary"
appearance="outline"
text="Empezar"
[disabled]="!!project.activeSprint()"
(execute)="project.startSprint(sprint.id)"
/>
</span>
}
</li>
}
</ul>
}
@if (closed().length) {
<h2 class="ts-sprints__title">Cerrados</h2>
<ul class="ts-sprints">
@for (sprint of closed(); track sprint.id) {
<li class="ts-sprint ts-panel" [class.is-focus]="isFocus(sprint)">
<button
type="button"
class="ts-sprint__pick"
(click)="show(sprint)"
>
<strong>{{ sprint.name }}</strong>
<span class="ts-muted">{{ when(sprint) }}</span>
<span class="ts-mono"
>{{ sprint.progress.finished }}/{{
sprint.progress.total
}}
terminados</span
>
</button>
</li>
}
</ul>
}
@if (!project.sprints().length) {
<pi-empty-state
icon="flag"
title="Sin sprints"
description="Planifica el primero: un nombre, unas fechas y un objetivo. Luego mete en él los tickets."
/>
}
<ng-template #sprintForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); save()"
novalidate
>
<pi-text-field placeholder="Sprint 14" [(value)]="name">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<pi-date-range-field
startPlaceholder="Empieza"
endPlaceholder="Acaba"
[(value)]="range"
>
<pi-label [isRequired]="true">Fechas</pi-label>
</pi-date-range-field>
<pi-textarea
placeholder="Qué tiene que quedar hecho al final"
[(value)]="goal"
>
<pi-label>Objetivo</pi-label>
</pi-textarea>
<pi-action-button
size="w100"
color="primary"
[text]="editing() ? 'Guardar' : 'Planificar'"
[disabled]="!canSave()"
(execute)="save()"
/>
</form>
</ng-template>
<ng-template #closeForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); close()"
novalidate
>
<p class="ts-muted">
{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en
este sprint como registro de lo hecho.
</p>
<pi-select-field [options]="carryOverOptions()" [(value)]="carryOverTo">
<pi-label>Lo pendiente va a</pi-label>
</pi-select-field>
<pi-action-button
size="w100"
color="primary"
text="Cerrar sprint"
(execute)="close()"
/>
</form>
</ng-template>
`,
styles: `
.ts-sprint {
display: grid;
gap: 0.875rem;
padding: 1rem 1.125rem;
border-left: 3px solid var(--ts-line-strong);
&[data-status='ACTIVE'] {
border-left-color: var(--ts-brand);
}
&.is-focus {
border-left-color: var(--ts-brand);
}
}
.ts-sprint--focus {
margin-bottom: 1.5rem;
}
.ts-sprint__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.75rem;
h2 {
margin: 0;
font-size: 1.25rem;
}
}
.ts-sprint__goal {
margin: 0;
}
.ts-sprint__chart {
display: grid;
gap: 0.5rem;
pi-segmented-control {
justify-self: end;
}
}
.ts-sprint__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.ts-sprints__title {
margin: 1.25rem 0 0.625rem;
font-size: 0.875rem;
color: var(--ts-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ts-sprints {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
.ts-sprint {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
padding: 0.625rem 0.875rem;
}
}
.ts-sprint__pick {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.875rem;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:hover strong {
color: var(--ts-brand);
}
}
.ts-sprint__row-actions {
display: inline-flex;
gap: 0.25rem;
}
.ts-pill[data-status='ACTIVE'] {
--tone: var(--ts-brand);
}
`,
})
export class SprintsPage {
protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService);
private readonly sprintForm =
viewChild.required<TemplateRef<unknown>>('sprintForm');
private readonly closeForm =
viewChild.required<TemplateRef<unknown>>('closeForm');
/** The sprint picked from the lists; by default the one under way. */
private readonly picked = signal<string | null>(null);
protected readonly focus = computed(() => {
const sprints = this.project.sprints();
return (
sprints.find((s) => s.id === this.picked()) ??
this.project.activeSprint() ??
sprints.find((s) => s.status === SprintStatus.PLANNED) ??
null
);
});
protected readonly planned = computed(() =>
this.project.sprints().filter((s) => s.status === SprintStatus.PLANNED),
);
protected readonly closed = computed(() =>
this.project.sprints().filter((s) => s.status === SprintStatus.CLOSED),
);
/* Burndown */
protected readonly units = [
{ value: 'tickets', label: 'Tickets' },
{ value: 'points', label: 'Puntos' },
];
protected readonly unit = signal<string>('tickets');
protected readonly burndownLabels = computed(
() => this.project.burndown()?.dates.map((d) => label(d)) ?? [],
);
protected readonly burndownLongLabels = computed(
() => this.project.burndown()?.dates.map((d) => label(d, 'long')) ?? [],
);
protected readonly burndownSeries = computed<ChartSeries[]>(() => {
const burndown = this.project.burndown();
if (!burndown) return [];
const points = this.unit() === 'points';
const remaining = points ? burndown.remainingPoints : burndown.remaining;
// The ideal line starts from what was open on the first day, in the unit shown.
const first = remaining[0] ?? 0;
const span = Math.max(burndown.dates.length - 1, 1);
return [
{
label: 'Ideal',
values: burndown.dates.map(
(_, i) => Math.round((first * (span - i) * 10) / span) / 10,
),
color: 'var(--ts-muted)',
dashed: true,
},
{
label: points ? 'Puntos pendientes' : 'Tickets pendientes',
values: remaining,
color: 'var(--ts-viz-1)',
},
];
});
protected readonly todayMarker = computed(() => {
const index = this.project.burndown()?.dates.indexOf(isoDay(new Date()));
return index === undefined || index === -1 ? null : { index, label: 'Hoy' };
});
protected readonly burndownSummary = computed(() => {
const burndown = this.project.burndown();
const open = last(burndown?.remaining.filter((v) => v !== null) ?? []);
return open === undefined
? 'Burndown del sprint'
: `Burndown del sprint: quedan ${open} tickets por terminar`;
});
/* Plan / edit */
protected readonly editing = signal<Sprint | null>(null);
protected readonly name = signal('');
protected readonly goal = signal('');
protected readonly range = signal<{ start: Date | null; end: Date | null }>({
start: null,
end: null,
});
protected readonly canSave = computed(
() => !!this.name().trim() && !!this.range().start && !!this.range().end,
);
/* Close */
private closing: Sprint | null = null;
protected readonly unfinished = signal(0);
protected readonly carryOverTo = signal<string | null>(BACKLOG);
protected readonly carryOverOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: BACKLOG, label: 'El backlog' },
...this.planned().map((s) => ({
value: s.id,
label: `${s.name} (${label(s.startDate)})`,
})),
],
);
constructor() {
// Load the burndown of whatever sprint is in focus.
effect(() => {
const sprint = this.focus();
if (sprint && sprint.status !== SprintStatus.PLANNED)
untracked(() => this.project.loadBurndown(sprint.id));
});
}
protected statusLabel = (sprint: Sprint) => STATUS[sprint.status];
protected isFocus = (sprint: Sprint) => this.focus()?.id === sprint.id;
/** "12 oct – 23 oct · quedan 5 días". */
protected when(sprint: Sprint): string {
const dates = `${label(sprint.startDate)} – ${label(sprint.endDate)}`;
if (sprint.status !== SprintStatus.ACTIVE) return dates;
const left = Math.ceil(
(localDate(sprint.endDate).getTime() + DAY - Date.now()) / DAY,
);
if (left < 0) return `${dates} · pasado de fecha`;
return `${dates} · ${left === 1 ? 'queda 1 día' : `quedan ${left} días`}`;
}
protected progressLabel(sprint: Sprint): string {
const { finished, total, finishedStoryPoints, storyPoints } =
sprint.progress;
const points = storyPoints
? ` · ${finishedStoryPoints} de ${storyPoints} puntos`
: '';
return `${finished} de ${total} tickets terminados${points}`;
}
protected show(sprint: Sprint): void {
this.picked.set(sprint.id);
}
protected openCreate(): void {
this.editing.set(null);
const lastEnd = last(
this.project
.sprints()
.map((s) => s.endDate)
.sort(),
);
// Right after the last one, two weeks long.
const start = lastEnd
? new Date(localDate(lastEnd).getTime() + DAY)
: new Date();
this.name.set(`Sprint ${this.project.sprints().length + 1}`);
this.goal.set('');
this.range.set({
start,
end: new Date(start.getTime() + 13 * DAY),
});
this.drawer.open('Planificar sprint', this.sprintForm());
}
protected openEdit(sprint: Sprint): void {
this.editing.set(sprint);
this.name.set(sprint.name);
this.goal.set(sprint.goal ?? '');
this.range.set({
start: localDate(sprint.startDate),
end: localDate(sprint.endDate),
});
this.drawer.open(`Editar ${sprint.name}`, this.sprintForm());
}
protected save(): void {
const { start, end } = this.range();
if (!this.canSave() || !start || !end) return;
const details = {
name: this.name().trim(),
goal: this.goal().trim() || null,
startDate: isoDay(start),
endDate: isoDay(end),
};
const sprint = this.editing();
if (sprint) this.project.updateSprint({ sprintId: sprint.id, ...details });
else this.project.createSprint(details);
this.drawer.closeAll();
}
protected openClose(sprint: Sprint): void {
this.closing = sprint;
this.unfinished.set(sprint.progress.total - sprint.progress.finished);
this.carryOverTo.set(this.planned()[0]?.id ?? BACKLOG);
this.drawer.open(`Cerrar ${sprint.name}`, this.closeForm());
}
protected close(): void {
if (!this.closing) return;
this.project.closeSprint(this.closing.id, this.carryOverTo() || null);
this.closing = null;
this.drawer.closeAll();
}
}

View File

@@ -44,6 +44,12 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
import('./pages/backlog.page').then((m) => m.BacklogPage),
},
{
path: 'sprints',
title: 'Sprints · TaskStream',
loadComponent: () =>
import('./pages/sprints.page').then((m) => m.SprintsPage),
},
{
path: 'epics',
title: 'Épicas · TaskStream',

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