diff --git a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx index 79c683835..09a7444dc 100644 --- a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx +++ b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx @@ -31,10 +31,28 @@ const SOURCE_TYPE_BY_VISUAL_TYPE = { note: 'note' } as const +/** + * The instant these tests pretend it is, on today's real local date. + * + * `use-today` snapshots the local date into module scope at import and re-reads the wall clock + * for its first subscriber. A clock faked onto any other date therefore arrives as a midnight + * rollover, which moves `todayCalendarRange`, moves the query key with it, and makes the widget + * fetch a second day on mount. Only the time of day is pinned, and from local fields rather than + * a UTC instant, which far enough from UTC would name a different day. + */ +const NOW = new Date() +NOW.setHours(9, 30, 0, 0) + +function todayAtHour(hour: number): string { + const at = new Date(NOW) + at.setHours(hour, 0, 0, 0) + return at.toISOString() +} + function projectionItem( id: string, title: string, - hourUtc: number, + hour: number, visualType: keyof typeof SOURCE_TYPE_BY_VISUAL_TYPE ): CalendarProjectionItem { return { @@ -43,8 +61,8 @@ function projectionItem( sourceId: id, title, descriptionPreview: null, - startAt: `2026-08-31T${String(hourUtc).padStart(2, '0')}:00:00.000Z`, - endAt: `2026-08-31T${String(hourUtc + 1).padStart(2, '0')}:00:00.000Z`, + startAt: todayAtHour(hour), + endAt: todayAtHour(hour + 1), isAllDay: false, timezone: 'UTC', visualType, @@ -101,7 +119,7 @@ function renderApp(): { showBoard: (visible: boolean) => void } { describe('home calendar widget stays current', () => { beforeEach(() => { - vi.setSystemTime(new Date('2026-08-31T09:30:00.000Z')) + vi.setSystemTime(NOW) listeners.clear() server.items = [projectionItem('e1', 'Standup', 10, 'event')] mockGetRange.mockReset() diff --git a/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.test.tsx b/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.test.tsx index 62e9bdeb0..ce967f274 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.test.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.test.tsx @@ -8,7 +8,7 @@ import React from 'react' import { describe, expect, it, vi, beforeEach } from 'vitest' -import { screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithProviders, getMockApi, userEvent } from '@tests/utils/render' import { JournalReminderButton } from './journal-reminder-button' @@ -39,14 +39,45 @@ vi.mock('@/components/ui/picker', async () => { return createPickerStub() }) +function reminderApi(): Record> { + return (getMockApi() as unknown as { reminders: Record> }) + .reminders +} + +/** + * `useNoteReminders` asks for both the note's own reminders and its highlight + * reminders, so the seed has to answer per target type. Returning the same row + * to both queries would list it twice and make the remove button ambiguous. + */ +function seedActiveReminder(targetType: 'journal' | 'note', targetId: string): void { + reminderApi().getForTarget.mockImplementation((input: { targetType: string }) => + Promise.resolve( + input.targetType === targetType + ? [ + { + id: `rem-${targetType}-1`, + targetType, + targetId, + remindAt: '2026-05-17T09:00:00.000Z', + status: 'pending', + note: null + } + ] + : [] + ) + ) +} + describe('JournalReminderButton', () => { beforeEach(() => { - const api = getMockApi() as unknown as { - reminders: Record> - } - api.reminders.getForTarget = vi.fn().mockResolvedValue([]) - api.reminders.create.mockClear() - api.reminders.create.mockResolvedValue({ success: true }) + const api = reminderApi() + api.getForTarget = vi.fn().mockResolvedValue([]) + api.create.mockClear() + api.create.mockResolvedValue({ success: true }) + api.update.mockClear() + api.update.mockResolvedValue({ success: true, reminder: null }) + api.delete.mockClear() + api.delete.mockResolvedValue({ success: true }) }) it('sends the note typed in the picker to reminders.create', async () => { @@ -72,4 +103,62 @@ describe('JournalReminderButton', () => { ) }) }) + + it('moves the reminder the entry already has instead of creating a second', async () => { + const user = userEvent.setup() + seedActiveReminder('journal', '2026-05-10') + renderWithProviders() + + await screen.findByRole('button', { + name: /phaseF.componentsReminderReminderPicker.deleteReminder/ + }) + await user.click(screen.getByTestId('preset-in-one-week')) + + await waitFor(() => { + expect(reminderApi().update).toHaveBeenCalledWith( + expect.objectContaining({ id: 'rem-journal-1' }) + ) + }) + expect(reminderApi().create).not.toHaveBeenCalled() + }) + + it('removes the reminder from the picker list', async () => { + const user = userEvent.setup() + seedActiveReminder('journal', '2026-05-10') + renderWithProviders() + + await user.click( + await screen.findByRole('button', { + name: /phaseF.componentsReminderReminderPicker.deleteReminder/ + }) + ) + + await waitFor(() => { + expect(reminderApi().delete).toHaveBeenCalledWith('rem-journal-1') + }) + }) + + it('changes the time on the reminder already set', async () => { + const user = userEvent.setup() + seedActiveReminder('journal', '2026-05-10') + renderWithProviders() + + await user.click( + await screen.findByRole('button', { + name: /phaseF.componentsReminderReminderPicker.editReminder/ + }) + ) + fireEvent.change(screen.getByLabelText(/phaseF.componentsReminderReminderPicker.time/), { + target: { value: '07:15' } + }) + await user.click( + screen.getByRole('button', { name: 'phaseF.componentsReminderReminderPicker.save' }) + ) + + await waitFor(() => expect(reminderApi().update).toHaveBeenCalledTimes(1)) + const [payload] = reminderApi().update.mock.calls[0] as [{ id: string; remindAt: string }] + expect(payload.id).toBe('rem-journal-1') + const moved = new Date(payload.remindAt) + expect([moved.getHours(), moved.getMinutes()]).toEqual([7, 15]) + }) }) diff --git a/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx b/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx index 32e8ea8d5..79d551ec3 100644 --- a/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx +++ b/apps/desktop/src/renderer/src/components/journal/journal-reminder-button.tsx @@ -43,7 +43,7 @@ export function JournalReminderButton({ const { settings: { clockFormat } } = useGeneralSettings() - const { hasActiveReminder, nextReminder, activeReminderCount, actions } = + const { activeReminders, hasActiveReminder, nextReminder, activeReminderCount, actions } = useJournalReminders(journalDate) const handleSetReminder = async (date: Date, note?: string): Promise => { @@ -74,6 +74,9 @@ export function JournalReminderButton({ telemetrySurface="journal" showNote disabled={disabled} + reminders={activeReminders} + onEdit={(id, date, note) => void actions.editReminder(id, date, note)} + onDelete={(id) => void actions.deleteReminder(id)} trigger={