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:
Marc-André Bombeck committed 2026-10-23 08:18:53 +02:00
1 parent cc04e0944d
commit c68e833dcc
64 files changed
+4319 -219

No files matched your search

+1
View File
@@ -58,3 +58,4 @@ export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
export { WorkoutHrChart } from "./workout-hr-chart";
export { WorkoutElevationChart } from "./workout-elevation-chart";
export { CoachResultChart } from "@/components/insights/coach-panel/result-chart";
export { TopicSideBySideChart } from "@/components/topics/topic-side-by-side-chart";
@@ -12,6 +12,7 @@ function index(over: Partial<PaletteIndexInput> = {}) {
t,
modules: {},
navModules: {},
previews: null,
inSharedRecord: false,
sections: null,
isAdmin: false,
@@ -27,6 +28,16 @@ function index(over: Partial<PaletteIndexInput> = {}) {
const ids = (entries: ReturnType<typeof index>) => entries.map((e) => e.id);
describe("buildPaletteIndex — previews", () => {
it("offers the Topics page only while the person switched the preview on", () => {
expect(ids(index())).not.toContain("nav:/topics");
expect(ids(index({ previews: { topics: false } }))).not.toContain(
"nav:/topics",
);
expect(ids(index({ previews: { topics: true } }))).toContain("nav:/topics");
});
});
describe("buildPaletteIndex — modules", () => {
it("a switched-off module takes its page, its Insights page and its cards along", () => {
const off = { mood: false, timeline: false, environment: false };
@@ -18,6 +18,7 @@ import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { PreviewFeatures } from "@/lib/settings/preview-features";
import type { ShareDomain } from "@/lib/sharing/scope";
import {
@@ -74,6 +75,8 @@ export interface PaletteIndexInput {
modules: SurfaceModuleMap | undefined;
/** The same map with `coach` following the AI capability (`useNavModules`). */
navModules: SurfaceModuleMap | undefined;
/** The person's own preview opt-ins; null offers no preview page. */
previews: PreviewFeatures | null;
inSharedRecord: boolean;
/** The shared record's covered sections, when there is one. */
sections: ReadonlyArray<ShareDomain> | null;
@@ -121,6 +124,7 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] {
true,
input.inSharedRecord,
input.sections,
input.previews,
);
const navHrefs = new Set(nav.map((d) => d.href));
for (const d of nav) {
@@ -10,6 +10,7 @@ import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useModuleEnabledState } from "@/hooks/use-module-toggle";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
buildPaletteIndex,
@@ -32,6 +33,7 @@ export function usePaletteIndex(): PaletteEntry[] {
const { t } = useTranslations();
const { user } = useAuth();
const navModules = useNavModules();
const previews = usePreviewFeatures();
const capabilities = useRecordCapabilities();
const { inSharedRecord, sections, recordKind, level } = capabilities;
const coach = useAiCapability("coach");
@@ -64,6 +66,7 @@ export function usePaletteIndex(): PaletteEntry[] {
t,
modules: user?.modules,
navModules,
previews,
inSharedRecord,
sections,
isAdmin: user?.role === "ADMIN",
@@ -85,6 +88,7 @@ export function usePaletteIndex(): PaletteEntry[] {
user?.modules,
user?.role,
navModules,
previews,
inSharedRecord,
sections,
recordKind,
+6 -37
View File
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { labTrendLines } from "./lab-trend-lines";
import type { BiomarkerTrendDto } from "./types";
/**
@@ -27,44 +28,12 @@ export function LabTrendCard({
const fmt = useFormatters();
if (!trend || trend.status !== "ok") return null;
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
const per = (v: number) => fmt.number(Math.abs(v), digits);
const direction =
trend.slopePerYear > 0
? "rising"
: trend.slopePerYear < 0
? "falling"
: "flat";
const slopeLine = t(`labs.trend.slope.${direction}`, {
amount: per(trend.slopePerYear),
const { slope: slopeLine, projection: projectionLine } = labTrendLines(
trend,
unit,
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
});
const projectionLine =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
date: fmt.dateShortSmartCalendar(trend.exitDate),
})
: trend.projection === "no_band"
? null
: trend.projection === "stale"
? t("labs.trend.projection.stale", {
date: fmt.dateShortSmartCalendar(trend.lastAt),
})
: trend.projection === "beyond_horizon"
? t(
trend.horizonCapped
? "labs.trend.projection.beyond_horizon_capped"
: "labs.trend.projection.beyond_horizon",
{
years: fmt.number(
trend.horizonYears,
trend.horizonCapped ? 0 : 1,
),
},
)
: t(`labs.trend.projection.${trend.projection}`);
t,
fmt,
);
return (
<Card data-slot="lab-trend">
+60
View File
@@ -0,0 +1,60 @@
import type { Formatters } from "@/lib/format-locale";
import type { BiomarkerTrendDto } from "./types";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* The two sentences a lab trend reads as: the slope across the readings, and
* where the straight line would meet the reference band (or why it is not
* drawn). Shared by the marker page's trend card and the metabolism topic, so
* one trend is never worded two ways.
*/
export function labTrendLines(
trend: Extract<BiomarkerTrendDto, { status: "ok" }>,
unit: string,
t: Translate,
fmt: Formatters,
): { slope: string; projection: string | null } {
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
const direction =
trend.slopePerYear > 0
? "rising"
: trend.slopePerYear < 0
? "falling"
: "flat";
const slope = t(`labs.trend.slope.${direction}`, {
amount: fmt.number(Math.abs(trend.slopePerYear), digits),
unit,
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
});
const projection =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
date: fmt.dateShortSmartCalendar(trend.exitDate),
})
: trend.projection === "no_band"
? null
: trend.projection === "stale"
? t("labs.trend.projection.stale", {
date: fmt.dateShortSmartCalendar(trend.lastAt),
})
: trend.projection === "beyond_horizon"
? t(
trend.horizonCapped
? "labs.trend.projection.beyond_horizon_capped"
: "labs.trend.projection.beyond_horizon",
{
years: fmt.number(
trend.horizonYears,
trend.horizonCapped ? 0 : 1,
),
},
)
: t(`labs.trend.projection.${trend.projection}`);
return { slope, projection };
}
@@ -105,13 +105,31 @@ describe("the destination list under a switch", () => {
});
it("changes nothing for a session in its own record", () => {
const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href);
const own = visibleNavDestinations(ALL_MODULES, true, false, null, {
topics: true,
}).map((d) => d.href);
expect(own).toEqual(
NAV_DESTINATIONS.filter((d) => !d.sharedRecordOnly).map((d) => d.href),
);
expect(own).not.toContain("/profile");
});
it("offers Topics inside a whole-record share only with the viewer's own preview on", () => {
const share = (previews: { topics: boolean } | null) =>
visibleNavDestinations(ALL_MODULES, true, true, null, previews).map(
(d) => d.href,
);
expect(share({ topics: true })).toContain("/topics");
expect(share({ topics: false })).not.toContain("/topics");
expect(share(null)).not.toContain("/topics");
// A grant scoped to sections never opens a page that lays them side by side.
expect(
visibleNavDestinations(ALL_MODULES, true, true, ["measurements"], {
topics: true,
}).map((d) => d.href),
).not.toContain("/topics");
});
it("offers the read-only profile summary only inside a shared record", () => {
expect(hrefsInSharedRecord()).toContain("/profile");
});
@@ -215,6 +215,42 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (feature-only h
});
});
describe("a preview entry (Topics)", () => {
it("exists only while the person reading switched the preview on", () => {
const hrefs = (previews: { topics: boolean } | null, mounted = true) =>
visibleNavDestinations({}, mounted, false, null, previews).map(
(d) => d.href,
);
expect(hrefs(null)).not.toContain("/topics");
expect(hrefs({ topics: false })).not.toContain("/topics");
expect(hrefs({ topics: true })).toContain("/topics");
// Fail-closed before mount, so SSR and the first paint agree.
expect(hrefs({ topics: true }, false)).not.toContain("/topics");
});
it("sits directly below Insights in the sidebar", () => {
const hrefs = visibleNavDestinations({}, true, false, null, {
topics: true,
}).map((d) => d.href);
expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics");
});
it("leads the phone's More hub with its badge and never takes a bottom-bar slot", () => {
const hub = mobileMoreHubDestinations({
modules: {},
previews: { topics: true },
});
expect(hub[0]).toMatchObject({ href: "/topics", preview: "topics" });
expect(BOTTOM_NAV_PRIMARY_SLOT_HREFS).not.toContain("/topics");
expect(
mobileMoreHubDestinations({
modules: {},
previews: { topics: false },
}).map((d) => d.href),
).not.toContain("/topics");
});
});
describe("isNavDestinationActive most-specific resolution", () => {
it("matches the dashboard only on an exact path", () => {
expect(isNavDestinationActive("/", "/")).toBe(true);
+70 -44
View File
@@ -21,6 +21,8 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { PreviewBadge } from "@/components/topics/preview-badge";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
@@ -42,6 +44,8 @@ interface NavLink {
href: string;
tKey: string;
icon: LucideIcon;
/** A preview entry; its tile carries the Beta badge. */
preview?: string;
}
// v1.12.x — iOS-parity bottom bar (additive middle-path).
@@ -111,6 +115,7 @@ export function BottomNav() {
// The Coach entry follows the `coach` AI capability (a Coach that cannot
// answer is not offered); the sidebar reads the same map.
const navModules = useNavModules();
const previews = usePreviewFeatures();
const moreHub = useMemo<ReadonlyArray<NavLink>>(
() =>
mobileMoreHubDestinations({
@@ -118,8 +123,9 @@ export function BottomNav() {
mounted,
sharedRecord,
sections,
previews,
}),
[navModules, mounted, sharedRecord, sections],
[navModules, mounted, sharedRecord, sections, previews],
);
// Every slot but Home asks two things: does sharing cover it, and does its
@@ -289,52 +295,72 @@ export function BottomNav() {
second line instead of clipping the last character at the tile
edge. */}
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
{moreHub.map((item) => {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMoreOpen(false)}
aria-current={isActive ? "page" : undefined}
// v1.12 — `min-h-14` matches the capture-picker tiles so
// the equivalent tappable rows share one height at this
// tier (and the larger row is a more comfortable target).
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
// to hand the label more horizontal room without changing
// the shared tile height.
className={cn(
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
isActive
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{/* v1.18.10 — show the FULL label, never an ellipsis
that hides meaning. The W7 `truncate` clipped long words
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
readable label, not a balanced tile. Shrink the type to
`text-xs` so long single-word locales (de/es/fr/it) fit on
one line where they can, and allow a 2-line `line-clamp-2`
fallback (with `[overflow-wrap:anywhere]` so a long word
breaks rather than overflowing) for the rare label that
still cannot fit one line. `min-w-0` lets the flex child
shrink below its content width. v1.22.1 — with the wider
text area above, "Benachrichtigungen" wraps fully inside
two lines down to a 320 px viewport. */}
<span
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
title={t(item.tKey)}
>
{t(item.tKey)}
</span>
</Link>
);
})}
{moreHub.map((item) => (
<MoreHubTile
key={item.href}
item={item}
active={isActiveLink(item.href)}
onNavigate={() => setMoreOpen(false)}
/>
))}
</div>
</SheetContent>
</Sheet>
</>
);
}
/** One destination tile in the More sheet. */
function MoreHubTile({
item,
active,
onNavigate,
}: {
item: NavLink;
active: boolean;
onNavigate: () => void;
}) {
const { t } = useTranslations();
return (
<Link
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
// v1.12 — `min-h-14` matches the capture-picker tiles so
// the equivalent tappable rows share one height at this
// tier (and the larger row is a more comfortable target).
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
// to hand the label more horizontal room without changing
// the shared tile height.
className={cn(
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
active
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{/* v1.18.10 — show the FULL label, never an ellipsis
that hides meaning. The W7 `truncate` clipped long words
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
readable label, not a balanced tile. Shrink the type to
`text-xs` so long single-word locales (de/es/fr/it) fit on
one line where they can, and allow a 2-line `line-clamp-2`
fallback (with `[overflow-wrap:anywhere]` so a long word
breaks rather than overflowing) for the rare label that
still cannot fit one line. `min-w-0` lets the flex child
shrink below its content width. v1.22.1 — with the wider
text area above, "Benachrichtigungen" wraps fully inside
two lines down to a 320 px viewport. */}
<span
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
title={t(item.tKey)}
>
{t(item.tKey)}
</span>
{item.preview ? (
<PreviewBadge label={t("nav.previewBadge")} className="ml-auto" />
) : null}
</Link>
);
}
+10 -4
View File
@@ -29,6 +29,7 @@ import {
} from "@/components/layout/nav-model";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
createShortcutReader,
@@ -103,6 +104,7 @@ export function openCapturePicker(): void {
function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
const { user } = useAuth();
const navModules = useNavModules();
const previews = usePreviewFeatures();
const capabilities = useRecordCapabilities();
const { inSharedRecord, sections, recordKind, level } = capabilities;
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
@@ -111,10 +113,14 @@ function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
.length > 0;
const navHrefs = useMemo(
() =>
visibleNavDestinations(navModules, true, inSharedRecord, sections).map(
(d) => d.href,
),
[navModules, inSharedRecord, sections],
visibleNavDestinations(
navModules,
true,
inSharedRecord,
sections,
previews,
).map((d) => d.href),
[navModules, inSharedRecord, sections, previews],
);
const settingsHref = useMemo(
() =>
+58 -3
View File
@@ -8,6 +8,7 @@ import {
FlaskConical,
History,
Home,
Layers,
Lightbulb,
MessagesSquare,
Pill,
@@ -29,6 +30,10 @@ import {
type SettingsRecordContext,
} from "@/lib/record-settings/classification";
import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record";
import type {
PreviewFeatureKey,
PreviewFeatures,
} from "@/lib/settings/preview-features";
import type { ShareDomain } from "@/lib/sharing/scope";
/**
@@ -78,6 +83,14 @@ export interface NavDestination {
sharedRecord?: boolean;
/** A record-only view, intentionally absent from the actor's own nav. */
sharedRecordOnly?: boolean;
/**
* A preview the person switches on for themselves (Settings >
* System > Preview features). The entry exists only while that flag is on
* for the person reading, carries a Beta badge, and leads the phone's More
* hub so a freshly switched-on preview is found. Fail-closed: hidden until
* the account payload has said yes.
*/
preview?: PreviewFeatureKey;
}
/**
@@ -250,6 +263,21 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
icon: Lightbulb,
tourId: "nav-insights",
},
// Topics (preview): overview pages that bring related signals
// together and link into the single-metric Insights pages, which stay
// exactly as they are. Directly below Insights in the sidebar; on a phone it
// lands in the More hub (first), never in the bottom bar. Only while the
// person reading switched the preview on. A record read across sections,
// like the timeline: a delegate holding the whole record sees it (with their
// own preview on), a scoped grant never does.
{
href: "/topics",
sharedRecord: true,
tKey: "nav.topics",
icon: Layers,
tourId: "nav-topics",
preview: "topics",
},
// v1.17.1 — the Coach finally gets a single labeled nav home. It
// was reachable from seven scattered entry points (FAB, hero CTA, empty
// states, per-metric icons …) but nowhere in the nav, so a new user
@@ -377,7 +405,11 @@ function isNavDestinationVisible(
mounted = true,
sharedRecord = false,
sections: readonly ShareDomain[] | null = null,
previews: PreviewFeatures | null = null,
): boolean {
// A preview entry exists only once the payload has said the person reading
// switched it on: fail-closed before mount and while the payload is absent.
if (d.preview && (!mounted || previews?.[d.preview] !== true)) return false;
if (
sharedRecord &&
(d.sharedRecord !== true ||
@@ -440,9 +472,17 @@ export function visibleNavDestinations(
mounted = true,
sharedRecord = false,
sections: readonly ShareDomain[] | null = null,
previews: PreviewFeatures | null = null,
): NavDestination[] {
return NAV_DESTINATIONS.filter((d) =>
isNavDestinationVisible(d, modules, mounted, sharedRecord, sections),
isNavDestinationVisible(
d,
modules,
mounted,
sharedRecord,
sections,
previews,
),
);
}
@@ -463,6 +503,8 @@ export interface MobileMoreHubEntry {
href: string;
tKey: string;
icon: LucideIcon;
/** Set for a preview entry, which the tile marks with a Beta badge. */
preview?: PreviewFeatureKey;
}
/**
@@ -483,15 +525,28 @@ export function mobileMoreHubDestinations(opts: {
sharedRecord?: boolean;
/** Server-resolved scope for the active shared record. */
sections?: readonly ShareDomain[] | null;
/** The person's own preview opt-ins; absent hides every preview. */
previews?: PreviewFeatures | null;
}): MobileMoreHubEntry[] {
return visibleNavDestinations(
const entries = visibleNavDestinations(
opts.modules,
opts.mounted ?? true,
opts.sharedRecord ?? false,
opts.sections ?? null,
opts.previews ?? null,
)
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
.map((d) => ({
href: d.href,
tKey: d.tKey,
icon: d.icon,
...(d.preview ? { preview: d.preview } : {}),
}));
// A preview someone just switched on leads the hub, where it is found; the
// rest keep model order. `Array.prototype.sort` is stable.
return entries.sort(
(a, b) => Number(b.preview !== undefined) - Number(a.preview !== undefined),
);
}
/**
+32 -94
View File
@@ -27,11 +27,18 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import {
RAIL_LIST_GAP,
RAIL_SHORT_PY,
railRowClass,
SidebarNavItem,
} from "@/components/layout/sidebar-rail";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
@@ -328,45 +335,6 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
);
}
/**
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
* the browser roughly 700–860 px, and the full destination list plus the
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
* On short windows the rows close their gaps first, then step their height
* down; tall windows keep the roomy default. Desktop-only by construction —
* the rail is hidden below `md`, so the phone layout never sees these steps.
*
* Steps (rows are full-width, so the click target is the whole row):
* - default: 40 px rows, 4 px apart
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
* - max-height 820 px: 36 px rows
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
* where the alternative is rows hidden behind a scroll edge
*/
// Disjoint height ranges, not nested max-heights: two arbitrary media
// variants on one property cascade in stylesheet order, not by how narrow
// the query is, so a nested pair let the 36 px step win on a 720 px window.
// The height steps apply to a mouse only. Under a finger (a touch tablet)
// the rows keep the 44 px touch floor and the list scrolls behind its fades
// instead. A phone held sideways never sees the rail: it keeps the phone
// shell (`shell-mobile`).
const RAIL_ROW_HEIGHT =
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
/** Rail row, collapsed (icon only) or expanded (icon + label). */
function railRowClass(collapsed: boolean): string {
return cn(
"flex items-center rounded-lg transition-colors",
RAIL_ROW_HEIGHT,
collapsed
? "justify-center px-2.5"
: "gap-3 px-3 py-1.5 text-sm font-medium",
);
}
/** Height of the fade at a scroll edge that has more content beyond it. */
const RAIL_FADE = "1.5rem";
@@ -525,9 +493,17 @@ export function SidebarNav() {
// The Coach entry follows the `coach` AI capability, like every other
// Coach entry point; the rest of the map is `modules` unchanged.
const navModules = useNavModules();
const previews = usePreviewFeatures();
const visibleNavItems = useMemo(
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
[navModules, mounted, sharedRecord, sections],
() =>
visibleNavDestinations(
navModules,
mounted,
sharedRecord,
sections,
previews,
),
[navModules, mounted, sharedRecord, sections, previews],
);
// Collapsed = the user's stored choice, or — with no stored choice — a
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
@@ -757,59 +733,21 @@ export function SidebarNav() {
}}
>
<div className={RAIL_LIST_GAP}>
{visibleNavItems.map((item) => {
const isActive = isNavDestinationActive(
item.href,
pathname,
visibleNavItems,
);
const label = t(item.tKey);
if (collapsed) {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
railRowClass(true),
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
railRowClass(false),
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
{label}
</Link>
);
})}
{visibleNavItems.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
label={t(item.tKey)}
badge={item.preview ? t("nav.previewBadge") : null}
active={isNavDestinationActive(
item.href,
pathname,
visibleNavItems,
)}
collapsed={collapsed}
intent={item.href === "/medications" ? medsIntent : undefined}
/>
))}
</div>
</nav>
+114
View File
@@ -0,0 +1,114 @@
"use client";
/**
* The rail's rows: the height steps every row shares and one destination
* row, collapsed or expanded. Split from `sidebar-nav.tsx`, which keeps the
* shell, the footer and the user menu.
*/
import Link from "next/link";
import type { NavDestination } from "@/components/layout/nav-model";
import { PreviewBadge } from "@/components/topics/preview-badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
import { cn } from "@/lib/utils";
/**
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
* the browser roughly 700–860 px, and the full destination list plus the
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
* On short windows the rows close their gaps first, then step their height
* down; tall windows keep the roomy default. Desktop-only by construction —
* the rail is hidden below `md`, so the phone layout never sees these steps.
*
* Steps (rows are full-width, so the click target is the whole row):
* - default: 40 px rows, 4 px apart
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
* - max-height 820 px: 36 px rows
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
* where the alternative is rows hidden behind a scroll edge
*/
// Disjoint height ranges, not nested max-heights: two arbitrary media
// variants on one property cascade in stylesheet order, not by how narrow
// the query is, so a nested pair let the 36 px step win on a 720 px window.
// The height steps apply to a mouse only. Under a finger (a touch tablet)
// the rows keep the 44 px touch floor and the list scrolls behind its fades
// instead. A phone held sideways never sees the rail: it keeps the phone
// shell (`shell-mobile`).
const RAIL_ROW_HEIGHT =
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
export const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
export const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
/** Rail row, collapsed (icon only) or expanded (icon + label). */
export function railRowClass(collapsed: boolean): string {
return cn(
"flex items-center rounded-lg transition-colors",
RAIL_ROW_HEIGHT,
collapsed
? "justify-center px-2.5"
: "gap-3 px-3 py-1.5 text-sm font-medium",
);
}
/** One destination in the rail: an icon row when collapsed, icon and label when expanded. */
export function SidebarNavItem({
item,
label,
badge,
active,
collapsed,
intent,
}: {
item: NavDestination;
label: string;
/** The Beta mark of a preview entry, or null. */
badge: string | null;
active: boolean;
collapsed: boolean;
/** The medications prefetch handlers, on that one row. */
intent?: ReturnType<typeof medicationsPrefetchIntentProps>;
}) {
const className = cn(
railRowClass(collapsed),
active ? "bg-primary/10 text-primary" : "text-foreground hover:bg-accent",
);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
data-tour-id={item.tourId}
{...intent}
className={className}
>
<item.icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
data-tour-id={item.tourId}
{...intent}
className={className}
>
<item.icon className="h-4 w-4" />
{label}
{badge ? <PreviewBadge label={badge} className="ml-auto" /> : null}
</Link>
);
}
@@ -0,0 +1,60 @@
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 { queryKeys } from "@/lib/query-keys";
import { PreviewSection } from "../preview-section";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
});
if (topics !== undefined) {
client.setQueryData(queryKeys.previewFeatures(), { topics });
}
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>
<PreviewSection />
</QueryClientProvider>
</I18nProvider>,
);
}
const switchState = (html: string, state: "true" | "false") =>
new RegExp(
`data-slot="settings-preview-topics-switch"[^>]*aria-checked="${state}"|aria-checked="${state}"[^>]*data-slot="settings-preview-topics-switch"`,
).test(html);
describe("<PreviewSection>", () => {
it("shows the Topics switch off by default, saved on change", () => {
const html = render(false);
expect(switchState(html, "false")).toBe(true);
expect(html).toContain("Topics");
expect(html).toContain('data-slot="preview-badge"');
// A switch, no Save: one save model per card.
expect(html).not.toContain('type="submit"');
// Nothing to open while it is off.
expect(html).not.toContain('data-slot="settings-preview-topics-open"');
});
it("reads on once the person switched it on and offers the way in", () => {
const html = render(true);
expect(switchState(html, "true")).toBe(true);
expect(html).toMatch(
/data-slot="settings-preview-topics-open"[^>]*href="\/topics"|href="\/topics"[^>]*data-slot="settings-preview-topics-open"/,
);
});
it("names the switch by its visible title and leaks no raw key", () => {
const html = render(false, "de");
expect(html).toContain('aria-labelledby="settings-preview-topics-title"');
expect(html).toContain("Themen");
expect(html).not.toContain("settings.preview.");
expect(html).not.toContain("nav.previewBadge");
});
});
@@ -170,19 +170,12 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one pane, after `advanced`.
"privacy",
// Per-user preview opt-ins, in the System group before About.
"preview",
"about",
]);
});
it("lists no Preview features page until the topic pages ship", () => {
// The stored flag and its API stay; the page and its nav entry return
// with the first preview the switch actually turns on.
expect(isSettingsSectionSlug("preview")).toBe(false);
expect(SETTINGS_SECTIONS.some((s) => s.slug === ("preview" as never))).toBe(
false,
);
});
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
for (const slug of SETTINGS_SECTION_SLUGS) {
expect(isSettingsSectionSlug(slug)).toBe(true);
@@ -249,6 +242,7 @@ describe("<SettingsShell>", () => {
gesundheitsakte: "data",
privacy: "data",
advanced: "data",
preview: "system",
about: "system",
});
// Every routable section has a nav entry, and nothing else does.
+116
View File
@@ -0,0 +1,116 @@
"use client";
/**
* Settings > System > Preview features.
*
* One switch per preview feature, each off until the person turns it on, each
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
* preview adds something beside what is there and never replaces a page.
*
* The switch is the person's own and stays theirs while they act on somebody
* else's record. Saving refreshes the account payload too, because the
* navigation reads the flag from there: the Topics entry appears or goes the
* moment the switch moves.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Layers } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { PreviewBadge } from "@/components/topics/preview-badge";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_PREVIEW_FEATURES,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
export function PreviewSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const key = queryKeys.previewFeatures();
const query = useQuery({
queryKey: key,
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
});
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
const toggle = useMutation({
mutationKey: key,
mutationFn: (topics: boolean) =>
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
onMutate: async (topics) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<PreviewFeatures>(key);
queryClient.setQueryData<PreviewFeatures>(key, {
...(previous ?? DEFAULT_PREVIEW_FEATURES),
topics,
});
return { previous };
},
onSuccess: (saved, topics) => {
if (saved) queryClient.setQueryData(key, saved);
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
toastWrittenOutcome(
"success",
topics
? t("settings.preview.topics.savedOn")
: t("settings.preview.topics.savedOff"),
);
},
onError: (failure, _topics, context) => {
queryClient.setQueryData(key, context?.previous);
showErrorToast(t("settings.savingError"), { cause: failure });
},
});
return (
<SettingsCard data-slot="settings-preview-topics">
<SettingsCardHeader
anchor="preview-topics"
icon={Layers}
title={t("settings.preview.topics.title")}
titleId="settings-preview-topics-title"
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
description={t("settings.preview.topics.description")}
status={
<Switch
data-slot="settings-preview-topics-switch"
checked={features.topics}
disabled={query.isLoading || toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-labelledby="settings-preview-topics-title"
/>
}
/>
{query.isError ? (
<QueryErrorRow
inset
error={query.error}
slot="settings-preview-error"
message={t("settings.preview.loadError")}
onRetry={() => void query.refetch()}
/>
) : null}
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
{features.topics ? (
<p className="text-sm">
<InlineAction asChild tone="primary" size="sm">
<Link href="/topics" data-slot="settings-preview-topics-open">
{t("settings.preview.topics.open")}
</Link>
</InlineAction>
</p>
) : null}
</SettingsCard>
);
}
@@ -124,6 +124,10 @@ const LOADERS: Record<
import("@/components/settings/privacy-section").then(
(m) => m.PrivacySection,
),
preview: () =>
import("@/components/settings/preview-section").then(
(m) => m.PreviewSection,
),
};
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
@@ -158,6 +162,7 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
};
/**
@@ -26,6 +26,7 @@ import {
CloudSun,
Download,
FileHeart,
FlaskConical,
Gauge,
Info,
KeyRound,
@@ -274,6 +275,12 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
group: "data",
},
// ── System ──
{
slug: "preview",
titleKey: "settings.sections.preview.title",
icon: FlaskConical,
group: "system",
},
{
slug: "about",
titleKey: "settings.sections.about.title",
@@ -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");
});
});
+29
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+145
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+126
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+74
View File
@@ -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>
);
}
+98
View File
@@ -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: "",
};
}
}
+100
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+104
View File
@@ -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}</>;
}
+146
View File
@@ -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]);
}
+37
View File
@@ -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,
});
}