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

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