Files
task-stream/libs/web/tracker/presenters/src/lib/pages/calendar.page.ts
Josep Piguillem 8c998d7423 feat: a sprint calendar
A Calendario tab lays the project's sprints out month by month as bands,
with the releases and the tickets due on each day. Owners and admins plan
a sprint by clicking the day it starts, and the whole plan downloads as an
iCalendar file. Tickets can be searched by due-date range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:20:28 +02:00

602 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
inject,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiLabelDirective,
PiTextareaComponent,
PiTextFieldComponent,
} from '@pi/ui';
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
addDays,
CalendarEvent,
dueState,
IssuesGateway,
IssueSummary,
localDay,
monthGrid,
monthOf,
ReleaseStatus,
shiftMonth,
Sprint,
SprintStatus,
toIcs,
weekBands,
} from '@task-stream/web/tracker/domain';
const WEEKDAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
/** Tickets listed in a day before "+N más". */
const PER_DAY = 3;
/**
* /p/:key/calendar — the sprints of the project month by month, with the
* releases and the tickets due each day. Managers plan a sprint by clicking
* the day it starts; the whole plan downloads as an .ics file.
*/
@Component({
selector: 'ts-calendar-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
PiActionButtonComponent,
PiLabelDirective,
PiTextareaComponent,
PiTextFieldComponent,
],
template: `
<div class="ts-cal__toolbar">
<div class="ts-cal__nav" role="group" aria-label="Mes">
<button
type="button"
class="ts-cal__arrow"
aria-label="Mes anterior"
(click)="move(-1)"
>
‹
</button>
<h2 class="ts-cal__month" aria-live="polite">{{ monthLabel() }}</h2>
<button
type="button"
class="ts-cal__arrow"
aria-label="Mes siguiente"
(click)="move(1)"
>
›
</button>
<button type="button" class="ts-plain-link" (click)="today()">
Hoy
</button>
</div>
<span class="ts-spacer"></span>
<ul class="ts-cal__legend" aria-label="Leyenda">
<li><span class="ts-cal__key is-active"></span>Sprint en curso</li>
<li><span class="ts-cal__key"></span>Sprint planificado</li>
<li><span class="ts-cal__key is-closed"></span>Cerrado</li>
<li><span aria-hidden="true">◆</span> Release</li>
</ul>
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Descargar .ics"
(execute)="downloadIcs()"
/>
</div>
@if (project.canManage()) {
<p class="ts-muted ts-cal__hint">
Pulsa el día en que empieza un sprint para planificarlo.
</p>
}
<div
class="ts-cal"
role="grid"
[attr.aria-label]="'Calendario de ' + monthLabel()"
>
<div class="ts-cal__weekdays" role="row">
@for (day of weekdays; track day) {
<span role="columnheader">{{ day }}</span>
}
</div>
@for (week of weeks(); track week[0]) {
<div class="ts-cal__week" role="row" [style.--rows]="rowsOf(week)">
<div class="ts-cal__bands">
@for (band of bandsOf(week); track band.item.id) {
<a
class="ts-cal__band"
routerLink="../sprints"
[attr.data-status]="band.item.status"
[class.continues-before]="band.continuesBefore"
[class.continues-after]="band.continuesAfter"
[style.grid-column]="band.column + ' / span ' + band.span"
[style.grid-row]="band.row + 1"
[title]="sprintTitle(band.item)"
>
{{ band.item.name }}
</a>
}
</div>
@for (day of week; track day) {
<div
class="ts-cal__day"
role="gridcell"
[class.is-other]="monthOf(day) !== month()"
[class.is-today]="day === todayDay"
[attr.aria-label]="dayLabel(day)"
>
<button
type="button"
class="ts-cal__number"
[disabled]="!project.canManage()"
[attr.aria-label]="
'Planificar un sprint desde el ' + dayLabel(day)
"
(click)="planFrom(day)"
>
{{ +day.slice(8) }}
</button>
<span class="ts-cal__spacer" aria-hidden="true"></span>
@for (release of releasesOn(day); track release.id) {
<a
class="ts-cal__release"
routerLink="../releases"
[class.is-shipped]="release.status === 'RELEASED'"
[title]="'Release ' + release.name"
>◆ {{ release.name }}</a
>
}
@for (issue of dueOn(day).slice(0, perDay); track issue.id) {
<a
class="ts-cal__issue"
[attr.data-due]="due(issue)"
[routerLink]="['..', 'issues', issue.key]"
[title]="issue.key + ' · ' + issue.title"
>{{ issue.key }} {{ issue.title }}</a
>
}
@if (dueOn(day).length > perDay) {
<span class="ts-cal__more"
>+{{ dueOn(day).length - perDay }} más</span
>
}
</div>
}
</div>
}
</div>
<ng-template #planForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); plan()"
novalidate
>
<pi-text-field [(value)]="name">
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
<div class="ts-form__row">
<pi-text-field placeholder="AAAA-MM-DD" [(value)]="start">
<pi-label [isRequired]="true">Empieza</pi-label>
</pi-text-field>
<pi-text-field placeholder="AAAA-MM-DD" [(value)]="end">
<pi-label [isRequired]="true">Acaba</pi-label>
</pi-text-field>
</div>
<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="Planificar"
[disabled]="!canPlan()"
(execute)="plan()"
/>
</form>
</ng-template>
`,
styles: `
.ts-cal__toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
}
.ts-cal__nav {
display: flex;
gap: 0.5rem;
align-items: center;
}
.ts-cal__month {
min-width: 10rem;
margin: 0;
font-size: 1.125rem;
text-align: center;
text-transform: capitalize;
}
.ts-cal__arrow {
width: 2rem;
height: 2rem;
border: 1px solid var(--ts-line);
border-radius: 0.5rem;
background: var(--ts-surface);
color: inherit;
font-size: 1.125rem;
cursor: pointer;
}
.ts-cal__legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
font-size: 0.75rem;
color: var(--ts-muted);
li {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
}
.ts-cal__key {
width: 0.875rem;
height: 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-brand) 35%, transparent);
&.is-active {
background: var(--ts-brand);
}
&.is-closed {
background: var(--ts-line-strong);
}
}
.ts-cal__hint {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
}
.ts-cal {
overflow-x: auto;
border: 1px solid var(--ts-line);
border-radius: 0.75rem;
background: var(--ts-surface);
}
.ts-cal__weekdays,
.ts-cal__week {
display: grid;
grid-template-columns: repeat(7, minmax(7.5rem, 1fr));
min-width: 52.5rem;
}
.ts-cal__weekdays span {
padding: 0.375rem 0.5rem;
color: var(--ts-muted);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.ts-cal__week {
position: relative;
border-top: 1px solid var(--ts-line);
}
/* Sprint bands sit under the day numbers, across the week. */
.ts-cal__bands {
position: absolute;
inset: 2rem 0 auto;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: 1.25rem;
gap: 0.125rem 0;
pointer-events: none;
}
.ts-cal__band {
overflow: hidden;
margin: 0 0.25rem;
padding: 0 0.5rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--ts-brand) 30%, transparent);
color: var(--ts-ink);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.25rem;
text-align: left;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: auto;
&[data-status='ACTIVE'] {
background: var(--ts-brand);
color: var(--ts-on-brand, #fff);
}
&[data-status='CLOSED'] {
background: var(--ts-line-strong);
color: var(--ts-muted);
}
&.continues-before {
margin-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&.continues-after {
margin-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.ts-cal__day {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-height: 7.5rem;
padding: 0.25rem 0.375rem 0.375rem;
border-left: 1px solid var(--ts-line);
&:first-of-type {
border-left: 0;
}
&.is-other {
background: color-mix(in srgb, var(--ts-line) 35%, transparent);
color: var(--ts-muted);
}
}
.ts-cal__spacer {
flex: none;
height: calc(var(--rows, 0) * 1.375rem);
}
.ts-cal__number {
flex: none;
width: 1.75rem;
height: 1.5rem;
border: 0;
border-radius: 0.375rem;
background: none;
color: inherit;
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
&:disabled {
cursor: default;
}
&:not(:disabled):hover {
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
}
}
.ts-cal__day.is-today .ts-cal__number {
background: var(--ts-brand);
color: var(--ts-on-brand, #fff);
font-weight: 700;
}
.ts-cal__release,
.ts-cal__issue,
.ts-cal__more {
overflow: hidden;
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.ts-cal__release {
color: var(--ts-c-violet, var(--ts-brand));
font-weight: 600;
text-decoration: none;
&.is-shipped {
color: var(--ts-c-green, var(--ts-muted));
}
}
.ts-cal__issue {
color: inherit;
text-decoration: none;
&:hover {
color: var(--ts-brand);
}
&[data-due='overdue'] {
color: var(--ts-c-red, inherit);
}
}
.ts-cal__more {
color: var(--ts-muted);
}
`,
})
export class CalendarPage {
protected readonly project = inject(ProjectFacade);
private readonly issues = inject(IssuesGateway);
private readonly drawer = inject(DrawerService);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
private readonly planForm =
viewChild.required<TemplateRef<unknown>>('planForm');
protected readonly weekdays = WEEKDAYS;
protected readonly perDay = PER_DAY;
protected readonly todayDay = localDay(new Date());
protected readonly monthOf = monthOf;
protected readonly month = signal(monthOf(this.todayDay));
protected readonly weeks = computed(() => monthGrid(this.month()));
private readonly dueIssues = signal<IssueSummary[]>([]);
protected readonly monthLabel = computed(() =>
new Date(`${this.month()}-01T00:00:00Z`).toLocaleDateString('es', {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}),
);
/* Plan a sprint */
protected readonly name = signal('');
protected readonly start = signal('');
protected readonly end = signal('');
protected readonly goal = signal('');
protected readonly canPlan = computed(
() =>
!!this.name().trim() &&
/^\d{4}-\d{2}-\d{2}$/.test(this.start()) &&
/^\d{4}-\d{2}-\d{2}$/.test(this.end()) &&
this.start() <= this.end(),
);
constructor() {
// Tickets due in the weeks shown (spill-over days included).
effect(() => {
const projectId = this.project.project()?.id;
const weeks = this.weeks();
if (!projectId) return;
const from = weeks[0][0];
const to = weeks[weeks.length - 1][6];
untracked(() =>
this.issues
.due(projectId, from, to)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (issues) => this.dueIssues.set(issues),
error: () => this.dueIssues.set([]),
}),
);
});
}
protected bandsOf(week: string[]) {
return weekBands(this.project.sprints(), week);
}
protected rowsOf(week: string[]): number {
return this.bandsOf(week).reduce(
(rows, band) => Math.max(rows, band.row + 1),
0,
);
}
protected releasesOn(day: string) {
return this.project
.releases()
.filter(
(r) =>
r.status !== ReleaseStatus.ARCHIVED &&
(r.releasedAt ?? r.targetDate)?.slice(0, 10) === day,
);
}
protected dueOn(day: string): IssueSummary[] {
return this.dueIssues().filter((i) => i.dueDate?.slice(0, 10) === day);
}
protected due(issue: IssueSummary) {
return dueState(issue.dueDate, issue.statusCategory, this.todayDay);
}
protected dayLabel(day: string): string {
return new Date(`${day}T00:00:00Z`).toLocaleDateString('es', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'UTC',
});
}
protected sprintTitle(sprint: Sprint): string {
const status =
sprint.status === SprintStatus.ACTIVE
? 'en curso'
: sprint.status === SprintStatus.CLOSED
? 'cerrado'
: 'planificado';
return `${sprint.name} (${status}) · ${sprint.startDate} – ${sprint.endDate} · ${sprint.progress.finished}/${sprint.progress.total} tickets`;
}
protected move(by: number): void {
this.month.update((month) => shiftMonth(month, by));
}
protected today(): void {
this.month.set(monthOf(this.todayDay));
}
/** Two weeks from the day clicked, named after the next number. */
protected planFrom(day: string): void {
if (!this.project.canManage()) return;
this.name.set(`Sprint ${this.project.sprints().length + 1}`);
this.start.set(day);
this.end.set(addDays(day, 13));
this.goal.set('');
this.drawer.open('Planificar sprint', this.planForm());
}
protected plan(): void {
if (!this.canPlan()) return;
this.project.createSprint({
name: this.name().trim(),
goal: this.goal().trim() || null,
startDate: this.start(),
endDate: this.end(),
});
this.drawer.closeAll();
}
/** Sprints, releases and due tickets of the project, for any calendar app. */
protected downloadIcs(): void {
const project = this.project.project();
if (!project) return;
const base = `${location.origin}/p/${project.key}`;
const events: CalendarEvent[] = [
...this.project.sprints().map((s) => ({
uid: `sprint-${s.id}`,
title: `${project.key} · ${s.name}`,
start: s.startDate,
end: s.endDate,
description: s.goal,
url: `${base}/sprints`,
})),
...this.project
.releases()
.filter((r) => r.targetDate || r.releasedAt)
.map((r) => {
const day = (r.releasedAt ?? r.targetDate ?? '').slice(0, 10);
return {
uid: `release-${r.id}`,
title: `${project.key} · Release ${r.name}`,
start: day,
end: day,
url: `${base}/releases`,
};
}),
...this.dueIssues().map((i) => {
const day = (i.dueDate ?? '').slice(0, 10);
return {
uid: `due-${i.id}`,
title: `${i.key} vence: ${i.title}`,
start: day,
end: day,
url: `${base}/issues/${i.key}`,
};
}),
];
const blob = new Blob([toIcs(`${project.key} · ${project.name}`, events)], {
type: 'text/calendar;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${project.key}-sprints.ics`;
link.click();
URL.revokeObjectURL(url);
this.toast.success('Calendario descargado');
}
}