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:
2026-10-08 19:20:28 +02:00
parent feea58d0e8
commit 8c998d7423
21 changed files with 1107 additions and 1 deletions

View File

@@ -11,3 +11,4 @@ export * from './lib/models/swimlanes';
export * from './lib/models/roadmap';
export * from './lib/models/shortcuts';
export * from './lib/models/due';
export * from './lib/models/calendar';

View File

@@ -0,0 +1,56 @@
import { addDays, monthGrid, shiftMonth, toIcs, weekBands } from './calendar';
describe('sprint calendar', () => {
it('lays a month out in weeks from Monday', () => {
const weeks = monthGrid('2026-10');
expect(weeks[0][0]).toBe('2026-09-28');
expect(weeks.at(-1)?.[6]).toBe('2026-11-01');
expect(weeks.every((w) => w.length === 7)).toBe(true);
expect(shiftMonth('2026-12', 1)).toBe('2027-01');
expect(shiftMonth('2026-01', -1)).toBe('2025-12');
expect(addDays('2026-02-28', 1)).toBe('2026-03-01');
});
it('places sprints as bands across a week, in rows that do not overlap', () => {
const week = monthGrid('2026-10')[2]; // 12 to 18 October
const bands = weekBands(
[
{ id: 'a', startDate: '2026-10-05', endDate: '2026-10-14' },
{ id: 'b', startDate: '2026-10-15', endDate: '2026-10-28' },
{ id: 'c', startDate: '2026-10-13', endDate: '2026-10-13' },
{ id: 'x', startDate: '2026-11-01', endDate: '2026-11-10' },
],
week,
);
expect(bands.map((b) => [b.item.id, b.column, b.span, b.row])).toEqual([
['a', 1, 3, 0],
['c', 2, 1, 1],
['b', 4, 4, 0],
]);
expect(bands[0]).toMatchObject({
continuesBefore: true,
continuesAfter: false,
});
expect(bands[2]).toMatchObject({ continuesAfter: true });
});
it('writes an iCalendar file with all-day events', () => {
const ics = toIcs(
'WEB, sprints',
[
{
uid: 's1',
title: 'Sprint 1; pagos',
start: '2026-10-12',
end: '2026-10-23',
},
],
new Date('2026-10-08T10:00:00Z'),
);
expect(ics).toContain('X-WR-CALNAME:WEB\\, sprints');
expect(ics).toContain('DTSTART;VALUE=DATE:20261012');
expect(ics).toContain('DTEND;VALUE=DATE:20261024');
expect(ics).toContain('SUMMARY:Sprint 1\\; pagos');
expect(ics.split('\r\n')[0]).toBe('BEGIN:VCALENDAR');
});
});

View File

