mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
fe13fa966b
commit
801295f55a
18 files changed
+372
-53
No files matched your search
@@ -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<{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user