From 71b807b94b691dd4c04f5b7e78b8d26879eec985 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 18:36:19 +0200 Subject: [PATCH] feat: due-date reminders Every hour the API reminds whoever has a ticket, and whoever follows it, the day before its due date, on the day and once when it is overdue, in the app and by e-mail for those who asked. Cards on the board flag what is due soon or late. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 + docker-compose.yml | 3 + docs/01-vision-general.md | 1 + docs/03-api.md | 5 + docs/07-despliegue.md | 2 + .../src/lib/notifications/index.ts | 1 + .../src/lib/notifications/notifier.ts | 18 ++- .../src/lib/notifications/reminders.spec.ts | 43 +++++++ .../src/lib/notifications/reminders.ts | 111 ++++++++++++++++++ .../src/lib/ports/find-issues.repository.ts | 2 + .../src/lib/tracker-application.module.ts | 2 + .../src/lib/mongo/find.repositories.ts | 12 ++ .../notifications/reminder.scheduler.spec.ts | 9 ++ .../lib/notifications/reminder.scheduler.ts | 69 +++++++++++ .../src/lib/tracker-infrastructure.module.ts | 8 +- libs/web/tracker/domain/src/index.ts | 1 + .../tracker/domain/src/lib/models/due.spec.ts | 16 +++ libs/web/tracker/domain/src/lib/models/due.ts | 27 +++++ .../presenters/src/lib/ui/issue-card.ts | 44 ++++++- 19 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/notifications/reminders.spec.ts create mode 100644 libs/api/tracker/application/src/lib/notifications/reminders.ts create mode 100644 libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.spec.ts create mode 100644 libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.ts create mode 100644 libs/web/tracker/domain/src/lib/models/due.spec.ts create mode 100644 libs/web/tracker/domain/src/lib/models/due.ts diff --git a/.env.example b/.env.example index 381c4ef..aff6001 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,10 @@ WEB_URL=http://localhost:4200 # Outgoing webhooks refuse hosts on this machine or its private network unless true. WEBHOOKS_ALLOW_PRIVATE=false +# Due-date reminders (hourly). The day changes at midnight in this time zone. +REMINDERS_ENABLED=true +REMINDERS_TIMEZONE=Europe/Madrid + # Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image). # Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) NEXUS_TOKEN= diff --git a/docker-compose.yml b/docker-compose.yml index 60dc726..9d2c05f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,9 @@ services: WEB_URL: ${WEB_URL:-} # Outgoing webhooks to private addresses (off: they could reach internal services). WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false} + # Due-date reminders, checked every hour. + REMINDERS_ENABLED: ${REMINDERS_ENABLED:-true} + REMINDERS_TIMEZONE: ${REMINDERS_TIMEZONE:-Europe/Madrid} volumes: # Files attached to tickets: lose this volume and the tickets point at nothing. - attachments-data:/data/attachments diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 18e177a..eef56a3 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -26,6 +26,7 @@ | 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | +| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence | | 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | diff --git a/docs/03-api.md b/docs/03-api.md index 94af509..7c9cf52 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -223,6 +223,11 @@ ningún agregado, solo cada uno lee y descarta las suyas. | Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` | | Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` | +**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo +tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en +`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder::::`, así que no se repite aunque +varias instancias lo comprueben a la vez. + Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana (una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»). diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index e7b4f7f..b13ad4d 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -99,6 +99,8 @@ donde los lee Compose. | `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos | | `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) | | `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) | +| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido | +| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios | La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). diff --git a/libs/api/tracker/application/src/lib/notifications/index.ts b/libs/api/tracker/application/src/lib/notifications/index.ts index 4ec238a..d44aa8a 100644 --- a/libs/api/tracker/application/src/lib/notifications/index.ts +++ b/libs/api/tracker/application/src/lib/notifications/index.ts @@ -1,3 +1,4 @@ export * from './notifier'; export * from './notifications.queries'; export * from './notifications.commands'; +export * from './reminders'; diff --git a/libs/api/tracker/application/src/lib/notifications/notifier.ts b/libs/api/tracker/application/src/lib/notifications/notifier.ts index df84199..5c09f8b 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifier.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifier.ts @@ -60,9 +60,8 @@ export class Notifier { ? { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME } : refs.of(event.actorId); - const delivered: NotificationReadModel[] = []; - for (const draft of wanted) { - const notification: NotificationReadModel = { + await this.send( + wanted.map((draft) => ({ id: `${event.aggregateId}:${versionOf(event)}:${draft.userId}`, userId: draft.userId, kind: draft.kind, @@ -71,13 +70,22 @@ export class Notifier { summary: draft.summary, readAt: null, createdAt: event.occurredAt, - }; + })), + ); + } + + /** + * Stores, pushes and e-mails ready-made notifications. Their id makes them + * idempotent: one that is already there is not sent again. + */ + async send(notifications: NotificationReadModel[]): Promise { + const delivered: NotificationReadModel[] = []; + for (const notification of notifications) { // Delivered again (at-least-once): already told, nothing to repeat. if (!(await this.notifications.insert(notification))) continue; await this.changeFeed.publishNotification(notification); delivered.push(notification); } - if (this.mailer.enabled && delivered.length) { await this.email(delivered); } diff --git a/libs/api/tracker/application/src/lib/notifications/reminders.spec.ts b/libs/api/tracker/application/src/lib/notifications/reminders.spec.ts new file mode 100644 index 0000000..5f95a5f --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/reminders.spec.ts @@ -0,0 +1,43 @@ +import { + IssueReadModel, + NotificationKind, +} from '@task-stream/api/tracker/domain'; +import { dueStage, nextDay, remindersFor } from './reminders'; + +const issue = (id: string, dueDate: string, watchers: string[] = []) => + ({ + id, + key: `WEB-${id}`, + title: `T${id}`, + projectId: 'p', + projectKey: 'WEB', + dueDate, + assignee: { id: 'ana', name: 'Ana' }, + watchers: watchers.map((w) => ({ id: w, name: w })), + }) as IssueReadModel; + +describe('due reminders', () => { + it('tells the stage of a due date', () => { + expect(nextDay('2026-12-31')).toBe('2027-01-01'); + expect(dueStage('2026-10-09', '2026-10-08')).toBe('tomorrow'); + expect(dueStage('2026-10-08', '2026-10-08')).toBe('today'); + expect(dueStage('2026-10-01', '2026-10-08')).toBe('overdue'); + expect(dueStage('2026-10-20', '2026-10-08')).toBeNull(); + }); + + it('reminds whoever has it and whoever follows it, once per stage', () => { + const reminders = remindersFor( + [issue('1', '2026-10-08', ['ana', 'bob']), issue('2', '2026-10-30')], + '2026-10-08', + '2026-10-08T07:00:00Z', + ); + expect(reminders.map((r) => [r.userId, r.summary])).toEqual([ + ['ana', 'WEB-1 vence hoy: T1'], + ['bob', 'WEB-1 vence hoy: T1'], + ]); + expect(reminders[0]).toMatchObject({ + kind: NotificationKind.REMINDER, + id: 'reminder:1:2026-10-08:today:ana', + }); + }); +}); diff --git a/libs/api/tracker/application/src/lib/notifications/reminders.ts b/libs/api/tracker/application/src/lib/notifications/reminders.ts new file mode 100644 index 0000000..f04e9d4 --- /dev/null +++ b/libs/api/tracker/application/src/lib/notifications/reminders.ts @@ -0,0 +1,111 @@ +import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { + IssueReadModel, + NotificationKind, + NotificationReadModel, + SYSTEM_ACTOR_ID, + SYSTEM_ACTOR_NAME, +} from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { Notifier } from './notifier'; + +/** Where a due date stands, from the reminder's point of view. */ +export type DueStage = 'tomorrow' | 'today' | 'overdue'; + +const DAY = 86_400_000; + +/** `YYYY-MM-DD` one day after `day`. */ +export function nextDay(day: string): string { + return new Date(Date.parse(`${day}T00:00:00Z`) + DAY) + .toISOString() + .slice(0, 10); +} + +export function dueStage(dueDate: string, today: string): DueStage | null { + const due = dueDate.slice(0, 10); + if (due < today) return 'overdue'; + if (due === today) return 'today'; + return due === nextDay(today) ? 'tomorrow' : null; +} + +const day = (iso: string) => + new Date(`${iso.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', { + day: 'numeric', + month: 'short', + timeZone: 'UTC', + }); + +/** + * One reminder per ticket, due date and stage — the day before, on the day, + * and once when it is overdue — for whoever has it and whoever follows it. + * Ids make it idempotent: running it every hour, on every instance, is fine. + */ +export function remindersFor( + issues: IssueReadModel[], + today: string, + now: string, +): NotificationReadModel[] { + const out: NotificationReadModel[] = []; + for (const issue of issues) { + if (!issue.dueDate) continue; + const stage = dueStage(issue.dueDate, today); + if (!stage) continue; + const summary = + stage === 'tomorrow' + ? `${issue.key} vence mañana: ${issue.title}` + : stage === 'today' + ? `${issue.key} vence hoy: ${issue.title}` + : `${issue.key} venció el ${day(issue.dueDate)} y sigue abierto: ${issue.title}`; + const people = new Set( + [issue.assignee?.id, ...(issue.watchers ?? []).map((w) => w.id)].filter( + (id): id is string => !!id, + ), + ); + for (const userId of people) + out.push({ + id: `reminder:${issue.id}:${issue.dueDate.slice(0, 10)}:${stage}:${userId}`, + userId, + kind: NotificationKind.REMINDER, + projectId: issue.projectId, + projectKey: issue.projectKey, + issueId: issue.id, + issueKey: issue.key, + actor: { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME }, + summary, + readAt: null, + createdAt: now, + }); + } + return out; +} + +/** Scheduler only: tells people about tickets due tomorrow, today or overdue. */ +export class SendDueRemindersCommand extends Command { + constructor( + /** `YYYY-MM-DD` in the installation's time zone. */ + public readonly today: string, + ) { + super(); + } +} + +@CommandHandler(SendDueRemindersCommand) +export class SendDueRemindersCommandHandler + implements ICommandHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly notifier: Notifier, + ) {} + + async execute(command: SendDueRemindersCommand): Promise { + const due = await this.findIssues.findDueBy(nextDay(command.today)); + const reminders = remindersFor( + due, + command.today, + new Date().toISOString(), + ); + await this.notifier.send(reminders); + return reminders.length; + } +} diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index 6a9ed71..9bb90cc 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -64,6 +64,8 @@ export abstract class FindIssuesRepository { abstract stats(projectId: string): Promise; /** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */ abstract findOpenAssignedTo(userId: string): Promise; + /** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */ + abstract findDueBy(day: string): Promise; /** Every ticket of a project (statistics). */ abstract findByProject(projectId: string): Promise; /** Top-level tickets with others hanging from them, by key. */ diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index faba987..8122d98 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -30,6 +30,7 @@ import { } from './notifications/notifications.commands'; import { SAVED_FILTERS } from './saved-filters/saved-filters'; +import { SendDueRemindersCommandHandler } from './notifications/reminders'; import { SavedFiltersService } from './saved-filters.service'; import { SprintsService } from './sprints.service'; import { GitService } from './git.service'; @@ -49,6 +50,7 @@ const NOTIFICATIONS = [ GetMyNotificationSettingsQueryHandler, MarkNotificationsReadCommandHandler, UpdateNotificationSettingsCommandHandler, + SendDueRemindersCommandHandler, ]; const SERVICES = [ diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index a166bba..4db8de0 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -238,6 +238,18 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { return docs.map(issueFromDoc); } + async findDueBy(day: string): Promise { + const docs = await this.issues + .find({ + dueDate: { $ne: null, $lte: `${day}\uffff` }, + statusCategory: { $nin: FINISHED }, + $or: [{ assignee: { $ne: null } }, { 'watchers.0': { $exists: true } }], + }) + .limit(5000) + .lean(); + return docs.map(issueFromDoc); + } + async findByProject(projectId: string): Promise { const docs = await this.issues.find({ projectId }).lean(); return docs.map(issueFromDoc); diff --git a/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.spec.ts b/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.spec.ts new file mode 100644 index 0000000..16c7bf2 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.spec.ts @@ -0,0 +1,9 @@ +import { dayIn } from './reminder.scheduler'; + +describe('reminder scheduler', () => { + it("knows the installation's date, not UTC's", () => { + const lateNight = new Date('2026-10-08T23:30:00Z'); + expect(dayIn(lateNight, 'UTC')).toBe('2026-10-08'); + expect(dayIn(lateNight, 'Europe/Madrid')).toBe('2026-10-09'); + }); +}); diff --git a/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.ts b/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.ts new file mode 100644 index 0000000..a3bd70a --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/notifications/reminder.scheduler.ts @@ -0,0 +1,69 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CommandBus } from '@nestjs/cqrs'; +import { SendDueRemindersCommand } from '@task-stream/api/tracker/application'; + +const HOUR = 3_600_000; + +/** `YYYY-MM-DD` of `date` in `timeZone`. */ +export function dayIn(date: Date, timeZone: string): string { + return new Intl.DateTimeFormat('sv-SE', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +} + +/** + * Sends due-date reminders a minute after start and then every hour, in the + * installation's time zone (REMINDERS_TIMEZONE). Reminders are idempotent, so + * several instances running this at once only repeat lookups. + * REMINDERS_ENABLED=false turns it off. + */ +@Injectable() +export class ReminderScheduler + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private readonly logger = new Logger(ReminderScheduler.name); + private timers: ReturnType[] = []; + + constructor( + private readonly commandBus: CommandBus, + private readonly config: ConfigService, + ) {} + + onApplicationBootstrap(): void { + if (String(this.config.get('REMINDERS_ENABLED') ?? 'true') === 'false') + return; + const first = setTimeout(() => void this.run(), 60_000); + const every = setInterval(() => void this.run(), HOUR); + first.unref?.(); + every.unref?.(); + this.timers = [first, every]; + } + + onApplicationShutdown(): void { + this.timers.forEach((timer) => clearTimeout(timer)); + } + + private async run(): Promise { + const timeZone = + this.config.get('REMINDERS_TIMEZONE') || 'Europe/Madrid'; + try { + const sent: number = await this.commandBus.execute( + new SendDueRemindersCommand(dayIn(new Date(), timeZone)), + ); + if (sent) this.logger.log(`Due-date reminders checked (${sent})`); + } catch (error) { + this.logger.warn( + `Due-date reminders failed: ${(error as Error).message}`, + ); + } + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index f28e0d2..1fa7367 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -53,6 +53,7 @@ import { } from './mongo/upsert-materialized.repositories'; import { StatusCategoryBackfill } from './mongo/status-category.backfill'; import { RankBackfill } from './mongo/rank.backfill'; +import { ReminderScheduler } from './notifications/reminder.scheduler'; import { LOOKUP_ENTITIES } from './postgres/lookup.entities'; import { PostgresIssueNumbersRepository, @@ -153,7 +154,12 @@ const PORTS = [ TypeOrmModule.forFeature(LOOKUP_ENTITIES), MongooseModule.forFeature(READ_MODEL_SCHEMAS), ], - providers: [...PORTS, StatusCategoryBackfill, RankBackfill], + providers: [ + ...PORTS, + StatusCategoryBackfill, + RankBackfill, + ReminderScheduler, + ], exports: PORTS.map((port) => port.provide), }) export class TrackerInfrastructureModule {} diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 6febb1b..cb1a175 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -10,3 +10,4 @@ export * from './lib/models/estimation'; export * from './lib/models/swimlanes'; export * from './lib/models/roadmap'; export * from './lib/models/shortcuts'; +export * from './lib/models/due'; diff --git a/libs/web/tracker/domain/src/lib/models/due.spec.ts b/libs/web/tracker/domain/src/lib/models/due.spec.ts new file mode 100644 index 0000000..e4a494d --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/due.spec.ts @@ -0,0 +1,16 @@ +import { StatusCategory } from './enums'; +import { dueState } from './due'; + +describe('due state', () => { + const today = '2026-10-08'; + it('flags late, today and the next two days, never finished tickets', () => { + expect(dueState('2026-10-07', StatusCategory.TODO, today)).toBe('overdue'); + expect(dueState('2026-10-08', StatusCategory.IN_PROGRESS, today)).toBe( + 'today', + ); + expect(dueState('2026-10-10', StatusCategory.QA, today)).toBe('soon'); + expect(dueState('2026-10-11', StatusCategory.TODO, today)).toBeNull(); + expect(dueState('2026-10-01', StatusCategory.DONE, today)).toBeNull(); + expect(dueState(null, StatusCategory.TODO, today)).toBeNull(); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/due.ts b/libs/web/tracker/domain/src/lib/models/due.ts new file mode 100644 index 0000000..d8480dc --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/due.ts @@ -0,0 +1,27 @@ +import { StatusCategory } from './enums'; + +export type DueState = 'overdue' | 'today' | 'soon' | null; + +/** `YYYY-MM-DD` of a local date. */ +export function localDay(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +/** + * How urgent a due date looks: overdue, today, or within the next two days. + * Finished tickets are never late. + */ +export function dueState( + dueDate: string | null, + category: StatusCategory, + today = localDay(new Date()), +): DueState { + if (!dueDate) return null; + if (category === StatusCategory.DONE || category === StatusCategory.CANCELLED) + return null; + const due = dueDate.slice(0, 10); + if (due < today) return 'overdue'; + if (due === today) return 'today'; + const days = (Date.parse(due) - Date.parse(today)) / 86_400_000; + return days <= 2 ? 'soon' : null; +} diff --git a/libs/web/tracker/presenters/src/lib/ui/issue-card.ts b/libs/web/tracker/presenters/src/lib/ui/issue-card.ts index 3ac20cd..f7b3519 100644 --- a/libs/web/tracker/presenters/src/lib/ui/issue-card.ts +++ b/libs/web/tracker/presenters/src/lib/ui/issue-card.ts @@ -6,7 +6,12 @@ import { } from '@angular/core'; import { RouterLink } from '@angular/router'; import { PiIconComponent } from '@pi/ui'; -import { IssueSummary, Workflow } from '@task-stream/web/tracker/domain'; +import { + DueState, + dueState, + IssueSummary, + Workflow, +} from '@task-stream/web/tracker/domain'; import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; /** A ticket on the board / in lists: type stripe, key, title, signals. */ @@ -45,6 +50,14 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; >↑ {{ parent.key }} } + @if (due(); as state) { + {{ dueLabel(state) }} + } @if (issue().storyPoints !== null) { {{ issue().storyPoints @@ -78,6 +91,25 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; :host { display: block; } + .ts-card__due { + padding: 0 0.375rem; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + background: color-mix( + in srgb, + var(--ts-c-amber, #d97706) 18%, + transparent + ); + &[data-due='overdue'] { + background: color-mix( + in srgb, + var(--ts-c-red, #dc2626) 18%, + transparent + ); + color: var(--ts-c-red, inherit); + } + } .ts-card__points { display: inline-grid; place-items: center; @@ -227,6 +259,16 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; `, }) export class IssueCardComponent { + /** Late, due today or in the next two days. */ + protected readonly due = computed(() => + dueState(this.issue().dueDate, this.issue().statusCategory), + ); + protected dueLabel(state: DueState): string { + if (state === 'overdue') return 'Vencido'; + if (state === 'today') return 'Vence hoy'; + const day = new Date(`${this.issue().dueDate?.slice(0, 10)}T00:00:00`); + return day.toLocaleDateString('es', { day: 'numeric', month: 'short' }); + } readonly issue = input.required(); /** The project's settings: names, icons and colours of types and areas. */ readonly workflow = input.required();