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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:36:19 +02:00
parent d6a434ca1d
commit 71b807b94b
19 changed files with 371 additions and 7 deletions

View File

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

View File

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

View File

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

View File

@@ -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 }}</span
>
}
@if (due(); as state) {
<span
class="ts-card__due"
[attr.data-due]="state"
[attr.title]="'Fecha límite: ' + issue().dueDate"
>{{ dueLabel(state) }}</span
>
}
@if (issue().storyPoints !== null) {
<span class="ts-card__points" title="Estimación">{{
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<IssueSummary>();
/** The project's settings: names, icons and colours of types and areas. */
readonly workflow = input.required<Workflow>();