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,

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
CalendarFeed,
CalendarFeedsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { CalendarFeedDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoCalendarFeedsRepository extends CalendarFeedsRepository {
constructor(
@InjectModel(ReadModels.CalendarFeed)
private readonly feeds: Model<CalendarFeedDoc>,
) {
super();
}
async findByUser(userId: string): Promise<CalendarFeed | null> {
const doc = await this.feeds
.findOne({ id: userId })
.lean<CalendarFeedDoc>();
return doc ? toFeed(doc) : null;
}
async findByToken(token: string): Promise<CalendarFeed | null> {
const doc = await this.feeds.findOne({ token }).lean<CalendarFeedDoc>();
return doc ? toFeed(doc) : null;
}
async save(feed: CalendarFeed): Promise<void> {
await this.feeds.updateOne(
{ id: feed.userId },
{ $set: { token: feed.token, createdAt: feed.createdAt } },
{ upsert: true },
);
}
async remove(userId: string): Promise<void> {
await this.feeds.deleteOne({ id: userId });
}
}
function toFeed(doc: CalendarFeedDoc): CalendarFeed {
return { userId: doc.id, token: doc.token, createdAt: doc.createdAt };
}

View File

@@ -266,6 +266,25 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
return docs.map(issueFromDoc);
}
async findDatedFor(
userId: string,
projectIds: string[],
): Promise<IssueReadModel[]> {
if (projectIds.length === 0) return [];
const docs = await this.issues
.find({
projectId: { $in: projectIds },
dueDate: { $ne: null },
trashedAt: null,
statusCategory: { $nin: FINISHED },
$or: [{ 'assignee.id': userId }, { 'watchers.id': userId }],
})
.sort({ dueDate: 1 })
.limit(1000)
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
async findByProject(projectId: string): Promise<IssueReadModel[]> {
const docs = await this.issues
.find({ projectId, trashedAt: null })

View File

@@ -64,6 +64,12 @@ export interface GitIntegrationDoc {
secret?: string;
createdAt?: string;
}
export interface CalendarFeedDoc {
/** The user's id. */
id: string;
token: string;
createdAt: string;
}
export interface NotificationSettingsDoc {
/** The user's id. */
id: string;
@@ -85,6 +91,7 @@ export const ReadModels = {
OutgoingWebhook: 'OutgoingWebhook',
Automation: 'Automation',
AutomationRun: 'AutomationRun',
CalendarFeed: 'CalendarFeed',
} as const;
/*
@@ -329,6 +336,17 @@ class AutomationRunDocument {
export const AutomationRunSchema = SchemaFactory.createForClass(
AutomationRunDocument,
);
/** One per person: the secret of their calendar URL. */
@Schema({ ...LOOSE, collection: 'calendar_feeds' })
class CalendarFeedDocument {
/** The user's id. */
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String, required: true, unique: true }) token!: string;
@Prop({ type: String }) createdAt!: string;
}
export const CalendarFeedSchema =
SchemaFactory.createForClass(CalendarFeedDocument);
// Redeliveries happen within minutes; a month of memory is plenty.
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });
@@ -347,6 +365,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Notification, schema: NotificationSchema },
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
];
/** A document as the model it holds: Mongo's own `_id` left out. */

View File

