mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(topics): add the Topics preview area, its navigation entry and its switch
/topics shows a tile per topic the record holds data for, and names the empty topics and the module-hidden ones once each. /topics/<topic> shows the topic today, its signals side by side over 7, 30, 90 days or a year against their usual range, how they move together, their trends, its blocks, the single-metric pages behind it and what it is missing. Every topic links into the metric pages, which stay as they are, and each page ends on a neutral note that this is a preview. Settings > System > Preview features is listed again and switches the area on for the person reading. While it is on, the sidebar carries a Topics entry with a Beta badge directly below Insights and the phone's More sheet leads with a Topics tile; the bottom bar does not change. The pages leave for Insights when the switch is off. The charts load through the shared chart runtime. The sidebar rows move into their own module and the More sheet tile into its own component, so neither shell file grows; the lab trend wording is shared between the marker page and the metabolism topic. Copy in all nine locales.
This commit is contained in:
1 parent
cc04e0944d
commit
c68e833dcc
64 files changed
+4319
-219
No files matched your search
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The topic pages build several message keys at run time (one per topic,
|
||||
* range, state, tone, verdict). The call-site coverage guard sees literal keys
|
||||
* only, so this holds every built key against every locale.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
|
||||
|
||||
import {
|
||||
topicDescriptionKey,
|
||||
topicSourcesKey,
|
||||
topicTitleKey,
|
||||
} from "../topic-meta";
|
||||
|
||||
function resolve(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
const BUILT_KEYS = [
|
||||
...TOPIC_KEYS.flatMap((key) => [
|
||||
topicTitleKey(key),
|
||||
topicDescriptionKey(key),
|
||||
topicSourcesKey(key),
|
||||
]),
|
||||
...TOPIC_RANGES.map((range) => `topics.range.${range}`),
|
||||
...["within", "above", "below", "learning"].map((s) => `topics.state.${s}`),
|
||||
...["steady", "watch", "learning"].map((s) => `topics.tone.${s}`),
|
||||
...["improved", "worsened", "within_threshold"].map(
|
||||
(v) => `topics.screeners.${v}`,
|
||||
),
|
||||
...["above", "below"].map((s) => `topics.labs.${s}`),
|
||||
...["SameDay", "NextDay"].flatMap((lag) =>
|
||||
["Higher", "Lower"].map(
|
||||
(dir) => `topics.relationships.correlation${lag}${dir}`,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
describe("topic message keys", () => {
|
||||
it.each(locales)("every built key resolves to text in %s", async (locale) => {
|
||||
const bundle = (await import(`../../../../messages/${locale}.json`))
|
||||
.default;
|
||||
const missing = BUILT_KEYS.filter(
|
||||
(key) => typeof resolve(bundle, key) !== "string",
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks a non-empty set", () => {
|
||||
expect(BUILT_KEYS.length).toBeGreaterThan(40);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { TopicOverviewEntry } from "@/lib/topics/build";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
import { TopicTile } from "../topic-tile";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
function render(entry: TopicOverviewEntry, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>
|
||||
<TopicTile entry={entry} />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const heart: TopicOverviewEntry = {
|
||||
key: "heart",
|
||||
headline: {
|
||||
kind: "signal",
|
||||
key: "RESTING_HEART_RATE",
|
||||
value: 59,
|
||||
day: "2026-10-22",
|
||||
},
|
||||
summary: {
|
||||
code: "outside",
|
||||
signal: "RESTING_HEART_RATE",
|
||||
direction: "above",
|
||||
count: null,
|
||||
total: null,
|
||||
},
|
||||
tone: "watch",
|
||||
sparkline: [58, 59, 61],
|
||||
recorded: 2,
|
||||
total: 4,
|
||||
};
|
||||
|
||||
describe("<TopicTile>", () => {
|
||||
it("links the visible title to the topic and words the server's sentence", () => {
|
||||
const html = render(heart);
|
||||
expect(html).toMatch(
|
||||
/href="\/topics\/heart"[^>]*data-slot="topic-tile-link"[^>]*>Heart<|data-slot="topic-tile-link"[^>]*href="\/topics\/heart"[^>]*>Heart</,
|
||||
);
|
||||
expect(html).toContain('data-code="outside"');
|
||||
expect(html).toContain("above your usual range");
|
||||
expect(html).toContain('data-tone="watch"');
|
||||
expect(html).toContain("2 of 4");
|
||||
expect(html).toMatch(/>59(\.0)?</);
|
||||
});
|
||||
|
||||
it("speaks German and leaks no raw key", () => {
|
||||
const html = render(heart, "de");
|
||||
expect(html).toContain(">Herz<");
|
||||
expect(html).toContain("über deinem üblichen Bereich");
|
||||
expect(html).not.toMatch(/topics\.[a-z]/);
|
||||
});
|
||||
|
||||
it("says a block headline in words, without a sparkline", () => {
|
||||
const html = render({
|
||||
...heart,
|
||||
key: "metabolism",
|
||||
headline: { kind: "labs", inRange: 11, total: 12 },
|
||||
summary: {
|
||||
code: "labs",
|
||||
signal: null,
|
||||
direction: null,
|
||||
count: 11,
|
||||
total: 12,
|
||||
},
|
||||
tone: "watch",
|
||||
sparkline: [],
|
||||
});
|
||||
expect(html).toContain("11/12");
|
||||
expect(html).toContain("11 of 12 lab values");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The "Beta" mark beside a preview: the nav entry, the More tile, the page
|
||||
* title and the Settings switch. A status, so a `Badge`, in the one accent a
|
||||
* preview carries. Its text is part of the accessible name of whatever it
|
||||
* sits in, which is what a visible label should be.
|
||||
*/
|
||||
export function PreviewBadge({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-slot="preview-badge"
|
||||
className={cn(
|
||||
"border-primary/40 text-primary text-2xs px-1.5 py-0 tracking-wide uppercase",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The line every topic page ends on: this is a preview, it may change, and
|
||||
* the single-metric pages are untouched. Neutral, with no link out.
|
||||
*/
|
||||
export function PreviewNote() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground flex items-start gap-2 text-xs"
|
||||
data-slot="topics-preview-note"
|
||||
>
|
||||
<FlaskConical className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("topics.previewNote")}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Brain, Droplets } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
|
||||
import type { TopicCycleSummary } from "@/lib/topics/read";
|
||||
|
||||
import { INSTRUMENT_LABEL, TOPIC_EXTRA_HREF } from "./topic-meta";
|
||||
|
||||
const VERDICT_TONE = {
|
||||
improved: "success",
|
||||
worsened: "warning",
|
||||
within_threshold: "neutral",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The mind topic's screener block: each instrument's latest total and how it
|
||||
* moved since the first check-in, measured against the change studies treat
|
||||
* as meaningful (the check-in page's own trend).
|
||||
*/
|
||||
export function TopicScreenersCard({
|
||||
screeners,
|
||||
}: {
|
||||
screeners: AssessmentTrend[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
return (
|
||||
<Card data-slot="topic-screeners">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Brain}
|
||||
titleAs="h2"
|
||||
title={t("topics.screeners.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{screeners.length === 0 ? (
|
||||
<p className="text-sm" data-slot="topic-screeners-none">
|
||||
{t("topics.screeners.none")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{screeners.map((trend) => (
|
||||
<li
|
||||
key={trend.instrument}
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
data-slot="topic-screener"
|
||||
data-instrument={trend.instrument}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{INSTRUMENT_LABEL[trend.instrument] ?? trend.instrument}{" "}
|
||||
<span className="tabular-nums">
|
||||
{fmt.number(trend.latest.score, 0)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("topics.screeners.checkIns", { count: trend.n })}
|
||||
</p>
|
||||
</div>
|
||||
{trend.sinceFirst ? (
|
||||
<ScorePill tone={VERDICT_TONE[trend.sinceFirst]}>
|
||||
{t(`topics.screeners.${trend.sinceFirst}`)}
|
||||
</ScorePill>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("topics.screeners.single")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={TOPIC_EXTRA_HREF.screeners} className="self-start">
|
||||
{t("topics.screeners.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cycle topic's block: today's cycle day and phase as the cycle page
|
||||
* decides them, and the lengths of the completed cycles on record.
|
||||
*/
|
||||
export function TopicCycleCard({ cycle }: { cycle: TopicCycleSummary }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
return (
|
||||
<Card data-slot="topic-cycle">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Droplets}
|
||||
titleAs="h2"
|
||||
title={t("topics.cycle.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{cycle.today ? (
|
||||
<p className="text-sm font-medium">
|
||||
{cycle.today.phase
|
||||
? t("topics.cycle.todayWithPhase", {
|
||||
day: cycle.today.dayOfCycle,
|
||||
phase: t(`cycle.phase.${cycle.today.phase}`),
|
||||
})
|
||||
: t("topics.cycle.today", { day: cycle.today.dayOfCycle })}
|
||||
</p>
|
||||
) : null}
|
||||
{cycle.averageLength !== null ? (
|
||||
<p className="text-sm">
|
||||
{t("topics.cycle.average", {
|
||||
days: fmt.number(cycle.averageLength, 1),
|
||||
count: cycle.lengths.length,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm" data-slot="topic-cycle-needs">
|
||||
{t("topics.cycle.needs", { count: cycle.periodsLogged })}
|
||||
</p>
|
||||
)}
|
||||
{cycle.lengths.length > 0 ? (
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("topics.cycle.lengths", {
|
||||
lengths: cycle.lengths.map((l) => fmt.integer(l)).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={TOPIC_EXTRA_HREF.cycle} className="self-start">
|
||||
{t("topics.cycle.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { TrainingLoadCard } from "@/components/workouts/training-load-card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
DEFAULT_TOPIC_RANGE,
|
||||
TOPIC_RANGES,
|
||||
type TopicKey,
|
||||
type TopicRange,
|
||||
} from "@/lib/topics/registry";
|
||||
|
||||
import { PreviewBadge } from "./preview-badge";
|
||||
import { PreviewNote } from "./preview-note";
|
||||
import { TopicCycleCard, TopicScreenersCard } from "./topic-blocks";
|
||||
import { TopicLabsCard } from "./topic-labs-card";
|
||||
import { TopicLinksCard, TopicMissingCard } from "./topic-links-card";
|
||||
import { topicDescriptionKey, topicTitleKey } from "./topic-meta";
|
||||
import { TopicRelationshipsCard } from "./topic-relationships-card";
|
||||
import { TopicSideBySideCard } from "./topic-side-by-side-card";
|
||||
import { TopicTodayCard } from "./topic-today-card";
|
||||
import { TopicTrendsCard } from "./topic-trends-card";
|
||||
import { useTopicDetail } from "./use-topics";
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" aria-hidden="true">
|
||||
<Skeleton className="h-40 rounded-xl" />
|
||||
<Skeleton className="h-80 rounded-xl" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/topics/<topic>`: the topic's signals today, side by side over a chosen
|
||||
* window, how they move together, their trends, the topic's blocks, the
|
||||
* single-metric pages behind it and what it holds nothing for yet.
|
||||
*/
|
||||
export function TopicDetailView({ topic }: { topic: TopicKey }) {
|
||||
const { t } = useTranslations();
|
||||
const [range, setRange] = useState<TopicRange>(DEFAULT_TOPIC_RANGE);
|
||||
const query = useTopicDetail(topic, range, true);
|
||||
const detail = query.data?.key === topic ? query.data : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="topic-detail" data-topic={topic}>
|
||||
<PageHeader
|
||||
title={t(topicTitleKey(topic))}
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t(topicDescriptionKey(topic))}
|
||||
backLink={{
|
||||
href: "/topics",
|
||||
label: t("topics.detail.back"),
|
||||
dataSlot: "topic-back",
|
||||
}}
|
||||
/>
|
||||
<Segmented
|
||||
options={TOPIC_RANGES.map((value) => ({
|
||||
value,
|
||||
label: t(`topics.range.${value}`),
|
||||
}))}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
label={t("topics.range.label")}
|
||||
slot="topic-range"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{query.isError ? (
|
||||
<QueryErrorCard
|
||||
error={query.error}
|
||||
title={t("topics.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : !detail ? (
|
||||
<DetailSkeleton />
|
||||
) : (
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="topic-detail-body"
|
||||
data-status={detail.status}
|
||||
>
|
||||
<TopicTodayCard detail={detail} />
|
||||
<TopicSideBySideCard detail={detail} />
|
||||
{detail.relationships.length > 0 ||
|
||||
detail.trends.length > 0 ||
|
||||
detail.training?.status === "ok" ? (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
<TopicRelationshipsCard relationships={detail.relationships} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<TopicTrendsCard trends={detail.trends} />
|
||||
<TrainingLoadCard trainingLoad={detail.training ?? undefined} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.labs ? <TopicLabsCard labs={detail.labs} /> : null}
|
||||
{detail.screeners ? (
|
||||
<TopicScreenersCard screeners={detail.screeners} />
|
||||
) : null}
|
||||
{detail.cycle ? <TopicCycleCard cycle={detail.cycle} /> : null}
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
<TopicLinksCard detail={detail} />
|
||||
<TopicMissingCard detail={detail} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PreviewNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FlaskConical, TrendingUp } from "lucide-react";
|
||||
|
||||
import { labTrendLines } from "@/components/labs/lab-trend-lines";
|
||||
import { useLabNumber } from "@/components/labs/use-lab-format";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import type { TopicLabMarker, TopicLabPanel } from "@/lib/topics/labs";
|
||||
|
||||
function MarkerTile({ marker }: { marker: TopicLabMarker }) {
|
||||
const { t } = useTranslations();
|
||||
const labNumber = useLabNumber();
|
||||
const range = formatReferenceRange(marker.low, marker.high, (v) =>
|
||||
labNumber(v),
|
||||
);
|
||||
const outside = marker.status === "above" || marker.status === "below";
|
||||
return (
|
||||
<Link
|
||||
href={`/labs/${marker.biomarkerId}`}
|
||||
data-slot="topic-lab-marker"
|
||||
data-status={marker.status}
|
||||
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-w-0 flex-col gap-1 rounded-lg border p-3 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-foreground truncate text-xs font-medium">
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{labNumber(marker.latest)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
</span>
|
||||
{range ? (
|
||||
<span className="text-muted-foreground text-xs">{range}</span>
|
||||
) : null}
|
||||
{outside ? (
|
||||
<ScorePill tone="warning" className="self-start">
|
||||
{t(`topics.labs.${marker.status}`)}
|
||||
</ScorePill>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The metabolism topic's lab block: each marker's newest reading against the
|
||||
* window it is judged by, then what the markers' trend lines say. A trend
|
||||
* needs three readings spanning a year; markers short of that are named in
|
||||
* one sentence instead of being given a line.
|
||||
*/
|
||||
export function TopicLabsCard({ labs }: { labs: TopicLabPanel }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const trending = labs.markers.filter((m) => m.trend.status === "ok");
|
||||
const short = labs.markers.filter((m) => m.trend.status !== "ok");
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-labs">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={FlaskConical}
|
||||
titleAs="h2"
|
||||
title={t("topics.labs.title")}
|
||||
right={
|
||||
labs.latestOn ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateShortSmartCalendar(labs.latestOn)}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
{labs.markers.map((marker) => (
|
||||
<MarkerTile key={marker.biomarkerId} marker={marker} />
|
||||
))}
|
||||
</div>
|
||||
<section
|
||||
className="flex flex-col gap-2"
|
||||
aria-labelledby="topic-lab-trends-heading"
|
||||
>
|
||||
<h3
|
||||
id="topic-lab-trends-heading"
|
||||
className="text-foreground flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<TrendingUp className="size-4" aria-hidden="true" />
|
||||
{t("topics.labs.trendsTitle")}
|
||||
</h3>
|
||||
{trending.map((marker) => {
|
||||
if (marker.trend.status !== "ok") return null;
|
||||
const lines = labTrendLines(marker.trend, marker.unit, t, fmt);
|
||||
return (
|
||||
<div
|
||||
key={marker.biomarkerId}
|
||||
data-slot="topic-lab-trend"
|
||||
data-projection={marker.trend.projection}
|
||||
>
|
||||
<p className="text-sm font-medium">{marker.name}</p>
|
||||
<p className="text-sm">{lines.slope}</p>
|
||||
{lines.projection ? (
|
||||
<p className="text-sm">{lines.projection}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{short.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topic-lab-trend-short"
|
||||
>
|
||||
{t("topics.labs.trendNeeds", {
|
||||
markers: short.map((m) => m.name).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Info, Lightbulb } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail } from "@/lib/topics/build";
|
||||
|
||||
import { TOPIC_EXTRA_HREF, topicSourcesKey } from "./topic-meta";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
interface PageLink {
|
||||
href: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-metric pages behind the topic, unchanged and one tap away: a
|
||||
* topic is an overview, never a replacement for any of them.
|
||||
*/
|
||||
export function TopicLinksCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const links: PageLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (link: PageLink) => {
|
||||
if (seen.has(link.href)) return;
|
||||
seen.add(link.href);
|
||||
links.push(link);
|
||||
};
|
||||
for (const signal of detail.signals) {
|
||||
if (!signal.href || !signal.latest) continue;
|
||||
add({
|
||||
href: signal.href,
|
||||
label: format.label(signal.key),
|
||||
value: format.value(signal.key, signal.latest.value),
|
||||
});
|
||||
}
|
||||
if (detail.labs) {
|
||||
add({ href: TOPIC_EXTRA_HREF.labs, label: t("nav.labs"), value: null });
|
||||
}
|
||||
if (detail.training?.status === "ok") {
|
||||
add({
|
||||
href: TOPIC_EXTRA_HREF.training,
|
||||
label: t("topics.links.workouts"),
|
||||
value: null,
|
||||
});
|
||||
}
|
||||
if (links.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-links">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Lightbulb}
|
||||
titleAs="h2"
|
||||
title={t("topics.links.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-border flex flex-col divide-y">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
data-slot="topic-link"
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 items-center gap-3 rounded-md py-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
{link.label}
|
||||
</span>
|
||||
{link.value ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{link.value}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the topic could show but holds nothing for yet, and where such values
|
||||
* come from. Never a zero, never an empty chart.
|
||||
*/
|
||||
export function TopicMissingCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
if (detail.missing.length === 0) return null;
|
||||
return (
|
||||
<Card
|
||||
data-slot="topic-missing"
|
||||
className="border-dashed shadow-none"
|
||||
density="compact"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<p className="flex items-start gap-2 text-sm font-medium">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{t("topics.missing.title", {
|
||||
signals: detail.missing.map((k) => format.label(k)).join(", "),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(topicSourcesKey(detail.key))}
|
||||
</p>
|
||||
{inSharedRecord ? null : (
|
||||
<Button asChild variant="outline" size="sm" className="self-start">
|
||||
<Link href="/settings/integrations">
|
||||
{t("topics.missing.connect")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Client-side presentation of the topics: the glyph per topic, the message
|
||||
* keys of its name and description, and where each extra block's own page
|
||||
* lives. Kept apart from the server registry so the browser bundle carries
|
||||
* no read code; the keys themselves come from `@/lib/topics/registry`.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
Droplets,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Heart,
|
||||
Moon,
|
||||
Scale,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { TopicExtra, TopicKey } from "@/lib/topics/registry";
|
||||
|
||||
export const TOPIC_ICONS: Readonly<Record<TopicKey, LucideIcon>> = {
|
||||
heart: Heart,
|
||||
body: Scale,
|
||||
"blood-pressure": Gauge,
|
||||
sleep: Moon,
|
||||
activity: Activity,
|
||||
metabolism: FlaskConical,
|
||||
mind: Brain,
|
||||
cycle: Droplets,
|
||||
};
|
||||
|
||||
/** Message-key segment per topic (`topics.names.<id>.title`). */
|
||||
export const TOPIC_MESSAGE_ID: Readonly<Record<TopicKey, string>> = {
|
||||
heart: "heart",
|
||||
body: "body",
|
||||
"blood-pressure": "bloodPressure",
|
||||
sleep: "sleep",
|
||||
activity: "activity",
|
||||
metabolism: "metabolism",
|
||||
mind: "mind",
|
||||
cycle: "cycle",
|
||||
};
|
||||
|
||||
export function topicTitleKey(key: TopicKey): string {
|
||||
return `topics.names.${TOPIC_MESSAGE_ID[key]}.title`;
|
||||
}
|
||||
|
||||
export function topicDescriptionKey(key: TopicKey): string {
|
||||
return `topics.names.${TOPIC_MESSAGE_ID[key]}.description`;
|
||||
}
|
||||
|
||||
/** Where to record what an empty topic is missing (`topics.empty.<id>`). */
|
||||
export function topicSourcesKey(key: TopicKey): string {
|
||||
return `topics.sources.${TOPIC_MESSAGE_ID[key]}`;
|
||||
}
|
||||
|
||||
/** The page each extra block opens. */
|
||||
export const TOPIC_EXTRA_HREF: Readonly<Record<TopicExtra, string>> = {
|
||||
training: "/insights/workouts",
|
||||
labs: "/labs",
|
||||
screeners: "/mental-wellbeing",
|
||||
cycle: "/cycle",
|
||||
};
|
||||
|
||||
export function topicHref(key: TopicKey): string {
|
||||
return `/topics/${key}`;
|
||||
}
|
||||
|
||||
/** The screener names as the check-in page writes them. */
|
||||
export const INSTRUMENT_LABEL: Readonly<Record<string, string>> = {
|
||||
PHQ9: "PHQ-9",
|
||||
GAD7: "GAD-7",
|
||||
WHO5: "WHO-5",
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Info, Link2 } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicRelationship } from "@/lib/topics/stats";
|
||||
|
||||
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.ScatterCorrelationChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-44 w-full" /> },
|
||||
);
|
||||
|
||||
const STATUS_DOT = {
|
||||
linked: "bg-chart-1",
|
||||
no_clear_link: "bg-muted-foreground",
|
||||
insufficient: "bg-border",
|
||||
} as const;
|
||||
|
||||
/** The values' own span with a tenth of it on each side, so dots sit inside. */
|
||||
function paddedDomain(values: number[]): [number, number] {
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const pad = (max - min) * 0.1 || Math.abs(max) * 0.05 || 1;
|
||||
return [min - pad, max + pad];
|
||||
}
|
||||
|
||||
/** The one sentence a relationship reads as, from the server's figures. */
|
||||
function useRelationshipSentence() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
return (rel: TopicRelationship): { text: string; meta: string | null } => {
|
||||
const x = format.label(rel.x);
|
||||
const y = format.label(rel.y);
|
||||
if (rel.status === "insufficient") {
|
||||
return {
|
||||
text: t("topics.relationships.insufficient", {
|
||||
x,
|
||||
y,
|
||||
count: rel.pairedDays,
|
||||
required: rel.requiredDays,
|
||||
}),
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
if (rel.status === "no_clear_link") {
|
||||
return {
|
||||
text: t("topics.relationships.noClearLink", {
|
||||
x,
|
||||
y,
|
||||
count: rel.pairedDays,
|
||||
}),
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
if (rel.kind === "split" && rel.split) {
|
||||
const higher = rel.split.difference > 0;
|
||||
return {
|
||||
text: t(
|
||||
higher
|
||||
? "topics.relationships.splitHigher"
|
||||
: "topics.relationships.splitLower",
|
||||
{
|
||||
x,
|
||||
y,
|
||||
threshold:
|
||||
rel.x === "SLEEP_DURATION" && rel.split.threshold % 60 === 0
|
||||
? t("common.durationClockHours", {
|
||||
time: fmt.number(rel.split.threshold / 60, 0),
|
||||
})
|
||||
: format.value(rel.x, rel.split.threshold),
|
||||
amount: format.amount(rel.y, rel.split.difference),
|
||||
},
|
||||
),
|
||||
meta: t("topics.relationships.splitDays", {
|
||||
below: rel.split.belowDays,
|
||||
other: rel.split.otherDays,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const higher = (rel.r ?? 0) > 0;
|
||||
const key = rel.lagDays === 1 ? "NextDay" : "SameDay";
|
||||
return {
|
||||
text: t(
|
||||
`topics.relationships.correlation${key}${higher ? "Higher" : "Lower"}`,
|
||||
{ x, y, r: fmt.number(rel.r ?? 0, 2) },
|
||||
),
|
||||
meta: t("topics.relationships.pairedDays", { count: rel.pairedDays }),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How the topic's signals move together: one statement per pair, each with
|
||||
* the number of days it rests on, and a scatter for the first pair that holds
|
||||
* a pattern. Fourteen paired days before anything is said; never a cause.
|
||||
*/
|
||||
export function TopicRelationshipsCard({
|
||||
relationships,
|
||||
}: {
|
||||
relationships: TopicRelationship[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const axis = useSignalAxis();
|
||||
const sentence = useRelationshipSentence();
|
||||
if (relationships.length === 0) return null;
|
||||
// A scatter only reads for a correlation: a split's x is two groups.
|
||||
const featured = relationships.find(
|
||||
(r) =>
|
||||
r.kind === "correlation" && r.status === "linked" && r.points.length > 0,
|
||||
);
|
||||
const scatter = featured
|
||||
? featured.points.map((p) => ({
|
||||
x: axis.toAxis(featured.x, p.x),
|
||||
y: axis.toAxis(featured.y, p.y),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-relationships">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Link2}
|
||||
titleAs="h2"
|
||||
title={t("topics.relationships.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{featured ? (
|
||||
<figure className="flex flex-col gap-1">
|
||||
<div aria-hidden="true">
|
||||
<ScatterCorrelationChart
|
||||
data={scatter}
|
||||
fill="var(--chart-1)"
|
||||
xAxis={{
|
||||
dataKey: "x",
|
||||
name: format.label(featured.x),
|
||||
domain: paddedDomain(scatter.map((p) => p.x)),
|
||||
tickFormatter: (v) => axis.tick(featured.x, v),
|
||||
}}
|
||||
yAxis={{
|
||||
dataKey: "y",
|
||||
name: format.label(featured.y),
|
||||
domain: paddedDomain(scatter.map((p) => p.y)),
|
||||
tickFormatter: (v) => axis.tick(featured.y, v),
|
||||
}}
|
||||
height={176}
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="text-muted-foreground text-xs">
|
||||
{t("topics.relationships.scatterCaption", {
|
||||
x: format.label(featured.x),
|
||||
y: format.label(featured.y),
|
||||
})}
|
||||
</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-3">
|
||||
{relationships.map((rel) => {
|
||||
const { text, meta } = sentence(rel);
|
||||
return (
|
||||
<li
|
||||
key={rel.id}
|
||||
className="flex items-start gap-2"
|
||||
data-slot="topic-relationship"
|
||||
data-relationship={rel.id}
|
||||
data-status={rel.status}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${STATUS_DOT[rel.status]}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">{text}</p>
|
||||
{meta ? (
|
||||
<p className="text-muted-foreground text-xs">{meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground flex items-start gap-2 text-xs">
|
||||
<Info className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("topics.relationships.footnote")}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail } from "@/lib/topics/build";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
|
||||
|
||||
// Recharts loads through the one shared chart chunk, never in this route's own.
|
||||
const TopicSideBySideChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.TopicSideBySideChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-72 w-full" /> },
|
||||
);
|
||||
|
||||
const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"];
|
||||
|
||||
/**
|
||||
* The topic's signals on one time axis for the chosen window, each against
|
||||
* its usual range. Sleep draws in hours on the axis and reads as hours and
|
||||
* minutes in the tooltip; every other type in the person's display unit.
|
||||
*/
|
||||
export function TopicSideBySideCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
const { toAxis, fromAxis, tick } = useSignalAxis();
|
||||
if (detail.series.length === 0) return null;
|
||||
|
||||
const days = daysBetweenDateKeys(detail.from, detail.to) + 1;
|
||||
|
||||
const series = detail.series.map((s, i) => ({
|
||||
key: s.key,
|
||||
label: format.label(s.key),
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length],
|
||||
points: s.points.map((p) => ({
|
||||
x: daysBetweenDateKeys(detail.from, p.day),
|
||||
y: toAxis(s.key, p.value),
|
||||
})),
|
||||
band: s.usual
|
||||
? { low: toAxis(s.key, s.usual.low), high: toAxis(s.key, s.usual.high) }
|
||||
: null,
|
||||
}));
|
||||
const names = series.map((s) => s.label).join(", ");
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-side-by-side">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Layers}
|
||||
titleAs="h2"
|
||||
title={t("topics.chart.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("topics.chart.legend")}
|
||||
</p>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t("topics.chart.label", { signals: names })}
|
||||
>
|
||||
<TopicSideBySideChart
|
||||
series={series}
|
||||
days={days}
|
||||
formatDay={(x) =>
|
||||
fmt.dateShortSmartCalendar(shiftDateKey(detail.from, x))
|
||||
}
|
||||
formatValue={(key, value) =>
|
||||
format.value(key, fromAxis(key, value))
|
||||
}
|
||||
formatTick={tick}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { RichChartTooltip } from "@/components/charts/chart-tooltip";
|
||||
|
||||
export interface SideBySideSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
/** A `var(--chart-N)` expression. */
|
||||
color: string;
|
||||
/** Day index from the window's first day, and the value in display units. */
|
||||
points: Array<{ x: number; y: number }>;
|
||||
/** The usual range in display units, shaded behind the line. */
|
||||
band: { low: number; high: number } | null;
|
||||
}
|
||||
|
||||
const AXIS_TICK = { fontSize: 11, fill: "var(--muted-foreground)" };
|
||||
const ROW_HEIGHT = 88;
|
||||
const AXIS_HEIGHT = 24;
|
||||
|
||||
/**
|
||||
* Up to three signals stacked on one time axis, each with its usual range
|
||||
* shaded, so a reader sees two lines move together before reading that they
|
||||
* do. Each row keeps its own scale; the axis, the grid and the band follow the
|
||||
* metric charts. Values arrive in display units, already converted.
|
||||
*/
|
||||
export function TopicSideBySideChart({
|
||||
series,
|
||||
days,
|
||||
formatDay,
|
||||
formatValue,
|
||||
formatTick,
|
||||
}: {
|
||||
series: SideBySideSeries[];
|
||||
/** Days in the window; the axis runs from 0 to `days - 1`. */
|
||||
days: number;
|
||||
formatDay: (x: number) => string;
|
||||
/** A value with its unit, for the tooltip. */
|
||||
formatValue: (key: string, value: number) => string;
|
||||
/** A bare axis number. */
|
||||
formatTick: (key: string, value: number) => string;
|
||||
}) {
|
||||
const ticks = [0, Math.round((days - 1) / 2), days - 1];
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{series.map((s, index) => {
|
||||
const last = index === series.length - 1;
|
||||
return (
|
||||
<div key={s.key} data-slot="topic-chart-row" data-signal={s.key}>
|
||||
<p className="text-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.label}
|
||||
</p>
|
||||
<div style={{ height: ROW_HEIGHT + (last ? AXIS_HEIGHT : 0) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={s.points}
|
||||
margin={{ top: 6, right: 8, bottom: 0, left: 0 }}
|
||||
syncId="topic-side-by-side"
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
domain={[0, Math.max(1, days - 1)]}
|
||||
ticks={ticks}
|
||||
hide={!last}
|
||||
height={last ? AXIS_HEIGHT : 0}
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatDay(Number(value))}
|
||||
/>
|
||||
<YAxis
|
||||
domain={["auto", "auto"]}
|
||||
width={44}
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickCount={3}
|
||||
tickFormatter={(value) =>
|
||||
typeof value === "number" ? formatTick(s.key, value) : ""
|
||||
}
|
||||
/>
|
||||
{s.band ? (
|
||||
<ReferenceArea
|
||||
y1={s.band.low}
|
||||
y2={s.band.high}
|
||||
fill="var(--success)"
|
||||
fillOpacity={0.12}
|
||||
strokeOpacity={0}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--border)" }}
|
||||
content={({ active, payload }) => {
|
||||
const point = payload?.[0]?.payload as
|
||||
{ x: number; y: number } | undefined;
|
||||
if (!active || !point) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDay(point.x)}
|
||||
rows={[
|
||||
{
|
||||
name: s.label,
|
||||
value: formatValue(s.key, point.y),
|
||||
color: s.color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="y"
|
||||
name={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={s.points.length <= 31 ? { r: 2 } : false}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicHeadline, TopicSummary } from "@/lib/topics/build";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { INSTRUMENT_LABEL } from "./topic-meta";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
/**
|
||||
* The topic's one sentence, worded from the server's code. The rule that
|
||||
* picked the code lives in `src/lib/topics/build.ts`; this only says it.
|
||||
*/
|
||||
export function useSummarySentence(summary: TopicSummary): string {
|
||||
const { t } = useTranslations();
|
||||
const signals = useSignalFormat();
|
||||
switch (summary.code) {
|
||||
case "outside":
|
||||
return t(
|
||||
summary.direction === "below"
|
||||
? "topics.summary.outsideBelow"
|
||||
: "topics.summary.outsideAbove",
|
||||
{ signal: summary.signal ? signals.label(summary.signal) : "" },
|
||||
);
|
||||
case "labs":
|
||||
return t("topics.summary.labs", {
|
||||
count: summary.count ?? 0,
|
||||
total: summary.total ?? 0,
|
||||
});
|
||||
case "cycleDay":
|
||||
return t("topics.summary.cycleDay", { day: summary.count ?? 0 });
|
||||
case "steady":
|
||||
return summary.count === summary.total
|
||||
? t("topics.summary.steadyAll")
|
||||
: t("topics.summary.steadySome", {
|
||||
count: summary.count ?? 0,
|
||||
total: summary.total ?? 0,
|
||||
});
|
||||
case "learning":
|
||||
return t("topics.summary.learning");
|
||||
default:
|
||||
return t("topics.summary.empty");
|
||||
}
|
||||
}
|
||||
|
||||
export function TopicSummaryText({
|
||||
summary,
|
||||
className,
|
||||
}: {
|
||||
summary: TopicSummary;
|
||||
className?: string;
|
||||
}) {
|
||||
const sentence = useSummarySentence(summary);
|
||||
return (
|
||||
<p
|
||||
data-slot="topic-summary"
|
||||
data-code={summary.code}
|
||||
className={cn("text-foreground", className)}
|
||||
>
|
||||
{sentence}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The headline as a value and a unit, for whichever kind the server sent. */
|
||||
export function useHeadline(
|
||||
headline: TopicHeadline | null,
|
||||
): { value: string; unit: string } | null {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const signals = useSignalFormat();
|
||||
if (!headline) return null;
|
||||
switch (headline.kind) {
|
||||
case "signal":
|
||||
return {
|
||||
value: signals.number(headline.key, headline.value),
|
||||
unit: signals.unit(headline.key),
|
||||
};
|
||||
case "labs":
|
||||
return {
|
||||
value: t("topics.headline.labsValue", {
|
||||
count: headline.inRange,
|
||||
total: headline.total,
|
||||
}),
|
||||
unit: t("topics.headline.labsUnit"),
|
||||
};
|
||||
case "cycleDay":
|
||||
return {
|
||||
value: t("topics.headline.cycleDay", { day: headline.dayOfCycle }),
|
||||
unit: "",
|
||||
};
|
||||
case "screener":
|
||||
return {
|
||||
value: `${INSTRUMENT_LABEL[headline.instrument] ?? headline.instrument} ${fmt.number(headline.score, 0)}`,
|
||||
unit: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicOverviewEntry } from "@/lib/topics/build";
|
||||
|
||||
import { TOPIC_ICONS, topicHref, topicTitleKey } from "./topic-meta";
|
||||
import { TopicSummaryText, useHeadline } from "./topic-summary";
|
||||
|
||||
// The sparkline loads through the shared chart runtime, so the overview's own
|
||||
// chunk carries no recharts; the tile owns the fixed band it draws in.
|
||||
const DeltaSparkline = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
const TONE_PILL = {
|
||||
steady: "success",
|
||||
watch: "warning",
|
||||
learning: "neutral",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One topic on the overview: its name (the link), the headline value, the
|
||||
* rule-based sentence, the last 30 days as a sparkline, and how many of the
|
||||
* topic's signals hold a value. The whole card is the target, through the
|
||||
* title link stretched over it, so the link's name is the visible title.
|
||||
*/
|
||||
export function TopicTile({ entry }: { entry: TopicOverviewEntry }) {
|
||||
const { t } = useTranslations();
|
||||
const headline = useHeadline(entry.headline);
|
||||
const title = t(topicTitleKey(entry.key));
|
||||
const points = entry.sparkline.map((v, i) => ({ i, v }));
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="topic-tile"
|
||||
data-topic={entry.key}
|
||||
data-tone={entry.tone}
|
||||
className="hover:bg-accent/40 relative gap-2 transition-colors md:gap-3"
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={TOPIC_ICONS[entry.key]}
|
||||
titleAs="h2"
|
||||
title={
|
||||
<Link
|
||||
href={topicHref(entry.key)}
|
||||
data-slot="topic-tile-link"
|
||||
className="focus-visible:ring-ring/50 rounded-sm after:absolute after:inset-0 after:rounded-xl focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{headline ? (
|
||||
<p className="text-foreground flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{headline.value}
|
||||
</span>
|
||||
{headline.unit ? (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{headline.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
<TopicSummaryText summary={entry.summary} className="text-sm" />
|
||||
{points.length >= 2 ? (
|
||||
<div className="h-10" aria-hidden="true">
|
||||
<DeltaSparkline data={points} strokeVar="var(--chart-1)" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-2">
|
||||
<ScorePill tone={TONE_PILL[entry.tone]}>
|
||||
{t(`topics.tone.${entry.tone}`)}
|
||||
</ScorePill>
|
||||
{entry.recorded > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("topics.recorded", {
|
||||
count: entry.recorded,
|
||||
total: entry.total,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Sun } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail, TopicSignal } from "@/lib/topics/build";
|
||||
|
||||
import { TopicSummaryText } from "./topic-summary";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const STATE_TONE = {
|
||||
within: "success",
|
||||
above: "warning",
|
||||
below: "warning",
|
||||
learning: "neutral",
|
||||
} as const;
|
||||
|
||||
function SignalReadout({
|
||||
signal,
|
||||
todayKey,
|
||||
}: {
|
||||
signal: TopicSignal;
|
||||
todayKey: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
if (!signal.latest) return null;
|
||||
const label = format.label(signal.key);
|
||||
const unit = format.unit(signal.key);
|
||||
const usual = signal.usual;
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-1"
|
||||
data-slot="topic-signal"
|
||||
data-signal={signal.key}
|
||||
data-state={signal.state}
|
||||
>
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground flex items-baseline gap-1">
|
||||
<span className="text-xl font-semibold tabular-nums">
|
||||
{format.number(signal.key, signal.latest.value)}
|
||||
</span>
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground text-xs">{unit}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{usual
|
||||
? t("topics.today.usual", {
|
||||
low: format.number(signal.key, usual.low),
|
||||
high: format.number(signal.key, usual.high),
|
||||
})
|
||||
: t("topics.today.learning")}
|
||||
</dd>
|
||||
{signal.latest.day !== todayKey ? (
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{t("topics.today.asOf", {
|
||||
date: fmt.dateShortSmartCalendar(signal.latest.day),
|
||||
})}
|
||||
</dd>
|
||||
) : null}
|
||||
<dd className="flex flex-wrap items-center gap-2">
|
||||
{signal.state !== "none" ? (
|
||||
<ScorePill tone={STATE_TONE[signal.state]}>
|
||||
{t(`topics.state.${signal.state}`)}
|
||||
</ScorePill>
|
||||
) : null}
|
||||
{signal.href ? (
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={signal.href}>
|
||||
{t("topics.today.details")}
|
||||
<span className="sr-only">: {label}</span>
|
||||
<ChevronRight className="size-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The topic today: its one sentence, then each recorded signal with its
|
||||
* latest value, its usual range and a way into its own page.
|
||||
*/
|
||||
export function TopicTodayCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const recorded = detail.signals.filter((s) => s.latest !== null);
|
||||
return (
|
||||
<Card data-slot="topic-today">
|
||||
<CardHeader>
|
||||
<TileHeader icon={Sun} titleAs="h2" title={t("topics.today.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<TopicSummaryText
|
||||
summary={detail.summary}
|
||||
className="text-base font-medium"
|
||||
/>
|
||||
{recorded.length > 0 ? (
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-4 md:grid-cols-4">
|
||||
{recorded.map((signal) => (
|
||||
<SignalReadout
|
||||
key={signal.key}
|
||||
signal={signal}
|
||||
todayKey={detail.to}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicTrend } from "@/lib/topics/stats";
|
||||
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const DeltaSparkline = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
/**
|
||||
* Each recorded signal over the trailing 90 days: the change the fitted line
|
||||
* amounts to, beside its weekly means. Below two weeks of days a row says how
|
||||
* many it has rather than drawing a slope through a handful of points.
|
||||
*/
|
||||
export function TopicTrendsCard({ trends }: { trends: TopicTrend[] }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
if (trends.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-trends">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={TrendingUp}
|
||||
titleAs="h2"
|
||||
title={t("topics.trends.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-border flex flex-col divide-y">
|
||||
{trends.map((trend) => {
|
||||
const points = trend.weekly.map((p, i) => ({ i, v: p.value }));
|
||||
return (
|
||||
<li
|
||||
key={trend.key}
|
||||
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
data-slot="topic-trend"
|
||||
data-signal={trend.key}
|
||||
data-status={trend.status}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{format.label(trend.key)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{trend.status === "ok"
|
||||
? t("topics.trends.window")
|
||||
: t("topics.trends.insufficient", { count: trend.days })}
|
||||
</p>
|
||||
</div>
|
||||
{trend.status === "ok" && points.length >= 2 ? (
|
||||
<div className="h-8 w-20 shrink-0" aria-hidden="true">
|
||||
<DeltaSparkline data={points} strokeVar="var(--chart-2)" />
|
||||
</div>
|
||||
) : null}
|
||||
{trend.status === "ok" && trend.change !== null ? (
|
||||
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{format.delta(trend.key, trend.change)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicKey } from "@/lib/topics/registry";
|
||||
|
||||
import { PreviewBadge } from "./preview-badge";
|
||||
import { PreviewNote } from "./preview-note";
|
||||
import { topicTitleKey } from "./topic-meta";
|
||||
import { TopicTile } from "./topic-tile";
|
||||
import { useTopicOverview } from "./use-topics";
|
||||
|
||||
function useTopicList() {
|
||||
const { t } = useTranslations();
|
||||
return (keys: readonly TopicKey[]) =>
|
||||
keys.map((key) => t(topicTitleKey(key))).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* `/topics`: one tile per topic the record holds data for. Topics with
|
||||
* nothing recorded yet and topics whose module is off are named once each,
|
||||
* at the end, with where to change that; neither gets an empty tile.
|
||||
*/
|
||||
export function TopicsOverview() {
|
||||
const { t } = useTranslations();
|
||||
const list = useTopicList();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const query = useTopicOverview(true);
|
||||
const data = query.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="topics-overview">
|
||||
<PageHeader
|
||||
title={t("topics.overview.title")}
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t("topics.overview.description")}
|
||||
/>
|
||||
|
||||
{query.isError ? (
|
||||
<QueryErrorCard
|
||||
error={query.error}
|
||||
title={t("topics.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : !data ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-56 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : data.topics.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Layers className="size-6" aria-hidden="true" />}
|
||||
title={t("topics.overview.emptyTitle")}
|
||||
description={t("topics.overview.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
|
||||
data-slot="topics-grid"
|
||||
>
|
||||
{data.topics.map((entry) => (
|
||||
<TopicTile key={entry.key} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.empty.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topics-empty-list"
|
||||
>
|
||||
{t("topics.overview.notRecorded", { topics: list(data.empty) })}
|
||||
</p>
|
||||
) : null}
|
||||
{data && data.hiddenByModule.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topics-hidden-list"
|
||||
>
|
||||
{t("topics.overview.hiddenByModule", {
|
||||
topics: list(data.hiddenByModule),
|
||||
})}{" "}
|
||||
{inSharedRecord ? null : (
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link href="/settings/modules">
|
||||
{t("topics.overview.modulesLink")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<PreviewNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* The Topics area exists only for a person who switched the preview on.
|
||||
*
|
||||
* Until the account payload answers, the page shows the shell's loading
|
||||
* state; signed out, it goes to sign-in like every page; with the preview off
|
||||
* (an old bookmark, a link from someone else) it leaves for Insights, where
|
||||
* the person's pages are. The routes refuse on their own (404
|
||||
* `topics.preview_off`), so this is the friendly end, not the guard.
|
||||
*/
|
||||
export function TopicsPreviewGate({ children }: { children: ReactNode }) {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const enabled = user?.previewFeatures?.topics === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) router.replace("/auth/login");
|
||||
else if (!enabled) router.replace("/insights");
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) return <PageAuthGate />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* How a topic signal reads on screen: its name, a value in the person's own
|
||||
* units, and a signed change. Measurement types go through the same unit
|
||||
* display every metric page uses (`useUnitDisplay`), so a topic never shows a
|
||||
* number its metric page would write differently; sleep reads as hours and
|
||||
* minutes, the mood score as its 1–5 value.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
|
||||
import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision";
|
||||
import { MOOD_SIGNAL } from "@/lib/topics/registry";
|
||||
|
||||
/**
|
||||
* Whole numbers for counts (steps, minutes) and for the vitals every device
|
||||
* reads whole (pulse, blood pressure, HRV, SpO2); one decimal for weight and
|
||||
* temperature; a converted unit at its transform's precision. The same
|
||||
* answer the Today overview and the server-side texts give.
|
||||
*/
|
||||
const WHOLE_VITALS = new Set([
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"OXYGEN_SATURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
"WALKING_HEART_RATE_AVERAGE",
|
||||
]);
|
||||
|
||||
function signalDigits(key: string, transformDecimals: number): number {
|
||||
if (rawDisplayFractionDigits(key) === 0) return 0;
|
||||
if (WHOLE_VITALS.has(key))
|
||||
return vitalDisplayDecimals(key, transformDecimals);
|
||||
return transformDecimals;
|
||||
}
|
||||
|
||||
export interface SignalFormat {
|
||||
label: (key: string) => string;
|
||||
/** The value with its unit, as one string. */
|
||||
value: (key: string, raw: number) => string;
|
||||
/** The number alone, in display units. */
|
||||
number: (key: string, raw: number) => string;
|
||||
unit: (key: string) => string;
|
||||
/** A signed change with its unit ("+1.2 kg", "−4 ms"). */
|
||||
delta: (key: string, raw: number) => string;
|
||||
/** The size of a change with its unit, unsigned ("1.2 kg"). */
|
||||
amount: (key: string, raw: number) => string;
|
||||
}
|
||||
|
||||
export function useSignalFormat(): SignalFormat {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const units = useUnitDisplay();
|
||||
|
||||
return useMemo(() => {
|
||||
const label = (key: string) => {
|
||||
if (key === MOOD_SIGNAL) return t("topics.signals.mood");
|
||||
const labelKey =
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[
|
||||
key as keyof typeof MEASUREMENT_TYPE_LABEL_KEYS
|
||||
];
|
||||
return labelKey ? t(labelKey) : key;
|
||||
};
|
||||
const number = (key: string, raw: number) => {
|
||||
if (key === MOOD_SIGNAL) return fmt.number(raw, 1);
|
||||
if (key === "SLEEP_DURATION") return formatDurationMinutes(raw, t);
|
||||
return fmt.number(
|
||||
units.toDisplay(key, raw),
|
||||
signalDigits(key, units.decimalsFor(key)),
|
||||
);
|
||||
};
|
||||
const unit = (key: string) => {
|
||||
if (key === MOOD_SIGNAL) return t("topics.units.moodScale");
|
||||
if (key === "SLEEP_DURATION") return "";
|
||||
return units.unitFor(key);
|
||||
};
|
||||
const value = (key: string, raw: number) => {
|
||||
const u = unit(key);
|
||||
return u ? `${number(key, raw)} ${u}` : number(key, raw);
|
||||
};
|
||||
const amount = (key: string, raw: number) => {
|
||||
const magnitude = Math.abs(raw);
|
||||
if (key === "SLEEP_DURATION") return formatDurationMinutes(magnitude, t);
|
||||
const shown =
|
||||
key === MOOD_SIGNAL
|
||||
? fmt.number(magnitude, 1)
|
||||
: fmt.number(
|
||||
Math.abs(units.toDisplayDelta(key, magnitude)),
|
||||
signalDigits(key, units.decimalsFor(key)),
|
||||
);
|
||||
const u = unit(key);
|
||||
return u ? `${shown} ${u}` : shown;
|
||||
};
|
||||
const delta = (key: string, raw: number) => {
|
||||
const sign = raw > 0 ? "+" : raw < 0 ? "−" : "±";
|
||||
return `${sign}${amount(key, raw)}`;
|
||||
};
|
||||
return { label, value, number, unit, delta, amount };
|
||||
}, [t, fmt, units]);
|
||||
}
|
||||
|
||||
export interface SignalAxis {
|
||||
/** A stored value in the unit a chart axis draws (sleep in hours). */
|
||||
toAxis: (key: string, raw: number) => number;
|
||||
/** Back from the axis unit to the stored value. */
|
||||
fromAxis: (key: string, value: number) => number;
|
||||
/** A bare axis tick. */
|
||||
tick: (key: string, value: number) => string;
|
||||
}
|
||||
|
||||
/** How a signal draws on a chart axis, in the person's own units. */
|
||||
export function useSignalAxis(): SignalAxis {
|
||||
const fmt = useFormatters();
|
||||
const units = useUnitDisplay();
|
||||
return useMemo(() => {
|
||||
const plain = (key: string) =>
|
||||
key === MOOD_SIGNAL || key === "SLEEP_DURATION";
|
||||
return {
|
||||
toAxis: (key, raw) =>
|
||||
key === "SLEEP_DURATION"
|
||||
? raw / 60
|
||||
: key === MOOD_SIGNAL
|
||||
? raw
|
||||
: units.toDisplay(key, raw),
|
||||
fromAxis: (key, value) =>
|
||||
key === "SLEEP_DURATION"
|
||||
? value * 60
|
||||
: key === MOOD_SIGNAL
|
||||
? value
|
||||
: units.fromDisplay(key, value),
|
||||
tick: (key, value) =>
|
||||
fmt.number(
|
||||
value,
|
||||
plain(key) ? 1 : signalDigits(key, units.decimalsFor(key)),
|
||||
),
|
||||
};
|
||||
}, [fmt, units]);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The two topic reads. Each answer is the server's, rendered as given; the
|
||||
* failed read hands its `ApiError` through untouched so the surface's
|
||||
* read-failure primitive decides what to offer.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { TopicDetail, TopicOverview } from "@/lib/topics/build";
|
||||
import type { TopicKey, TopicRange } from "@/lib/topics/registry";
|
||||
|
||||
export function useTopicOverview(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.topicsOverview(),
|
||||
queryFn: () => apiGet<TopicOverview>("/api/topics"),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopicDetail(
|
||||
topic: TopicKey,
|
||||
range: TopicRange,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.topicDetail(topic, range),
|
||||
queryFn: () =>
|
||||
apiGet<TopicDetail>(
|
||||
`/api/topics/${encodeURIComponent(topic)}?range=${encodeURIComponent(range)}`,
|
||||
),
|
||||
enabled,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user