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: `

{{ monthLabel() }}

@if (project.canManage()) {

Pulsa el día en que empieza un sprint para planificarlo.

}
@for (day of weekdays; track day) { {{ day }} }
@for (week of weeks(); track week[0]) {
@for (band of bandsOf(week); track band.item.id) { {{ band.item.name }} }
@for (day of week; track day) {
@for (release of releasesOn(day); track release.id) { ◆ {{ release.name }} } @for (issue of dueOn(day).slice(0, perDay); track issue.id) { {{ issue.key }} {{ issue.title }} } @if (dueOn(day).length > perDay) { +{{ dueOn(day).length - perDay }} más }
}
}
Nombre
Empieza Acaba
Objetivo
`, 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>('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([]); 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'); } }