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

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