From 801295f55a49c50a7741275cb452d540fd3428f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 22:14:07 +0200 Subject: [PATCH] fix(dates): read date-only lab and document dates as calendar dates A date-only value is stored at 12:00 UTC. The lab list and history, the document cards and pickers, the document date suggestion, extracted fact dates, source-picker rows and the compliance and mood heatmaps formatted that instant in the reader's zone, which is the next day from UTC+12 to UTC+14. `useCalendarDate` formats such a value through the UTC-pinned calendar formatter the vaccination list already uses, with the same field order and locale as instant dates. Lab readings hold both kinds, so `useLabDate` reads a noon-UTC anchor as its calendar date and a reading typed with its time of day in the reader's zone. A document without a filing date shows its upload day, an instant, in the reader's zone. --- src/components/charts/compliance-heatmap.tsx | 16 +-- src/components/charts/mood-heatmap.tsx | 16 +-- .../__tests__/document-card-dates.test.tsx | 106 +++++++++++++++++ src/components/documents/document-card.tsx | 8 +- .../documents/document-detail-sheet.tsx | 4 +- .../documents/document-facts-review.tsx | 7 +- .../documents/document-link-picker.tsx | 8 +- .../documents/episode-documents-card.tsx | 8 +- .../sources/document-source-picker.tsx | 7 +- src/components/documents/use-document-date.ts | 27 +++++ .../labs/__tests__/lab-history-dates.test.tsx | 110 ++++++++++++++++++ src/components/labs/lab-history-list.tsx | 10 +- src/components/labs/lab-list.tsx | 8 +- src/components/labs/use-lab-format.ts | 22 ++++ .../settings/share-document-picker.tsx | 8 +- src/hooks/use-calendar-date.ts | 32 +++++ src/lib/tz/__tests__/date-only.test.ts | 10 ++ src/lib/tz/date-only.ts | 18 +++ 18 files changed, 372 insertions(+), 53 deletions(-) create mode 100644 src/components/documents/__tests__/document-card-dates.test.tsx create mode 100644 src/components/documents/use-document-date.ts create mode 100644 src/components/labs/__tests__/lab-history-dates.test.tsx create mode 100644 src/hooks/use-calendar-date.ts diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index cc4a0583b..b4fe29356 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,11 +1,8 @@ "use client"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState, useMemo, useEffect, useRef } from "react"; -import { - useDisplayTimezone, - useFormatters, - useTranslations, -} from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { heatmapDays } from "@/lib/charts/heatmap-days"; interface DailyData { @@ -83,12 +80,11 @@ export function ComplianceHeatmap({ stretch = false, }: ComplianceHeatmapProps) { const { t } = useTranslations(); - const fmt = useFormatters(); const timeZone = useDisplayTimezone(); - // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the - // locale-aware renderer never lands a day off in either direction. - const formatDay = (dateKey: string) => - fmt.date(new Date(`${dateKey}T12:00:00Z`)); + // Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone. + // A noon-UTC instant formatted in the reader's zone was a day late from + // UTC+12 to UTC+14. + const formatDay = useCalendarDate(); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 959d25d7e..6e0276ea8 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -1,11 +1,8 @@ "use client"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState, useMemo, useEffect, useRef } from "react"; -import { - useDisplayTimezone, - useFormatters, - useTranslations, -} from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { heatmapDays } from "@/lib/charts/heatmap-days"; import { moodLabelKeyForScore } from "@/lib/mood/labels"; @@ -66,12 +63,11 @@ export function MoodHeatmap({ stretch = false, }: MoodHeatmapProps) { const { t } = useTranslations(); - const fmt = useFormatters(); const timeZone = useDisplayTimezone(); - // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the - // locale-aware renderer never lands a day off in either direction. - const formatDay = (dateKey: string) => - fmt.date(new Date(`${dateKey}T12:00:00Z`)); + // Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone. + // A noon-UTC instant formatted in the reader's zone was a day late from + // UTC+12 to UTC+14. + const formatDay = useCalendarDate(); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ diff --git a/src/components/documents/__tests__/document-card-dates.test.tsx b/src/components/documents/__tests__/document-card-dates.test.tsx new file mode 100644 index 000000000..dcd2e86ba --- /dev/null +++ b/src/components/documents/__tests__/document-card-dates.test.tsx @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { makeFormatters } from "@/lib/format-locale"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +/** + * A document's filing date is a calendar date. The card used to anchor it at + * 12:00 UTC and format that instant in the reader's zone, which shows the + * next day from UTC+12 to UTC+14. A document without a filing date shows its + * upload day, an instant, in the reader's zone. + * + * Server rendering cannot see the reader's profile zone, so the formatter + * hook is handed one. + */ +const zone = vi.hoisted(() => ({ current: "UTC" })); + +vi.mock("@/lib/i18n/context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormatters: () => makeFormatters("en", zone.current, "AUTO", "AUTO"), + }; +}); + +const { DocumentCard } = await import("../document-card"); + +function doc(overrides: Partial): InboundDocumentDto { + return { + id: "doc-1", + kind: "IMAGING", + title: "Scan", + filename: "scan.pdf", + mimeType: "application/pdf", + byteSize: 1000, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: null, + errorReason: null, + factCount: 0, + pendingCount: 0, + conditionLinks: [], + encounterLinks: [], + servingClass: "inline", + hasContentIndex: false, + contentIndexSource: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, + hasThumbnail: false, + sourceSystem: null, + sourceId: null, + createdAt: "2025-10-04T20:30:00.000Z", + updatedAt: "2025-10-04T20:30:00.000Z", + ...overrides, + }; +} + +function render(document: InboundDocumentDto) { + const noop = () => {}; + return renderToStaticMarkup( + + + , + ); +} + +const previousTz = process.env.TZ; + +describe("document card dates across zones", () => { + afterEach(() => { + process.env.TZ = previousTz; + zone.current = "UTC"; + }); + + describe.each(["Pacific/Kiritimati", "America/Los_Angeles"])( + "in %s", + (tz) => { + beforeEach(() => { + process.env.TZ = tz; + zone.current = tz; + }); + + it("shows the filing date as stated", () => { + const html = render(doc({ documentDate: "2025-10-04" })); + expect(html).toContain("10/04/2025"); + expect(html).not.toContain("10/05/2025"); + expect(html).not.toContain("10/03/2025"); + }); + }, + ); + + it("shows the upload day in the reader's zone when no filing date is set", () => { + // 20:30 UTC on 4 October is 10:30 on 5 October in Kiritimati. + process.env.TZ = "Pacific/Kiritimati"; + zone.current = "Pacific/Kiritimati"; + expect(render(doc({ documentDate: null }))).toContain("10/05/2025"); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 6fa88499e..92f12addf 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -16,6 +16,7 @@ * whole card is clickable through an invisible overlay button; the checkbox * floats above it. */ +import { useDocumentDate } from "@/components/documents/use-document-date"; import { Check, Download, Loader2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -24,14 +25,13 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; import { documentCardKeyAction, - documentDateKey, formatBytes, isDocumentProcessing, } from "./vault-utils"; @@ -71,7 +71,7 @@ export function DocumentCard({ onPrefetch?: (id: string) => void; }) { const { t, locale } = useTranslations(); - const format = useFormatters(); + const documentDate = useDocumentDate(); // A preview thumbnail that fails to load (still rendering, decrypt error, // 404) falls back to the kind icon — never a broken image. @@ -101,7 +101,7 @@ export function DocumentCard({ const title = document.title ?? document.filename ?? t("documents.card.untitled"); const Icon = DOCUMENT_KIND_ICONS[document.kind]; - const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`); + const date = documentDate(document); const size = formatBytes(document.byteSize, locale); const showFilename = document.filename !== null && document.filename !== title; diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 070fcec12..4ced44eca 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -16,6 +16,7 @@ * copy. A Share action in the footer opens the clinician share-link create * flow (shared `ShareLinkCreateForm`) with this document pre-attached. */ +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -344,6 +345,7 @@ export function DocumentDetailSheet({ }) { const { t, tCount, locale } = useTranslations(); const format = useFormatters(); + const calendarDate = useCalendarDate(); const queryClient = useQueryClient(); // v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL // fenced coach conversation in the shared side drawer (scoped to this @@ -695,7 +697,7 @@ export function DocumentDetailSheet({ ? t(`documents.kind.${suggestion.kind}`) : null; const suggestionDateLabel = suggestion?.documentDate - ? format.date(`${suggestion.documentDate}T12:00:00.000Z`) + ? calendarDate(suggestion.documentDate) : null; const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled"); diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index 4885a93fa..ae20eedff 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -24,6 +24,7 @@ * rejected (stated in the copy); an unverified low-confidence fact gets NO * decision and stays pending rather than being silently discarded. */ +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState } from "react"; import { FlaskConical, Loader2, ScanSearch } from "lucide-react"; import { toast } from "sonner"; @@ -34,7 +35,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { ConditionFactData, @@ -209,7 +210,7 @@ function FactRow({ failure: string | null; }) { const { t } = useTranslations(); - const format = useFormatters(); + const calendarDate = useCalendarDate(); const editFact = useEditFact(); const [verifying, setVerifying] = useState(false); const [draft, setDraft] = useState(() => draftFor(fact)); @@ -260,7 +261,7 @@ function FactRow({

