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: `
@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 (day of week; track day) {
{{ +day.slice(8) }}
@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
}
}
}
`,
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');
}
}