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:
2026-10-20 20:08:50 +02:00
parent d79ef418d3
commit b88296c22b
37 changed files with 2020 additions and 50 deletions

View File

@@ -43,6 +43,9 @@ export * from './lib/alerts.service';
export * from './lib/follows/tree-follows';
export * from './lib/ports/tree-follows.repository';
export * from './lib/tree-follows.service';
export * from './lib/reminders/issue-reminders';
export * from './lib/ports/issue-reminders.repository';
export * from './lib/issue-reminders.service';
export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -0,0 +1,53 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { IssueReminder } from './ports/issue-reminders.repository';
import {
ClearIssueReminderCommand,
GetIssueReminderQuery,
GetMyRemindersQuery,
SendIssueRemindersCommand,
SetIssueReminderCommand,
} from './reminders/issue-reminders';
/** «Remind me on Thursday»: one's own reminders on tickets. */
@Injectable()
export class IssueRemindersService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
set(
userId: string,
issueId: string,
at: string,
note: string | null,
): Promise<IssueReminder> {
return this.commands.execute(
new SetIssueReminderCommand(userId, issueId, at, note),
);
}
clear(userId: string, issueId: string): Promise<void> {
return this.commands.execute(
new ClearIssueReminderCommand(userId, issueId),
);
}
of(userId: string, issueId: string): Promise<IssueReminder | null> {
return this.queryBus.execute(new GetIssueReminderQuery(userId, issueId));
}
mine(
userId: string,
): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> {
return this.queryBus.execute(new GetMyRemindersQuery(userId));
}
/** Scheduler only. */
sendDue(): Promise<number> {
return this.commands.execute(new SendIssueRemindersCommand());
}
}

View File

@@ -0,0 +1,29 @@
/** «Remind me on Thursday»: one person's, on one ticket. */
export interface IssueReminder {
id: string;
userId: string;
issueId: string;
projectId: string;
/** When to tell (ISO, UTC). */
at: string;
/** What to remember, in one line. */
note: string | null;
createdAt: string;
/** When it was told (null: still waiting). */
sentAt: string | null;
}
export abstract class IssueRemindersRepository {
/** Each person has at most one waiting per ticket: setting it replaces it. */
abstract save(reminder: IssueReminder): Promise<void>;
abstract waitingFor(
userId: string,
issueId: string,
): Promise<IssueReminder | null>;
/** One person's waiting reminders, soonest first. */
abstract waitingOf(userId: string): Promise<IssueReminder[]>;
abstract remove(userId: string, issueId: string): Promise<void>;
/** Waiting ones whose time has come, oldest first. */
abstract due(now: string, limit: number): Promise<IssueReminder[]>;
abstract markSent(id: string, at: string): Promise<void>;
}

View File

