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:
@@ -380,6 +380,114 @@
|
|||||||
"security": []
|
"security": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/calendar/{file}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Unfinished tickets with a due date (assigned or followed), planned and running sprints, and planned releases with a date, as all-day events.",
|
||||||
|
"operationId": "CalendarController_ics",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "file",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "A calendar to subscribe to (`{token}.ics`, no session)",
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
],
|
||||||
|
"security": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/me/calendar": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "CalendarController_mine",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CalendarFeedDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"204": {
|
||||||
|
"description": "I have none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "My calendar URL, if I made one",
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "CalendarController_create",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CalendarFeedDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "A new secret calendar URL (the previous one stops working)",
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "CalendarController_revoke",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "My calendar URL stops working",
|
||||||
|
"tags": [
|
||||||
|
"Users"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/projects": {
|
"/api/projects": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ProjectsController_mine",
|
"operationId": "ProjectsController_mine",
|
||||||
@@ -9967,6 +10075,22 @@
|
|||||||
"isActive"
|
"isActive"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"CalendarFeedDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)"
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"token",
|
||||||
|
"createdAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UserRefDto": {
|
"UserRefDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -158,6 +158,15 @@ input BulkUpdateIssuesInput {
|
|||||||
unassign: Boolean
|
unassign: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CalendarFeed {
|
||||||
|
createdAt: String!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session)
|
||||||
|
"""
|
||||||
|
token: String!
|
||||||
|
}
|
||||||
|
|
||||||
input ChangeIssueStatusInput {
|
input ChangeIssueStatusInput {
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
|
|
||||||
@@ -677,6 +686,9 @@ type Mutation {
|
|||||||
Add a rule. It acts as TaskStream, within the workflow, and what it does triggers no other rule.
|
Add a rule. It acts as TaskStream, within the workflow, and what it does triggers no other rule.
|
||||||
"""
|
"""
|
||||||
createAutomation(input: CreateAutomationInput!): Automation!
|
createAutomation(input: CreateAutomationInput!): Automation!
|
||||||
|
|
||||||
|
"""A new secret calendar URL; the previous one stops working"""
|
||||||
|
createCalendarFeed: CalendarFeed!
|
||||||
createIssue(input: CreateIssueInput!): CommandResult!
|
createIssue(input: CreateIssueInput!): CommandResult!
|
||||||
createProject(input: CreateProjectInput!): CommandResult!
|
createProject(input: CreateProjectInput!): CommandResult!
|
||||||
createRelease(input: CreateReleaseInput!): CommandResult!
|
createRelease(input: CreateReleaseInput!): CommandResult!
|
||||||
@@ -737,6 +749,9 @@ type Mutation {
|
|||||||
"""Back from the trash"""
|
"""Back from the trash"""
|
||||||
restoreIssue(issueId: ID!): CommandResult!
|
restoreIssue(issueId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""My calendar URL stops working"""
|
||||||
|
revokeCalendarFeed: Boolean!
|
||||||
|
|
||||||
"""A new secret for a repository; the old one stops working"""
|
"""A new secret for a repository; the old one stops working"""
|
||||||
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
|
rotateGitRepositorySecret(projectId: ID!, repositoryId: ID!): GitRepository!
|
||||||
|
|
||||||
@@ -996,6 +1011,9 @@ type Query {
|
|||||||
"""The authenticated user"""
|
"""The authenticated user"""
|
||||||
me: Me!
|
me: Me!
|
||||||
|
|
||||||
|
"""My calendar URL, if I made one"""
|
||||||
|
myCalendarFeed: CalendarFeed
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -132,6 +132,45 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/calendar/{file}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
get: operations["CalendarController_ics"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/me/calendar": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** My calendar URL, if I made one */
|
||||||
|
get: operations["CalendarController_mine"];
|
||||||
|
put?: never;
|
||||||
|
/** A new secret calendar URL (the previous one stops working) */
|
||||||
|
post: operations["CalendarController_create"];
|
||||||
|
/** My calendar URL stops working */
|
||||||
|
delete: operations["CalendarController_revoke"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/projects": {
|
"/api/projects": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1537,6 +1576,11 @@ export interface components {
|
|||||||
email: string;
|
email: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
CalendarFeedDto: {
|
||||||
|
/** @description Secret of the calendar URL: GET /api/calendar/{token}.ics (iCalendar, no session) */
|
||||||
|
token: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
UserRefDto: {
|
UserRefDto: {
|
||||||
/** @description Pireon user id */
|
/** @description Pireon user id */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -3032,6 +3076,87 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
CalendarController_ics: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
CalendarController_mine: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CalendarFeedDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I have none */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
CalendarController_create: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CalendarFeedDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
CalendarController_revoke: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
ProjectsController_mine: {
|
ProjectsController_mine: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -256,6 +256,21 @@ secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (n
|
|||||||
y se borran con el proyecto.
|
y se borran con el proyecto.
|
||||||
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
||||||
|
|
||||||
|
### Calendario (iCalendar)
|
||||||
|
|
||||||
|
Cada persona puede suscribir sus fechas en Google Calendar, Outlook o Apple Calendar con una URL secreta:
|
||||||
|
`GET /api/calendar/{token}.ics` (sin sesión: las aplicaciones de calendario no pueden iniciarla). Trae, como eventos de
|
||||||
|
día completo:
|
||||||
|
|
||||||
|
- sus tickets sin terminar con fecha límite, asignados o que sigue;
|
||||||
|
- los sprints planificados o en curso de sus proyectos;
|
||||||
|
- las releases planificadas con fecha objetivo.
|
||||||
|
|
||||||
|
Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea, renueva o retira con `GET`, `POST` y
|
||||||
|
`DELETE /api/me/calendar` (GraphQL: `myCalendarFeed`, `createCalendarFeed`, `revokeCalendarFeed`); renovarlo deja sin
|
||||||
|
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
|
||||||
|
fechas y títulos que ya están en el read model.
|
||||||
|
|
||||||
### Notificaciones
|
### Notificaciones
|
||||||
|
|
||||||
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
||||||
|
|||||||
@@ -127,6 +127,12 @@ hijas de `/p/:key` reciben la misma `ProjectFacade`.
|
|||||||
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
|
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
|
||||||
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
||||||
|
|
||||||
|
### Calendario
|
||||||
|
|
||||||
|
«Mi trabajo» tiene un botón «Calendario» que abre `ts-calendar-feed`: crea la URL secreta, la copia, la renueva o la
|
||||||
|
desactiva (`CalendarFacade`, sin store). La URL es absoluta, compuesta con `restUrl` y el origen de la página, porque es
|
||||||
|
lo que se pega en la aplicación de calendario.
|
||||||
|
|
||||||
### Texto con formato
|
### Texto con formato
|
||||||
|
|
||||||
Descripciones y comentarios se escriben con `ts-rich-editor`, un editor visual sobre
|
Descripciones y comentarios se escriben con `ts-rich-editor`, un editor visual sobre
|
||||||
|
|||||||
@@ -32,14 +32,11 @@ cuando hay pistas, por dónde empezar.
|
|||||||
|
|
||||||
En este orden:
|
En este orden:
|
||||||
|
|
||||||
1. **Calendario suscribible (ICS)**: una URL privada por persona con las fechas límite, los sprints y las releases de
|
1. **Notificaciones push (Web Push)**: los avisos de la campana como aviso del sistema, aunque la pestaña esté
|
||||||
sus proyectos, para Google Calendar, Outlook o Apple Calendar. La URL lleva un token propio (revocable), no el de
|
|
||||||
sesión.
|
|
||||||
2. **Notificaciones push (Web Push)**: los avisos de la campana como aviso del sistema, aunque la pestaña esté
|
|
||||||
cerrada. Un _service worker_ en la web (que la deja instalable como PWA), «Activar avisos» en los ajustes de
|
cerrada. Un _service worker_ en la web (que la deja instalable como PWA), «Activar avisos» en los ajustes de
|
||||||
notificaciones, las suscripciones de cada persona guardadas en la API y el envío con Web Push (claves VAPID, dos
|
notificaciones, las suscripciones de cada persona guardadas en la API y el envío con Web Push (claves VAPID, dos
|
||||||
variables nuevas). En iPhone solo con la web instalada en la pantalla de inicio (iOS 16.4+).
|
variables nuevas). En iPhone solo con la web instalada en la pantalla de inicio (iOS 16.4+).
|
||||||
3. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de
|
2. **Campos personalizados por proyecto**: «Navegador», «Cliente», «Entorno»… de texto, número, fecha o lista de
|
||||||
opciones, definidos en Configuración; editables en la ficha, filtrables y como columna en la vista de lista.
|
opciones, definidos en Configuración; editables en la ficha, filtrables y como columna en la vista de lista.
|
||||||
|
|
||||||
### Valoradas para después
|
### Valoradas para después
|
||||||
@@ -104,3 +101,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
|
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
|
||||||
- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada
|
- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada
|
||||||
release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente.
|
release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente.
|
||||||
|
- Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta
|
||||||
|
renovable.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -19,6 +19,8 @@ export * from './lib/git.service';
|
|||||||
export * from './lib/webhooks.service';
|
export * from './lib/webhooks.service';
|
||||||
export * from './lib/transfer.service';
|
export * from './lib/transfer.service';
|
||||||
export * from './lib/automations.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/automations';
|
||||||
export * from './lib/automations/automation-rules';
|
export * from './lib/automations/automation-rules';
|
||||||
export * from './lib/transfer/import-export';
|
export * from './lib/transfer/import-export';
|
||||||
|
|||||||
36
libs/api/tracker/application/src/lib/calendar.service.ts
Normal file
36
libs/api/tracker/application/src/lib/calendar.service.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
193
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal file
193
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal 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,
|
||||||
|
];
|
||||||
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal file
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal 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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal file
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal 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 ');
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -73,6 +73,14 @@ export abstract class FindIssuesRepository {
|
|||||||
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
||||||
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
||||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
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). */
|
/** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */
|
||||||
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
|
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
|
||||||
/** The project's tickets in the trash, newest first. */
|
/** The project's tickets in the trash, newest first. */
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ export * from './git-integrations.repository';
|
|||||||
export * from './outgoing-webhooks.repository';
|
export * from './outgoing-webhooks.repository';
|
||||||
export * from './automations.repository';
|
export * from './automations.repository';
|
||||||
export * from './rate-limit.store';
|
export * from './rate-limit.store';
|
||||||
|
export * from './calendar-feeds.repository';
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import { TransferService } from './transfer.service';
|
|||||||
import { TRANSFER_HANDLERS } from './transfer/import-export';
|
import { TRANSFER_HANDLERS } from './transfer/import-export';
|
||||||
import { AutomationsService } from './automations.service';
|
import { AutomationsService } from './automations.service';
|
||||||
import { AUTOMATION_HANDLERS } from './automations/automations';
|
import { AUTOMATION_HANDLERS } from './automations/automations';
|
||||||
|
import { CALENDAR_HANDLERS } from './calendar/calendar';
|
||||||
|
import { CalendarService } from './calendar.service';
|
||||||
import { AutomationsEventHandler } from './automations/automations.event-handler';
|
import { AutomationsEventHandler } from './automations/automations.event-handler';
|
||||||
|
|
||||||
/** Notifications: derived from events, kept and dismissed per person. */
|
/** Notifications: derived from events, kept and dismissed per person. */
|
||||||
@@ -57,6 +59,7 @@ const NOTIFICATIONS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
|
CalendarService,
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
IssuesService,
|
IssuesService,
|
||||||
CommentsService,
|
CommentsService,
|
||||||
@@ -98,6 +101,7 @@ const SERVICES = [
|
|||||||
...WEBHOOK_HANDLERS,
|
...WEBHOOK_HANDLERS,
|
||||||
...TRANSFER_HANDLERS,
|
...TRANSFER_HANDLERS,
|
||||||
...AUTOMATION_HANDLERS,
|
...AUTOMATION_HANDLERS,
|
||||||
|
...CALENDAR_HANDLERS,
|
||||||
AutomationsEventHandler,
|
AutomationsEventHandler,
|
||||||
],
|
],
|
||||||
exports: SERVICES,
|
exports: SERVICES,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -266,6 +266,25 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
|||||||
return docs.map(issueFromDoc);
|
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[]> {
|
async findByProject(projectId: string): Promise<IssueReadModel[]> {
|
||||||
const docs = await this.issues
|
const docs = await this.issues
|
||||||
.find({ projectId, trashedAt: null })
|
.find({ projectId, trashedAt: null })
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export interface GitIntegrationDoc {
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
export interface CalendarFeedDoc {
|
||||||
|
/** The user's id. */
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
export interface NotificationSettingsDoc {
|
export interface NotificationSettingsDoc {
|
||||||
/** The user's id. */
|
/** The user's id. */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -85,6 +91,7 @@ export const ReadModels = {
|
|||||||
OutgoingWebhook: 'OutgoingWebhook',
|
OutgoingWebhook: 'OutgoingWebhook',
|
||||||
Automation: 'Automation',
|
Automation: 'Automation',
|
||||||
AutomationRun: 'AutomationRun',
|
AutomationRun: 'AutomationRun',
|
||||||
|
CalendarFeed: 'CalendarFeed',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -329,6 +336,17 @@ class AutomationRunDocument {
|
|||||||
export const AutomationRunSchema = SchemaFactory.createForClass(
|
export const AutomationRunSchema = SchemaFactory.createForClass(
|
||||||
AutomationRunDocument,
|
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.
|
// Redeliveries happen within minutes; a month of memory is plenty.
|
||||||
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });
|
AutomationRunSchema.index({ at: 1 }, { expireAfterSeconds: 30 * 86_400 });
|
||||||
|
|
||||||
@@ -347,6 +365,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||||
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
||||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||||
|
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** A document as the model it holds: Mongo's own `_id` left out. */
|
/** A document as the model it holds: Mongo's own `_id` left out. */
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ChangeFeed,
|
ChangeFeed,
|
||||||
NotificationMailer,
|
NotificationMailer,
|
||||||
NotificationsRepository,
|
NotificationsRepository,
|
||||||
|
CalendarFeedsRepository,
|
||||||
SavedFiltersRepository,
|
SavedFiltersRepository,
|
||||||
GitIntegrationsRepository,
|
GitIntegrationsRepository,
|
||||||
OutgoingWebhooksRepository,
|
OutgoingWebhooksRepository,
|
||||||
@@ -65,6 +66,7 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
|
|||||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||||
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
||||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||||
|
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
|
||||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||||
@@ -130,6 +132,7 @@ const PORTS = [
|
|||||||
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
||||||
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
||||||
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
||||||
|
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
|
||||||
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||||
{
|
{
|
||||||
provide: GitIntegrationsRepository,
|
provide: GitIntegrationsRepository,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -625,6 +625,16 @@ export class MyNotificationsGql {
|
|||||||
unread!: number;
|
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')
|
@ObjectType('NotificationSettings')
|
||||||
export class NotificationSettingsGql {
|
export class NotificationSettingsGql {
|
||||||
@Field({ description: 'Copies by e-mail' }) email!: boolean;
|
@Field({ description: 'Copies by e-mail' }) email!: boolean;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -831,6 +831,15 @@ export class MyNotificationsDto {
|
|||||||
unread!: number;
|
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 {
|
export class NotificationSettingsResponseDto {
|
||||||
@ApiProperty({ description: 'Copies by e-mail' }) email!: boolean;
|
@ApiProperty({ description: 'Copies by e-mail' }) email!: boolean;
|
||||||
@ApiProperty({ description: 'Whether this installation can send e-mail' })
|
@ApiProperty({ description: 'Whether this installation can send e-mail' })
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { CommentsController } from './http/comments.controller';
|
|||||||
import { IssuesController } from './http/issues.controller';
|
import { IssuesController } from './http/issues.controller';
|
||||||
import { ExploreController } from './http/explore.controller';
|
import { ExploreController } from './http/explore.controller';
|
||||||
import { ProposalLimiter } from './http/proposal-limiter';
|
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 { ReminderScheduler } from './schedulers/reminder.scheduler';
|
||||||
import { TrashScheduler } from './schedulers/trash.scheduler';
|
import { TrashScheduler } from './schedulers/trash.scheduler';
|
||||||
import { ProjectsController } from './http/projects.controller';
|
import { ProjectsController } from './http/projects.controller';
|
||||||
@@ -51,6 +53,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
|
CalendarController,
|
||||||
ProjectsController,
|
ProjectsController,
|
||||||
IssuesController,
|
IssuesController,
|
||||||
AttachmentsController,
|
AttachmentsController,
|
||||||
@@ -67,6 +70,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
|||||||
providers: [
|
providers: [
|
||||||
Refs,
|
Refs,
|
||||||
ProposalLimiter,
|
ProposalLimiter,
|
||||||
|
CalendarResolver,
|
||||||
// Timers: entry points like the HTTP and GraphQL ones.
|
// Timers: entry points like the HTTP and GraphQL ones.
|
||||||
ReminderScheduler,
|
ReminderScheduler,
|
||||||
TrashScheduler,
|
TrashScheduler,
|
||||||
|
|||||||
@@ -1,43 +1,12 @@
|
|||||||
/** Internal type. DO NOT USE DIRECTLY. */
|
/** Internal type. DO NOT USE DIRECTLY. */
|
||||||
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||||
/** Internal type. DO NOT USE DIRECTLY. */
|
/** Internal type. DO NOT USE DIRECTLY. */
|
||||||
export type Incremental<T> =
|
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
|
||||||
| T
|
|
||||||
| {
|
|
||||||
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
|
||||||
};
|
|
||||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
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 = {
|
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } };
|
||||||
kind: 'Document',
|
|
||||||
definitions: [
|
|
||||||
{
|
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>;
|
||||||
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>;
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './lib/notifications';
|
export * from './lib/notifications';
|
||||||
export * from './lib/facades/projects.facade';
|
export * from './lib/facades/projects.facade';
|
||||||
|
export * from './lib/facades/calendar.facade';
|
||||||
export * from './lib/facades/project.facade';
|
export * from './lib/facades/project.facade';
|
||||||
export * from './lib/facades/board.facade';
|
export * from './lib/facades/board.facade';
|
||||||
export * from './lib/facades/issue.facade';
|
export * from './lib/facades/issue.facade';
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -429,6 +429,13 @@ export interface Notification {
|
|||||||
createdAt: string;
|
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 {
|
export interface NotificationSettings {
|
||||||
/** Copies by e-mail. */
|
/** Copies by e-mail. */
|
||||||
email: boolean;
|
email: boolean;
|
||||||
|
|||||||
@@ -2,21 +2,16 @@ import { Observable } from 'rxjs';
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Attachment,
|
Attachment,
|
||||||
|
Automation,
|
||||||
|
AutomationInput,
|
||||||
BoardColumn,
|
BoardColumn,
|
||||||
|
CalendarFeed,
|
||||||
|
CommandResult,
|
||||||
Comment,
|
Comment,
|
||||||
Epic,
|
Epic,
|
||||||
GitIntegration,
|
GitIntegration,
|
||||||
GitRepository,
|
GitRepository,
|
||||||
Automation,
|
|
||||||
AutomationInput,
|
|
||||||
ImportReport,
|
ImportReport,
|
||||||
Webhook,
|
|
||||||
WebhookDelivery,
|
|
||||||
SavedFilter,
|
|
||||||
Sprint,
|
|
||||||
SprintBurndown,
|
|
||||||
SavedFilterCriteria,
|
|
||||||
CommandResult,
|
|
||||||
Issue,
|
Issue,
|
||||||
IssuePage,
|
IssuePage,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
@@ -27,7 +22,13 @@ import {
|
|||||||
ProjectStats,
|
ProjectStats,
|
||||||
Release,
|
Release,
|
||||||
ReleaseBurnup,
|
ReleaseBurnup,
|
||||||
|
SavedFilter,
|
||||||
|
SavedFilterCriteria,
|
||||||
|
Sprint,
|
||||||
|
SprintBurndown,
|
||||||
UserDirectoryEntry,
|
UserDirectoryEntry,
|
||||||
|
Webhook,
|
||||||
|
WebhookDelivery,
|
||||||
} from '../models/models';
|
} from '../models/models';
|
||||||
import { ProjectSettings } from '../models/workflow';
|
import { ProjectSettings } from '../models/workflow';
|
||||||
import {
|
import {
|
||||||
@@ -332,6 +333,14 @@ export abstract class UsersGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The signed-in person's notifications. */
|
/** 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 {
|
export abstract class NotificationsGateway {
|
||||||
abstract mine(
|
abstract mine(
|
||||||
unreadOnly: boolean,
|
unreadOnly: boolean,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
query MyCalendarFeed {
|
||||||
|
myCalendarFeed {
|
||||||
|
token
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation CreateCalendarFeed {
|
||||||
|
createCalendarFeed {
|
||||||
|
token
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation RevokeCalendarFeed {
|
||||||
|
revokeCalendarFeed
|
||||||
|
}
|
||||||
@@ -438,6 +438,26 @@ export type DeleteAutomationMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type DeleteAutomationMutation = { deleteAutomation: boolean };
|
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 UserRefFieldsFragment = { id: string; name: string };
|
||||||
|
|
||||||
export type ProjectFieldsFragment = {
|
export type ProjectFieldsFragment = {
|
||||||
@@ -3796,6 +3816,83 @@ export const DeleteAutomationDocument = {
|
|||||||
DeleteAutomationMutation,
|
DeleteAutomationMutation,
|
||||||
DeleteAutomationMutationVariables
|
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 = {
|
export const GitIntegrationDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CommentsGateway,
|
CommentsGateway,
|
||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
NotificationsGateway,
|
NotificationsGateway,
|
||||||
|
CalendarGateway,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
SavedFiltersGateway,
|
SavedFiltersGateway,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from './adapters/graphql-collaboration.gateways';
|
} from './adapters/graphql-collaboration.gateways';
|
||||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||||
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
||||||
|
import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway';
|
||||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||||
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
|
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
|
||||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||||
@@ -51,6 +53,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: ProposalsGateway, useClass: HttpProposalsGateway },
|
{ provide: ProposalsGateway, useClass: HttpProposalsGateway },
|
||||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||||
|
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
|
||||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||||
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
/* Selected rows have a background: kept inside the rounded corners. */
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.ts-backlog__row {
|
.ts-backlog__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import {
|
|||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
TemplateRef,
|
||||||
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
|
import { PiIconComponent, PiSkeletonComponent } from '@pi/ui';
|
||||||
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||||
import {
|
import {
|
||||||
HomeFacade,
|
HomeFacade,
|
||||||
ProjectsFacade,
|
ProjectsFacade,
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
statusLook,
|
statusLook,
|
||||||
typeLook,
|
typeLook,
|
||||||
} from '../ui/copy';
|
} from '../ui/copy';
|
||||||
|
import { CalendarFeedComponent } from '../ui/calendar-feed';
|
||||||
|
|
||||||
const VISIBLE = 12;
|
const VISIBLE = 12;
|
||||||
|
|
||||||
@@ -40,7 +44,12 @@ const VISIBLE = 12;
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-my-work',
|
selector: 'ts-my-work',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [RouterLink, PiIconComponent, PiSkeletonComponent],
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
PiIconComponent,
|
||||||
|
PiSkeletonComponent,
|
||||||
|
CalendarFeedComponent,
|
||||||
|
],
|
||||||
template: `
|
template: `
|
||||||
@if (!home.loaded()) {
|
@if (!home.loaded()) {
|
||||||
<div class="ts-work">
|
<div class="ts-work">
|
||||||
@@ -58,6 +67,14 @@ const VISIBLE = 12;
|
|||||||
<span class="ts-muted ts-mono"
|
<span class="ts-muted ts-mono"
|
||||||
>{{ home.mineTotal() }} abiertos</span
|
>{{ 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>
|
</header>
|
||||||
@if (home.qaFailed()) {
|
@if (home.qaFailed()) {
|
||||||
<p class="ts-work__alert" role="status">
|
<p class="ts-work__alert" role="status">
|
||||||
@@ -228,6 +245,9 @@ const VISIBLE = 12;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<ng-template #calendarPanel>
|
||||||
|
<ts-calendar-feed />
|
||||||
|
</ng-template>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
:host {
|
:host {
|
||||||
@@ -250,14 +270,20 @@ const VISIBLE = 12;
|
|||||||
.ts-work__panel {
|
.ts-work__panel {
|
||||||
padding: 1.375rem 1.5rem 1.25rem;
|
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 {
|
.ts-work__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 0.875rem;
|
margin-bottom: 0.875rem;
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
/* Pushes the count and the calendar link to the right. */
|
||||||
|
margin: 0 auto 0 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -415,6 +441,9 @@ const VISIBLE = 12;
|
|||||||
})
|
})
|
||||||
export class MyWorkComponent implements OnInit {
|
export class MyWorkComponent implements OnInit {
|
||||||
protected readonly home = inject(HomeFacade);
|
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 projects = inject(ProjectsFacade);
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -483,4 +512,8 @@ export class MyWorkComponent implements OnInit {
|
|||||||
release.projectId
|
release.projectId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected openCalendar(): void {
|
||||||
|
this.drawer.open('Tu calendario', this.calendarPanel());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts
Normal file
173
libs/web/tracker/presenters/src/lib/ui/calendar-feed.ts
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user