feat: a calendar to subscribe to, and the backlog's corners

- Each person can subscribe their dates in Google Calendar, Outlook or
  Apple Calendar: GET /api/calendar/{token}.ics (no session) brings
  their unfinished tickets with a due date (assigned or followed), the
  planned and running sprints and the planned releases of their
  projects, as all-day events linking to the web. The token is made,
  renewed or dropped under /me/calendar (and in GraphQL); in the web,
  from «Calendario» in «Mi trabajo».
- The backlog panel keeps the selected rows' background inside its
  rounded corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 22:48:33 +02:00
parent 993f80b4b9
commit f45069a327
38 changed files with 1417 additions and 54 deletions

View File

@@ -19,6 +19,8 @@ export * from './lib/git.service';
export * from './lib/webhooks.service';
export * from './lib/transfer.service';
export * from './lib/automations.service';
export * from './lib/calendar.service';
export * from './lib/calendar/calendar';
export * from './lib/automations/automations';
export * from './lib/automations/automation-rules';
export * from './lib/transfer/import-export';

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
CreateCalendarFeedCommand,
GetCalendarIcsQuery,
GetMyCalendarFeedQuery,
RevokeCalendarFeedCommand,
} from './calendar/calendar';
import { CalendarFeed } from './ports/calendar-feeds.repository';
/** A person's calendar subscription (iCalendar), behind a secret URL. */
@Injectable()
export class CalendarService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
mine(userId: string): Promise<CalendarFeed | null> {
return this.queryBus.execute(new GetMyCalendarFeedQuery(userId));
}
/** A new secret URL; the previous one, if any, stops working. */
create(userId: string): Promise<CalendarFeed> {
return this.commandBus.execute(new CreateCalendarFeedCommand(userId));
}
revoke(userId: string): Promise<void> {
return this.commandBus.execute(new RevokeCalendarFeedCommand(userId));
}
/** The calendar behind `token`, or null if no feed has it. */
ics(token: string, webUrl: string): Promise<string | null> {
return this.queryBus.execute(new GetCalendarIcsQuery(token, webUrl));
}
}

View File

