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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 22:14:07 +02:00
1 parent fe13fa966b
commit 801295f55a
18 files changed
+372 -53

No files matched your search

+6 -10
View File
@@ -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<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
+6 -10
View File
@@ -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<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
@@ -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<typeof import("@/lib/i18n/context")>();
return {
...actual,
useFormatters: () => makeFormatters("en", zone.current, "AUTO", "AUTO"),
};
});
const { DocumentCard } = await import("../document-card");
function doc(overrides: Partial<InboundDocumentDto>): 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(
<I18nProvider initialLocale="en">
<DocumentCard
document={document}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>
</I18nProvider>,
);
}
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");
});
});
+4 -4
View File
@@ -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;
@@ -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");
@@ -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<VerifyDraft>(() => draftFor(fact));
@@ -260,7 +261,7 @@ function FactRow({
</p>
<p className="text-muted-foreground text-xs">
{typeLabel}
{date ? ` · ${format.date(`${date}T12:00:00.000Z`)}` : ""}
{date ? ` · ${calendarDate(date)}` : ""}
{isObservation &&
(fact.data as ObservationFactData).referenceText ? (
<>
@@ -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}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
{documentDate(doc)}
</span>
</span>
<Check
@@ -14,6 +14,7 @@
* module-gated surfaces never leak. An episode without documents shows a
* quiet one-line affordance, not a teaching empty state.
*/
import { useDocumentDate } from "@/components/documents/use-document-date";
import { useQuery } from "@tanstack/react-query";
import { FolderOpen, Link2, Upload } from "lucide-react";
import Link from "next/link";
@@ -28,13 +29,12 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
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 type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentLinkPicker } from "./document-link-picker";
import { ImportFromSourceButton } from "./sources/import-from-source-button";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
/** Compact preview size — "Alle anzeigen" carries the long tail. */
const PREVIEW_LIMIT = 5;
@@ -46,7 +46,7 @@ interface ListPage {
export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const documentDate = useDocumentDate();
const { user } = useAuth();
// Linking a document to an episode writes through
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
@@ -137,7 +137,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
{title}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
{documentDate(doc)}
</span>
</Link>
</ListRow>
@@ -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<DocumentPickerSystem | null>(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,
@@ -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<InboundDocumentDto, "documentDate" | "createdAt">,
) => string {
const fmt = useFormatters();
const calendarDate = useCalendarDate();
return useCallback(
(doc) =>
doc.documentDate
? calendarDate(doc.documentDate)
: fmt.date(doc.createdAt),
[fmt, calendarDate],
);
}
@@ -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<typeof import("@/lib/timezone-mirror")>()),
readStoredTimezone: () => zone.current,
}));
vi.mock("@/lib/i18n/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/i18n/context")>();
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(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<LabHistoryList readings={readings} />
</I18nProvider>
</QueryClientProvider>,
);
}
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");
});
});
+4 -6
View File
@@ -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}
/>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
</span>
<span className="whitespace-nowrap">{labDate(r.takenAt)}</span>
<ReferenceRangeBadge status={r.rangeStatus} />
{r.hasNote ? (
<span className="text-xs">{t("labs.hasNote")}</span>
@@ -433,7 +431,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDateShort(r.takenAt, true)}
{labDate(r.takenAt)}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
+4 -4
View File
@@ -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 } = {}) {
<span className="text-foreground font-semibold tabular-nums">
{formatLabReading(group.latest, labNumber)}
</span>
<span>{formatDate(group.latest.takenAt)}</span>
<span>{labDate(group.latest.takenAt)}</span>
</div>
</div>
);
@@ -394,7 +394,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
className="text-xs"
/>
<span className="text-xs">
{formatDate(group.latest.takenAt)}
{labDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
+22
View File
@@ -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],
);
}
@@ -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}
</span>
<span className="text-muted-foreground block text-xs">
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
{documentDate(doc)}
</span>
</span>
<Check
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useCallback } from "react";
import { formatDate } from "@/lib/date-format";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
/**
* Format a date-only value: a `YYYY-MM-DD` key, or a stored instant that
* stands for a calendar date (noon UTC, or a `@db.Date` column's UTC
* midnight). The calendar date is read in UTC, so it is the same day in
* every zone.
*
* Formatting such a value in the reader's zone is how a noon-UTC date came
* to show the next day from UTC+12 to UTC+14. Same field order and locale as
* `useFormatters().date`, so it sits beside instant-formatted dates without
* looking different.
*/
export function useCalendarDate(): (value: string | Date) => 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],
);
}
+10
View File
@@ -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);
});
});
+18
View File
@@ -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: