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: