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:
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -122,6 +122,10 @@ fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
pinned
|
||||
followingTree
|
||||
myReminder {
|
||||
at
|
||||
note
|
||||
}
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user