mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'wave/v125-delight' into release/v1.25.0
This commit is contained in:
commit
ccbf4e07ae
28 files changed
+1877
-8
No files matched your search
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Wind } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import type {
|
||||
BreathingClassification,
|
||||
BreathingTrend,
|
||||
} from "@/lib/insights/breathing-screening";
|
||||
|
||||
/**
|
||||
* v1.25 — sleep-breathing screening card.
|
||||
*
|
||||
* A calm read off the read-only `/api/insights/breathing-screening` route: the
|
||||
* device's own classification, the recent index trend, and the device-flagged
|
||||
* event count. It un-mounts when there is no data (`present === false`). Every
|
||||
* render carries the mandatory screening-only disclaimer — this is a SCREENING
|
||||
* SIGNAL, not a diagnosis. Neutral tone, no chart, no red.
|
||||
*/
|
||||
|
||||
interface BreathingScreeningResponse {
|
||||
present: boolean;
|
||||
nights: number;
|
||||
recentMeanIndex: number | null;
|
||||
trend: BreathingTrend;
|
||||
eventCount: number;
|
||||
classification: BreathingClassification;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
interface BreathingScreeningCardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BreathingScreeningCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: BreathingScreeningCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsBreathingScreening(),
|
||||
queryFn: () =>
|
||||
apiGet<BreathingScreeningResponse>("/api/insights/breathing-screening"),
|
||||
enabled: enabled && isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!data || data.present === false) return null;
|
||||
|
||||
const classificationLabel =
|
||||
data.classification === "elevated"
|
||||
? t("insights.breathingScreening.classificationElevated")
|
||||
: data.classification === "not-elevated"
|
||||
? t("insights.breathingScreening.classificationNotElevated")
|
||||
: null;
|
||||
|
||||
const trendLabel =
|
||||
data.trend === "up"
|
||||
? t("insights.breathingScreening.trendUp")
|
||||
: data.trend === "down"
|
||||
? t("insights.breathingScreening.trendDown")
|
||||
: data.trend === "stable"
|
||||
? t("insights.breathingScreening.trendStable")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="breathing-screening-section"
|
||||
aria-label={t("insights.breathingScreening.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Wind}
|
||||
title={t("insights.breathingScreening.sectionTitle")}
|
||||
subtitle={t("insights.breathingScreening.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="breathing-screening-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
{classificationLabel ? (
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="breathing-classification"
|
||||
>
|
||||
{classificationLabel}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.breathingScreening.nights", { count: data.nights })}
|
||||
{data.recentMeanIndex != null
|
||||
? ` · ${t("insights.breathingScreening.indexLine", {
|
||||
value: data.recentMeanIndex,
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{trendLabel ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="breathing-trend"
|
||||
>
|
||||
{trendLabel}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.eventCount > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="breathing-events"
|
||||
>
|
||||
{t("insights.breathingScreening.eventsFlagged", {
|
||||
count: data.eventCount,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p
|
||||
className="text-muted-foreground mt-1 text-xs leading-snug"
|
||||
data-slot="breathing-disclaimer"
|
||||
>
|
||||
{t("insights.breathingScreening.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import type {
|
||||
HealthStatusDeviation,
|
||||
HealthStatusShift,
|
||||
} from "@/lib/insights/health-status";
|
||||
|
||||
/**
|
||||
* v1.25 — baseline-drift card.
|
||||
*
|
||||
* A calm read of what is drifting from the user's own normal, drawn off the
|
||||
* read-only `/api/insights/health-status` route (which folds the personal-band
|
||||
* deviations from the coincident engine with the dated changepoint shifts). The
|
||||
* card un-mounts entirely when nothing is drifting (`present === false`) rather
|
||||
* than surfacing an empty header. Awareness framing only — at most a neutral
|
||||
* tone, never a red alert, never a diagnosis.
|
||||
*/
|
||||
|
||||
interface HealthStatusResponse {
|
||||
present: boolean;
|
||||
deviations: HealthStatusDeviation[];
|
||||
shifts: HealthStatusShift[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
interface HealthStatusCardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HealthStatusCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: HealthStatusCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsHealthStatus(),
|
||||
queryFn: () => apiGet<HealthStatusResponse>("/api/insights/health-status"),
|
||||
enabled: enabled && isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!data || data.present === false) return null;
|
||||
|
||||
const vitalName = (type: string): string => {
|
||||
const key = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
return key ? t(key) : type;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="health-status-section"
|
||||
aria-label={t("insights.healthStatus.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.healthStatus.sectionTitle")}
|
||||
subtitle={t("insights.healthStatus.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="health-status-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
{data.deviations.map((d) => (
|
||||
<div
|
||||
key={`dev-${d.type}`}
|
||||
className="flex items-start gap-2"
|
||||
data-slot="health-status-deviation"
|
||||
>
|
||||
{d.direction === "above" ? (
|
||||
<ArrowUpRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ArrowDownRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-foreground text-sm">
|
||||
{t(
|
||||
d.direction === "above"
|
||||
? "insights.healthStatus.deviationAbove"
|
||||
: "insights.healthStatus.deviationBelow",
|
||||
{ vital: vitalName(d.type) },
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.healthStatus.rangeContext", {
|
||||
value: d.value,
|
||||
low: d.low,
|
||||
high: d.high,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.shifts.map((s) => (
|
||||
<div
|
||||
key={`shift-${s.metric}`}
|
||||
className="flex items-start gap-2"
|
||||
data-slot="health-status-shift"
|
||||
>
|
||||
{s.direction === "up" ? (
|
||||
<ArrowUpRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ArrowDownRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-foreground text-sm">
|
||||
{t(
|
||||
s.direction === "up"
|
||||
? "insights.healthStatus.shiftUp"
|
||||
: "insights.healthStatus.shiftDown",
|
||||
{ vital: vitalName(s.metric) },
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.healthStatus.shiftContext", {
|
||||
before: s.beforeMean,
|
||||
after: s.afterMean,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,9 @@ const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
|
||||
"cycle-summary": "cycle.insightsSummary.title",
|
||||
signals: "insights.derived.coincident.cardTitle",
|
||||
"rhythm-events": "insights.rhythmEvents.sectionTitle",
|
||||
"health-status": "insights.healthStatus.sectionTitle",
|
||||
breathing: "insights.breathingScreening.sectionTitle",
|
||||
"labs-changes": "insights.labsChanges.sectionTitle",
|
||||
};
|
||||
|
||||
interface InsightsEditModeProps {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
*
|
||||
* A neutral read off the read-only `/api/insights/labs-changes` route: for each
|
||||
* analyte present in both the latest and the previous panel, the latest value,
|
||||
* the signed delta, and where the latest value sits against its reference band.
|
||||
* It un-mounts when there are fewer than two panels or no shared analyte
|
||||
* (`present === false`). Never a diagnosis — neutral framing only.
|
||||
*/
|
||||
|
||||
interface LabsChangesResponse {
|
||||
present: boolean;
|
||||
latestDate: string | null;
|
||||
previousDate: string | null;
|
||||
changes: LabChange[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
interface LabsChangesCardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function statusLabelKey(status: LabChange["status"]): string | null {
|
||||
switch (status) {
|
||||
case "in-range":
|
||||
return "insights.labsChanges.statusInRange";
|
||||
case "below":
|
||||
return "insights.labsChanges.statusBelow";
|
||||
case "above":
|
||||
return "insights.labsChanges.statusAbove";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function LabsChangesCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: LabsChangesCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
queryFn: () => apiGet<LabsChangesResponse>("/api/insights/labs-changes"),
|
||||
enabled: enabled && isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!data || data.present === false) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="labs-changes-section"
|
||||
aria-label={t("insights.labsChanges.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={FlaskConical}
|
||||
title={t("insights.labsChanges.sectionTitle")}
|
||||
subtitle={t("insights.labsChanges.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="labs-changes-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col divide-y rounded-xl border"
|
||||
>
|
||||
{data.changes.map((c) => {
|
||||
const labelKey = statusLabelKey(c.status);
|
||||
const signedDelta =
|
||||
c.direction === "flat"
|
||||
? t("insights.labsChanges.directionFlat")
|
||||
: `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim();
|
||||
return (
|
||||
<div
|
||||
key={c.analyte}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
data-slot="labs-change-row"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{c.analyte}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{`${c.latest} ${c.unit}`.trim()}
|
||||
{labelKey ? ` · ${t(labelKey)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground shrink-0 text-xs tabular-nums"
|
||||
data-slot="labs-change-delta"
|
||||
>
|
||||
{signedDelta}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user