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>
This commit is contained in:
601
libs/web/tracker/presenters/src/lib/pages/calendar.page.ts
Normal file
601
libs/web/tracker/presenters/src/lib/pages/calendar.page.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
<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="calendar" routerLinkActive="is-active">Calendario</a>
|
||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||
<a routerLink="roadmap" routerLinkActive="is-active">Hoja de ruta</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
|
||||
Reference in New Issue
Block a user