{typeLabel} - {date ? ` · ${format.date(`${date}T12:00:00.000Z`)}` : ""} + {date ? ` · ${calendarDate(date)}` : ""} {isObservation && (fact.data as ObservationFactData).referenceText ? ( <> diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index aa5c50158..3cc1edcc4 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -12,6 +12,7 @@ * Shows one page (50) of matches — the search input is the way to reach an * older document, mirroring the vault's own recall model. */ +import { useDocumentDate } from "@/components/documents/use-document-date"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -22,7 +23,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { @@ -30,7 +31,6 @@ import type { InboundDocumentDto, } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; -import { documentDateKey } from "./vault-utils"; interface ListPage { documents: InboundDocumentDto[]; @@ -47,7 +47,7 @@ export function DocumentLinkPicker({ onOpenChange: (open: boolean) => void; }) { const { t } = useTranslations(); - const format = useFormatters(); + const documentDate = useDocumentDate(); const queryClient = useQueryClient(); const [searchDraft, setSearchDraft] = useState(""); @@ -174,7 +174,7 @@ export function DocumentLinkPicker({ {title} - {format.date(`${documentDateKey(doc)}T12:00:00.000Z`)} + {documentDate(doc)} - {format.date(`${documentDateKey(doc)}T12:00:00.000Z`)} + {documentDate(doc)} diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 09dc62404..cbd599ca5 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -27,6 +27,7 @@ * With either, a document already in HealthLog can be picked too: it is not * stored twice, it is linked or handed to the form. */ +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useInfiniteQuery, useQuery, @@ -66,7 +67,7 @@ import { type DocumentSourceSearchDto, type DocumentSourceTagDto, } from "@/lib/documents/sources/types"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; @@ -119,7 +120,7 @@ export function DocumentSourcePicker({ onImported, }: DocumentSourcePickerProps) { const { t, locale } = useTranslations(); - const format = useFormatters(); + const calendarDate = useCalendarDate(); const queryClient = useQueryClient(); const [system, setSystem] = useState(null); @@ -581,7 +582,7 @@ export function DocumentSourcePicker({ const run = runs.get(row.sourceId); const allowed = selectable(row); const meta = [ - row.date ? format.date(`${row.date}T12:00:00.000Z`) : null, + row.date ? calendarDate(row.date) : null, row.sizeBytes !== null ? formatBytes(row.sizeBytes, locale) : null, diff --git a/src/components/documents/use-document-date.ts b/src/components/documents/use-document-date.ts new file mode 100644 index 000000000..f0d08ec7f --- /dev/null +++ b/src/components/documents/use-document-date.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useCallback } from "react"; + +import { useCalendarDate } from "@/hooks/use-calendar-date"; +import { useFormatters } from "@/lib/i18n/context"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +/** + * The date a document files under, as a reader sees it. A filing date is a + * calendar date and reads the same in every zone; a document without one + * shows the day it was uploaded, which is an instant and reads in the + * reader's zone. + */ +export function useDocumentDate(): ( + doc: Pick, +) => string { + const fmt = useFormatters(); + const calendarDate = useCalendarDate(); + return useCallback( + (doc) => + doc.documentDate + ? calendarDate(doc.documentDate) + : fmt.date(doc.createdAt), + [fmt, calendarDate], + ); +} diff --git a/src/components/labs/__tests__/lab-history-dates.test.tsx b/src/components/labs/__tests__/lab-history-dates.test.tsx new file mode 100644 index 000000000..9e1766436 --- /dev/null +++ b/src/components/labs/__tests__/lab-history-dates.test.tsx @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { makeFormatters } from "@/lib/format-locale"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { LabResultDto } from "../types"; + +/** + * A reading imported from a report that states only its date is stored at + * 12:00 UTC. Formatted in the reader's zone it read as the next day from + * UTC+12 to UTC+14. A reading typed with its time of day is an instant and + * keeps reading in the reader's zone. + * + * Server rendering cannot see the reader's profile zone, so the test hands + * it over the two ways the app reads it: the formatter hook and the + * mirrored zone the legacy helpers read. + */ +const zone = vi.hoisted(() => ({ current: "UTC" })); + +vi.mock("@/lib/timezone-mirror", async (importOriginal) => ({ + ...(await importOriginal()), + readStoredTimezone: () => zone.current, +})); +vi.mock("@/lib/i18n/context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormatters: () => makeFormatters("en", zone.current, "AUTO", "AUTO"), + }; +}); +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiDelete: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), +})); + +const { LabHistoryList } = await import("../lab-history-list"); + +function reading(id: string, takenAt: string): LabResultDto { + return { + id, + biomarkerId: "bm-1", + panel: null, + analyte: "TSH", + value: 1.8, + valueText: null, + unit: "mIU/L", + referenceLow: 0.4, + referenceHigh: 4, + catalogReferenceLow: 0.4, + catalogReferenceHigh: 4, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt, + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: takenAt, + updatedAt: takenAt, + } as LabResultDto; +} + +function render(readings: LabResultDto[]) { + return renderToStaticMarkup( + + + + + , + ); +} + +const previousTz = process.env.TZ; + +describe("lab reading dates across zones", () => { + afterEach(() => { + process.env.TZ = previousTz; + zone.current = "UTC"; + }); + + describe.each(["Pacific/Kiritimati", "America/Los_Angeles"])( + "in %s", + (tz) => { + beforeEach(() => { + process.env.TZ = tz; + zone.current = tz; + }); + + it("shows a date-only reading on its stated date", () => { + const html = render([reading("r1", "2025-10-04T12:00:00.000Z")]); + expect(html).toContain("10/04/2025"); + expect(html).not.toContain("10/05/2025"); + expect(html).not.toContain("10/03/2025"); + }); + }, + ); + + it("dates a reading typed with its time of day in the reader's zone", () => { + // 20:30 UTC on 4 October is 10:30 on 5 October in Kiritimati. + process.env.TZ = "Pacific/Kiritimati"; + zone.current = "Pacific/Kiritimati"; + const html = render([reading("r1", "2025-10-04T20:30:00.000Z")]); + expect(html).toContain("10/05/2025"); + }); +}); diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index fa69d2405..a42edbc90 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -28,7 +28,6 @@ import { Textarea } from "@/components/ui/textarea"; import { useTableSort } from "@/hooks/use-table-sort"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; -import { formatDateShort } from "@/lib/format"; import { formatLabReading } from "@/lib/labs/format-value"; import { resolveNoteForUpdate } from "@/lib/labs/note-update"; import { useTranslations } from "@/lib/i18n/context"; @@ -38,7 +37,7 @@ import { ReferenceRangeBadge } from "./reference-range-badge"; import { LabReferenceRangeBar } from "./lab-reference-range-bar"; import { SourceRangeNote } from "./source-range-note"; import type { LabResultDetailDto, LabResultDto } from "./types"; -import { useLabNumber } from "./use-lab-format"; +import { useLabDate, useLabNumber } from "./use-lab-format"; const NOTE_MAX_LENGTH = 2000; @@ -79,6 +78,7 @@ function parseDecimal(raw: string): number | null { export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const { t } = useTranslations(); const labNumber = useLabNumber(); + const labDate = useLabDate(); const { canManageDomain } = useRecordCapabilities(); const canManageLabs = canManageDomain("labs"); const queryClient = useQueryClient(); @@ -364,9 +364,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { unit={r.unit} />

- - {formatDateShort(r.takenAt, true)} - + {labDate(r.takenAt)} {r.hasNote ? ( {t("labs.hasNote")} @@ -433,7 +431,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
- {formatDateShort(r.takenAt, true)} + {labDate(r.takenAt)}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 3e0f5f761..f57e1c5d3 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -13,7 +13,6 @@ import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; -import { formatDate } from "@/lib/format"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabReading } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -22,7 +21,7 @@ import { queryKeys } from "@/lib/query-keys"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { LabTrendSparkline } from "./lab-trend-sparkline"; -import { useLabNumber } from "./use-lab-format"; +import { useLabDate, useLabNumber } from "./use-lab-format"; import { LabReferenceRangeBar } from "./lab-reference-range-bar"; import { ReferenceRangeBadge } from "./reference-range-badge"; import { SourceRangeNote } from "./source-range-note"; @@ -124,6 +123,7 @@ const MOBILE_CELL = { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); const labNumber = useLabNumber(); + const labDate = useLabDate(); const { canWriteDomain } = useRecordCapabilities(); const canAddLab = canWriteDomain("labs"); const { prefs } = useModuleListPrefs("labs"); @@ -259,7 +259,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {formatLabReading(group.latest, labNumber)} - {formatDate(group.latest.takenAt)} + {labDate(group.latest.takenAt)}
); @@ -394,7 +394,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { className="text-xs" /> - {formatDate(group.latest.takenAt)} + {labDate(group.latest.takenAt)} {group.readings.length > 1 ? ( diff --git a/src/components/labs/use-lab-format.ts b/src/components/labs/use-lab-format.ts index a153ecc11..ef7820fc2 100644 --- a/src/components/labs/use-lab-format.ts +++ b/src/components/labs/use-lab-format.ts @@ -2,8 +2,10 @@ import { useCallback } from "react"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useFormatters } from "@/lib/i18n/context"; import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value"; +import { isNoonUtcAnchor } from "@/lib/tz/date-only"; /** * A lab value in the reader's number format, trimmed the way every lab @@ -16,3 +18,23 @@ export function useLabNumber(): LabNumberFormat { [fmt], ); } + +/** + * The date a lab reading was taken. A reading entered by hand carries its + * time of day and is dated in the reader's zone; one imported from a report + * that states only the date is stored at noon UTC and is dated by that + * calendar date, so it does not move to the next day east of UTC+12. + */ +export function useLabDate(): (takenAt: string) => string { + const fmt = useFormatters(); + const calendarDate = useCalendarDate(); + return useCallback( + (takenAt: string) => { + const instant = new Date(takenAt); + return isNoonUtcAnchor(instant) + ? calendarDate(instant) + : fmt.date(instant); + }, + [fmt, calendarDate], + ); +} diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx index 4320f448f..70509f8d5 100644 --- a/src/components/settings/share-document-picker.tsx +++ b/src/components/settings/share-document-picker.tsx @@ -13,6 +13,7 @@ * the central factory; reads unwrap `(await res.json()).data` via `apiGet`. * No write happens here — attaching is the create mutation's job. */ +import { useDocumentDate } from "@/components/documents/use-document-date"; import { useQuery } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -23,9 +24,8 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { documentDateKey } from "@/components/documents/vault-utils"; import { apiGet } from "@/lib/api/api-fetch"; -import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; @@ -55,7 +55,7 @@ export function ShareDocumentPicker({ max: number; }) { const { t } = useTranslations(); - const format = useFormatters(); + const documentDate = useDocumentDate(); const [searchDraft, setSearchDraft] = useState(""); const [query, setQuery] = useState(""); @@ -195,7 +195,7 @@ export function ShareDocumentPicker({ {title} - {format.date(`${documentDateKey(doc)}T12:00:00.000Z`)} + {documentDate(doc)} string { + const { locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); + return useCallback( + (value: string | Date) => + formatDate( + // `formatDate` reads a bare key as a calendar date already. + typeof value === "string" ? value.slice(0, 10) : value, + dateFormat, + locale, + ), + [dateFormat, locale], + ); +} diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts index 6c09a06b2..541fddbce 100644 --- a/src/lib/tz/__tests__/date-only.test.ts +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isNoonUtcAnchor, dateOnlyAtNoonUtc, dateOnlyKey, dayKeyAsUtcMidnight, @@ -50,3 +51,12 @@ describe("calendar key arithmetic", () => { expect(weekdayOfDateKey("2026-10-04")).toBe(0); // Sunday }); }); + +describe("isNoonUtcAnchor", () => { + it("recognises the stored anchor of a date-only value and nothing else", () => { + expect(isNoonUtcAnchor(dateOnlyAtNoonUtc("2025-10-04"))).toBe(true); + expect(isNoonUtcAnchor(new Date("2025-10-04T12:00:01.000Z"))).toBe(false); + expect(isNoonUtcAnchor(new Date("2025-10-04T00:00:00.000Z"))).toBe(false); + expect(isNoonUtcAnchor(new Date("2025-10-04T20:30:00.000Z"))).toBe(false); + }); +}); diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index 589fee3c5..5018f530a 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -58,6 +58,24 @@ export function dateOnlyAtNoonUtc(key: string): Date { return new Date(`${key}T12:00:00.000Z`); } +/** + * True when a stored instant is the noon-UTC anchor of a date-only value. + * + * For a column that holds both kinds (a lab reading typed with its time of + * day, or imported from a report that states only the date), this is how a + * reader tells them apart. A reading typed at exactly 12:00:00.000 UTC reads + * as its UTC calendar date, which is its local date everywhere from UTC-11 + * to UTC+11. + */ +export function isNoonUtcAnchor(value: Date): boolean { + return ( + value.getUTCHours() === 12 && + value.getUTCMinutes() === 0 && + value.getUTCSeconds() === 0 && + value.getUTCMilliseconds() === 0 + ); +} + /** * The calendar date of a stored date-only value (or of a `@db.Date` column, * which Prisma hands back as UTC midnight). Only for values that ARE dates: