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

@@ -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 },