feat: «Recordármelo», a reminder of a ticket only for me
On a ticket, «Recordármelo» offers in an hour, this evening, tomorrow or
next Monday at 9, in a week, or any date and time, with an optional note.
The ticket shows the waiting one («Te lo recordaré el mié 21 a las 9:00»)
to change or drop. Every minute a scheduler tells the ones whose time
came as a REMINDER notification (bell, push, e-mail), once, and forgets
those whose ticket went to the trash or out of reach. One per person and
ticket, private, no history (Mongo `issue_reminders`).
API: setReminder / clearReminder, Issue.myReminder, myReminders;
PUT/DELETE /api/issues/{issue}/reminder, GET /api/me/reminders. MCP:
remind_me.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
export * from './lib/models/enums';
|
||||
export * from './lib/models/models';
|
||||
export * from './lib/models/bulk';
|
||||
export * from './lib/models/reminders';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
|
||||
@@ -196,6 +196,8 @@ export interface Issue extends IssueSummary {
|
||||
pinned: boolean;
|
||||
/** Whether I follow it with everything under it (an epic). */
|
||||
followingTree: boolean;
|
||||
/** My waiting reminder on it («remind me on Thursday»). */
|
||||
myReminder: IssueReminder | null;
|
||||
/** Its comments marked as decisions, oldest first. */
|
||||
decisions: Comment[];
|
||||
checklist: ChecklistItem[];
|
||||
@@ -206,6 +208,13 @@ export interface Issue extends IssueSummary {
|
||||
}
|
||||
|
||||
/** Someone else on a ticket's page right now. */
|
||||
/** My own reminder on a ticket. */
|
||||
export interface IssueReminder {
|
||||
/** ISO date-time. */
|
||||
at: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** Someone writing on a ticket right now. */
|
||||
export interface Typing {
|
||||
user: UserRef;
|
||||
|
||||
40
libs/web/tracker/domain/src/lib/models/reminders.spec.ts
Normal file
40
libs/web/tracker/domain/src/lib/models/reminders.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { reminderChoices } from './reminders';
|
||||
|
||||
const local = (y: number, m: number, d: number, h: number, min = 0) =>
|
||||
new Date(y, m - 1, d, h, min);
|
||||
|
||||
describe('reminder choices', () => {
|
||||
it('offers this evening only while there is time', () => {
|
||||
// Tuesday 20 October 2026.
|
||||
const morning = reminderChoices(local(2026, 10, 20, 10)).map(
|
||||
(c) => c.label,
|
||||
);
|
||||
expect(morning).toEqual([
|
||||
'Dentro de 1 hora',
|
||||
'Esta tarde, a las 18:00',
|
||||
'Mañana a las 9:00',
|
||||
'El lunes a las 9:00',
|
||||
'Dentro de una semana',
|
||||
]);
|
||||
const late = reminderChoices(local(2026, 10, 20, 19)).map((c) => c.label);
|
||||
expect(late).not.toContain('Esta tarde, a las 18:00');
|
||||
});
|
||||
|
||||
it('lands on the right days and hours, local time', () => {
|
||||
const [, evening, tomorrow, monday, week] = reminderChoices(
|
||||
local(2026, 10, 20, 10, 30),
|
||||
).map((c) => new Date(c.at));
|
||||
expect([evening.getDate(), evening.getHours()]).toEqual([20, 18]);
|
||||
expect([tomorrow.getDate(), tomorrow.getHours()]).toEqual([21, 9]);
|
||||
expect([monday.getDay(), monday.getDate(), monday.getHours()]).toEqual([
|
||||
1, 26, 9,
|
||||
]);
|
||||
expect([week.getDate(), week.getHours()]).toEqual([27, 9]);
|
||||
});
|
||||
|
||||
it('does not offer Monday twice on a Sunday (it is tomorrow)', () => {
|
||||
const sunday = reminderChoices(local(2026, 10, 25, 10)).map((c) => c.label);
|
||||
expect(sunday.filter((l) => l.includes('lunes'))).toEqual([]);
|
||||
expect(sunday).toContain('Mañana a las 9:00');
|
||||
});
|
||||
});
|
||||
48
libs/web/tracker/domain/src/lib/models/reminders.ts
Normal file
48
libs/web/tracker/domain/src/lib/models/reminders.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** A ready-made moment for «remind me…». */
|
||||
export interface ReminderChoice {
|
||||
label: string;
|
||||
/** ISO date-time. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** «el jue 23 a las 9:00», in the browser's time zone. */
|
||||
export function whenLabel(at: string): string {
|
||||
const date = new Date(at);
|
||||
const day = date.toLocaleDateString('es', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
const time = date.toLocaleTimeString('es', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `el ${day} a las ${time}`;
|
||||
}
|
||||
|
||||
/** `now` moved `days` days on, at `hour`:00 (local time). */
|
||||
function at(now: Date, days: number, hour: number): Date {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() + days);
|
||||
date.setHours(hour, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The usual moments, from `now` in local time: in an hour, this evening
|
||||
* (while there is time), tomorrow and next Monday at 9, and in a week.
|
||||
*/
|
||||
export function reminderChoices(now: Date = new Date()): ReminderChoice[] {
|
||||
const choices: { label: string; at: Date }[] = [
|
||||
{ label: 'Dentro de 1 hora', at: new Date(now.getTime() + 3_600_000) },
|
||||
];
|
||||
if (now.getHours() < 17)
|
||||
choices.push({ label: 'Esta tarde, a las 18:00', at: at(now, 0, 18) });
|
||||
choices.push({ label: 'Mañana a las 9:00', at: at(now, 1, 9) });
|
||||
// Monday is 1; from Sunday it is tomorrow, from Monday next week.
|
||||
const toMonday = (8 - now.getDay()) % 7 || 7;
|
||||
if (toMonday > 1)
|
||||
choices.push({ label: 'El lunes a las 9:00', at: at(now, toMonday, 9) });
|
||||
choices.push({ label: 'Dentro de una semana', at: at(now, 7, 9) });
|
||||
return choices.map((c) => ({ label: c.label, at: c.at.toISOString() }));
|
||||
}
|
||||
@@ -150,6 +150,13 @@ export abstract class IssuesGateway {
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/** Follows the ticket with everything under it (or stops). */
|
||||
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
||||
/** Reminds me of the ticket then (replaces the waiting one). */
|
||||
abstract setReminder(
|
||||
issueId: string,
|
||||
at: string,
|
||||
note: string | null,
|
||||
): Observable<void>;
|
||||
abstract clearReminder(issueId: string): Observable<void>;
|
||||
/**
|
||||
* Decisions (comments marked as such) of all my projects by their text,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
|
||||
Reference in New Issue
Block a user