@@ -0,0 +1,171 @@
import {
CommandBus,
CqrsModule,
IQueryHandler,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import { NotFoundError, ValidationError } from '@task-stream/api/shared/domain';
import {
IssueReadModel,
NotificationReadModel,
} from '@task-stream/api/tracker/domain';
import { Notifier } from '../notifications/notifier';
import {
IssueReminder,
IssueRemindersRepository,
} from '../ports/issue-reminders.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
import {
GetMyRemindersQuery,
ISSUE_REMINDERS,
SendIssueRemindersCommand,
SetIssueReminderCommand,
} from './issue-reminders';
class InMemoryReminders extends IssueRemindersRepository {
rows: IssueReminder[] = [];
async save(reminder: IssueReminder) {
await this.remove(reminder.userId, reminder.issueId);
this.rows.push(reminder);
}
async waitingFor(userId: string, issueId: string) {
return (
this.rows.find(
(r) => r.userId === userId && r.issueId === issueId && !r.sentAt,
) ?? null
);
}
async waitingOf(userId: string) {
return this.rows
.filter((r) => r.userId === userId && !r.sentAt)
.sort((a, b) => a.at.localeCompare(b.at));
}
async remove(userId: string, issueId: string) {
this.rows = this.rows.filter(
(r) => !(r.userId === userId && r.issueId === issueId && !r.sentAt),
);
}
async due(now: string) {
return this.rows.filter((r) => !r.sentAt && r.at <= now);
}
async markSent(id: string, at: string) {
const row = this.rows.find((r) => r.id === id);
if (row) row.sentAt = at;
}
}
/** Ana reads i1 and i2; i2 goes to the trash in one test. */
const issues = new Map<string, IssueReadModel>();
@QueryHandler(GetIssuesByIdsQuery)
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
async execute(query: GetIssuesByIdsQuery) {
return query.viewerId === 'ana'
? query.ids.flatMap((id) => issues.get(id) ?? [])
: [];
}
}
const inHours = (h: number) =>
new Date(Date.now() + h * 3_600_000).toISOString();
async function setup() {
issues.clear();
for (const id of ['i1', 'i2'])
issues.set(id, {
id,
key: id === 'i1' ? 'WEB-1' : 'WEB-2',
title: 'Pagos',
projectId: 'p1',
projectKey: 'WEB',
trashedAt: null,
} as IssueReadModel);
const rows = new InMemoryReminders();
const sent: NotificationReadModel[] = [];
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...ISSUE_REMINDERS,
FakeIssuesByIds,
{ provide: IssueRemindersRepository, useValue: rows },
{
provide: Notifier,
useValue: {
send: async (n: NotificationReadModel[]) => void sent.push(...n),
},
},
],
}).compile();
await moduleRef.init();
return {
rows,
sent,
commands: moduleRef.get(CommandBus),
queries: moduleRef.get(QueryBus),
};
}
describe('ticket reminders', () => {
it('keeps one per person and ticket, in the future and within a year', async () => {
const { commands, queries } = await setup();
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(2)),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(5), ' Llamar a QA '),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
);
const mine = await queries.execute(new GetMyRemindersQuery('ana'));
expect(mine.map((m: { issue: IssueReadModel }) => m.issue.key)).toEqual([
'WEB-2',
'WEB-1',
]);
expect(mine[1].reminder.note).toBe('Llamar a QA');
await expect(
commands.execute(new SetIssueReminderCommand('ana', 'i1', inHours(-2))),
).rejects.toBeInstanceOf(ValidationError);
await expect(
commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(24 * 400)),
),
).rejects.toBeInstanceOf(ValidationError);
await expect(
commands.execute(new SetIssueReminderCommand('eve', 'i1', inHours(2))),
).rejects.toBeInstanceOf(NotFoundError);
});
it('tells the ones whose time came, once, and forgets those out of reach', async () => {
const { commands, rows, sent } = await setup();
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(1), 'Revisar el PR'),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
);
await commands.execute(
new SetIssueReminderCommand('ana', 'i1', inHours(1)),
);
expect(await commands.execute(new SendIssueRemindersCommand())).toBe(0);
issues.set('i2', {
...(issues.get('i2') as IssueReadModel),
trashedAt: 'x',
});
const later = inHours(2);
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
1,
);
expect(sent.map((n) => [n.userId, n.issueKey, n.summary])).toEqual([
['ana', 'WEB-1', 'Recordatorio: WEB-1 · Pagos'],
]);
expect(rows.rows.every((r) => r.sentAt)).toBe(true);
// Run again: nothing left.
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
0,
);
});
});

View File