@@ -0,0 +1,146 @@
/*
* The sprint calendar: a month as weeks of days (Monday first), the sprints
* as bands across them, and the same plan as an iCalendar file. Days are
* `YYYY-MM-DD` strings throughout: no time zones to trip over.
*/
const DAY = 86_400_000;
const toDay = (time: number) => new Date(time).toISOString().slice(0, 10);
const timeOf = (day: string) => Date.parse(`${day}T00:00:00Z`);
export function addDays(day: string, days: number): string {
return toDay(timeOf(day) + days * DAY);
}
/** `YYYY-MM` of a day, and the month after or before it. */
export function monthOf(day: string): string {
return day.slice(0, 7);
}
export function shiftMonth(month: string, by: number): string {
const [year, m] = month.split('-').map(Number);
const date = new Date(Date.UTC(year, m - 1 + by, 1));
return date.toISOString().slice(0, 7);
}
/** The weeks (Monday to Sunday) that cover a month, with spill-over days. */
export function monthGrid(month: string): string[][] {
const first = `${month}-01`;
const weekday = (new Date(timeOf(first)).getUTCDay() + 6) % 7;
let day = addDays(first, -weekday);
const next = `${shiftMonth(month, 1)}-01`;
const weeks: string[][] = [];
while (day < next) {
const week: string[] = [];
for (let i = 0; i < 7; i++) {
week.push(day);
day = addDays(day, 1);
}
weeks.push(week);
}
return weeks;
}
export interface CalendarSpan {
id: string;
startDate: string;
endDate: string;
}
/** Where a span sits in a week: columns 1-7, and a row so none overlap. */
export interface WeekBand<T extends CalendarSpan> {
item: T;
/** First column (1 = Monday). */
column: number;
span: number;
row: number;
/** It goes on before this week / after it. */
continuesBefore: boolean;
continuesAfter: boolean;
}
/** The spans that touch a week, laid out in rows (first come, first row). */
export function weekBands<T extends CalendarSpan>(
items: T[],
week: string[],
): WeekBand<T>[] {
const [first, last] = [week[0], week[6]];
const rows: string[][] = [];
return items
.filter((item) => item.startDate <= last && item.endDate >= first)
.sort((a, b) => a.startDate.localeCompare(b.startDate))
.map((item) => {
const start = item.startDate < first ? first : item.startDate;
const end = item.endDate > last ? last : item.endDate;
const column = week.indexOf(start) + 1;
const span = week.indexOf(end) - week.indexOf(start) + 1;
// The first row whose last band ended before this one starts.
let row = rows.findIndex((ends) => ends[ends.length - 1] < start);
if (row === -1) {
rows.push([]);
row = rows.length - 1;
}
rows[row].push(end);
return {
item,
column,
span,
row,
continuesBefore: item.startDate < first,
continuesAfter: item.endDate > last,
};
});
}
export interface CalendarEvent {
uid: string;
title: string;
/** First day, and the last one included (same for one-day events). */
start: string;
end: string;
description?: string | null;
url?: string | null;
}
const icsText = (text: string) =>
text
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/[,;]/g, (c) => `\\${c}`);
const icsDay = (day: string) => day.replace(/-/g, '');
/** The plan as an iCalendar file, all-day events, for any calendar app. */
export function toIcs(
name: string,
events: CalendarEvent[],
now = new Date(),
): string {
const stamp = now
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//TaskStream//Calendario de sprints//ES',
'CALSCALE:GREGORIAN',
`X-WR-CALNAME:${icsText(name)}`,
...events.flatMap((event) => [
'BEGIN:VEVENT',
`UID:${event.uid}@taskstream`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${icsDay(event.start)}`,
// DTEND is exclusive: the day after the last one.
`DTEND;VALUE=DATE:${icsDay(addDays(event.end, 1))}`,
`SUMMARY:${icsText(event.title)}`,
...(event.description
? [`DESCRIPTION:${icsText(event.description)}`]
: []),
...(event.url ? [`URL:${event.url}`] : []),
'END:VEVENT',
]),
'END:VCALENDAR',
];
return lines.join('\r\n') + '\r\n';
}

View File

@@ -115,6 +115,12 @@ export abstract class IssuesGateway {
parentId: string | null,
): Observable<CommandResult>;
abstract epics(projectId: string): Observable<Epic[]>;
/** Tickets due between two days (`YYYY-MM-DD`, both included). */
abstract due(
projectId: string,
from: string,
to: string,
): Observable<IssueSummary[]>;
/** To the trash (whoever opened it, or a manager). */
abstract trash(issueId: string): Observable<CommandResult>;
abstract restore(issueId: string): Observable<CommandResult>;

View File

@@ -25,6 +25,7 @@ import {
BoardDocument,
ChangeIssueStatusDocument,
CreateIssueDocument,
DueIssuesDocument,
EpicsDocument,
IssueChildrenDocument,
IssueDocument,
@@ -183,6 +184,14 @@ export class GraphqlIssuesGateway extends IssuesGateway {
);
}
due(projectId: string, from: string, to: string): Observable<IssueSummary[]> {
return this.gql
.query(DueIssuesDocument, {
filter: { projectId, dueFrom: from, dueTo: to },
})
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
}
trash(issueId: string): Observable<CommandResult> {
return this.gql
.mutate(TrashIssueDocument, { issueId })

View File

@@ -149,6 +149,10 @@ export type IssueFilterInput = {
areas?: Array<string> | null | undefined;
assigneeId?: string | null | undefined;
categories?: Array<StatusCategory> | null | undefined;
/** Due on or after this day (YYYY-MM-DD) */
dueFrom?: string | null | undefined;
/** Due on or before this day (YYYY-MM-DD) */
dueTo?: string | null | undefined;
labels?: Array<string> | null | undefined;
/** Only tickets in no sprint */
noSprint?: boolean | null | undefined;
@@ -1124,6 +1128,39 @@ export type TrashQuery = {
}>;
};
export type DueIssuesQueryVariables = Exact<{
filter: IssueFilterInput;
}>;
export type DueIssuesQuery = {
issues: {
items: Array<{
id: string;
projectId: string;
key: string;
type: string;
area: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
priority: IssuePriority;
labels: Array<string>;
commentCount: number;
updatedAt: string;
version: number;
rank: string;
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
};
};
export type NotificationFieldsFragment = {
id: string;
kind: NotificationKind;
@@ -6993,6 +7030,167 @@ export const TrashDocument = {
},
],
} as unknown as DocumentNode<TrashQuery, TrashQueryVariables>;
export const DueIssuesDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'DueIssues' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'filter' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'IssueFilterInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'issues' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'filter' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'filter' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'limit' },
value: { kind: 'IntValue', value: '200' },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'items' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' },
},
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'IssueSummaryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Issue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'release' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
],
} as unknown as DocumentNode<DueIssuesQuery, DueIssuesQueryVariables>;
export const MyNotificationsDocument = {
kind: 'Document',
definitions: [

View File

@@ -239,3 +239,11 @@ query Trash($projectId: ID!) {
...IssueSummaryFields
}
}
query DueIssues($filter: IssueFilterInput!) {
issues(filter: $filter, limit: 200) {
items {
...IssueSummaryFields
}
}
}

View 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');
}
}

View File

@@ -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>

View File

@@ -50,6 +50,12 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
import('./pages/sprints.page').then((m) => m.SprintsPage),
},
{
path: 'calendar',
title: 'Calendario · TaskStream',
loadComponent: () =>
import('./pages/calendar.page').then((m) => m.CalendarPage),
},
{
path: 'roadmap',
title: 'Hoja de ruta · TaskStream',