@@ -0,0 +1,193 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
IssueReadModel,
ProjectReadModel,
ReleaseStatus,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
CalendarFeed,
CalendarFeedsRepository,
} from '../ports/calendar-feeds.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UserDirectory } from '../ports/user-directory';
import { CalendarEntry, buildIcs } from './ics';
/** A new secret URL for the person's calendar; the old one stops working. */
export class CreateCalendarFeedCommand extends Command<CalendarFeed> {
constructor(public readonly userId: string) {
super();
}
}
/** The person's calendar URL stops working. */
export class RevokeCalendarFeedCommand extends Command<void> {
constructor(public readonly userId: string) {
super();
}
}
export class GetMyCalendarFeedQuery extends Query<CalendarFeed | null> {
constructor(public readonly userId: string) {
super();
}
}
/** The calendar behind a token, as iCalendar text; null for an unknown one. */
export class GetCalendarIcsQuery extends Query<string | null> {
constructor(
public readonly token: string,
/** Where tickets open (`WEB_URL`), for the events' links; may be empty. */
public readonly webUrl: string,
) {
super();
}
}
@CommandHandler(CreateCalendarFeedCommand)
export class CreateCalendarFeedCommandHandler
implements ICommandHandler<CreateCalendarFeedCommand>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
async execute(command: CreateCalendarFeedCommand): Promise<CalendarFeed> {
const feed: CalendarFeed = {
userId: command.userId,
// 32 random bytes: nobody guesses it.
token: randomBytes(32).toString('base64url'),
createdAt: new Date().toISOString(),
};
await this.feeds.save(feed);
return feed;
}
}
@CommandHandler(RevokeCalendarFeedCommand)
export class RevokeCalendarFeedCommandHandler
implements ICommandHandler<RevokeCalendarFeedCommand>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
async execute(command: RevokeCalendarFeedCommand): Promise<void> {
await this.feeds.remove(command.userId);
}
}
@QueryHandler(GetMyCalendarFeedQuery)
export class GetMyCalendarFeedQueryHandler
implements IQueryHandler<GetMyCalendarFeedQuery, CalendarFeed | null>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
execute(query: GetMyCalendarFeedQuery): Promise<CalendarFeed | null> {
return this.feeds.findByUser(query.userId);
}
}
/**
* What the person has to keep an eye on, as all-day events: their unfinished
* tickets with a due date (assigned or followed), the planned and running
* sprints of their projects and their planned releases with a date. Read at
* every download, so it is as fresh as the calendar app asks.
*/
@QueryHandler(GetCalendarIcsQuery)
export class GetCalendarIcsQueryHandler
implements IQueryHandler<GetCalendarIcsQuery, string | null>
{
constructor(
private readonly feeds: CalendarFeedsRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly users: UserDirectory,
) {}
async execute(query: GetCalendarIcsQuery): Promise<string | null> {
const feed = await this.feeds.findByToken(query.token);
if (!feed) return null;
const projects = await this.findProjects.findByMember(feed.userId);
const byId = new Map(projects.map((p) => [p.id, p]));
const ids = projects.map((p) => p.id);
const [issues, sprints, releases, me] = await Promise.all([
this.findIssues.findDatedFor(feed.userId, ids),
Promise.all(ids.map((id) => this.findSprints.findByProject(id))),
Promise.all(ids.map((id) => this.findReleases.findByProject(id))),
this.users.findById(feed.userId),
]);
const link = linkTo(query.webUrl);
const entries: CalendarEntry[] = [
...issues.flatMap((issue) => {
const project = byId.get(issue.projectId);
return project ? [issueEntry(issue, project, link)] : [];
}),
...sprints
.flat()
.filter(
(s) =>
s.status === SprintStatus.PLANNED ||
s.status === SprintStatus.ACTIVE,
)
.map((s) => ({
uid: `sprint-${s.id}@taskstream`,
start: s.startDate,
end: s.endDate,
summary: `${s.name} · ${byId.get(s.projectId)?.key ?? ''}`.trim(),
description: s.goal ?? undefined,
url: link(`/p/${byId.get(s.projectId)?.key}/sprints`),
})),
...releases
.flat()
.filter((r) => r.status === ReleaseStatus.PLANNED && r.targetDate)
.map((r) => ({
uid: `release-${r.id}@taskstream`,
start: r.targetDate as string,
summary:
`Release ${r.name} · ${byId.get(r.projectId)?.key ?? ''}`.trim(),
description: r.description ?? undefined,
url: link(`/p/${byId.get(r.projectId)?.key}/releases`),
})),
];
return buildIcs(me ? `TaskStream · ${me.name}` : 'TaskStream', entries);
}
}
function issueEntry(
issue: IssueReadModel,
project: ProjectReadModel,
link: (path: string) => string | null,
): CalendarEntry {
const status =
project.settings?.statuses.find((s) => s.id === issue.status)?.name ??
issue.status;
return {
uid: `issue-${issue.id}@taskstream`,
start: issue.dueDate as string,
summary: `${issue.key} · ${issue.title}`,
description: `${project.name} · ${status}`,
url: link(`/p/${project.key}/issues/${issue.key}`),
};
}
function linkTo(webUrl: string): (path: string) => string | null {
const base = webUrl.replace(/\/+$/, '');
return (path) => (base ? `${base}${path}` : null);
}
export const CALENDAR_HANDLERS = [
CreateCalendarFeedCommandHandler,
RevokeCalendarFeedCommandHandler,
GetMyCalendarFeedQueryHandler,
GetCalendarIcsQueryHandler,
];

View File

