From abb311f5784af15b7d3cd630b8d52ef8d674a46f Mon Sep 17 00:00:00 2001 From: Gabriel Birman <25272206+gbirman@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:13:52 +0000 Subject: [PATCH 1/5] feat(reminders): make scheduling drafts reliable --- .../reminders/ReminderComposerModal.test.tsx | 167 +++-- .../reminders/ReminderComposerModal.tsx | 122 ++-- .../reminders/ReminderEditorSplit.tsx | 46 +- .../features/reminders/ReminderForm.test.tsx | 296 ++++++++ .../src/features/reminders/ReminderForm.tsx | 651 +++++++++++++----- .../reminders/reminder-composer.test.ts | 4 +- .../features/reminders/reminder-composer.ts | 7 +- .../reminders/reminder-schedule.test.ts | 101 +++ .../features/reminders/reminder-schedule.ts | 151 ++++ apps/web/src/lib/core/util/cron.test.ts | 15 + apps/web/src/lib/core/util/cron.ts | 5 + .../core/util/dateSearch/dateParser.test.ts | 10 + .../lib/core/util/dateSearch/dateParser.ts | 8 +- docs/AGENT_GUIDE/README.md | 1 + docs/AGENT_GUIDE/reminders.md | 51 ++ 15 files changed, 1366 insertions(+), 269 deletions(-) create mode 100644 apps/web/src/features/reminders/ReminderForm.test.tsx create mode 100644 docs/AGENT_GUIDE/reminders.md diff --git a/apps/web/src/features/reminders/ReminderComposerModal.test.tsx b/apps/web/src/features/reminders/ReminderComposerModal.test.tsx index 974905c471b..2b44620c80c 100644 --- a/apps/web/src/features/reminders/ReminderComposerModal.test.tsx +++ b/apps/web/src/features/reminders/ReminderComposerModal.test.tsx @@ -5,7 +5,7 @@ import { screen, waitFor, } from '@solidjs/testing-library'; -import type { ParentProps } from 'solid-js'; +import { createSignal, type ParentProps, Show } from 'solid-js'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { ReminderComposerModal } from './ReminderComposerModal'; import { @@ -16,21 +16,19 @@ import { const mocks = vi.hoisted(() => ({ save: vi.fn(), - pending: false, - failure: vi.fn(), + success: vi.fn(), })); + vi.mock('@queries/reminders/reminders', () => ({ reminderTarget: vi.fn(), - useCreateReminderMutation: () => ({ - mutateAsync: mocks.save, - get isPending() { - return mocks.pending; - }, - }), + useCreateReminderMutation: () => ({ mutateAsync: mocks.save }), })); vi.mock('@queries/soup/cache', () => ({ refetchSoupEntity: vi.fn() })); +vi.mock('../../lib/signals/splitLayout', () => ({ + globalSplitManager: () => undefined, +})); vi.mock('@core/component/Toast/Toast', () => ({ - toast: { success: vi.fn(), failure: mocks.failure }, + toast: { success: mocks.success, failure: vi.fn() }, })); vi.mock('@entity/components/EntitySelectionBadge', () => ({ EntitySelectionBadge: () => null, @@ -47,57 +45,148 @@ vi.mock('@ui', () => { }; }); vi.mock('./ReminderForm', () => ({ - ReminderForm: (props: { onSubmit: (values: unknown) => void }) => ( -
- -
- ), + setDescription(event.currentTarget.value)} + /> + + +
{props.error}
+
+ + ); + }, })); + +function savedReminder() { + return { + id: 'reminder-1', + schedule: { type: 'once', remindAt: '2099-01-01T09:00:00Z' }, + }; +} + beforeEach(() => { mocks.save.mockReset(); - mocks.pending = false; - mocks.failure.mockClear(); + mocks.success.mockReset(); closeReminderComposer(); }); + afterEach(() => { cleanup(); closeReminderComposer(); }); -it('dismisses before saving and calls the captured handler after success', async () => { - let resolve!: (value: object) => void; + +it('keeps the draft open and prevents concurrent duplicate submits', async () => { + let rejectRequest!: (reason: Error) => void; mocks.save.mockReturnValueOnce( - new Promise((done) => { - resolve = done; + new Promise((_resolve, reject) => { + rejectRequest = reject; }) ); const onCreated = vi.fn(); openStandaloneReminderComposer({ onCreated }); render(() => ); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - expect(reminderComposerOpen()).toBe(false); + + const input = screen.getByRole('textbox', { + name: 'Reminder description', + }); + fireEvent.input(input, { target: { value: 'Keep this draft' } }); + input.focus(); + const form = screen.getByRole('form', { name: 'Reminder form' }); + fireEvent.submit(form); + fireEvent.submit(form); + + expect(mocks.save).toHaveBeenCalledOnce(); + expect(reminderComposerOpen()).toBe(true); + expect( + (screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement) + .disabled + ).toBe(true); + expect((input as HTMLInputElement).disabled).toBe(true); + + rejectRequest(new Error('offline')); + const error = await screen.findByRole('alert'); + expect(error.textContent).toContain('Your draft is still here'); + expect(error.textContent).toContain('may already exist'); + expect((input as HTMLInputElement).value).toBe('Keep this draft'); + expect((input as HTMLInputElement).disabled).toBe(false); + expect(document.activeElement).toBe(input); + expect(reminderComposerOpen()).toBe(true); expect(onCreated).not.toHaveBeenCalled(); - resolve({}); - await waitFor(() => expect(onCreated).toHaveBeenCalledOnce()); }); -it('reports a failed save by toast without reopening or calling the handler', async () => { - mocks.save.mockRejectedValueOnce(new Error('offline')); + +it('retries a rejected mutation with the same draft and closes only on success', async () => { + mocks.save + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(savedReminder()); const onCreated = vi.fn(); openStandaloneReminderComposer({ onCreated }); render(() => ); - fireEvent.click(screen.getByRole('button', { name: 'Submit' })); - await waitFor(() => - expect(mocks.failure).toHaveBeenCalledWith('Failed to create reminder') + + const input = screen.getByRole('textbox', { + name: 'Reminder description', + }); + fireEvent.input(input, { target: { value: 'Retry this reminder' } }); + const form = screen.getByRole('form', { name: 'Reminder form' }); + fireEvent.submit(form); + await screen.findByRole('alert'); + + fireEvent.submit(form); + await waitFor(() => expect(reminderComposerOpen()).toBe(false)); + + expect(mocks.save).toHaveBeenCalledTimes(2); + expect(mocks.save.mock.calls[1]?.[0]).toMatchObject({ + description: 'Retry this reminder', + }); + expect(onCreated).toHaveBeenCalledOnce(); + expect(mocks.success).toHaveBeenCalledWith( + expect.stringContaining('Reminder set ·'), + expect.objectContaining({ + actions: [expect.objectContaining({ label: 'View' })], + }) ); - expect(reminderComposerOpen()).toBe(false); +}); + +it('waits for a deferred mutation before closing and running the follow-up', async () => { + let resolveRequest!: (value: ReturnType) => void; + mocks.save.mockReturnValueOnce( + new Promise((resolve) => { + resolveRequest = resolve; + }) + ); + const onCreated = vi.fn(); + openStandaloneReminderComposer({ onCreated }); + render(() => ); + + fireEvent.submit(screen.getByRole('form', { name: 'Reminder form' })); + expect(reminderComposerOpen()).toBe(true); expect(onCreated).not.toHaveBeenCalled(); + + resolveRequest(savedReminder()); + await waitFor(() => expect(reminderComposerOpen()).toBe(false)); + expect(onCreated).toHaveBeenCalledOnce(); }); diff --git a/apps/web/src/features/reminders/ReminderComposerModal.tsx b/apps/web/src/features/reminders/ReminderComposerModal.tsx index c6ff6d6598b..88efa6bef27 100644 --- a/apps/web/src/features/reminders/ReminderComposerModal.tsx +++ b/apps/web/src/features/reminders/ReminderComposerModal.tsx @@ -6,9 +6,12 @@ import { useCreateReminderMutation, } from '@queries/reminders/reminders'; import { refetchSoupEntity } from '@queries/soup/cache'; +import type { CreateReminderRequest } from '@service-storage/generated/schemas/createReminderRequest'; +import type { Reminder } from '@service-storage/generated/schemas/reminder'; import type { ReminderSchedule } from '@service-storage/generated/schemas/reminderSchedule'; import { ActionDialogShell, Dialog } from '@ui'; -import { Show } from 'solid-js'; +import { createSignal, Show } from 'solid-js'; +import { globalSplitManager } from '../../lib/signals/splitLayout'; import { ReminderForm } from './ReminderForm'; import { closeReminderComposer, @@ -17,10 +20,14 @@ import { takeReminderCreatedHandler, } from './reminder-composer'; import { + describeReminderConfirmation, resolveReminderDescription, resolveStandaloneDescription, } from './reminder-schedule'; +const CREATE_FAILURE_MESSAGE = + 'We couldn’t save this reminder. Your draft is still here—try again. If the request timed out, it may already exist; check Reminders before retrying.'; + /** * Creates a reminder — one about an entity, or one about nothing at all — in a * single panel. Editing an existing reminder happens in its own split view @@ -38,6 +45,61 @@ export function ReminderComposerModal() { const entity = () => reminderComposerState.entity; const standalone = () => reminderComposerState.standalone === true; + const [submitting, setSubmitting] = createSignal(false); + const [saveError, setSaveError] = createSignal(); + let focusBeforeSave: HTMLElement | undefined; + + const save = async (args: CreateReminderRequest) => { + if (submitting()) return; + focusBeforeSave = + document.activeElement instanceof HTMLElement + ? document.activeElement + : undefined; + setSubmitting(true); + setSaveError(undefined); + + let reminder: Reminder; + try { + reminder = await createReminder.mutateAsync(args); + } catch { + setSaveError(CREATE_FAILURE_MESSAGE); + setSubmitting(false); + queueMicrotask(() => { + if (focusBeforeSave?.isConnected) focusBeforeSave.focus(); + }); + return; + } + + const onCreated = takeReminderCreatedHandler(); + setSubmitting(false); + closeReminderComposer(); + toast.success( + `Reminder set · ${describeReminderConfirmation(reminder.schedule)}`, + { + actions: [ + { + label: 'View', + onClick: () => + globalSplitManager()?.openWithSplit( + { + type: 'component', + id: `reminder-view~${reminder.id}`, + }, + { activate: true } + ), + }, + ], + } + ); + // This host-owned follow-up runs only after persistence. It is intentionally + // outside the request catch: a downstream row action failing does not mean + // the reminder failed to save and must never invite a duplicate retry. + try { + await onCreated?.(); + } catch { + toast.failure('Reminder saved, but the source could not be updated'); + } + }; const submitCreate = async ( schedule: ReminderSchedule, @@ -46,27 +108,12 @@ export function ReminderComposerModal() { ) => { const resolved = resolveReminderDescription(input, target); const attachTo = reminderTarget(target); - // Taken before the close, which clears it. - const onCreated = takeReminderCreatedHandler(); - closeReminderComposer(); - - try { - await createReminder.mutateAsync({ - description: resolved, - schedule, - // Both or neither: the API rejects one without the other. - ...(attachTo ?? undefined), - }); - toast.success('Reminder set'); - } catch { - toast.failure('Failed to create reminder'); - return; - } - - // Whatever the invoking surface does with its row now that the reminder - // will bring it back — marking it done, in every soup list. Runs only once - // the reminder exists, so a failed create leaves the row alone. - await onCreated?.(); + await save({ + description: resolved, + schedule, + // Both or neither: the API rejects one without the other. + ...(attachTo ?? undefined), + }); }; /** @@ -85,20 +132,7 @@ export function ReminderComposerModal() { // on it rather than a `!` on the value above. if (!resolved) return; - // Taken before the close, which clears it. Nothing passes one today, but - // taking it is what keeps a handler from leaking into the next open. - const onCreated = takeReminderCreatedHandler(); - closeReminderComposer(); - - try { - await createReminder.mutateAsync({ description: resolved, schedule }); - toast.success('Reminder set'); - } catch { - toast.failure('Failed to create reminder'); - return; - } - - await onCreated?.(); + await save({ description: resolved, schedule }); }; const handleSubmit = (values: { @@ -123,10 +157,13 @@ export function ReminderComposerModal() { { - if (!open) closeReminderComposer(); + if (!open && !submitting()) { + setSaveError(undefined); + closeReminderComposer(); + } }} position="center" - class="w-110" + class="w-[calc(100vw-2rem)] max-w-110" > @@ -147,6 +184,9 @@ export function ReminderComposerModal() { } descriptionRequired={standalone()} submitLabel="Set reminder" + autofocus + pending={submitting()} + error={saveError()} reference={ {(target) => ( @@ -156,7 +196,11 @@ export function ReminderComposerModal() { )} } - onCancel={closeReminderComposer} + onCancel={() => { + if (submitting()) return; + setSaveError(undefined); + closeReminderComposer(); + }} onSubmit={(values) => void handleSubmit(values)} /> diff --git a/apps/web/src/features/reminders/ReminderEditorSplit.tsx b/apps/web/src/features/reminders/ReminderEditorSplit.tsx index 5652e7f2323..3a87848c4f3 100644 --- a/apps/web/src/features/reminders/ReminderEditorSplit.tsx +++ b/apps/web/src/features/reminders/ReminderEditorSplit.tsx @@ -14,9 +14,17 @@ import { optimisticUpdateSoupEntity, } from '@queries/soup/cache'; import type { Reminder } from '@service-storage/generated/schemas/reminder'; -import { createMemo, Match, onMount, Show, Switch } from 'solid-js'; +import { + createMemo, + createSignal, + Match, + onMount, + Show, + Switch, +} from 'solid-js'; import { ReminderForm, type ReminderFormValues } from './ReminderForm'; import { + describeReminderConfirmation, reminderEditPatch, resolveEditedDescription, } from './reminder-schedule'; @@ -69,6 +77,8 @@ export function ReminderEditorSplit(props: { reminderId: string }) { onMount(() => panel.handle.setDisplayName('Reminder')); const query = useReminderQuery(() => props.reminderId); + const [updateError, setUpdateError] = createSignal(); + let focusBeforeSave: HTMLElement | undefined; // Soup rows come from the normalized soup cache, not the reminders queries, so // the mutation's own invalidation leaves the row reading its old description @@ -85,11 +95,17 @@ export function ReminderEditorSplit(props: { reminderId: string }) { }); const reference = createMemo(() => { - const reminder = query.data; + const reminder = query.isSuccess ? query.data : undefined; return reminder ? referenceMention(reminder) : undefined; }); const save = async (values: ReminderFormValues, reminder: Reminder) => { + if (updateReminder.isPending) return; + focusBeforeSave = + document.activeElement instanceof HTMLElement + ? document.activeElement + : undefined; + setUpdateError(undefined); const patch = reminderEditPatch( { description: reminder.description, @@ -116,11 +132,21 @@ export function ReminderEditorSplit(props: { reminderId: string }) { return; } try { - await updateReminder.mutateAsync({ id: reminder.id, patch }); - toast.success('Reminder updated'); + const updated = await updateReminder.mutateAsync({ + id: reminder.id, + patch, + }); + toast.success( + `Reminder updated · ${describeReminderConfirmation(updated.schedule)}` + ); panel.handle.close(); } catch { - toast.failure('Failed to update reminder'); + setUpdateError( + 'We couldn’t save these changes. Your edits are still here—try again.' + ); + queueMicrotask(() => { + if (focusBeforeSave?.isConnected) focusBeforeSave.focus(); + }); } }; @@ -128,7 +154,7 @@ export function ReminderEditorSplit(props: { reminderId: string }) {
- + {(reminder) => ( {(ref) => ( @@ -146,12 +173,7 @@ export function ReminderEditorSplit(props: { reminderId: string }) { )} } - revertOnCancel - onCancel={(wasDirty) => { - // Reverting an edit keeps the panel open; a clean cancel - // dismisses the preview. - if (!wasDirty) panel.handle.close(); - }} + onCancel={() => panel.handle.close()} onSubmit={(values) => void save(values, reminder())} /> )} diff --git a/apps/web/src/features/reminders/ReminderForm.test.tsx b/apps/web/src/features/reminders/ReminderForm.test.tsx new file mode 100644 index 00000000000..78d3f8899a7 --- /dev/null +++ b/apps/web/src/features/reminders/ReminderForm.test.tsx @@ -0,0 +1,296 @@ +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ReminderForm, type ReminderFormValues } from './ReminderForm'; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-21T12:00:00.000Z')); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +function renderForm( + overrides: Partial[0]> = {} +) { + const onSubmit = vi.fn<(values: ReminderFormValues) => void>(); + const onCancel = vi.fn(); + render(() => ( + + )); + return { onSubmit, onCancel }; +} + +describe('one-shot scheduling', () => { + it('parses natural date language and previews the exact timezone', () => { + const { onSubmit } = renderForm({ initialDescription: 'Follow up' }); + + fireEvent.input( + screen.getByPlaceholderText('Try “tomorrow 9am” or “in 30 minutes”'), + { target: { value: 'in 30 minutes' } } + ); + + const preview = screen.getByText(/Scheduled:/).closest('p'); + expect(preview?.textContent).toContain('12:30 PM'); + expect(preview?.textContent).toMatch(/\([A-Z]+\)/); + fireEvent.click(screen.getByRole('button', { name: 'Set reminder' })); + + expect(onSubmit).toHaveBeenCalledWith({ + description: 'Follow up', + schedule: { + type: 'once', + remindAt: '2026-09-21T12:30:00.000Z', + }, + }); + }); + + it('offers quick presets with their actual resolved times', () => { + const { onSubmit } = renderForm({ initialDescription: 'Follow up' }); + + const inThirty = screen.getByRole('button', { + name: /In 30m.*12:30 PM/, + }); + const tomorrow = screen.getByRole('button', { + name: /Tomorrow.*Sep 22.*9:00 AM/, + }); + expect(inThirty).not.toBeNull(); + expect(tomorrow).not.toBeNull(); + + fireEvent.click(inThirty); + fireEvent.click(screen.getByRole('button', { name: 'Set reminder' })); + expect(onSubmit.mock.calls[0]?.[0].schedule).toEqual({ + type: 'once', + remindAt: '2026-09-21T12:30:00.000Z', + }); + }); + + it('preserves an overdue schedule for a description-only edit', () => { + const overdue = { + type: 'once' as const, + remindAt: '2026-09-20T09:00:00.000Z', + }; + const { onSubmit } = renderForm({ + initialDescription: 'Old title', + initialSchedule: overdue, + initialRemindAt: overdue.remindAt, + submitLabel: 'Save', + }); + + fireEvent.input(screen.getByLabelText('Reminder description'), { + target: { value: 'New title' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).toHaveBeenCalledWith({ + description: 'New title', + schedule: overdue, + }); + }); + + it('preserves the selected instant when In 30m crosses the fall-back fold', () => { + const originalTimezone = process.env.TZ; + process.env.TZ = 'America/New_York'; + try { + vi.setSystemTime(new Date('2026-11-01T01:45:00-04:00')); + const { onSubmit } = renderForm({ initialDescription: 'Follow up' }); + + fireEvent.click(screen.getByRole('button', { name: /In 30m/ })); + fireEvent.click(screen.getByRole('button', { name: 'Set reminder' })); + + expect(onSubmit.mock.calls[0]?.[0].schedule).toEqual({ + type: 'once', + remindAt: '2026-11-01T06:15:00.000Z', + }); + } finally { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + } + }); + + it('keeps the second fall-back-hour instant on a description-only edit', () => { + const originalTimezone = process.env.TZ; + process.env.TZ = 'America/New_York'; + try { + vi.setSystemTime(new Date('2026-10-31T12:00:00-04:00')); + const secondFold = { + type: 'once' as const, + remindAt: '2026-11-01T06:30:00.000Z', + }; + const { onSubmit } = renderForm({ + initialDescription: 'Before the clocks change', + initialSchedule: secondFold, + initialRemindAt: secondFold.remindAt, + submitLabel: 'Save', + }); + + fireEvent.input(screen.getByLabelText('Reminder description'), { + target: { value: 'After the clocks change' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).toHaveBeenCalledWith({ + description: 'After the clocks change', + schedule: secondFold, + }); + } finally { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + } + }); + + it('rejects a custom local time skipped by spring-forward', () => { + const originalTimezone = process.env.TZ; + process.env.TZ = 'America/New_York'; + try { + vi.setSystemTime(new Date('2026-03-07T12:00:00-05:00')); + const { onSubmit } = renderForm({ initialDescription: 'Follow up' }); + + fireEvent.click(screen.getByRole('button', { name: /Custom/ })); + fireEvent.input(screen.getByLabelText('Custom reminder date'), { + target: { value: '2026-03-08' }, + }); + fireEvent.input(screen.getByLabelText('Custom reminder time'), { + target: { value: '02:30' }, + }); + + expect( + screen.getByText(/local time doesn’t exist because the clocks change/) + ).not.toBeNull(); + const submit = screen.getByRole('button', { name: 'Set reminder' }); + expect((submit as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(submit); + expect(onSubmit).not.toHaveBeenCalled(); + } finally { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + } + }); +}); + +describe('recurrence', () => { + it.each([ + ['Daily', '0 0 9 * * 1,2,3,4,5,6,7'], + ['Weekdays', '0 0 9 * * 2,3,4,5,6'], + ])( + 'round-trips the %s preset through the existing cron model', + (label, cron) => { + const { onSubmit } = renderForm({ initialDescription: 'Standup' }); + + fireEvent.click(screen.getByText('Repeat')); + fireEvent.click(screen.getByRole('button', { name: label })); + fireEvent.click(screen.getByRole('button', { name: 'Set reminder' })); + + expect(onSubmit.mock.calls[0]?.[0].schedule).toMatchObject({ + type: 'recurring', + cron, + }); + } + ); + + it('keeps an unsupported stored cron verbatim on a description-only edit', () => { + const custom = { + type: 'recurring' as const, + cron: '0 */15 9 * * *', + timezone: 'America/New_York', + }; + const { onSubmit } = renderForm({ + initialDescription: 'Custom cadence', + initialSchedule: custom, + initialRemindAt: '2026-09-22T13:00:00.000Z', + submitLabel: 'Save', + }); + + expect(screen.getByText('Custom schedule')).not.toBeNull(); + expect( + screen.getByText(/will stay unchanged unless you choose a replacement/) + ).not.toBeNull(); + fireEvent.input(screen.getByLabelText('Reminder description'), { + target: { value: 'Renamed custom cadence' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).toHaveBeenCalledWith({ + description: 'Renamed custom cadence', + schedule: custom, + }); + }); + + it('seeds Weekly from the selected monthly occurrence', () => { + const { onSubmit } = renderForm({ + initialDescription: 'Monthly review', + initialSchedule: { + type: 'recurring', + cron: '0 30 14 15 * *', + timezone: 'UTC', + }, + initialRemindAt: '2026-10-15T14:30:00.000Z', + submitLabel: 'Save', + }); + + fireEvent.click(screen.getByText('Repeat')); + fireEvent.click(screen.getByRole('button', { name: 'Weekly' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit.mock.calls[0]?.[0].schedule).toEqual({ + type: 'recurring', + cron: '0 30 14 * * 5', + timezone: 'UTC', + }); + }); + + it('seeds Monthly from the selected weekly occurrence', () => { + const { onSubmit } = renderForm({ + initialDescription: 'Weekly review', + initialSchedule: { + type: 'recurring', + cron: '0 0 9 * * 2,3,4,5,6', + timezone: 'UTC', + }, + initialRemindAt: '2026-09-22T09:00:00.000Z', + submitLabel: 'Save', + }); + + fireEvent.click(screen.getByText('Repeat')); + fireEvent.click(screen.getByRole('button', { name: 'Monthly' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit.mock.calls[0]?.[0].schedule).toEqual({ + type: 'recurring', + cron: '0 0 9 22 * *', + timezone: 'UTC', + }); + }); + + it('seeds an explicitly chosen Weekly schedule with one weekday', () => { + const { onSubmit } = renderForm({ initialDescription: 'Weekly review' }); + + fireEvent.click(screen.getByText('Repeat')); + fireEvent.click(screen.getByRole('button', { name: 'Daily' })); + fireEvent.click(screen.getByRole('button', { name: 'Weekly' })); + fireEvent.click(screen.getByRole('button', { name: 'Set reminder' })); + + expect(onSubmit.mock.calls[0]?.[0].schedule).toEqual({ + type: 'recurring', + cron: '0 0 9 * * 3', + timezone: 'UTC', + }); + }); +}); + +it('uses predictable Cancel behavior', () => { + const { onCancel } = renderForm({ initialDescription: 'Follow up' }); + fireEvent.input(screen.getByLabelText('Reminder description'), { + target: { value: 'Unsaved edit' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/features/reminders/ReminderForm.tsx b/apps/web/src/features/reminders/ReminderForm.tsx index 4d6d2061bef..cbc65943124 100644 --- a/apps/web/src/features/reminders/ReminderForm.tsx +++ b/apps/web/src/features/reminders/ReminderForm.tsx @@ -1,17 +1,22 @@ import { toast } from '@core/component/Toast/Toast'; import { type CronParts, + DEFAULT_WEEKDAYS, describeCron, getDefaultTimezone, + isCronRepresentable, isValidCronParts, type ScheduleFrequency, WEEKDAY_OPTIONS, } from '@core/util/cron'; +import { useDateSearch } from '@core/util/dateSearch/useDateSearch'; import { TZDateMini } from '@date-fns/tz'; +import CaretDownIcon from '@phosphor/caret-down.svg'; +import SpinnerIcon from '@phosphor/spinner.svg'; import type { ReminderSchedule } from '@service-storage/generated/schemas/reminderSchedule'; -import { ActionDialogShell, Button, Input, Tabs } from '@ui'; +import { ActionDialogShell, Button, Input } from '@ui'; import { - createEffect, + createMemo, createSignal, createUniqueId, For, @@ -23,11 +28,14 @@ import { } from 'solid-js'; import { Dynamic } from 'solid-js/web'; import { + formatReminderInstant, isRecurring, onceSchedule, + parseLocalReminderDateTime, REMINDER_DEFAULT_TIME, REMINDER_DESCRIPTION_MAX_LENGTH, recurringSchedule, + reminderQuickPresets, repeatPartsFromDate, repeatPartsFromSchedule, } from './reminder-schedule'; @@ -69,24 +77,12 @@ export interface ReminderFormProps { reference?: JSX.Element; submitLabel: string; pending?: boolean; + error?: string; /** Dialog hosts provide their heading and use a padded body with a fixed footer. */ header?: JSX.Element; layout?: 'dialog' | 'inline'; autofocus?: boolean; - /** - * Cancel reverts the fields to what they were seeded with rather than only - * bubbling `onCancel` — for an editor that stays open (the split view), so a - * cancelled edit undoes itself instead of tearing the panel down. - */ - revertOnCancel?: boolean; - /** Notified when the fields drift from (or return to) their seeded values. */ - onDirtyChange?: (dirty: boolean) => void; - /** - * Cancel. `wasDirty` is whether there were unsaved edits when it was clicked: - * with `revertOnCancel`, those edits have already been reverted, so the host - * can keep the panel open on a revert and only dismiss on a clean cancel. - */ - onCancel: (wasDirty: boolean) => void; + onCancel: () => void; onSubmit: (values: ReminderFormValues) => void; } @@ -126,6 +122,16 @@ function atDefault(now: Date): Date { return result; } +const ALL_WEEKDAYS = WEEKDAY_OPTIONS.map((option) => option.value); + +function sameDays(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((day) => b.includes(day)); +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + /** The control values to open with, derived from the reminder (or the defaults). */ function deriveSeed( description: string | undefined, @@ -228,10 +234,27 @@ export function ReminderForm(props: ReminderFormProps) { const isEdit = props.initialSchedule !== undefined; const localZone = getDefaultTimezone(); + const openedAt = new Date(); const [description, setDescription] = createSignal(seed.description); const [repeat, setRepeat] = createSignal(seed.repeat); const [onceDate, setOnceDate] = createSignal(seed.onceDate); const [onceTime, setOnceTime] = createSignal(seed.onceTime); + // Presets and parsed durations carry an instant as well as wall-clock fields. + // Keep it so the repeated hour at DST fall-back does not collapse to the + // browser's first interpretation of an ambiguous `YYYY-MM-DDTHH:MM` value. + // Existing one-shots need the same treatment: their ISO timestamp says + // which copy of a repeated wall time they use, while the native fields do not. + const [selectedOnceInstant, setSelectedOnceInstant] = createSignal< + Date | undefined + >( + isEdit + ? seed.originalSchedule.type === 'once' + ? new Date(seed.originalSchedule.remindAt) + : props.initialRemindAt + ? new Date(props.initialRemindAt) + : undefined + : undefined + ); const [repeatParts, setRepeatParts] = createSignal(seed.parts); // A recurring cron fires at a wall-clock time in this zone. It defaults to the // reminder's stored zone (or the viewer's, for a new recurrence) and is @@ -239,6 +262,23 @@ export function ReminderForm(props: ReminderFormProps) { const [timezone, setTimezone] = createSignal( seed.recurringTimezone ?? localZone ); + const [whenQuery, setWhenQuery] = createSignal(''); + const [showCustomTime, setShowCustomTime] = createSignal(false); + const storedCronIsCustom = + isEdit && + props.initialSchedule !== undefined && + isRecurring(props.initialSchedule) && + !isCronRepresentable(props.initialSchedule.cron); + const [customScheduleReplaced, setCustomScheduleReplaced] = + createSignal(false); + const quickPresets = reminderQuickPresets(openedAt); + + const dateOptions = useDateSearch({ + query: whenQuery, + baseDate: openedAt, + defaultTime: REMINDER_DEFAULT_TIME, + maxItems: 4, + }); // What the schedule controls were seeded to, so an untouched edit can be told // from a real change without depending on second-level precision the pickers @@ -246,24 +286,58 @@ export function ReminderForm(props: ReminderFormProps) { const initialRepeat = seed.repeat; const initialOnceDate = seed.onceDate; const initialOnceTime = seed.onceTime; + const initialOnceInstant = + seed.originalSchedule.type === 'once' + ? new Date(seed.originalSchedule.remindAt) + : new Date(`${initialOnceDate}T${initialOnceTime}`); const initialParts = seed.parts; const initialTimezone = seed.recurringTimezone ?? localZone; const formId = createUniqueId(); + const descriptionId = createUniqueId(); + const whenInputId = createUniqueId(); + const whenOptionsId = createUniqueId(); let titleRef: HTMLInputElement | undefined; onMount(() => { if (props.autofocus) titleRef?.focus(); }); - const onceDateTime = () => new Date(`${onceDate()}T${onceTime()}`); + const pickedOnceDateTime = () => + selectedOnceInstant() ?? parseLocalReminderDateTime(onceDate(), onceTime()); + const typedOnceDateTime = () => { + if (!whenQuery().trim()) return undefined; + const option = dateOptions()[0]; + if (!option) return undefined; + return new Date(option.date); + }; + const onceDateTime = () => typedOnceDateTime() ?? pickedOnceDateTime(); + const typedWhenIsValid = () => + !whenQuery().trim() || typedOnceDateTime() !== undefined; + const customWallTimeIsInvalid = () => + showCustomTime() && + !whenQuery().trim() && + selectedOnceInstant() === undefined && + onceDate() !== '' && + onceTime() !== '' && + parseLocalReminderDateTime(onceDate(), onceTime()) === undefined; /** Whether the schedule controls still hold exactly what they were seeded to. */ const scheduleUntouched = () => { + if (storedCronIsCustom && customScheduleReplaced()) return false; if (repeat() !== initialRepeat) return false; - return repeat() === 'once' - ? onceDate() === initialOnceDate && onceTime() === initialOnceTime - : samePartsShape(repeatParts(), initialParts) && - timezone() === initialTimezone; + if (repeat() === 'once') { + const date = onceDateTime(); + return ( + typedWhenIsValid() && + date !== undefined && + Math.trunc(date.getTime() / 60_000) === + Math.trunc(initialOnceInstant.getTime() / 60_000) + ); + } + return ( + samePartsShape(repeatParts(), initialParts) && + timezone() === initialTimezone + ); }; /** @@ -279,50 +353,48 @@ export function ReminderForm(props: ReminderFormProps) { return titleChanged || !scheduleUntouched(); }; - // Let the host reflect the unsaved state (e.g. a dot on the split's title). - createEffect(() => props.onDirtyChange?.(isDirty())); - - const reset = () => { - setDescription(seed.description); - setRepeat(seed.repeat); - setOnceDate(seed.onceDate); - setOnceTime(seed.onceTime); - setRepeatParts(seed.parts); - setTimezone(initialTimezone); - }; - - const cancel = () => { - const wasDirty = isDirty(); - // Revert the edits in place; the host decides whether to also dismiss. - if (props.revertOnCancel && wasDirty) reset(); - props.onCancel(wasDirty); + const seedRepeatParts = (kind: ScheduleFrequency) => { + const from = onceDateTime(); + if (from) { + const inZone = TZDateMini.tz(timezone(), from.getTime()); + return repeatPartsFromDate(inZone, kind); + } + return { ...repeatParts(), frequency: kind }; }; - const setRepeatKind = (kind: RepeatKind) => { - const wasOnce = repeat() === 'once'; - setRepeat(kind); - if (kind === 'once') return; - // Coming from a one-shot, seed the recurrence from the date and time the - // one-shot fields currently hold rather than the mount-time parts, so - // switching to Weekly or Monthly lands on that weekday and time. Those - // fields are the viewer's local wall-clock but the cron is read in - // `timezone()`, so take the weekday and time of that instant AS SEEN in - // that zone — otherwise the local time would be reinterpreted in a - // different zone and the reminder would fire at another moment. Between two - // recurring kinds, keep the parts the user set and only flip frequency. - if (wasOnce) { - const from = onceDateTime(); - if (!Number.isNaN(from.getTime())) { - const inZone = TZDateMini.tz(timezone(), from.getTime()); - setRepeatParts(repeatPartsFromDate(inZone, kind)); - return; - } + const selectRepeat = ( + option: 'once' | 'daily' | 'weekdays' | 'weekly' | 'monthly' + ) => { + if (option === 'once') { + setCustomScheduleReplaced(true); + setRepeat('once'); + return; } - setRepeatParts((parts) => ({ ...parts, frequency: kind })); + + const frequency: ScheduleFrequency = + option === 'monthly' ? 'month' : 'week'; + const currentChoice = repeatChoice(); + const shouldReseed = + repeat() === 'once' || + (storedCronIsCustom && !customScheduleReplaced()) || + repeat() !== frequency || + (option === 'weekly' && currentChoice !== 'weekly'); + const parts = shouldReseed + ? seedRepeatParts(frequency) + : { ...repeatParts(), frequency }; + setRepeat(frequency); + setRepeatParts({ + ...parts, + ...(option === 'daily' ? { daysOfWeek: [...ALL_WEEKDAYS] } : {}), + ...(option === 'weekdays' ? { daysOfWeek: [...DEFAULT_WEEKDAYS] } : {}), + }); + setCustomScheduleReplaced(true); }; - const updateParts = (patch: Partial) => + const updateParts = (patch: Partial) => { + setCustomScheduleReplaced(true); setRepeatParts((parts) => ({ ...parts, ...patch })); + }; const toggleDay = (value: string) => { const days = repeatParts().daysOfWeek; @@ -334,6 +406,36 @@ export function ReminderForm(props: ReminderFormProps) { if (next.length > 0) updateParts({ daysOfWeek: next }); }; + const selectOnceDate = (date: Date) => { + setWhenQuery(''); + setSelectedOnceInstant(new Date(date)); + setOnceDate(toDateInput(date)); + setOnceTime(toTimeInput(date)); + }; + + const repeatChoice = () => { + if (storedCronIsCustom && !customScheduleReplaced()) return 'custom'; + if (repeat() === 'once') return 'once'; + if (repeat() === 'month') return 'monthly'; + if (sameDays(repeatParts().daysOfWeek, ALL_WEEKDAYS)) return 'daily'; + if (sameDays(repeatParts().daysOfWeek, DEFAULT_WEEKDAYS)) return 'weekdays'; + return 'weekly'; + }; + + const schedulePreview = createMemo(() => { + if (repeat() === 'once') { + if (!typedWhenIsValid()) return; + const date = onceDateTime(); + return date + ? formatReminderInstant(date, localZone, openedAt) + : undefined; + } + if (storedCronIsCustom && !customScheduleReplaced()) { + return `Custom repeating schedule · ${timezone().replace(/_/g, ' ')} (${shortZone(timezone())})`; + } + return `${capitalize(describeCron(repeatParts()))} · ${timezone().replace(/_/g, ' ')} (${shortZone(timezone())})`; + }); + const submit = () => { // Editing without touching the schedule keeps the stored one verbatim, so // the caller's diff omits it — which is what lets an overdue reminder be @@ -349,7 +451,7 @@ export function ReminderForm(props: ReminderFormProps) { if (repeat() === 'once') { const date = onceDateTime(); - if (Number.isNaN(date.getTime())) return; + if (!date) return; // The controls can sit open long enough for a picked time to slip into the // past; re-check rather than let the API reject it with an opaque failure. if (date.getTime() <= Date.now()) { @@ -384,7 +486,7 @@ export function ReminderForm(props: ReminderFormProps) { if (props.descriptionRequired && !description().trim()) return false; if (props.pending) return false; return repeat() === 'once' - ? !Number.isNaN(onceDateTime().getTime()) + ? typedWhenIsValid() && onceDateTime() !== undefined : isValidCronParts(repeatParts()); }; @@ -407,140 +509,336 @@ export function ReminderForm(props: ReminderFormProps) {
{ event.preventDefault(); submit(); }} > -
- setDescription(event.currentTarget.value)} - placeholder={props.placeholder} - aria-label="Reminder description" - // Counts UTF-16 code units where the service counts characters, so this - // only ever stops short of the real limit, never past it. The - // description resolvers apply the exact cap. - maxLength={REMINDER_DESCRIPTION_MAX_LENGTH} - size="lg" - /> - +
- Repeat - { - if (value === 'once' || value === 'week' || value === 'month') - setRepeatKind(value); - }} - list={[ - { value: 'once', label: 'Does not repeat' }, - { value: 'week', label: 'Weekly' }, - { value: 'month', label: 'Monthly' }, - ]} + + setDescription(event.currentTarget.value)} + placeholder={props.placeholder} + aria-label="Reminder description" + // Counts UTF-16 code units where the service counts characters, so this + // only ever stops short of the real limit, never past it. The + // description resolvers apply the exact cap. + maxLength={REMINDER_DESCRIPTION_MAX_LENGTH} + size="lg" />
- - -
-
+ +
+ + setWhenQuery(event.currentTarget.value)} + placeholder="Try “tomorrow 9am” or “in 30 minutes”" + autocomplete="off" + aria-controls={whenOptionsId} + aria-expanded={whenQuery().trim().length > 0} + aria-invalid={!typedWhenIsValid()} + size="lg" + /> + +
+ 0} + fallback={ + + No date found. Try “tomorrow 9am” or use Custom. + + } + > + + {(option) => ( + + )} + + +
+
+ +
+ + {(preset) => ( + + )} + + +
+ + +
- setOnceDate(event.currentTarget.value) - } + onInput={(event) => { + setWhenQuery(''); + setSelectedOnceInstant(undefined); + setOnceDate(event.currentTarget.value); + }} class="min-w-0 flex-1 text-sm" /> - setOnceTime(event.currentTarget.value) - } + onInput={(event) => { + setWhenQuery(''); + setSelectedOnceInstant(undefined); + setOnceTime(event.currentTarget.value); + }} class="min-w-0 flex-1 text-sm" />
- - {localZone.replace(/_/g, ' ')} ({shortZone(localZone)}) + + + +
+
+
+ + + {(preview) => ( +

+ Scheduled:{' '} + {preview()} +

+ )} +
+ +
+ + + + Repeat + + {repeatChoice() === 'once' + ? 'Does not repeat' + : repeatChoice() === 'custom' + ? 'Custom schedule' + : repeatChoice() === 'daily' + ? 'Daily' + : repeatChoice() === 'weekdays' + ? 'Weekdays' + : repeatChoice() === 'monthly' + ? 'Monthly' + : 'Weekly'} + + + + +
+ +

+ This reminder uses a custom repeat schedule. It will stay + unchanged unless you choose a replacement below. +

+
+
+ + {([value, label]) => ( + + )} +
- - -
-
- - {(day) => ( - - )} - -
- updateParts({ time })} - /> -
-
- -
-
-
- - - -
- - - {describeCron(repeatParts())} · {shortZone(timezone())} - +
+
+ + + {(error) => ( + + )}
@@ -559,7 +857,13 @@ export function ReminderForm(props: ReminderFormProps) { Unsaved changes -
diff --git a/apps/web/src/features/reminders/reminder-composer.test.ts b/apps/web/src/features/reminders/reminder-composer.test.ts index 87fb39be01d..31d4da3c327 100644 --- a/apps/web/src/features/reminders/reminder-composer.test.ts +++ b/apps/web/src/features/reminders/reminder-composer.test.ts @@ -99,8 +99,8 @@ describe('reminder composer created handler', () => { closeReminderComposer(); }); - // The composer closes before the create request is awaited, so the follow-up - // has to be taken out of here first rather than read after the fact. + // The modal takes this only after the create request succeeds, immediately + // before closing clears the composer state. it('hands the created handler over once', () => { const onCreated = vi.fn(); openReminderComposer(doc('doc-1', 'Q3 Contract'), { onCreated }); diff --git a/apps/web/src/features/reminders/reminder-composer.ts b/apps/web/src/features/reminders/reminder-composer.ts index fee4b357686..1d67d47a851 100644 --- a/apps/web/src/features/reminders/reminder-composer.ts +++ b/apps/web/src/features/reminders/reminder-composer.ts @@ -29,10 +29,9 @@ export type ReminderCreatedHandler = () => void | Promise; let createdHandler: ReminderCreatedHandler | undefined; /** - * Hand the pending handler to the caller and forget it. - * - * Taken rather than read because the composer closes — and so clears its - * target — before the create request is awaited. + * Hand the pending handler to the caller and forget it after create succeeds. + * A rejected create leaves it here so the preserved draft can be retried and + * still perform the invoking surface's follow-up exactly once. */ export function takeReminderCreatedHandler(): | ReminderCreatedHandler diff --git a/apps/web/src/features/reminders/reminder-schedule.test.ts b/apps/web/src/features/reminders/reminder-schedule.test.ts index dbb54905552..f07f698aeaa 100644 --- a/apps/web/src/features/reminders/reminder-schedule.test.ts +++ b/apps/web/src/features/reminders/reminder-schedule.test.ts @@ -2,16 +2,20 @@ import type { EntityData } from '@entity'; import { describe, expect, it } from 'vitest'; import { + describeReminderConfirmation, describeReminderSchedule, describeReminderWhen, + formatReminderInstant, isRecurring, onceSchedule, + parseLocalReminderDateTime, REMINDER_DEFAULT_TIME, REMINDER_DESCRIPTION_MAX_LENGTH, recurringSchedule, reminderDescriptionFor, reminderDescriptionForReference, reminderEditPatch, + reminderQuickPresets, repeatPartsFromDate, repeatPartsFromSchedule, resolveEditedDescription, @@ -20,6 +24,103 @@ import { sameSchedule, } from './reminder-schedule'; +describe('reminderQuickPresets', () => { + it('shows actual common times and hides Later today after 5pm', () => { + const morning = reminderQuickPresets(new Date(2026, 8, 21, 10, 12, 45)); + + expect(morning.map((preset) => preset.id)).toEqual([ + 'in-30-minutes', + 'later-today', + 'tomorrow-morning', + 'next-week', + ]); + expect(morning[0]?.date).toEqual(new Date(2026, 8, 21, 10, 42, 45)); + expect(morning[1]?.date).toEqual(new Date(2026, 8, 21, 17)); + expect(morning[2]?.date).toEqual(new Date(2026, 8, 22, 9)); + expect(morning[3]?.date).toEqual(new Date(2026, 8, 28, 9)); + + expect( + reminderQuickPresets(new Date(2026, 8, 21, 17, 1)).map( + (preset) => preset.id + ) + ).not.toContain('later-today'); + }); + + it('makes Next week the following Monday when opened on Monday', () => { + const presets = reminderQuickPresets(new Date(2026, 8, 21, 10)); + expect(presets.find((preset) => preset.id === 'next-week')?.date).toEqual( + new Date(2026, 8, 28, 9) + ); + }); + + it('adds 30 elapsed minutes across the daylight-saving fall-back hour', () => { + const beforeFallback = new Date('2026-11-01T01:45:00-04:00'); + const inThirty = reminderQuickPresets(beforeFallback)[0]?.date; + if (!inThirty) throw new Error('Expected In 30m preset'); + + expect(inThirty.getTime() - beforeFallback.getTime()).toBe(30 * 60 * 1000); + expect( + new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }).format(inThirty) + ).toBe('1:15 AM EST'); + }); +}); + +describe('parseLocalReminderDateTime', () => { + it('rejects a wall time skipped by daylight-saving spring-forward', () => { + const originalTimezone = process.env.TZ; + process.env.TZ = 'America/New_York'; + try { + expect(parseLocalReminderDateTime('2026-03-08', '02:30')).toBeUndefined(); + expect( + parseLocalReminderDateTime('2026-03-08', '03:30')?.toISOString() + ).toBe('2026-03-08T07:30:00.000Z'); + } finally { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + } + }); +}); + +describe('formatReminderInstant', () => { + it('includes the relative day, exact date, time, and timezone', () => { + const described = formatReminderInstant( + new Date('2026-09-22T09:00:00.000Z'), + 'UTC', + new Date('2026-09-21T12:00:00.000Z') + ); + + expect(described).toContain('Tomorrow'); + expect(described).toContain('Sep 22'); + expect(described).toMatch(/9:00\sAM/); + expect(described).toContain('(UTC)'); + }); + + it('describes persisted schedules for success feedback', () => { + expect( + describeReminderConfirmation({ + type: 'recurring', + cron: '0 0 9 * * 2-6', + timezone: 'America/New_York', + }) + ).toContain('Weekdays at 9:00 AM'); + }); + + it('does not invent a cadence for an unsupported custom cron', () => { + expect( + describeReminderConfirmation({ + type: 'recurring', + cron: '0 */15 9 * * *', + timezone: 'America/New_York', + }) + ).toBe('Custom repeating schedule (America/New_York)'); + }); +}); + describe('onceSchedule', () => { it('builds a one-shot schedule at the given instant', () => { const date = new Date('2026-07-30T13:00:00.000Z'); diff --git a/apps/web/src/features/reminders/reminder-schedule.ts b/apps/web/src/features/reminders/reminder-schedule.ts index fa57a671d58..f951c8224f7 100644 --- a/apps/web/src/features/reminders/reminder-schedule.ts +++ b/apps/web/src/features/reminders/reminder-schedule.ts @@ -4,6 +4,7 @@ import { type CronParts, describeCron, getDefaultTimezone, + isCronRepresentable, normalizeCron, parseCron, type ScheduleFrequency, @@ -24,6 +25,156 @@ export const REMINDER_DEFAULT_TIME = { hours: 9, minutes: 0 } as const; /** Longest description the API accepts, mirroring the service's own limit. */ export const REMINDER_DESCRIPTION_MAX_LENGTH = 2000; +/** + * Parse native date/time control values without accepting DST normalization. + * + * `new Date('2026-03-08T02:30')` in New York silently becomes 03:30 because + * 02:30 never occurs on the spring-forward day. A reminder must not save at a + * different time than the controls display, so round-trip every component. + */ +export function parseLocalReminderDateTime( + dateValue: string, + timeValue: string +): Date | undefined { + const dateMatch = dateValue.match(/^(\d{4})-(\d{2})-(\d{2})$/); + const timeMatch = timeValue.match(/^(\d{2}):(\d{2})$/); + if (!dateMatch || !timeMatch) return undefined; + + const [, year, month, day] = dateMatch.map(Number); + const [, hour, minute] = timeMatch.map(Number); + const parsed = new Date(year, month - 1, day, hour, minute, 0, 0); + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day || + parsed.getHours() !== hour || + parsed.getMinutes() !== minute + ) { + return undefined; + } + return parsed; +} + +export interface ReminderQuickPreset { + id: 'in-30-minutes' | 'later-today' | 'tomorrow-morning' | 'next-week'; + label: string; + date: Date; +} + +/** Common one-shot choices, computed when the form opens so labels are exact. */ +export function reminderQuickPresets(now: Date): ReminderQuickPreset[] { + // Elapsed time, not a local wall-clock mutation: adding 30 via setMinutes + // becomes 90 elapsed minutes when daylight saving time falls back. + const inThirtyMinutes = new Date(now.getTime() + 30 * 60 * 1000); + + const laterToday = new Date(now); + laterToday.setHours(17, 0, 0, 0); + + const tomorrowMorning = new Date(now); + tomorrowMorning.setDate(tomorrowMorning.getDate() + 1); + tomorrowMorning.setHours( + REMINDER_DEFAULT_TIME.hours, + REMINDER_DEFAULT_TIME.minutes, + 0, + 0 + ); + + const nextWeek = new Date(now); + const daysUntilNextMonday = (8 - nextWeek.getDay()) % 7 || 7; + nextWeek.setDate(nextWeek.getDate() + daysUntilNextMonday); + nextWeek.setHours( + REMINDER_DEFAULT_TIME.hours, + REMINDER_DEFAULT_TIME.minutes, + 0, + 0 + ); + + return [ + { id: 'in-30-minutes', label: 'In 30m', date: inThirtyMinutes }, + ...(laterToday > now + ? ([ + { id: 'later-today', label: 'Later today', date: laterToday }, + ] satisfies ReminderQuickPreset[]) + : []), + { + id: 'tomorrow-morning', + label: 'Tomorrow', + date: tomorrowMorning, + }, + { id: 'next-week', label: 'Next week', date: nextWeek }, + ]; +} + +/** An exact, human-readable instant for previews and save confirmation. */ +export function formatReminderInstant( + date: Date, + timezone: string = getDefaultTimezone(), + now: Date = new Date() +): string { + const dateParts = (value: Date) => + new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(value); + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + + const calendarLabel = + dateParts(date) === dateParts(now) + ? 'Today' + : dateParts(date) === dateParts(tomorrow) + ? 'Tomorrow' + : new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + weekday: 'long', + }).format(date); + const year = + new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + }).format(date) === + new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + }).format(now) + ? undefined + : 'numeric'; + const exactDate = new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + month: 'short', + day: 'numeric', + year, + }).format(date); + const exactTime = new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + hour: 'numeric', + minute: '2-digit', + }).format(date); + const zone = new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + timeZoneName: 'short', + }) + .formatToParts(date) + .find((part) => part.type === 'timeZoneName')?.value; + + return `${calendarLabel}, ${exactDate} at ${exactTime} (${zone ?? timezone})`; +} + +/** The schedule wording shown after persistence succeeds. */ +export function describeReminderConfirmation( + schedule: ReminderSchedule +): string { + if (isRecurring(schedule)) { + if (!isCronRepresentable(schedule.cron)) { + return `Custom repeating schedule (${schedule.timezone})`; + } + return describeReminderSchedule(schedule) ?? 'Repeating reminder'; + } + return formatReminderInstant(new Date(schedule.remindAt)); +} + /** A one-shot schedule firing at `date`. */ export function onceSchedule(date: Date): ReminderSchedule { return { type: 'once', remindAt: date.toISOString() }; diff --git a/apps/web/src/lib/core/util/cron.test.ts b/apps/web/src/lib/core/util/cron.test.ts index 944cb493406..871e42e2c9e 100644 --- a/apps/web/src/lib/core/util/cron.test.ts +++ b/apps/web/src/lib/core/util/cron.test.ts @@ -5,6 +5,7 @@ import { type CronParts, DEFAULT_TIME, describeCron, + isCronRepresentable, isValidCronParts, isValidTime, normalizeCron, @@ -162,6 +163,20 @@ describe('parseCron', () => { }); }); +describe('isCronRepresentable', () => { + it('accepts schedules the shared picker can round-trip', () => { + expect(isCronRepresentable('0 0 9 * * *')).toBe(true); + expect(isCronRepresentable('0 30 14 * * 2-6')).toBe(true); + expect(isCronRepresentable('0 0 9 15 * *')).toBe(true); + }); + + it('rejects valid custom fields the shared picker cannot express', () => { + expect(isCronRepresentable('0 */15 9 * * *')).toBe(false); + expect(isCronRepresentable('0 0 9 1 3 *')).toBe(false); + expect(isCronRepresentable('0 0 9 * * 2 2027')).toBe(false); + }); +}); + /** * `09:00` as the runtime's locale writes it. * diff --git a/apps/web/src/lib/core/util/cron.ts b/apps/web/src/lib/core/util/cron.ts index 926716e8cf7..5666ae4537b 100644 --- a/apps/web/src/lib/core/util/cron.ts +++ b/apps/web/src/lib/core/util/cron.ts @@ -246,6 +246,11 @@ export function parseCron(cron: string): CronParts { return interpretCron(cron).parts; } +/** Whether the shared recurrence controls can round-trip this expression. */ +export function isCronRepresentable(cron: string): boolean { + return interpretCron(cron).representable; +} + /** * A parse, plus whether the expression was one the pickers can actually say. * diff --git a/apps/web/src/lib/core/util/dateSearch/dateParser.test.ts b/apps/web/src/lib/core/util/dateSearch/dateParser.test.ts index 6f25db0a529..7a7aeed434f 100644 --- a/apps/web/src/lib/core/util/dateSearch/dateParser.test.ts +++ b/apps/web/src/lib/core/util/dateSearch/dateParser.test.ts @@ -57,6 +57,16 @@ describe('parseDurationString', () => { expect(parseDurationString('90min')).toEqual({ value: 90, unit: 'min' }); }); + it('accepts natural "in" duration phrasing', () => { + expect(parseDurationString('in 30 minutes')).toEqual({ + value: 30, + unit: 'min', + }); + expect( + parseDateFromDuration('in 2 hours', new Date(2026, 8, 21, 10)) + ).toEqual(new Date(2026, 8, 21, 12)); + }); + it('should parse seconds correctly', () => { expect(parseDurationString('1s')).toEqual({ value: 1, unit: 's' }); expect(parseDurationString('30s')).toEqual({ value: 30, unit: 's' }); diff --git a/apps/web/src/lib/core/util/dateSearch/dateParser.ts b/apps/web/src/lib/core/util/dateSearch/dateParser.ts index 60bf37469d3..1d12b6f2503 100644 --- a/apps/web/src/lib/core/util/dateSearch/dateParser.ts +++ b/apps/web/src/lib/core/util/dateSearch/dateParser.ts @@ -52,7 +52,13 @@ const UNIT_ALIASES: Record = { * Returns null if the input doesn't match the expected format */ export function parseDurationString(input: string): ParsedDuration | null { - const s = input.trim().toLowerCase(); + const normalized = input.trim().toLowerCase(); + // Date-language fields commonly phrase a duration as "in 30 minutes". + // Keep the duration grammar itself small while accepting that natural + // prefix everywhere the shared parser is used. + const s = normalized.startsWith('in ') + ? normalized.slice(3).trim() + : normalized; if (!s) return null; const firstLetter = s.search(/[a-z]/); diff --git a/docs/AGENT_GUIDE/README.md b/docs/AGENT_GUIDE/README.md index caea37a9626..0871ac958d5 100644 --- a/docs/AGENT_GUIDE/README.md +++ b/docs/AGENT_GUIDE/README.md @@ -13,6 +13,7 @@ verified live against a local stack (`just run_local`). | [../CLAUDE_CLOUD_DEMO.md](../CLAUDE_CLOUD_DEMO.md) | Claude in Harness settings, encrypted saved connection, Open in Claude, and cloud-side transcript polling | | [channels.md](channels.md) | Channels: create, invite, message, participants, bots | | [tasks.md](tasks.md) | Task list and creation dialog | +| [reminders.md](reminders.md) | Creating and editing reminders, scheduling controls, and safe failure verification | | [surfaces.md](surfaces.md) | Every other surface: inbox, email, search, files, calendar, calls, customers, activity, settings | | [browser-technique.md](browser-technique.md) | Generic chrome-devtools MCP lessons learned on this app | | [observability.md](observability.md) | Correlating a UI action to backend traces/logs with the Grafana MCP | diff --git a/docs/AGENT_GUIDE/reminders.md b/docs/AGENT_GUIDE/reminders.md new file mode 100644 index 00000000000..55936d65d8e --- /dev/null +++ b/docs/AGENT_GUIDE/reminders.md @@ -0,0 +1,51 @@ +# Reminders + +Use the dedicated Reminders workspace to inspect reminder lists and open an +existing reminder. The create and edit surfaces share the same scheduling +controls; reminder creation is a real hosted-data mutation, so use request +interception when checking failures and cancel any draft used only for visual +inspection. + +## Create or edit a reminder + +The create dialog is titled **New reminder**. A standalone reminder requires the +**Reminder description** field; an entity-attached reminder may derive its title +from the source badge. The **When** field accepts date language such as +`tomorrow 9am`, `in 30 minutes`, weekdays, and explicit dates. The resolved +weekday, date, time, and timezone appear in the **Scheduled:** preview before +saving. + +Quick choices are **In 30m**, **Later today** (only before 5 PM), **Tomorrow**, +**Next week**, and **Custom**. Each choice names its resolved time. Custom reveals +native **Custom reminder date** and **Custom reminder time** controls. These are +normal buttons and fields: Tab reaches them, Enter submits a valid form, and the +dialog restores focus to its opener on dismissal. + +A local time skipped by a daylight-saving clock change (for example 2:30 AM on +a spring-forward day) is rejected inline. Choose a time before or after the gap; +the form must never silently normalize it to a different displayed time. + +**Repeat** is a collapsed secondary section. It offers **Does not repeat**, +**Daily**, **Weekdays**, **Weekly**, and **Monthly**, followed by weekday/day, +time, and timezone controls where relevant. An existing cron expression the +picker cannot represent is labeled **Custom schedule** and remains byte-for-byte +unchanged unless a replacement repeat choice is selected. + +The primary action reads **Set reminder** for create and **Save** for edit. +**Cancel** dismisses without saving. A description-only edit of an overdue +reminder keeps its old schedule instead of trying to reschedule it in the past. + +## Verify save failure without changing dev data + +Intercept `POST **/dss/reminders` and hold or reject the response. While held, +the primary action shows a spinner, every form control is frozen, and duplicate +submits issue only one request. On rejection, the dialog stays open, the entered +title and time remain, focus returns to the control used to submit, and an inline +alert explains that the draft can be retried. +The alert also warns that a timed-out request may already have succeeded; the +create API has no idempotency key, so the UI does not claim retries are +duplicate-safe. + +After a confirmed response, the dialog closes and the success toast includes the +exact persisted time or recurrence. Only a confirmed create runs the invoking +surface's follow-up action. From 973a67dce0ab8d386ca1f413f2880b37f5966543 Mon Sep 17 00:00:00 2001 From: Gabriel Birman <25272206+gbirman@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:30:24 +0000 Subject: [PATCH 2/5] chore(reminders): align stacked integration --- apps/web/src/features/reminders/ReminderComposerModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/features/reminders/ReminderComposerModal.tsx b/apps/web/src/features/reminders/ReminderComposerModal.tsx index 6c8eff09533..8ff100d0c76 100644 --- a/apps/web/src/features/reminders/ReminderComposerModal.tsx +++ b/apps/web/src/features/reminders/ReminderComposerModal.tsx @@ -13,13 +13,13 @@ import { ActionDialogShell, Dialog } from '@ui'; import { createSignal, Show } from 'solid-js'; import { globalSplitManager } from '../../lib/signals/splitLayout'; import { ReminderForm } from './ReminderForm'; -import { reminderDetailDestination } from './reminder-navigation'; import { closeReminderComposer, reminderComposerOpen, reminderComposerState, takeReminderCreatedHandler, } from './reminder-composer'; +import { reminderDetailDestination } from './reminder-navigation'; import { describeReminderConfirmation, resolveReminderDescription, From dca3900fe7323b67fa36405c01cb317458542c36 Mon Sep 17 00:00:00 2001 From: Gabriel Birman <25272206+gbirman@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:54:30 +0000 Subject: [PATCH 3/5] fix(reminders): preserve scheduling transitions --- .../features/reminders/ReminderForm.test.tsx | 123 +++++++++++++++ .../src/features/reminders/ReminderForm.tsx | 142 ++++++++++++++---- 2 files changed, 234 insertions(+), 31 deletions(-) diff --git a/apps/web/src/features/reminders/ReminderForm.test.tsx b/apps/web/src/features/reminders/ReminderForm.test.tsx index 78d3f8899a7..e9ac0ed510e 100644 --- a/apps/web/src/features/reminders/ReminderForm.test.tsx +++ b/apps/web/src/features/reminders/ReminderForm.test.tsx @@ -2,7 +2,11 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ReminderForm, type ReminderFormValues } from './ReminderForm'; +let originalTimezone: string | undefined; + beforeEach(() => { + originalTimezone = process.env.TZ; + process.env.TZ = 'UTC'; vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-21T12:00:00.000Z')); }); @@ -10,6 +14,8 @@ beforeEach(() => { afterEach(() => { cleanup(); vi.useRealTimers(); + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; }); function renderForm( @@ -73,6 +79,28 @@ describe('one-shot scheduling', () => { }); }); + it('carries a typed instant into Custom instead of restoring the default', () => { + const { onSubmit } = renderForm({ initialDescription: 'Follow up' }); + + fireEvent.input( + screen.getByPlaceholderText('Try “tomorrow 9am” or “in 30 minutes”'), + { target: { value: 'in 30 minutes' } } + ); + fireEvent.click(screen.getByRole('button', { name: /Custom/ })); + + expect( + (screen.getByLabelText('Custom reminder date') as HTMLInputElement).value + ).toBe('2026-09-21'); + expect( + (screen.getByLabelText('Custom reminder time') as HTMLInputElement).value + ).toBe('12:30'); + fireEvent.click(screen.getByRole('button', { name: 'Set reminder' })); + expect(onSubmit.mock.calls[0]?.[0].schedule).toEqual({ + type: 'once', + remindAt: '2026-09-21T12:30:00.000Z', + }); + }); + it('preserves an overdue schedule for a description-only edit', () => { const overdue = { type: 'once' as const, @@ -174,6 +202,48 @@ describe('one-shot scheduling', () => { else process.env.TZ = originalTimezone; } }); + + it('rejects a date-language time skipped by spring-forward', () => { + const originalTimezone = process.env.TZ; + process.env.TZ = 'America/New_York'; + try { + vi.setSystemTime(new Date('2026-03-07T12:00:00-05:00')); + const { onSubmit } = renderForm({ initialDescription: 'Follow up' }); + + fireEvent.input( + screen.getByPlaceholderText('Try “tomorrow 9am” or “in 30 minutes”'), + { target: { value: 'Mar 8 2026 2:30am' } } + ); + + expect( + screen.getByText(/local time doesn’t exist because the clocks change/) + ).not.toBeNull(); + expect( + ( + screen.getByRole('button', { + name: 'Set reminder', + }) as HTMLButtonElement + ).disabled + ).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: /Custom/ })); + expect( + (screen.getByLabelText('Custom reminder date') as HTMLInputElement) + .value + ).toBe('2026-03-08'); + expect( + (screen.getByLabelText('Custom reminder time') as HTMLInputElement) + .value + ).toBe('02:30'); + expect( + screen.getByText(/local time doesn’t exist because the clocks change/) + ).not.toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + } finally { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + } + }); }); describe('recurrence', () => { @@ -284,6 +354,59 @@ describe('recurrence', () => { timezone: 'UTC', }); }); + + it('keeps edited time and cadence-specific days while switching repeat shapes', () => { + const { onSubmit } = renderForm({ + initialDescription: 'Monthly review', + initialSchedule: { + type: 'recurring', + cron: '0 30 14 15 * *', + timezone: 'UTC', + }, + initialRemindAt: '2026-10-15T14:30:00.000Z', + submitLabel: 'Save', + }); + + fireEvent.click(screen.getByText('Repeat')); + fireEvent.input(screen.getByLabelText('Day'), { + target: { value: '20' }, + }); + fireEvent.input(screen.getByLabelText('At'), { + target: { value: '16:45' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Weekly' })); + expect((screen.getByLabelText('At') as HTMLInputElement).value).toBe( + '16:45' + ); + fireEvent.click(screen.getByRole('button', { name: 'Friday' })); + fireEvent.click(screen.getByRole('button', { name: 'Thursday' })); + + fireEvent.click(screen.getByRole('button', { name: 'Monthly' })); + expect((screen.getByLabelText('Day') as HTMLInputElement).value).toBe('20'); + expect((screen.getByLabelText('At') as HTMLInputElement).value).toBe( + '16:45' + ); + + fireEvent.click(screen.getByRole('button', { name: 'Weekly' })); + expect( + screen + .getByRole('button', { name: 'Friday' }) + .getAttribute('aria-pressed') + ).toBe('true'); + expect( + screen + .getByRole('button', { name: 'Thursday' }) + .getAttribute('aria-pressed') + ).toBe('false'); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit.mock.calls[0]?.[0].schedule).toEqual({ + type: 'recurring', + cron: '0 45 16 * * 6', + timezone: 'UTC', + }); + }); }); it('uses predictable Cancel behavior', () => { diff --git a/apps/web/src/features/reminders/ReminderForm.tsx b/apps/web/src/features/reminders/ReminderForm.tsx index cbc65943124..672dc425084 100644 --- a/apps/web/src/features/reminders/ReminderForm.tsx +++ b/apps/web/src/features/reminders/ReminderForm.tsx @@ -9,7 +9,7 @@ import { type ScheduleFrequency, WEEKDAY_OPTIONS, } from '@core/util/cron'; -import { useDateSearch } from '@core/util/dateSearch/useDateSearch'; +import { parseTime, useDateSearch } from '@core/util/dateSearch/useDateSearch'; import { TZDateMini } from '@date-fns/tz'; import CaretDownIcon from '@phosphor/caret-down.svg'; import SpinnerIcon from '@phosphor/spinner.svg'; @@ -27,6 +27,7 @@ import { Switch, } from 'solid-js'; import { Dynamic } from 'solid-js/web'; +import { match } from 'ts-pattern'; import { formatReminderInstant, isRecurring, @@ -271,6 +272,26 @@ export function ReminderForm(props: ReminderFormProps) { !isCronRepresentable(props.initialSchedule.cron); const [customScheduleReplaced, setCustomScheduleReplaced] = createSignal(false); + // Week and month have different editable day shapes. Remember each shape + // once it is intentional so crossing cadences can seed a missing shape from + // the selected occurrence without throwing away edits when switching back. + const [savedWeeklyDays, setSavedWeeklyDays] = createSignal< + string[] | undefined + >( + !storedCronIsCustom && + seed.repeat === 'week' && + !sameDays(seed.parts.daysOfWeek, ALL_WEEKDAYS) && + !sameDays(seed.parts.daysOfWeek, DEFAULT_WEEKDAYS) + ? [...seed.parts.daysOfWeek] + : undefined + ); + const [savedMonthlyDay, setSavedMonthlyDay] = createSignal< + string | undefined + >( + !storedCronIsCustom && seed.repeat === 'month' + ? seed.parts.dayOfMonth + : undefined + ); const quickPresets = reminderQuickPresets(openedAt); const dateOptions = useDateSearch({ @@ -295,6 +316,7 @@ export function ReminderForm(props: ReminderFormProps) { const formId = createUniqueId(); const descriptionId = createUniqueId(); + const whenLabelId = createUniqueId(); const whenInputId = createUniqueId(); const whenOptionsId = createUniqueId(); let titleRef: HTMLInputElement | undefined; @@ -304,8 +326,20 @@ export function ReminderForm(props: ReminderFormProps) { const pickedOnceDateTime = () => selectedOnceInstant() ?? parseLocalReminderDateTime(onceDate(), onceTime()); + const typedTime = () => parseTime(whenQuery())?.time; + const typedWallTimeIsInvalid = () => { + const time = typedTime(); + const option = dateOptions()[0]; + return ( + time !== undefined && + option !== undefined && + (option.date.getHours() !== time.hours || + option.date.getMinutes() !== time.minutes) + ); + }; const typedOnceDateTime = () => { if (!whenQuery().trim()) return undefined; + if (typedWallTimeIsInvalid()) return undefined; const option = dateOptions()[0]; if (!option) return undefined; return new Date(option.date); @@ -373,15 +407,24 @@ export function ReminderForm(props: ReminderFormProps) { const frequency: ScheduleFrequency = option === 'monthly' ? 'month' : 'week'; - const currentChoice = repeatChoice(); - const shouldReseed = - repeat() === 'once' || - (storedCronIsCustom && !customScheduleReplaced()) || - repeat() !== frequency || - (option === 'weekly' && currentChoice !== 'weekly'); - const parts = shouldReseed - ? seedRepeatParts(frequency) - : { ...repeatParts(), frequency }; + const seeded = seedRepeatParts(frequency); + const canPreserveEditedTime = + repeat() !== 'once' && (!storedCronIsCustom || customScheduleReplaced()); + const parts: CronParts = { + ...repeatParts(), + frequency, + time: canPreserveEditedTime ? repeatParts().time : seeded.time, + }; + if (option === 'weekly') { + const days = savedWeeklyDays() ?? seeded.daysOfWeek; + parts.daysOfWeek = [...days]; + setSavedWeeklyDays([...days]); + } + if (option === 'monthly') { + const day = savedMonthlyDay() ?? seeded.dayOfMonth; + parts.dayOfMonth = day; + setSavedMonthlyDay(day); + } setRepeat(frequency); setRepeatParts({ ...parts, @@ -393,6 +436,9 @@ export function ReminderForm(props: ReminderFormProps) { const updateParts = (patch: Partial) => { setCustomScheduleReplaced(true); + if (patch.dayOfMonth !== undefined && repeat() === 'month') { + setSavedMonthlyDay(patch.dayOfMonth); + } setRepeatParts((parts) => ({ ...parts, ...patch })); }; @@ -403,7 +449,10 @@ export function ReminderForm(props: ReminderFormProps) { const next = days.includes(value) ? days.filter((day) => day !== value) : [...days, value]; - if (next.length > 0) updateParts({ daysOfWeek: next }); + if (next.length > 0) { + setSavedWeeklyDays([...next]); + updateParts({ daysOfWeek: next }); + } }; const selectOnceDate = (date: Date) => { @@ -413,6 +462,27 @@ export function ReminderForm(props: ReminderFormProps) { setOnceTime(toTimeInput(date)); }; + const toggleCustomTime = () => { + const opening = !showCustomTime(); + if (opening && whenQuery().trim()) { + const option = dateOptions()[0]; + const intendedTime = typedTime(); + if (typedWallTimeIsInvalid() && option && intendedTime) { + // Carry the requested wall time into Custom so its existing DST-gap + // validation can explain the problem instead of silently discarding it. + setSelectedOnceInstant(undefined); + setOnceDate(toDateInput(option.date)); + setOnceTime(`${pad(intendedTime.hours)}:${pad(intendedTime.minutes)}`); + setWhenQuery(''); + } else { + const typed = typedOnceDateTime(); + if (typed) selectOnceDate(typed); + else setWhenQuery(''); + } + } + setShowCustomTime(opening); + }; + const repeatChoice = () => { if (storedCronIsCustom && !customScheduleReplaced()) return 'custom'; if (repeat() === 'once') return 'once'; @@ -546,9 +616,10 @@ export function ReminderForm(props: ReminderFormProps) {