@@ -7,6 +7,7 @@ import {
ChangeFeed,
NotificationMailer,
NotificationsRepository,
CalendarFeedsRepository,
SavedFiltersRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
@@ -65,6 +66,7 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
@@ -130,6 +132,7 @@ const PORTS = [
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{
provide: GitIntegrationsRepository,

View File

@@ -0,0 +1,32 @@
import { Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { CalendarService } from '@task-stream/api/tracker/application';
import { CalendarFeedGql } from './types';
/** The signed-in person's calendar subscription (iCalendar URL). */
@Resolver(() => CalendarFeedGql)
export class CalendarResolver {
constructor(private readonly calendar: CalendarService) {}
@Query(() => CalendarFeedGql, {
nullable: true,
description: 'My calendar URL, if I made one',
})
myCalendarFeed(@CurrentUser() me: User) {
return this.calendar.mine(me.id);
}
@Mutation(() => CalendarFeedGql, {
description: 'A new secret calendar URL; the previous one stops working',
})
createCalendarFeed(@CurrentUser() me: User) {
return this.calendar.create(me.id);
}
@Mutation(() => Boolean, { description: 'My calendar URL stops working' })
async revokeCalendarFeed(@CurrentUser() me: User) {
await this.calendar.revoke(me.id);
return true;
}
}

View File

@@ -625,6 +625,16 @@ export class MyNotificationsGql {
unread!: number;
}
@ObjectType('CalendarFeed')
export class CalendarFeedGql {
@Field({
description:
'Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)',
})
token!: string;
@Field() createdAt!: string;
}
@ObjectType('NotificationSettings')
export class NotificationSettingsGql {
@Field({ description: 'Copies by e-mail' }) email!: boolean;

View File

@@ -0,0 +1,96 @@
import {
Controller,
Delete,
Get,
Header,
HttpCode,
NotFoundException,
Param,
Post,
Res,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiProduces,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import { CalendarService } from '@task-stream/api/tracker/application';
import type { Response } from 'express';
import { CalendarFeedDto } from './dto/responses.dto';
/**
* A person's calendar as iCalendar, for Google Calendar, Outlook or Apple
* Calendar to subscribe to. The URL carries a secret token instead of a
* session (calendar apps cannot sign in); the person makes, renews or
* drops it under /me/calendar.
*/
@ApiTags('Users')
@Controller()
export class CalendarController {
constructor(
private readonly calendar: CalendarService,
private readonly config: ConfigService,
) {}
@Get('calendar/:file')
@Public()
@Header('Content-Type', 'text/calendar; charset=utf-8')
@Header('Cache-Control', 'private, max-age=300')
@ApiProduces('text/calendar')
@ApiOperation({
summary: 'A calendar to subscribe to (`{token}.ics`, no session)',
description:
'Unfinished tickets with a due date (assigned or followed), planned and running sprints, and planned releases with a date, as all-day events.',
})
async ics(@Param('file') file: string): Promise<string> {
const token = /^([\w-]{20,100})\.ics$/.exec(file)?.[1];
const body = token
? await this.calendar.ics(token, this.config.get<string>('WEB_URL') ?? '')
: null;
if (body === null) throw new NotFoundException('Unknown calendar');
return body;
}
@Get('me/calendar')
@ApiPireonAuth()
@ApiOperation({ summary: 'My calendar URL, if I made one' })
@ApiOkResponse({ type: CalendarFeedDto })
@ApiNoContentResponse({ description: 'I have none' })
async mine(
@CurrentUser() me: User,
@Res({ passthrough: true }) response: Response,
) {
const feed = await this.calendar.mine(me.id);
if (!feed) response.status(204);
return feed ?? undefined;
}
@Post('me/calendar')
@ApiPireonAuth()
@ApiOperation({
summary: 'A new secret calendar URL (the previous one stops working)',
})
@ApiOkResponse({ type: CalendarFeedDto })
@HttpCode(200)
create(@CurrentUser() me: User) {
return this.calendar.create(me.id);
}
@Delete('me/calendar')
@ApiPireonAuth()
@ApiOperation({ summary: 'My calendar URL stops working' })
@ApiNoContentResponse()
@HttpCode(204)
async revoke(@CurrentUser() me: User): Promise<void> {
await this.calendar.revoke(me.id);
}
}

View File

@@ -831,6 +831,15 @@ export class MyNotificationsDto {
unread!: number;
}
export class CalendarFeedDto {
@ApiProperty({
description:
'Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)',
})
token!: string;
@ApiProperty() createdAt!: string;
}
export class NotificationSettingsResponseDto {
@ApiProperty({ description: 'Copies by e-mail' }) email!: boolean;
@ApiProperty({ description: 'Whether this installation can send e-mail' })

View File

@@ -13,6 +13,8 @@ import { CommentsController } from './http/comments.controller';
import { IssuesController } from './http/issues.controller';
import { ExploreController } from './http/explore.controller';
import { ProposalLimiter } from './http/proposal-limiter';
import { CalendarController } from './http/calendar.controller';
import { CalendarResolver } from './graphql/calendar.resolver';
import { ReminderScheduler } from './schedulers/reminder.scheduler';
import { TrashScheduler } from './schedulers/trash.scheduler';
import { ProjectsController } from './http/projects.controller';
@@ -51,6 +53,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
}),
],
controllers: [
CalendarController,
ProjectsController,
IssuesController,
AttachmentsController,
@@ -67,6 +70,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
providers: [
Refs,
ProposalLimiter,
CalendarResolver,
// Timers: entry points like the HTTP and GraphQL ones.
ReminderScheduler,
TrashScheduler,

View File

@@ -1,43 +1,12 @@
/** Internal type. DO NOT USE DIRECTLY. */
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
/** Internal type. DO NOT USE DIRECTLY. */
export type Incremental<T> =
| T
| {
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
};
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
export type MeQuery = {
me: { id: string; name: string; email: string; appRoles: Array<string> };
};
export const MeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } };
export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode<MeQuery, MeQueryVariables>;

View File

@@ -1,5 +1,6 @@
export * from './lib/notifications';
export * from './lib/facades/projects.facade';
export * from './lib/facades/calendar.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';
export * from './lib/facades/issue.facade';

View File

@@ -0,0 +1,56 @@
import { inject, Injectable, signal } from '@angular/core';
import { CalendarFeed, CalendarGateway } from '@task-stream/web/tracker/domain';
import { Observable } from 'rxjs';
/**
* My calendar subscription for presenters: the secret URL to paste into a
* calendar app, made, renewed or dropped here. Per person and seldom
* touched, so it lives in this facade alone, without a store.
*/
@Injectable({ providedIn: 'root' })
export class CalendarFacade {
private readonly gateway = inject(CalendarGateway);
readonly feed = signal<CalendarFeed | null>(null);
readonly loaded = signal(false);
readonly busy = signal(false);
readonly error = signal<string | null>(null);
load(): void {
this.run(this.gateway.mine());
}
/** A new URL: the previous one, if any, stops working. */
create(): void {
this.run(this.gateway.create());
}
revoke(): void {
this.busy.set(true);
this.gateway.revoke().subscribe({
next: () => this.done(null),
error: () => this.failed(),
});
}
private run(call: Observable<CalendarFeed | null>): void {
this.busy.set(true);
this.error.set(null);
call.subscribe({
next: (feed) => this.done(feed),
error: () => this.failed(),
});
}
private done(feed: CalendarFeed | null): void {
this.feed.set(feed);
this.loaded.set(true);
this.busy.set(false);
}
private failed(): void {
this.loaded.set(true);
this.busy.set(false);
this.error.set('No se ha podido. Inténtalo de nuevo en un momento.');
}
}

View File

@@ -429,6 +429,13 @@ export interface Notification {
createdAt: string;
}
/** My calendar subscription: a secret URL calendar apps read (iCalendar). */
export interface CalendarFeed {
/** Absolute: what goes into Google Calendar, Outlook or Apple Calendar. */
url: string;
createdAt: string;
}
export interface NotificationSettings {
/** Copies by e-mail. */
email: boolean;

View File

@@ -2,21 +2,16 @@ import { Observable } from 'rxjs';
import {
Activity,
Attachment,
Automation,
AutomationInput,
BoardColumn,
CalendarFeed,
CommandResult,
Comment,
Epic,
GitIntegration,
GitRepository,
Automation,
AutomationInput,
ImportReport,
Webhook,
WebhookDelivery,
SavedFilter,
Sprint,
SprintBurndown,
SavedFilterCriteria,
CommandResult,
Issue,
IssuePage,
IssueSummary,
@@ -27,7 +22,13 @@ import {
ProjectStats,
Release,
ReleaseBurnup,
SavedFilter,
SavedFilterCriteria,
Sprint,
SprintBurndown,
UserDirectoryEntry,
Webhook,
WebhookDelivery,
} from '../models/models';
import { ProjectSettings } from '../models/workflow';
import {
@@ -332,6 +333,14 @@ export abstract class UsersGateway {
}
/** The signed-in person's notifications. */
export abstract class CalendarGateway {
/** Mine, if I made one. */
abstract mine(): Observable<CalendarFeed | null>;
/** A new secret URL; the previous one stops working. */
abstract create(): Observable<CalendarFeed>;
abstract revoke(): Observable<void>;
}
export abstract class NotificationsGateway {
abstract mine(
unreadOnly: boolean,

View File

@@ -0,0 +1,48 @@
import { inject, Injectable } from '@angular/core';
import { API_CONFIG } from '@task-stream/web/shared/infrastructure';
import { CalendarFeed, CalendarGateway } from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
CreateCalendarFeedDocument,
MyCalendarFeedDocument,
RevokeCalendarFeedDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlCalendarGateway extends CalendarGateway {
private readonly gql = inject(GraphqlOperations);
private readonly api = inject(API_CONFIG);
mine(): Observable<CalendarFeed | null> {
return this.gql
.query(MyCalendarFeedDocument)
.pipe(
map((d) => (d.myCalendarFeed ? this.toFeed(d.myCalendarFeed) : null)),
);
}
create(): Observable<CalendarFeed> {
return this.gql
.mutate(CreateCalendarFeedDocument, {})
.pipe(map((d) => this.toFeed(d.createCalendarFeed)));
}
revoke(): Observable<void> {
return this.gql
.mutate(RevokeCalendarFeedDocument, {})
.pipe(map(() => undefined));
}
/** The API answers the secret; calendar apps need the whole address. */
private toFeed(feed: { token: string; createdAt: string }): CalendarFeed {
const base = new URL(
this.api.restUrl.replace(/\/+$/, '') + '/',
location.origin,
);
return {
url: new URL(`calendar/${feed.token}.ics`, base).toString(),
createdAt: feed.createdAt,
};
}
}

View File

@@ -0,0 +1,17 @@
query MyCalendarFeed {
myCalendarFeed {
token
createdAt
}
}
mutation CreateCalendarFeed {
createCalendarFeed {
token
createdAt
}
}
mutation RevokeCalendarFeed {
revokeCalendarFeed
}

View File

@@ -438,6 +438,26 @@ export type DeleteAutomationMutationVariables = Exact<{
export type DeleteAutomationMutation = { deleteAutomation: boolean };
export type MyCalendarFeedQueryVariables = Exact<{ [key: string]: never }>;
export type MyCalendarFeedQuery = {
myCalendarFeed: { token: string; createdAt: string } | null;
};
export type CreateCalendarFeedMutationVariables = Exact<{
[key: string]: never;
}>;
export type CreateCalendarFeedMutation = {
createCalendarFeed: { token: string; createdAt: string };
};
export type RevokeCalendarFeedMutationVariables = Exact<{
[key: string]: never;
}>;
export type RevokeCalendarFeedMutation = { revokeCalendarFeed: boolean };
export type UserRefFieldsFragment = { id: string; name: string };
export type ProjectFieldsFragment = {
@@ -3796,6 +3816,83 @@ export const DeleteAutomationDocument = {
DeleteAutomationMutation,
DeleteAutomationMutationVariables
>;
export const MyCalendarFeedDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'MyCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'myCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<MyCalendarFeedQuery, MyCalendarFeedQueryVariables>;
export const CreateCalendarFeedDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'CreateCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'createCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CreateCalendarFeedMutation,
CreateCalendarFeedMutationVariables
>;
export const RevokeCalendarFeedDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RevokeCalendarFeed' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'revokeCalendarFeed' },
},
],
},
},
],
} as unknown as DocumentNode<
RevokeCalendarFeedMutation,
RevokeCalendarFeedMutationVariables
>;
export const GitIntegrationDocument = {
kind: 'Document',
definitions: [

View File

@@ -9,6 +9,7 @@ import {
CommentsGateway,
IssuesGateway,
NotificationsGateway,
CalendarGateway,
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
@@ -28,6 +29,7 @@ import {
} from './adapters/graphql-collaboration.gateways';
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway';
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
@@ -51,6 +53,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: ProposalsGateway, useClass: HttpProposalsGateway },
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },

View File

@@ -178,6 +178,8 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
margin: 0;
padding: 0.25rem 0;
list-style: none;
/* Selected rows have a background: kept inside the rounded corners. */
overflow: hidden;
}
.ts-backlog__row {
display: grid;

View File

@@ -6,9 +6,12 @@ import {
DOCUMENT,
inject,
OnInit,
TemplateRef,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
HomeFacade,
ProjectsFacade,
@@ -28,6 +31,7 @@ import {
statusLook,
typeLook,
} from '../ui/copy';
import { CalendarFeedComponent } from '../ui/calendar-feed';
const VISIBLE = 12;
@@ -40,7 +44,12 @@ const VISIBLE = 12;
@Component({
selector: 'ts-my-work',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent, PiSkeletonComponent],
imports: [
RouterLink,
PiIconComponent,
PiSkeletonComponent,
CalendarFeedComponent,
],
template: `
@if (!home.loaded()) {
<div class="ts-work">
@@ -58,6 +67,14 @@ const VISIBLE = 12;
<span class="ts-muted ts-mono"
>{{ home.mineTotal() }} abiertos</span
>
<button
type="button"
class="ts-plain-link ts-work__calendar"
(click)="openCalendar()"
>
<pi-icon icon="calendar" size="x-small" aria-hidden="true" />
Calendario
</button>
</header>
@if (home.qaFailed()) {
<p class="ts-work__alert" role="status">
@@ -228,6 +245,9 @@ const VISIBLE = 12;
</div>
</div>
}
<ng-template #calendarPanel>
<ts-calendar-feed />
</ng-template>
`,
styles: `
:host {
@@ -250,14 +270,20 @@ const VISIBLE = 12;
.ts-work__panel {
padding: 1.375rem 1.5rem 1.25rem;
}
.ts-work__calendar {
display: inline-flex;
gap: 0.25rem;
align-items: center;
font-size: 0.875rem;
}
.ts-work__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.875rem;
h2 {
margin: 0;
/* Pushes the count and the calendar link to the right. */
margin: 0 auto 0 0;
font-size: 1.125rem;
font-weight: 700;
}
@@ -415,6 +441,9 @@ const VISIBLE = 12;
})
export class MyWorkComponent implements OnInit {
protected readonly home = inject(HomeFacade);
private readonly drawer = inject(DrawerService);
private readonly calendarPanel =
viewChild.required<TemplateRef<unknown>>('calendarPanel');
private readonly projects = inject(ProjectsFacade);
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
@@ -483,4 +512,8 @@ export class MyWorkComponent implements OnInit {
release.projectId
);
}
protected openCalendar(): void {
this.drawer.open('Tu calendario', this.calendarPanel());
}
}

View File

@@ -0,0 +1,173 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
} from '@angular/core';
import { PiActionButtonComponent, PiNoteComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { CalendarFacade } from '@task-stream/web/tracker/application';
/**
* My calendar URL: what to paste into Google Calendar, Outlook or Apple
* Calendar to see my due dates, sprints and releases there. Renewing it
* cuts off whoever had the old one.
*/
@Component({
selector: 'ts-calendar-feed',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiActionButtonComponent, PiNoteComponent],
template: `
<div class="ts-cal">
<p class="ts-muted">
Tus tickets sin terminar con fecha límite (los asignados y los que
sigues), los sprints en marcha o planificados y las releases con fecha
de tus proyectos, en tu calendario. Se actualiza solo, normalmente cada
pocas horas.
</p>
@if (calendar.error(); as error) {
<pi-note
gap="xs"
status="error"
appearance="fill"
type="status"
title="Calendario"
[text]="error"
/>
}
@if (!calendar.loaded()) {
<p class="ts-muted">Cargando…</p>
} @else if (calendar.feed(); as feed) {
<label class="ts-cal__label" for="ts-cal-url"
>Dirección del calendario</label
>
<div class="ts-cal__url">
<input
id="ts-cal-url"
readonly
[value]="feed.url"
(focus)="$any($event.target).select()"
/>
<pi-action-button
size="small"
color="primary"
text="Copiar"
(execute)="copy(feed.url)"
/>
</div>
<p class="ts-cal__warn">
Es privada: quien la tenga ve estas fechas y títulos. Si se ha
compartido de más, genera otra.
</p>
<details class="ts-cal__how">
<summary>Cómo añadirla</summary>
<ul>
<li>
<strong>Google Calendar</strong>: Otros calendarios → «+» → Desde
URL.
</li>
<li>
<strong>Outlook</strong>: Agregar calendario → Suscribirse desde
la Web.
</li>
<li>
<strong>Apple Calendar</strong>: Archivo → Nueva suscripción a
calendario.
</li>
</ul>
</details>
<div class="ts-cal__actions">
<pi-action-button
size="small"
appearance="outline"
color="grey"
text="Generar otra"
[disabled]="calendar.busy()"
(execute)="calendar.create()"
/>
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Desactivar"
[disabled]="calendar.busy()"
(execute)="calendar.revoke()"
/>
</div>
} @else {
<pi-action-button
size="w100"
color="primary"
text="Crear la dirección del calendario"
[disabled]="calendar.busy()"
(execute)="calendar.create()"
/>
}
</div>
`,
styles: `
.ts-cal {
display: grid;
gap: 0.875rem;
p {
margin: 0;
}
}
.ts-cal__label {
font-size: 0.75rem;
color: var(--ts-muted);
}
.ts-cal__url {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: -0.5rem;
input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.625rem;
border: 1px solid var(--ts-line-strong, var(--ts-line));
border-radius: 0.5rem;
background: var(--ts-surface, transparent);
color: inherit;
font: inherit;
font-family: var(--ts-mono, ui-monospace, monospace);
font-size: 0.75rem;
}
}
.ts-cal__warn {
color: var(--ts-muted);
font-size: 0.8125rem;
}
.ts-cal__how {
font-size: 0.875rem;
summary {
cursor: pointer;
}
ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
}
.ts-cal__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
`,
})
export class CalendarFeedComponent implements OnInit {
protected readonly calendar = inject(CalendarFacade);
private readonly toast = inject(ToastService);
ngOnInit(): void {
this.calendar.load();
}
protected copy(url: string): void {
navigator.clipboard
?.writeText(url)
.then(() => this.toast.success('Dirección copiada'))
.catch(() => this.toast.error('No se pudo copiar'));
}
}