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:
56
libs/web/tracker/domain/src/lib/models/calendar.spec.ts
Normal file
56
libs/web/tracker/domain/src/lib/models/calendar.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
146
libs/web/tracker/domain/src/lib/models/calendar.ts
Normal file
146
libs/web/tracker/domain/src/lib/models/calendar.ts
Normal 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';
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user