@@ -0,0 +1,232 @@
import { randomUUID } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { assertThat, NotFoundError } from '@task-stream/api/shared/domain';
import {
IssueReadModel,
NotificationKind,
SYSTEM_ACTOR_ID,
SYSTEM_ACTOR_NAME,
} from '@task-stream/api/tracker/domain';
import { Notifier } from '../notifications/notifier';
import {
IssueReminder,
IssueRemindersRepository,
} from '../ports/issue-reminders.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* «Remind me on Thursday»: a person's own reminder on a ticket, told as a
* notification (bell, push, e-mail) when its time comes. One waiting per
* person and ticket; private, no history.
*/
/** As far ahead as a reminder goes. */
export const MAX_REMINDER_DAYS = 366;
export const MAX_REMINDER_NOTE = 280;
export class SetIssueReminderCommand extends Command<IssueReminder> {
constructor(
public readonly userId: string,
public readonly issueId: string,
/** ISO date-time. */
public readonly at: string,
public readonly note: string | null = null,
) {
super();
}
}
export class ClearIssueReminderCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
/** Scheduler only: tells the reminders whose time has come. */
export class SendIssueRemindersCommand extends Command<number> {
constructor(public readonly now: string = new Date().toISOString()) {
super();
}
}
export class GetIssueReminderQuery extends Query<IssueReminder | null> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
/** My waiting reminders with their tickets, soonest first. */
export class GetMyRemindersQuery extends Query<
{ reminder: IssueReminder; issue: IssueReadModel }[]
> {
constructor(public readonly userId: string) {
super();
}
}
@CommandHandler(SetIssueReminderCommand)
export class SetIssueReminderCommandHandler
implements ICommandHandler<SetIssueReminderCommand>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: SetIssueReminderCommand): Promise<IssueReminder> {
const at = Date.parse(command.at);
const now = Date.now();
assertThat(!Number.isNaN(at), 'A reminder needs a date and time');
assertThat(at > now - 60_000, 'A reminder must be in the future');
assertThat(
at <= now + MAX_REMINDER_DAYS * 86_400_000,
`A reminder can be at most a year ahead`,
);
const note = command.note?.replace(/\s+/g, ' ').trim() || null;
assertThat(
!note || note.length <= MAX_REMINDER_NOTE,
`A reminder's note takes ${MAX_REMINDER_NOTE} characters at most`,
);
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(command.userId, [command.issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue || issue.trashedAt)
throw new NotFoundError('Issue', command.issueId);
const reminder: IssueReminder = {
id: randomUUID(),
userId: command.userId,
issueId: issue.id,
projectId: issue.projectId,
at: new Date(at).toISOString(),
note,
createdAt: new Date(now).toISOString(),
sentAt: null,
};
await this.reminders.save(reminder);
return reminder;
}
}
@CommandHandler(ClearIssueReminderCommand)
export class ClearIssueReminderCommandHandler
implements ICommandHandler<ClearIssueReminderCommand>
{
constructor(private readonly reminders: IssueRemindersRepository) {}
execute(command: ClearIssueReminderCommand): Promise<void> {
return this.reminders.remove(command.userId, command.issueId);
}
}
@CommandHandler(SendIssueRemindersCommand)
export class SendIssueRemindersCommandHandler
implements ICommandHandler<SendIssueRemindersCommand>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
private readonly notifier: Notifier,
) {}
async execute(command: SendIssueRemindersCommand): Promise<number> {
const due = await this.reminders.due(command.now, 200);
let sent = 0;
for (const reminder of due) {
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(reminder.userId, [reminder.issueId]),
);
// Out of reach or in the trash by now: nothing to remind of.
if (issue && !issue.trashedAt) {
// Its id makes it idempotent: two instances tell it once.
await this.notifier.send([
{
id: `issue-reminder:${reminder.id}`,
userId: reminder.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: reminder.note
? `Recordatorio de ${issue.key}: ${reminder.note}`
: `Recordatorio: ${issue.key} · ${issue.title}`,
readAt: null,
createdAt: command.now,
},
]);
sent++;
}
await this.reminders.markSent(reminder.id, command.now);
}
return sent;
}
}
@QueryHandler(GetIssueReminderQuery)
export class GetIssueReminderQueryHandler
implements IQueryHandler<GetIssueReminderQuery, IssueReminder | null>
{
constructor(private readonly reminders: IssueRemindersRepository) {}
execute(query: GetIssueReminderQuery): Promise<IssueReminder | null> {
return this.reminders.waitingFor(query.userId, query.issueId);
}
}
@QueryHandler(GetMyRemindersQuery)
export class GetMyRemindersQueryHandler
implements
IQueryHandler<
GetMyRemindersQuery,
{ reminder: IssueReminder; issue: IssueReadModel }[]
>
{
constructor(
private readonly reminders: IssueRemindersRepository,
private readonly queryBus: QueryBus,
) {}
async execute(
query: GetMyRemindersQuery,
): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> {
const waiting = await this.reminders.waitingOf(query.userId);
if (!waiting.length) return [];
const issues: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(
query.userId,
waiting.map((r) => r.issueId),
),
);
const byId = new Map(
issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
);
return waiting.flatMap((reminder) => {
const issue = byId.get(reminder.issueId);
return issue ? [{ reminder, issue }] : [];
});
}
}
export const ISSUE_REMINDERS = [
SetIssueReminderCommandHandler,
ClearIssueReminderCommandHandler,
SendIssueRemindersCommandHandler,
GetIssueReminderQueryHandler,
GetMyRemindersQueryHandler,
];

