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>
602 lines
17 KiB
TypeScript
602 lines
17 KiB
TypeScript
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');
|
||
}
|
||
}
|