From 3edb63d7590988baed21cb33c1b2043a24ae0b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 22:33:41 +0100 Subject: [PATCH] fix(illness): log a day of a recovered illness inside the episode "Log day" always opened on today and let the picker run to today, so for an illness marked recovered on Monday a note added on Thursday was filed three days after the recovery, and the day view showed a sick day with no illness beside it. Both places that open the sheet now hand it the episode's last day: today while it runs, the recovery day (read in the profile zone, as the server reads it) once it ended. The picker opens there and offers nothing later. --- .../__tests__/illness-view-nesting.test.tsx | 5 +- .../__tests__/log-day-sheet-last-day.test.ts | 48 +++++++++++++++++++ .../illness/illness-episode-detail.tsx | 5 +- src/components/illness/illness-view.tsx | 5 +- src/components/illness/log-day-sheet.tsx | 40 ++++++++++++++-- 5 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 src/components/illness/__tests__/log-day-sheet-last-day.test.ts diff --git a/src/components/illness/__tests__/illness-view-nesting.test.tsx b/src/components/illness/__tests__/illness-view-nesting.test.tsx index 547a4a20d..01f0d0c55 100644 --- a/src/components/illness/__tests__/illness-view-nesting.test.tsx +++ b/src/components/illness/__tests__/illness-view-nesting.test.tsx @@ -47,7 +47,10 @@ vi.mock("@/components/symptoms/symptoms-section", () => ({ SymptomsSection: () => null, })); vi.mock("../new-episode-sheet", () => ({ NewEpisodeSheet: () => null })); -vi.mock("../log-day-sheet", () => ({ LogDaySheet: () => null })); +vi.mock("../log-day-sheet", () => ({ + LogDaySheet: () => null, + useLastLoggableDay: (today: string) => today, +})); vi.mock("@/components/day/day-link", () => ({ DAY_LINK_SLOT: "{date}", DayLinkStated: ({ children }: { children: React.ReactNode }) => children, diff --git a/src/components/illness/__tests__/log-day-sheet-last-day.test.ts b/src/components/illness/__tests__/log-day-sheet-last-day.test.ts new file mode 100644 index 000000000..29f8bac3f --- /dev/null +++ b/src/components/illness/__tests__/log-day-sheet-last-day.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { lastLoggableDay } from "../log-day-sheet"; + +/** + * "Log day" on an illness opens on the day it suggests and bounds the picker + * there. For a recovered episode that used to be today, so one tap on Save + * filed a sick day days after the recovery, and the day view then showed it + * with no illness running beside it. + */ +describe("lastLoggableDay", () => { + it("is today while the episode runs", () => { + expect(lastLoggableDay("2026-11-12", null, "Europe/Berlin")).toBe( + "2026-11-12", + ); + }); + + it("is the recovery day once the episode ended before today", () => { + expect( + lastLoggableDay( + "2026-11-12", + "2026-11-09T08:00:00.000Z", + "Europe/Berlin", + ), + ).toBe("2026-11-09"); + }); + + it("stays today when the episode ended today", () => { + expect( + lastLoggableDay( + "2026-11-12", + "2026-11-12T08:00:00.000Z", + "Europe/Berlin", + ), + ).toBe("2026-11-12"); + }); + + it("reads the recovery day in the profile zone", () => { + // 23:30 UTC is already the next day in Berlin. + expect( + lastLoggableDay( + "2026-11-12", + "2026-11-09T23:30:00.000Z", + "Europe/Berlin", + ), + ).toBe("2026-11-10"); + }); +}); diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index 63a89e0e3..59c66a0c5 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -30,7 +30,7 @@ import { canReadBodySites } from "@/hooks/use-body-sites"; import { IllnessCorrelationCard } from "./illness-correlation-card"; import { IllnessDayTimeline } from "./illness-day-timeline"; -import { LogDaySheet } from "./log-day-sheet"; +import { LogDaySheet, useLastLoggableDay } from "./log-day-sheet"; import { NewEpisodeSheet } from "./new-episode-sheet"; import { EpisodeMenu } from "./episode-menu"; import { useIllnessEpisode, useResolveEpisode } from "./use-illness"; @@ -66,6 +66,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { const [logOpen, setLogOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const today = todayLocal(); + const logLastDay = useLastLoggableDay(today, episode?.resolvedAt); if (isError) { return ( @@ -226,7 +227,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { open={logOpen} onOpenChange={setLogOpen} episodeId={episode.id} - date={today} + date={logLastDay} onsetDate={episode.onsetAt.slice(0, 10)} /> (null); const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null; + const logLastDay = useLastLoggableDay(today, logEpisode?.resolvedAt); /** * Group the flat list into active / resolved, nesting flares and recurring @@ -532,7 +533,7 @@ export function IllnessView() { if (!open) setLogEpisodeId(null); }} episodeId={logEpisodeId} - date={today} + date={logLastDay} onsetDate={logEpisode?.onsetAt.slice(0, 10)} /> ) : null} diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 5284689ab..047f7aa21 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -24,7 +24,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { userDayKey } from "@/lib/tz/format"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; @@ -35,7 +40,11 @@ interface LogDaySheetProps { open: boolean; onOpenChange: (open: boolean) => void; episodeId: string; - /** The day to seed the picker with (usually today). */ + /** + * The last day of the episode (`YYYY-MM-DD`): the day the picker opens on + * and the latest it offers. Today while the episode runs, its recovery day + * once it ended (`useLastLoggableDay`). + */ date: string; /** * Episode onset day (`YYYY-MM-DD`) — the earliest day the picker allows, so @@ -45,6 +54,29 @@ interface LogDaySheetProps { onsetDate?: string; } +/** + * The last day a log can belong to: today, or the recovery day when the + * episode ended before today. The recovery day is read in the profile zone, + * the zone the server reads it in. + */ +export function lastLoggableDay( + today: string, + resolvedAt: string | null | undefined, + timeZone: string, +): string { + if (!resolvedAt) return today; + const resolvedDay = userDayKey(new Date(resolvedAt), timeZone); + return resolvedDay < today ? resolvedDay : today; +} + +/** `lastLoggableDay` in the viewer's profile zone. */ +export function useLastLoggableDay( + today: string, + resolvedAt: string | null | undefined, +): string { + return lastLoggableDay(today, resolvedAt, useDisplayTimezone()); +} + /** Mini toggle chip — the shared selection vocabulary. */ function Chip({ active, @@ -147,8 +179,8 @@ export function LogDaySheet({ const { toDisplay, fromDisplay, unitFor } = useUnitDisplay(); const feverUnit = unitFor("BODY_TEMPERATURE"); - // The day being logged — seeded from `date` (today), but the picker lets the - // user backdate within [onset, today] for the retrospective journal. + // The day being logged — seeded from `date` (the episode's last day), and + // the picker lets the user backdate within [onset, date] for the journal. const [selectedDate, setSelectedDate] = useState(date); // Re-seed the picker each time the sheet opens (the open transition). const [pickerWasOpen, setPickerWasOpen] = useState(false);