View File

@@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views';
import { PINS } from './saved-filters/pins';
import { ALERT_HANDLERS } from './alerts/alerts';
import { TREE_FOLLOWS } from './follows/tree-follows';
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
import { IssueRemindersService } from './issue-reminders.service';
import { TreeFollowsService } from './tree-follows.service';
import { AlertsService } from './alerts.service';
import { PinsService } from './pins.service';
@@ -87,6 +89,7 @@ const SERVICES = [
PinsService,
AlertsService,
TreeFollowsService,
IssueRemindersService,
SprintsService,
GitService,
WebhooksService,
@@ -120,6 +123,7 @@ const SERVICES = [
...PINS,
...ALERT_HANDLERS,
...TREE_FOLLOWS,
...ISSUE_REMINDERS,
...GIT_HANDLERS,
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
IssueReminder,
IssueRemindersRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
type Stored = IssueReminder & { _id?: unknown };
const strip = ({ _id: _ignored, ...reminder }: Stored): IssueReminder =>
reminder;
@Injectable()
export class MongoIssueRemindersRepository extends IssueRemindersRepository {
constructor(
@InjectModel(ReadModels.IssueReminder)
private readonly reminders: Model<Stored>,
) {
super();
}
async save(reminder: IssueReminder): Promise<void> {
await this.remove(reminder.userId, reminder.issueId);
await this.reminders.create(reminder);
}
async waitingFor(
userId: string,
issueId: string,
): Promise<IssueReminder | null> {
const doc = await this.reminders
.findOne({ userId, issueId, sentAt: null })
.lean<Stored>();
return doc ? strip(doc) : null;
}
async waitingOf(userId: string): Promise<IssueReminder[]> {
const docs = await this.reminders
.find({ userId, sentAt: null })
.sort({ at: 1 })
.lean<Stored[]>();
return docs.map(strip);
}
async remove(userId: string, issueId: string): Promise<void> {
await this.reminders.deleteMany({ userId, issueId, sentAt: null });
}
async due(now: string, limit: number): Promise<IssueReminder[]> {
const docs = await this.reminders
.find({ sentAt: null, at: { $lte: now } })
.sort({ at: 1 })
.limit(limit)
.lean<Stored[]>();
return docs.map(strip);
}
async markSent(id: string, at: string): Promise<void> {
await this.reminders.updateOne({ id }, { $set: { sentAt: at } });
}
}

View File

@@ -110,6 +110,7 @@ export const ReadModels = {
AlertIntegration: 'AlertIntegration',
AlertTrace: 'AlertTrace',
TreeFollow: 'TreeFollow',
IssueReminder: 'IssueReminder',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
@@ -370,6 +371,20 @@ export const TreeFollowSchema =
SchemaFactory.createForClass(TreeFollowDocument);
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'issue_reminders' })
class IssueReminderDocument {
@Prop({ type: String, required: true, unique: true }) id!: string;
@Prop({ type: String }) userId!: string;
@Prop({ type: String }) issueId!: string;
@Prop({ type: String }) at!: string;
@Prop({ type: String, default: null }) sentAt!: string | null;
}
export const IssueReminderSchema = SchemaFactory.createForClass(
IssueReminderDocument,
);
IssueReminderSchema.index({ sentAt: 1, at: 1 });
IssueReminderSchema.index({ userId: 1, issueId: 1 });
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@@ -476,6 +491,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -16,6 +16,7 @@ import {
PinsRepository,
AlertIntegrationsRepository,
TreeFollowsRepository,
IssueRemindersRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
AutomationsRepository,
@@ -84,6 +85,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -166,6 +168,10 @@ const PORTS = [
useClass: MongoAlertIntegrationsRepository,
},
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
{
provide: IssueRemindersRepository,
useClass: MongoIssueRemindersRepository,
},
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,

View File

@@ -19,6 +19,7 @@ import {
IssuesService,
PinsService,
TreeFollowsService,
IssueRemindersService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
@@ -52,6 +53,8 @@ import {
IssueGql,
IssuePageGql,
IssueParentGql,
IssueReminderGql,
ReminderOnIssueGql,
PresenceGql,
} from './types';
@@ -65,6 +68,7 @@ export class IssueResolver {
private readonly presence: PresenceService,
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
) {}
@Mutation(() => [PresenceGql], {
@@ -282,6 +286,17 @@ export class IssueResolver {
return this.commentsService.decisions(viewerOf(me), issue.id);
}
@ResolveField(() => IssueReminderGql, {
nullable: true,
description: 'My waiting reminder on it (null without one or a token)',
})
myReminder(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
) {
return me ? this.reminders.of(me.id, issue.id) : null;
}
@ResolveField(() => Boolean, {
description:
'Whether I follow it with everything under it (false without a token)',
@@ -427,6 +442,35 @@ export class IssueResolver {
return true;
}
@Query(() => [ReminderOnIssueGql], {
description: 'My waiting reminders on tickets, soonest first',
})
myReminders(@CurrentUser() me: User) {
return this.reminders.mine(me.id);
}
@Mutation(() => IssueReminderGql, {
description:
'Remind me of a ticket at a date and time (a notification), with an optional note. One per ticket: it replaces the waiting one',
})
setReminder(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('at', { description: 'ISO date-time, in the future' }) at: string,
@Args('note', { nullable: true }) note: string | null,
) {
return this.reminders.set(me.id, issueId, at, note ?? null);
}
@Mutation(() => Boolean, { description: 'Drop my reminder on a ticket' })
async clearReminder(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.reminders.clear(me.id, issueId);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,

View File

@@ -1377,3 +1377,19 @@ export class AlertIntegrationGql {
typeId!: string | null;
@Field(() => IssuePriority) priority!: IssuePriority;
}
@ObjectType('IssueReminder', {
description: 'My own reminder on a ticket («remind me on Thursday»)',
})
export class IssueReminderGql {
@Field({ description: 'When it tells me (ISO, UTC)' }) at!: string;
@Field(() => String, { nullable: true }) note!: string | null;
}
@ObjectType('ReminderOnIssue', {
description: 'One of my waiting reminders, with its ticket',
})
export class ReminderOnIssueGql {
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
@Field(() => IssueGql) issue!: IssueGql;
}

View File

@@ -1552,6 +1552,21 @@ export class PushEndpointDto {
endpoint!: string;
}
export class SetReminderDto {
@ApiProperty({
format: 'date-time',
description: 'When to tell me; in the future, at most a year ahead',
})
@IsISO8601()
at!: string;
@ApiPropertyOptional({ maxLength: 280, description: 'What to remember' })
@IsOptional()
@IsString()
@MaxLength(280)
note?: string;
}
export class ResolveThreadDto {
@ApiPropertyOptional({
description:

View File

@@ -1417,3 +1417,13 @@ export class AlertOutcomeDto {
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
issueKey!: string | null;
}
export class IssueReminderDto {
@ApiProperty({ format: 'date-time' }) at!: string;
@ApiProperty({ type: String, nullable: true }) note!: string | null;
}
export class ReminderOnIssueDto {
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
}

View File

@@ -34,6 +34,7 @@ import {
IssuesService,
PinsService,
TreeFollowsService,
IssueRemindersService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
@@ -47,6 +48,7 @@ import {
AddCommentDto,
CreateIssueDto,
DecisionsQueryDto,
SetReminderDto,
IssueSearchQueryDto,
PresenceBodyDto,
LinkIssueDto,
@@ -66,6 +68,8 @@ import {
CommentDto,
CommentPageDto,
DecisionDto,
IssueReminderDto,
ReminderOnIssueDto,
EpicDto,
IssueDto,
IssuePageDto,
@@ -101,6 +105,7 @@ export class IssuesController {
private readonly projects: ProjectsService,
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
) {}
@Get('me/home')
@@ -205,6 +210,55 @@ export class IssuesController {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Get('me/reminders')
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
@ApiOkResponse({ type: [ReminderOnIssueDto] })
@ApiReadErrors()
async myReminders(@CurrentUser() me: User) {
const mine = await this.reminders.mine(me.id);
const issues = await this.refs.issueDtos(
mine.map((m) => m.issue),
viewerOf(me),
);
return mine.map((m, i) => ({
reminder: { at: m.reminder.at, note: m.reminder.note },
issue: issues[i],
}));
}
@Put('issues/:issue/reminder')
@ISSUE
@ApiOperation({
summary: 'Remind me of a ticket at a date and time',
description:
'A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.',
})
@ApiOkResponse({ type: IssueReminderDto })
@ApiCommandErrors()
async setReminder(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: SetReminderDto,
) {
const reminder = await this.reminders.set(
me.id,
await this.refs.issueId(me.id, issue),
body.at,
body.note ?? null,
);
return { at: reminder.at, note: reminder.note };
}
@Delete('issues/:issue/reminder')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Drop my reminder on a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async clearReminder(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue));
}
@Put('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE

View File

@@ -0,0 +1,55 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IssueRemindersService } from '@task-stream/api/tracker/application';
const MINUTE = 60_000;
/**
* Tells people's own ticket reminders («remind me on Thursday») every
* minute. Each is told once (its notification id), so several instances
* running this at once only repeat lookups. REMINDERS_ENABLED=false turns it
* off, with the due-date ones.
*/
@Injectable()
export class IssueReminderScheduler
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(IssueReminderScheduler.name);
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(
private readonly reminders: IssueRemindersService,
private readonly config: ConfigService,
) {}
onApplicationBootstrap(): void {
if (String(this.config.get('REMINDERS_ENABLED') ?? 'true') === 'false')
return;
this.timer = setInterval(() => void this.run(), MINUTE);
this.timer.unref?.();
}
onApplicationShutdown(): void {
if (this.timer) clearInterval(this.timer);
}
private async run(): Promise<void> {
// A slow round must not pile up with the next.
if (this.running) return;
this.running = true;
try {
const sent = await this.reminders.sendDue();
if (sent) this.logger.log(`Ticket reminders sent (${sent})`);
} catch (error) {
this.logger.warn(`Ticket reminders failed: ${(error as Error).message}`);
} finally {
this.running = false;
}
}
}

View File

@@ -17,6 +17,7 @@ import { CalendarController } from './http/calendar.controller';
import { CalendarResolver } from './graphql/calendar.resolver';
import { ReminderScheduler } from './schedulers/reminder.scheduler';
import { TrashScheduler } from './schedulers/trash.scheduler';
import { IssueReminderScheduler } from './schedulers/issue-reminder.scheduler';
import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller';
@@ -80,6 +81,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
// Timers: entry points like the HTTP and GraphQL ones.
ReminderScheduler,
TrashScheduler,
IssueReminderScheduler,
ProjectResolver,
IssueResolver,
CommentResolver,

View File

@@ -50,6 +50,12 @@ export const issuePageEvents = eventGroup({
watchRequested: type<{ issueId: string; watching: boolean }>(),
pinRequested: type<{ issueId: string; pinned: boolean }>(),
followTreeRequested: type<{ issueId: string; on: boolean }>(),
reminderRequested: type<{
issueId: string;
/** null: drop it. */
at: string | null;
note: string | null;
}>(),
trashRequested: type<{
issueId: string;
trashed: boolean;

View File

@@ -120,6 +120,17 @@ export class IssueFacade {
);
}
/** Remind me of the ticket then (null: drop it); one per ticket. */
setReminder(at: string | null, note: string | null = null): void {
this.withIssue((issueId) =>
this.dispatch.reminderRequested({
issueId,
at,
note: note?.trim() || null,
}),
);
}
/** Follow the ticket with everything under it (an epic), or stop. */
setFollowingTree(on: boolean): void {
this.withIssue((issueId) =>

View File

@@ -22,6 +22,7 @@ import {
IssueSummary,
SprintsGateway,
TrackerRealtimeGateway,
whenLabel,
} from '@task-stream/web/tracker/domain';
import {
concatMap,
@@ -379,6 +380,26 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.reminderRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
payload.at
? issues.setReminder(
payload.issueId,
payload.at,
payload.note,
)
: issues.clearReminder(payload.issueId),
payload.at
? `Te lo recordaré ${whenLabel(payload.at)}`
: 'Recordatorio quitado',
done,
failed,
),
),
),
events
.on(issuePageEvents.followTreeRequested)
.pipe(

View File

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

View File

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

View 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');
});
});

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

View File

@@ -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.

View File

@@ -52,6 +52,8 @@ import {
WatchIssueDocument,
MyPinsDocument,
FollowTreeDocument,
SetReminderDocument,
ClearReminderDocument,
UnfollowTreeDocument,
SearchDecisionsDocument,
PinIssueDocument,
@@ -220,6 +222,22 @@ export class GraphqlIssuesGateway extends IssuesGateway {
),
);
}
setReminder(
issueId: string,
at: string,
note: string | null,
): Observable<void> {
return this.gql
.mutate(SetReminderDocument, { issueId, at, note })
.pipe(map(() => undefined));
}
clearReminder(issueId: string): Observable<void> {
return this.gql
.mutate(ClearReminderDocument, { issueId })
.pipe(map(() => undefined));
}
followTree(issueId: string, on: boolean): Observable<void> {
const done$: Observable<unknown> = on
? this.gql.mutate(FollowTreeDocument, { issueId })

View File

@@ -122,6 +122,10 @@ fragment IssueFields on Issue {
...IssueSummaryFields
pinned
followingTree
myReminder {
at
note
}
decisions {
...CommentFields
}

View File

@@ -714,6 +714,7 @@ export type IssueFieldsFragment = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
decisions: Array<{
id: string;
issueId: string;
@@ -960,6 +961,7 @@ export type IssueQuery = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
decisions: Array<{
id: string;
issueId: string;
@@ -1153,6 +1155,20 @@ export type MyPinsQuery = {
}>;
};
export type SetReminderMutationVariables = Exact<{
issueId: string;
at: string;
note?: string | null | undefined;
}>;
export type SetReminderMutation = { setReminder: { at: string } };
export type ClearReminderMutationVariables = Exact<{
issueId: string;
}>;
export type ClearReminderMutation = { clearReminder: boolean };
export type FollowTreeMutationVariables = Exact<{
issueId: string;
}>;
@@ -3836,6 +3852,17 @@ export const IssueFieldsFragmentDoc = {
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'myReminder' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -6437,6 +6464,17 @@ export const IssueDocument = {
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'myReminder' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -7469,6 +7507,131 @@ export const MyPinsDocument = {
},
],
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
export const SetReminderDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'SetReminder' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'at' } },
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'note' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'setReminder' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'at' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'at' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'note' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'note' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<SetReminderMutation, SetReminderMutationVariables>;
export const ClearReminderDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'ClearReminder' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'clearReminder' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
ClearReminderMutation,
ClearReminderMutationVariables
>;
export const FollowTreeDocument = {
kind: 'Document',
definitions: [

View File

@@ -101,6 +101,16 @@ query MyPins {
}
}
mutation SetReminder($issueId: ID!, $at: String!, $note: String) {
setReminder(issueId: $issueId, at: $at, note: $note) {
at
}
}
mutation ClearReminder($issueId: ID!) {
clearReminder(issueId: $issueId)
}
mutation FollowTree($issueId: ID!) {
followTree(issueId: $issueId)
}

View File

@@ -768,3 +768,72 @@ a.ts-key {
border-radius: 50%;
background: var(--ts-c-green, #2e9e6a);
}
/* My own reminder on the ticket. */
.ts-reminder {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.625rem;
margin: 0;
padding: 0.5rem 0.875rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent);
font-size: 0.875rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-reminder__action {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-weight: 600;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.ts-reminder__choices {
display: grid;
gap: 0.375rem;
}
.ts-reminder__choice {
display: flex;
justify-content: space-between;
gap: 1rem;
height: auto !important;
min-width: 0 !important;
padding: 0.625rem 0.875rem !important;
border: 1px solid var(--ts-line);
border-radius: 0.5rem;
background: var(--ts-surface);
color: var(--ts-ink);
font: inherit;
text-align: left;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--ts-brand);
}
}
.ts-reminder__field {
display: grid;
gap: 0.375rem;
color: var(--ts-muted);
font-size: 0.8125rem;
.ts-input {
font: inherit;
color: var(--ts-ink);
}
}

View File

@@ -53,7 +53,10 @@ import {
isTyping,
POINT_SCALE,
ReleaseStatus,
ReminderChoice,
reminderChoices,
StatusCategory,
whenLabel,
} from '@task-stream/web/tracker/domain';
import {
clock,
@@ -206,6 +209,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (project.myRole()) {
<pi-icon-button
icon="bell"
color="grey"
appearance="outline"
[text]="issue.myReminder ? 'Recordatorio' : 'Recordármelo'"
[attr.aria-pressed]="!!issue.myReminder"
title="Avisarme de este ticket cuando yo diga"
(execute)="openReminder()"
/>
}
@if (
project.myRole() &&
(facade.children().length || issue.followingTree)
@@ -265,6 +279,30 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
</header>
@if (issue.myReminder; as reminder) {
<p class="ts-reminder" role="status">
<span aria-hidden="true">⏰</span>
Te lo recordaré {{ when(reminder.at) }}
@if (reminder.note) {
· «{{ reminder.note }}»
}
<button
type="button"
class="ts-reminder__action"
(click)="openReminder()"
>
Cambiar
</button>
<button
type="button"
class="ts-reminder__action"
(click)="facade.setReminder(null)"
>
Quitar
</button>
</p>
}
@if (project.canContribute() && issue.allowedTransitions.length) {
<div class="ts-transitions" role="group" aria-label="Mover a">
<span class="ts-eyebrow">Mover a</span>
@@ -1163,6 +1201,58 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-skeleton shape="text" [lines]="8" />
}
<ng-template #reminderForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveReminder(reminderAt())"
novalidate
>
<p class="ts-muted">
Te llegará un aviso (en la campana y, si los tienes activados, en este
dispositivo o por correo). Solo lo ves tú.
</p>
<div class="ts-reminder__choices" role="group" aria-label="Cuándo">
@for (choice of reminderChoices(); track choice.label) {
<button
type="button"
class="ts-reminder__choice"
(click)="saveReminder(choice.at)"
>
<span>{{ choice.label }}</span>
<span class="ts-muted">{{ when(choice.at) }}</span>
</button>
}
</div>
<label class="ts-reminder__field">
Otro momento
<input
class="ts-input"
type="datetime-local"
[min]="nowLocal()"
[value]="reminderAt()"
(input)="reminderAt.set($any($event.target).value)"
/>
</label>
<label class="ts-reminder__field">
Nota (opcional)
<input
class="ts-input"
maxlength="280"
placeholder="Llamar a QA antes de la release"
[value]="reminderNote()"
(input)="reminderNote.set($any($event.target).value)"
/>
</label>
<pi-action-button
size="w100"
color="primary"
text="Recordármelo"
[disabled]="!reminderAt()"
(execute)="saveReminder(reminderAt())"
/>
</form>
</ng-template>
<ng-template #reasonForm>
<form
class="ts-form"
@@ -1264,6 +1354,13 @@ export class IssuePage {
private readonly route = inject(ActivatedRoute);
private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly reminderForm =
viewChild.required<TemplateRef<unknown>>('reminderForm');
/** The reminder being written: `datetime-local` value and note. */
protected readonly reminderAt = signal('');
protected readonly reminderNote = signal('');
protected readonly reminderChoices = signal<ReminderChoice[]>([]);
protected readonly when = whenLabel;
private readonly editForm =
viewChild.required<TemplateRef<unknown>>('editForm');
private readonly commentEditForm =
@@ -1638,6 +1735,28 @@ export class IssuePage {
this.parentKey.set(null);
}
protected openReminder(): void {
const current = this.facade.issue()?.myReminder;
this.reminderChoices.set(reminderChoices());
this.reminderAt.set(current ? toLocalInput(new Date(current.at)) : '');
this.reminderNote.set(current?.note ?? '');
this.drawer.open('Recordármelo', this.reminderForm());
}
/** `at`: ISO, or the `datetime-local` value (local time). */
protected saveReminder(at: string): void {
if (!at) return;
const when = new Date(at);
if (Number.isNaN(when.getTime())) return;
this.facade.setReminder(when.toISOString(), this.reminderNote());
this.drawer.closeAll();
}
/** Now, as the `min` of a `datetime-local` input. */
protected nowLocal(): string {
return toLocalInput(new Date());
}
protected move(to: string): void {
const open = this.openThreads();
if (
@@ -1934,3 +2053,9 @@ export class IssuePage {
this.drawer.closeAll();
}
}
/** A date as a `datetime-local` value (local time, to the minute). */
function toLocalInput(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}