mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'feat/biomarker-detail-page' into release/v1.25.0
This commit is contained in:
commit
95ee088646
36 files changed
+2209
-147
No files matched your search
@@ -44,6 +44,12 @@ export interface AskCoachActionProps {
|
||||
* briefing), which read better against the default all-source snapshot.
|
||||
*/
|
||||
scope?: CoachLaunchScope;
|
||||
/**
|
||||
* Auto-send the seeded question as the conversation's first turn instead
|
||||
* of only seeding the composer. The assessment hand-off sets this so the
|
||||
* Coach answers immediately; defaults to false (seed-only).
|
||||
*/
|
||||
autoSend?: boolean;
|
||||
/** Optional visible-label override; defaults to the shared CTA copy. */
|
||||
label?: string;
|
||||
/** className passthrough for per-card alignment. */
|
||||
@@ -53,6 +59,7 @@ export interface AskCoachActionProps {
|
||||
export function AskCoachAction({
|
||||
question,
|
||||
scope,
|
||||
autoSend,
|
||||
label,
|
||||
className,
|
||||
}: AskCoachActionProps) {
|
||||
@@ -76,7 +83,7 @@ export function AskCoachAction({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="ask-coach-action"
|
||||
onClick={() => launch.askCoach(question, scope)}
|
||||
onClick={() => launch.askCoach(question, scope, autoSend)}
|
||||
className={cn(
|
||||
// Discreet: muted text, hover lifts to foreground, no fill. Tight
|
||||
// height so it sits as a quiet footer link, not a primary CTA.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { useEffect, useReducer, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -117,6 +117,13 @@ export interface CoachConversationProps {
|
||||
* established scope. Null → the route's default all-source snapshot.
|
||||
*/
|
||||
launchScope?: CoachLaunchScope | null;
|
||||
/**
|
||||
* When true, the `prefill` is dispatched as the conversation's first turn
|
||||
* automatically, exactly once on mount (ref-guarded). Used by the
|
||||
* assessment hand-off so the answer lands without a manual send. The send
|
||||
* only fires for a fresh conversation with a non-empty prefill.
|
||||
*/
|
||||
autoSend?: boolean;
|
||||
/**
|
||||
* Renders the conversation title. The surface passes the resolved
|
||||
* title string; the drawer wraps it in `<SheetTitle>`, the page in an
|
||||
@@ -188,6 +195,7 @@ export interface CoachConversationProps {
|
||||
export function CoachConversation({
|
||||
prefill,
|
||||
launchScope,
|
||||
autoSend,
|
||||
renderTitle,
|
||||
renderDescription,
|
||||
leadingHeaderActions,
|
||||
@@ -340,6 +348,33 @@ export function CoachConversation({
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-send the prefill as the conversation's first turn, exactly once.
|
||||
// A card hand-off (e.g. the assessment "ask about this") opens the Coach
|
||||
// with `autoSend` so the answer lands without a manual send. Ref-guarded so
|
||||
// it fires a single time per mount; only for a fresh conversation with a
|
||||
// non-empty prefill and no stream in flight.
|
||||
const autoSentRef = useRef(false);
|
||||
// Latest-handler ref so the auto-send effect never lists `handleSubmit` (it
|
||||
// re-creates each render) and never calls it synchronously in the effect
|
||||
// body — the dispatch is deferred to a microtask so the composer state it
|
||||
// touches settles outside the effect.
|
||||
const handleSubmitRef = useRef(handleSubmit);
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!autoSend) return;
|
||||
if (autoSentRef.current) return;
|
||||
if (currentConversationId !== null) return;
|
||||
if (send.isStreaming) return;
|
||||
const seed = (prefill ?? "").trim();
|
||||
if (!seed) return;
|
||||
autoSentRef.current = true;
|
||||
queueMicrotask(() => {
|
||||
void handleSubmitRef.current(seed);
|
||||
});
|
||||
}, [autoSend, prefill, currentConversationId, send.isStreaming]);
|
||||
|
||||
// v1.22 — "Try again": regenerate an assistant reply by resubmitting the
|
||||
// user turn that produced it as a FRESH turn. The composer value is left
|
||||
// untouched (unlike `handleSubmit`, which clears it) so a half-typed
|
||||
|
||||
@@ -54,6 +54,11 @@ export interface CoachDrawerProps {
|
||||
* source(s) + window. Null → the route's default all-source snapshot.
|
||||
*/
|
||||
scope?: CoachLaunchScope | null;
|
||||
/**
|
||||
* When true, the conversation auto-sends `prefill` as its first turn
|
||||
* exactly once on open. Used by the assessment hand-off.
|
||||
*/
|
||||
autoSend?: boolean;
|
||||
}
|
||||
|
||||
export function CoachDrawer({
|
||||
@@ -61,6 +66,7 @@ export function CoachDrawer({
|
||||
onOpenChange,
|
||||
prefill,
|
||||
scope,
|
||||
autoSend,
|
||||
}: CoachDrawerProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -139,6 +145,7 @@ export function CoachDrawer({
|
||||
surface="drawer"
|
||||
prefill={prefill}
|
||||
launchScope={scope}
|
||||
autoSend={autoSend}
|
||||
autoFocusComposer
|
||||
registerReset={registerReset}
|
||||
// v1.16.1 — the "Conversations" affordance hands off to a
|
||||
|
||||
@@ -69,6 +69,11 @@ interface InsightStatusCardProps {
|
||||
*/
|
||||
coachQuestion?: string;
|
||||
coachScope?: CoachLaunchScope;
|
||||
/**
|
||||
* Auto-send the seeded opener as the first turn (assessment hand-off)
|
||||
* instead of only seeding the composer. Defaults to false.
|
||||
*/
|
||||
coachAutoSend?: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────
|
||||
@@ -83,6 +88,7 @@ export function InsightStatusCard({
|
||||
preparing = false,
|
||||
coachQuestion,
|
||||
coachScope,
|
||||
coachAutoSend,
|
||||
}: InsightStatusCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const flags = useFeatureFlags();
|
||||
@@ -248,7 +254,11 @@ export function InsightStatusCard({
|
||||
caller supplied an opener. */}
|
||||
{coachQuestion ? (
|
||||
<div className="flex justify-end">
|
||||
<AskCoachAction question={coachQuestion} scope={coachScope} />
|
||||
<AskCoachAction
|
||||
question={coachQuestion}
|
||||
scope={coachScope}
|
||||
autoSend={coachAutoSend}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -56,6 +56,9 @@ export function LayoutCoachMount() {
|
||||
// opened from a metric surface or insight card is pre-narrowed to
|
||||
// the relevant source(s).
|
||||
scope={launch.scope}
|
||||
// Auto-send the prefill as the first turn when the launch requested it
|
||||
// (assessment hand-off), so the answer lands without a manual send.
|
||||
autoSend={launch.autoSend}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -46,12 +46,9 @@ export function MetricStatusCard({
|
||||
// carry a snapshot block. Unmapped metrics still get a metric-named
|
||||
// opener, just against the default snapshot.
|
||||
const coachSource = scopeSourceFromMetricKey(metric);
|
||||
const coachQuestion = `Walk me through my ${metric
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/_/g,
|
||||
" ",
|
||||
)} assessment — what does it mean and what should I do about it?`;
|
||||
const coachQuestion = t("insights.coach.assessmentPrompt", {
|
||||
metric: metric.toLowerCase().replace(/_/g, " "),
|
||||
});
|
||||
|
||||
return (
|
||||
<InsightStatusCard
|
||||
@@ -62,6 +59,7 @@ export function MetricStatusCard({
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
coachQuestion={coachQuestion}
|
||||
coachScope={coachSource ? { metric: coachSource } : undefined}
|
||||
coachAutoSend
|
||||
// Same hydration gate as `<SlugInsightStatusCard>`: the hook is
|
||||
// `enabled: isAuthenticated && enabled`, and both inputs can settle
|
||||
// before a late-hydrating boundary replays its first render — the
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
BiomarkerDto,
|
||||
LabResultDto,
|
||||
LabResultListResponse,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* The per-biomarker detail page mirrors the metric sub-pages: a numbers-first
|
||||
* stat strip over the numeric readings, the (lazy) chart, the history, and the
|
||||
* AI assessment card. This test seeds the marker + reading queries and asserts
|
||||
* the strip, the chart placeholder, and the assessment card all paint.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiDelete: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-feature-flags", () => ({
|
||||
useFeatureFlags: () => ({ insightStatus: true }),
|
||||
}));
|
||||
|
||||
import { LabBiomarkerDetail } from "../lab-biomarker-detail";
|
||||
|
||||
const BIOMARKER_ID = "bm-1";
|
||||
|
||||
const MARKER: BiomarkerDto = {
|
||||
id: BIOMARKER_ID,
|
||||
name: "LDL Cholesterol",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 0,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
hasContext: false,
|
||||
context: null,
|
||||
hidden: false,
|
||||
createdAt: "2026-06-01T00:00:00.000Z",
|
||||
updatedAt: "2026-06-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function reading(id: string, value: number, takenAt: string): LabResultDto {
|
||||
return {
|
||||
id,
|
||||
biomarkerId: BIOMARKER_ID,
|
||||
panel: null,
|
||||
analyte: "LDL Cholesterol",
|
||||
value,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: 0,
|
||||
referenceHigh: 100,
|
||||
takenAt,
|
||||
source: "MANUAL",
|
||||
hasNote: false,
|
||||
rangeStatus: "in-range",
|
||||
createdAt: takenAt,
|
||||
updatedAt: takenAt,
|
||||
};
|
||||
}
|
||||
|
||||
const LIST: LabResultListResponse = {
|
||||
results: [
|
||||
reading("r3", 95, "2026-06-20T08:00:00.000Z"),
|
||||
reading("r2", 88, "2026-05-20T08:00:00.000Z"),
|
||||
reading("r1", 102, "2026-04-20T08:00:00.000Z"),
|
||||
],
|
||||
meta: { total: 3, limit: 500, offset: 0 },
|
||||
};
|
||||
|
||||
function renderWithCache() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.labResultsList({
|
||||
biomarkerId: BIOMARKER_ID,
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
}),
|
||||
LIST,
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"),
|
||||
{
|
||||
hasProvider: true,
|
||||
text: "Your LDL is steady inside the reference range.",
|
||||
cached: true,
|
||||
updatedAt: "2026-06-20T09:00:00.000Z",
|
||||
},
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<LabBiomarkerDetail biomarkerId={BIOMARKER_ID} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabBiomarkerDetail>", () => {
|
||||
const html = renderWithCache();
|
||||
|
||||
it("paints the marker heading", () => {
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
});
|
||||
|
||||
it("paints the numbers-first stat strip", () => {
|
||||
expect(html).toContain('data-slot="metric-stat-strip"');
|
||||
});
|
||||
|
||||
it("paints the chart placeholder (lazy chart shell)", () => {
|
||||
// The recharts chart is `next/dynamic({ ssr: false })`; the server render
|
||||
// paints the layout-stable skeleton shell in its place.
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
});
|
||||
|
||||
it("paints the assessment card", () => {
|
||||
// useMounted() is false during SSR, so the card renders its loading
|
||||
// skeleton geometry — proof the assessment card mounted on the page.
|
||||
expect(html).toContain('data-testid="insight-status-card-loading"');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Pencil, Plus } from "lucide-react";
|
||||
import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -65,11 +73,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
// v1.24 — the per-marker description used to live on the labs overview rows.
|
||||
// It belongs on the detail page beneath the heading (mirroring the metric
|
||||
// pages' explainer caption). Resolve the catalog slug from the marker name
|
||||
// exactly as the overview did, then fall back to the user's own `context`.
|
||||
const slugByName = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
|
||||
if (norm) map.set(norm, seed.slug);
|
||||
}
|
||||
return map;
|
||||
}, [t]);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
// Sticky-footer slot for the add-value sheet (the form portals here).
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
// v1.24 — focused "adjust target range" sheet. Edits only the reference
|
||||
// bounds (the full marker editor lives behind the pencil); seeded from the
|
||||
// marker each time it opens.
|
||||
const [rangeOpen, setRangeOpen] = useState(false);
|
||||
const [lowerInput, setLowerInput] = useState("");
|
||||
const [upperInput, setUpperInput] = useState("");
|
||||
|
||||
const { data: marker, isError: markerError } = useQuery({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
@@ -96,7 +122,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
),
|
||||
});
|
||||
|
||||
const readings: LabResultDto[] = list?.results ?? [];
|
||||
const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]);
|
||||
// The reading feed caps at 500 server-side; surface a hint when truncated.
|
||||
const total = list?.meta?.total ?? 0;
|
||||
const truncated = total > readings.length;
|
||||
@@ -108,6 +134,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
)[0]
|
||||
: null;
|
||||
|
||||
// v1.24 — the numbers-first stat strip + the AI assessment card mirror the
|
||||
// metric sub-pages. The strip reads a client-side `DataSummary` over the
|
||||
// numeric readings; the assessment card consumes the read-only biomarker
|
||||
// route (stale-while-revalidate, regenerated only on a new reading). The
|
||||
// strip + card both self-gate on data, so a qualitative-only or brand-new
|
||||
// marker paints neither.
|
||||
const numericReadings = useMemo(
|
||||
() => readings.filter((r) => r.value !== null),
|
||||
[readings],
|
||||
);
|
||||
const summary = useMemo<DataSummary | null>(
|
||||
() =>
|
||||
numericReadings.length > 0
|
||||
? summarize(
|
||||
numericReadings.map((r) => ({
|
||||
date: new Date(r.takenAt),
|
||||
value: r.value as number,
|
||||
})),
|
||||
)
|
||||
: null,
|
||||
[numericReadings],
|
||||
);
|
||||
const mounted = useMounted();
|
||||
const { data: assessment, isLoading: assessmentLoading } =
|
||||
useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0);
|
||||
|
||||
function afterAdd() {
|
||||
setAddOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
@@ -138,6 +190,33 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
onError: () => toast.error(t("labs.biomarker.deleteError")),
|
||||
});
|
||||
|
||||
function openRange() {
|
||||
setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : "");
|
||||
setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : "");
|
||||
setRangeOpen(true);
|
||||
}
|
||||
|
||||
const saveRange = useMutation({
|
||||
mutationFn: () => {
|
||||
const lower = lowerInput.trim() === "" ? null : Number(lowerInput);
|
||||
const upper = upperInput.trim() === "" ? null : Number(upperInput);
|
||||
return apiPut(`/api/biomarkers/${biomarkerId}`, {
|
||||
lowerBound: lower,
|
||||
upperBound: upper,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("labs.biomarker.targetRange.savedToast"));
|
||||
setRangeOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.biomarkerDetail(biomarkerId),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
|
||||
},
|
||||
onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
|
||||
});
|
||||
|
||||
if (markerError || listError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
@@ -189,6 +268,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={openRange}
|
||||
disabled={!marker}
|
||||
aria-label={t("labs.biomarker.targetRange.title")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
|
||||
@@ -223,9 +312,29 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{marker?.context ? (
|
||||
<p className="text-muted-foreground text-sm">{marker.context}</p>
|
||||
) : null}
|
||||
{(() => {
|
||||
const slug = marker?.name
|
||||
? slugByName.get(marker.name.trim().toLowerCase())
|
||||
: undefined;
|
||||
const description = slug
|
||||
? t(`labs.catalog.desc.${slug}`)
|
||||
: (marker?.context ?? null);
|
||||
return description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Numbers-first stat strip — Min / Max / Median / Mean over the
|
||||
numeric readings, mirroring the metric sub-pages. Self-gating:
|
||||
renders nothing for a qualitative-only or empty marker. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
@@ -272,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* AI assessment — the spine's closing block, mirroring the metric
|
||||
sub-pages (intro → stat strip → chart → history → assessment). The
|
||||
card self-suppresses when the operator disabled status cards, and
|
||||
the hook is gated on the marker having numeric readings. */}
|
||||
{numericReadings.length > 0 ? (
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<FlaskConical className="h-5 w-5" />}
|
||||
text={assessment?.text ?? null}
|
||||
hasProvider={assessment?.hasProvider ?? false}
|
||||
updatedAt={assessment?.updatedAt ?? null}
|
||||
coachQuestion={
|
||||
marker?.name
|
||||
? t("insights.coach.assessmentPrompt", { metric: marker.name })
|
||||
: undefined
|
||||
}
|
||||
coachAutoSend
|
||||
loading={!mounted || assessmentLoading}
|
||||
preparing={assessment?.preparing ?? false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
@@ -310,6 +441,62 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
/>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={rangeOpen}
|
||||
onOpenChange={setRangeOpen}
|
||||
title={t("labs.biomarker.targetRange.title")}
|
||||
description={t("labs.biomarker.targetRange.description")}
|
||||
footer={
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRangeOpen(false)}
|
||||
disabled={saveRange.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveRange.mutate()}
|
||||
disabled={saveRange.isPending}
|
||||
>
|
||||
{t("labs.biomarker.targetRange.save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-lower-bound">
|
||||
{t("labs.biomarker.form.lowerBound")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-lower-bound"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={lowerInput}
|
||||
onChange={(e) => setLowerInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="biomarker-upper-bound">
|
||||
{t("labs.biomarker.form.upperBound")}
|
||||
</Label>
|
||||
<Input
|
||||
id="biomarker-upper-bound"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={upperInput}
|
||||
onChange={(e) => setUpperInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("labs.biomarker.form.rangeHint")}
|
||||
</p>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { DeleteButton, SortableHead } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
@@ -30,6 +39,19 @@ import type { LabResultDetailDto, LabResultDto } from "./types";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
// The sample-date column opens descending (newest first); every other column
|
||||
// opens ascending.
|
||||
const LAB_DESC_COLUMNS = new Set(["takenAt"]);
|
||||
|
||||
// Stable ordering for the reference-range column so a sort produces a
|
||||
// predictable below → in-range → above → unknown progression.
|
||||
const RANGE_RANK: Record<string, number> = {
|
||||
below: 0,
|
||||
"in-range": 1,
|
||||
above: 2,
|
||||
unknown: 3,
|
||||
};
|
||||
|
||||
function toDateTimeLocal(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const offset = d.getTimezoneOffset();
|
||||
@@ -55,6 +77,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Client-side column sort over the in-memory reading feed.
|
||||
const { sortBy, sortDir, toggleSort } = useTableSort({
|
||||
defaultColumn: "takenAt",
|
||||
defaultDir: "desc",
|
||||
descColumns: LAB_DESC_COLUMNS,
|
||||
});
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
// v1.18.9 — true when the row being edited is a qualitative reading; the
|
||||
@@ -200,54 +229,124 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = [...readings].sort(
|
||||
(a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
|
||||
);
|
||||
const ordered = useMemo(() => {
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
return [...readings].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortBy) {
|
||||
case "value": {
|
||||
// Numeric rows compare by value; qualitative rows (value null) sort
|
||||
// after the numeric ones and tie-break on their text.
|
||||
if (a.value !== null && b.value !== null) {
|
||||
cmp = a.value - b.value;
|
||||
} else if (a.value === null && b.value === null) {
|
||||
cmp = (a.valueText ?? "").localeCompare(b.valueText ?? "");
|
||||
} else {
|
||||
cmp = a.value === null ? 1 : -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "range":
|
||||
cmp =
|
||||
(RANGE_RANK[a.rangeStatus] ?? 99) -
|
||||
(RANGE_RANK[b.rangeStatus] ?? 99);
|
||||
break;
|
||||
case "note":
|
||||
cmp = Number(a.hasNote) - Number(b.hasNote);
|
||||
break;
|
||||
case "takenAt":
|
||||
default:
|
||||
cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime();
|
||||
break;
|
||||
}
|
||||
// Stable secondary ordering by sample date keeps ties deterministic.
|
||||
if (cmp === 0) {
|
||||
cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime();
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
}, [readings, sortBy, sortDir]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="divide-border divide-y">
|
||||
{ordered.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex items-center justify-between gap-3 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabReading(r)}
|
||||
</span>
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead
|
||||
column="value"
|
||||
label={t("labs.form.value")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="takenAt"
|
||||
label={t("labs.form.takenAt")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="range"
|
||||
label={t("labs.referenceLabel")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="note"
|
||||
label={t("labs.hasNote")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
<span className="sr-only">{t("labs.editReading")}</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ordered.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
{formatLabReading(r)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{formatDateShort(r.takenAt, true)}
|
||||
{r.hasNote ? ` · ${t("labs.hasNote")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG 2.5.5),
|
||||
// compact 36px on desktop. The primary edit control for a row.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteReading")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ReferenceRangeBadge status={r.rangeStatus} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.hasNote ? t("labs.hasNote") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
|
||||
// 2.5.5), compact 36px on desktop.
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => void openEdit(r)}
|
||||
aria-label={t("labs.editReading")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(r.id)}
|
||||
title={t("labs.deleteConfirmTitle")}
|
||||
description={t("labs.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.deleteReading")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={editingId !== null}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabReading, formatLabValue } from "@/lib/labs/format-value";
|
||||
@@ -81,23 +80,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
// v1.22 — a short, factual line under each marker heading describing what the
|
||||
// biomarker measures, sourced from the catalog via i18n. Free-text markers
|
||||
// (no catalog match) carry no subtitle rather than a fabricated one.
|
||||
// v1.22.1 — build the localized name→slug map once per locale instead of
|
||||
// re-scanning all catalog seeds (calling `t()` each) per group every render.
|
||||
const slugByName = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase();
|
||||
if (norm) map.set(norm, seed.slug);
|
||||
}
|
||||
return map;
|
||||
}, [t]);
|
||||
const describeMarker = (analyte: string): string | null => {
|
||||
const norm = analyte.trim().toLowerCase();
|
||||
if (!norm) return null;
|
||||
const slug = slugByName.get(norm);
|
||||
return slug ? t(`labs.catalog.desc.${slug}`) : null;
|
||||
};
|
||||
|
||||
// v1.24 — the per-marker description moved to the biomarker detail page
|
||||
// (beneath the heading); the overview rows no longer carry it.
|
||||
const listKey = queryKeys.labResultsList({
|
||||
biomarkerId: undefined,
|
||||
analyte: undefined,
|
||||
@@ -204,7 +188,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<Card>
|
||||
<CardContent className="divide-border divide-y p-0">
|
||||
{groups.map((group) => {
|
||||
const description = describeMarker(group.analyte);
|
||||
const inner = (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
@@ -216,11 +199,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatLabReading(group.latest)}
|
||||
@@ -278,7 +256,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
</li>
|
||||
) : null}
|
||||
{groups.map((group) => {
|
||||
const description = describeMarker(group.analyte);
|
||||
return (
|
||||
<li key={group.key} className="contents">
|
||||
<Card className="h-full gap-3">
|
||||
@@ -298,11 +275,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
linkLabel={group.analyte}
|
||||
/>
|
||||
<CardContent>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useCallback, useId, useState } from "react";
|
||||
import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
|
||||
@@ -105,6 +106,10 @@ import {
|
||||
*/
|
||||
const CUMULATIVE_TYPES = new Set<string>(CUMULATIVE_DAY_SUM_TYPES);
|
||||
|
||||
// Columns that open descending when first selected. The date column reads
|
||||
// newest-first by default; every other column opens ascending.
|
||||
const MEASUREMENT_DESC_COLUMNS: ReadonlySet<string> = new Set(["measuredAt"]);
|
||||
|
||||
interface Measurement {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -313,8 +318,16 @@ export function MeasurementList({
|
||||
const [valueMinInput, setValueMinInputRaw] = useState<string>("");
|
||||
const [valueMaxInput, setValueMaxInputRaw] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<string>("measuredAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
// Shared column-sort state (measuredAt opens descending).
|
||||
const {
|
||||
sortBy,
|
||||
sortDir,
|
||||
toggleSort: applySort,
|
||||
} = useTableSort({
|
||||
defaultColumn: "measuredAt",
|
||||
defaultDir: "desc",
|
||||
descColumns: MEASUREMENT_DESC_COLUMNS,
|
||||
});
|
||||
|
||||
// v1.15.13 — page-scoped multi-select. Holds the ids selected on the
|
||||
// CURRENT page; cleared on any page / filter / sort change (per the
|
||||
@@ -399,13 +412,11 @@ export function MeasurementList({
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
// Compose the page-reset + selection-clear side effects around the
|
||||
// shared sort toggle (the rows the selection referred to are about to
|
||||
// re-order and the list snaps back to page 1).
|
||||
function toggleSort(column: string) {
|
||||
if (sortBy === column) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortDir(column === "measuredAt" ? "desc" : "asc");
|
||||
}
|
||||
applySort(column);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTableSort } from "@/hooks/use-table-sort";
|
||||
|
||||
// Columns that open descending when first selected. The logged-at column
|
||||
// reads newest-first by default; every other column opens ascending.
|
||||
const MOOD_DESC_COLUMNS: ReadonlySet<string> = new Set(["moodLoggedAt"]);
|
||||
import { toast } from "sonner";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -129,8 +134,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
const [fromDay, setFromDayRaw] = useState<string>("");
|
||||
const [toDay, setToDayRaw] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<string>("moodLoggedAt");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
// Shared column-sort state (moodLoggedAt opens descending).
|
||||
const {
|
||||
sortBy,
|
||||
sortDir,
|
||||
toggleSort: applySort,
|
||||
} = useTableSort({
|
||||
defaultColumn: "moodLoggedAt",
|
||||
defaultDir: "desc",
|
||||
descColumns: MOOD_DESC_COLUMNS,
|
||||
});
|
||||
|
||||
// v1.15.13 — page-scoped multi-select selection (current page ids only).
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
@@ -202,13 +215,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
// Compose the page-reset + selection-clear side effects around the
|
||||
// shared sort toggle.
|
||||
function toggleSort(column: string) {
|
||||
if (sortBy === column) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortDir(column === "moodLoggedAt" ? "desc" : "asc");
|
||||
}
|
||||
applySort(column);
|
||||
setPage(1);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user