@@ -0,0 +1,62 @@
import { buildIcs } from './ics';
const NOW = new Date('2026-10-14T09:30:00.000Z');
describe('buildIcs', () => {
it('writes all-day events with an exclusive end', () => {
const ics = buildIcs(
'TaskStream',
[
{
uid: 'issue-1@taskstream',
start: '2026-10-20',
summary: 'WEB-1 · Login',
},
{
uid: 'sprint-1@taskstream',
start: '2026-10-05',
end: '2026-10-19',
summary: 'Sprint 4',
},
],
NOW,
);
expect(ics.startsWith('BEGIN:VCALENDAR\r\nVERSION:2.0\r\n')).toBe(true);
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ics).toContain('DTSTAMP:20261014T093000Z');
expect(ics).toContain(
'DTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021',
);
expect(ics).toContain(
'DTSTART;VALUE=DATE:20261005\r\nDTEND;VALUE=DATE:20261020',
);
expect(ics.split('BEGIN:VEVENT')).toHaveLength(3);
});
it('escapes text and skips what has no date', () => {
const ics = buildIcs(
'Ana; López, QA',
[
{ uid: 'a', start: '2026-10-20', summary: 'Pago; roto, en\nSafari' },
{ uid: 'b', start: '', summary: 'sin fecha' },
],
NOW,
);
expect(ics).toContain('X-WR-CALNAME:Ana\\; López\\, QA');
expect(ics).toContain('SUMMARY:Pago\\; roto\\, en\\nSafari');
expect(ics).not.toContain('sin fecha');
});
it('folds long lines at 75 octets without splitting a character', () => {
const summary = 'Revisión de la exportación á'.repeat(5);
const ics = buildIcs(
'x',
[{ uid: 'u', start: '2026-10-20', summary }],
NOW,
);
for (const line of ics.split('\r\n'))
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);
// Unfolding gives the text back.
expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${summary}`);
});
});

View File

@@ -0,0 +1,97 @@
/** One all-day event of the calendar feed. */
export interface CalendarEntry {
/** Stable across downloads, so calendars update the event, not duplicate it. */
uid: string;
/** `YYYY-MM-DD`. */
start: string;
/** `YYYY-MM-DD`, inclusive; the same day when absent. */
end?: string;
summary: string;
description?: string;
url?: string | null;
}
const DAY = /^\d{4}-\d{2}-\d{2}/;
/**
* An iCalendar (RFC 5545) document of all-day events: what Google Calendar,
* Outlook and Apple Calendar subscribe to. All-day dates carry no time
* zone, so a deadline falls on its day wherever the reader is.
*/
export function buildIcs(
name: string,
entries: CalendarEntry[],
now = new Date(),
): string {
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Pireon//TaskStream//ES',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${text(name)}`,
// How often to look again: calendars mostly decide for themselves.
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
'X-PUBLISHED-TTL:PT1H',
];
for (const entry of entries) {
const start = DAY.exec(entry.start)?.[0];
if (!start) continue;
const last = DAY.exec(entry.end ?? '')?.[0] ?? start;
lines.push(
'BEGIN:VEVENT',
`UID:${text(entry.uid)}`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${compact(start)}`,
// DTEND is exclusive: the day after the last one.
`DTEND;VALUE=DATE:${compact(nextDay(last < start ? start : last))}`,
`SUMMARY:${text(entry.summary)}`,
...(entry.description ? [`DESCRIPTION:${text(entry.description)}`] : []),
...(entry.url ? [`URL:${entry.url}`] : []),
'TRANSP:TRANSPARENT',
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
return lines.map(fold).join('\r\n') + '\r\n';
}
/** TEXT values escape backslashes, semicolons, commas and line breaks. */
function text(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');
}
const compact = (day: string) => day.replace(/-/g, '');
function nextDay(day: string): string {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString().slice(0, 10);
}
/** Lines of more than 75 octets go on in the next one, after a space. */
function fold(line: string): string {
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const parts: string[] = [];
let current = '';
let size = 0;
for (const char of line) {
const length = Buffer.byteLength(char, 'utf8');
// The first line holds 75 octets; the next ones 74 after their space.
if (size + length > (parts.length ? 74 : 75)) {
parts.push(current);
current = '';
size = 0;
}
current += char;
size += length;
}
parts.push(current);
return parts.join('\r\n ');
}

View File

@@ -0,0 +1,20 @@
/** A person's calendar subscription: the secret in its URL. */
export interface CalendarFeed {
userId: string;
/** Random and long; whoever has it reads that person's calendar. */
token: string;
createdAt: string;
}
/**
* One feed per person. The token is kept as it is (not hashed) so the URL
* can be copied again, as calendar apps expect; it only reads dates and
* titles the read model holds anyway.
*/
export abstract class CalendarFeedsRepository {
abstract findByUser(userId: string): Promise<CalendarFeed | null>;
abstract findByToken(token: string): Promise<CalendarFeed | null>;
/** Replaces the person's feed, if any. */
abstract save(feed: CalendarFeed): Promise<void>;
abstract remove(userId: string): Promise<void>;
}

View File

@@ -73,6 +73,14 @@ export abstract class FindIssuesRepository {
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
/**
* Unfinished tickets with a due date, in those projects, assigned to or
* followed by `userId` (their calendar).
*/
abstract findDatedFor(
userId: string,
projectIds: string[],
): Promise<IssueReadModel[]>;
/** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
/** The project's tickets in the trash, newest first. */

View File

@@ -27,3 +27,4 @@ export * from './git-integrations.repository';
export * from './outgoing-webhooks.repository';
export * from './automations.repository';
export * from './rate-limit.store';
export * from './calendar-feeds.repository';

View File

@@ -43,6 +43,8 @@ import { TransferService } from './transfer.service';
import { TRANSFER_HANDLERS } from './transfer/import-export';
import { AutomationsService } from './automations.service';
import { AUTOMATION_HANDLERS } from './automations/automations';
import { CALENDAR_HANDLERS } from './calendar/calendar';
import { CalendarService } from './calendar.service';
import { AutomationsEventHandler } from './automations/automations.event-handler';
/** Notifications: derived from events, kept and dismissed per person. */
@@ -57,6 +59,7 @@ const NOTIFICATIONS = [
];
const SERVICES = [
CalendarService,
ProjectsService,
IssuesService,
CommentsService,
@@ -98,6 +101,7 @@ const SERVICES = [
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,
...AUTOMATION_HANDLERS,
...CALENDAR_HANDLERS,
AutomationsEventHandler,
],
exports: SERVICES,