Merge branch 'release/v1.46.0' into feat/v146-ui

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:08:24 +02:00
commit 0a88155aec
100 files changed
+259 -8727

No files matched your search

-1
View File
@@ -58,4 +58,3 @@ 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,7 +12,6 @@ function index(over: Partial<PaletteIndexInput> = {}) {
t,
modules: {},
navModules: {},
previews: null,
inSharedRecord: false,
sections: null,
isAdmin: false,
@@ -28,16 +27,6 @@ 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,7 +18,6 @@ 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 {
@@ -75,8 +74,6 @@ 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;
@@ -124,7 +121,6 @@ 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,7 +10,6 @@ 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,
@@ -33,7 +32,6 @@ 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");
@@ -66,7 +64,6 @@ export function usePaletteIndex(): PaletteEntry[] {
t,
modules: user?.modules,
navModules,
previews,
inSharedRecord,
sections,
isAdmin: user?.role === "ADMIN",
@@ -88,7 +85,6 @@ export function usePaletteIndex(): PaletteEntry[] {
user?.modules,
user?.role,
navModules,
previews,
inSharedRecord,
sections,
recordKind,
+37 -6
View File
@@ -6,7 +6,6 @@ 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";
/**
@@ -28,12 +27,44 @@ export function LabTrendCard({
const fmt = useFormatters();
if (!trend || trend.status !== "ok") return null;
const { slope: slopeLine, projection: projectionLine } = labTrendLines(
trend,
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),
unit,
t,
fmt,
);
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}`);
return (
<Card data-slot="lab-trend">
-60
View File
@@ -1,60 +0,0 @@
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,31 +105,13 @@ describe("the destination list under a switch", () => {
});
it("changes nothing for a session in its own record", () => {
const own = visibleNavDestinations(ALL_MODULES, true, false, null, {
topics: true,
}).map((d) => d.href);
const own = visibleNavDestinations(ALL_MODULES, 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,42 +215,6 @@ 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);
+44 -70
View File
@@ -21,8 +21,6 @@ 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 {
@@ -44,8 +42,6 @@ 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).
@@ -115,7 +111,6 @@ 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({
@@ -123,9 +118,8 @@ export function BottomNav() {
mounted,
sharedRecord,
sections,
previews,
}),
[navModules, mounted, sharedRecord, sections, previews],
[navModules, mounted, sharedRecord, sections],
);
// Every slot but Home asks two things: does sharing cover it, and does its
@@ -295,72 +289,52 @@ 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) => (
<MoreHubTile
key={item.href}
item={item}
active={isActiveLink(item.href)}
onNavigate={() => setMoreOpen(false)}
/>
))}
{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>
);
})}
</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>
);
}
+4 -10
View File
@@ -29,7 +29,6 @@ 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,
@@ -104,7 +103,6 @@ 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;
@@ -113,14 +111,10 @@ function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
.length > 0;
const navHrefs = useMemo(
() =>
visibleNavDestinations(
navModules,
true,
inSharedRecord,
sections,
previews,
).map((d) => d.href),
[navModules, inSharedRecord, sections, previews],
visibleNavDestinations(navModules, true, inSharedRecord, sections).map(
(d) => d.href,
),
[navModules, inSharedRecord, sections],
);
const settingsHref = useMemo(
() =>
+3 -58
View File
@@ -8,7 +8,6 @@ import {
FlaskConical,
History,
Home,
Layers,
Lightbulb,
MessagesSquare,
Pill,
@@ -30,10 +29,6 @@ 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";
/**
@@ -83,14 +78,6 @@ 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;
}
/**
@@ -263,21 +250,6 @@ 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
@@ -405,11 +377,7 @@ 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 ||
@@ -472,17 +440,9 @@ 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,
previews,
),
isNavDestinationVisible(d, modules, mounted, sharedRecord, sections),
);
}
@@ -503,8 +463,6 @@ export interface MobileMoreHubEntry {
href: string;
tKey: string;
icon: LucideIcon;
/** Set for a preview entry, which the tile marks with a Beta badge. */
preview?: PreviewFeatureKey;
}
/**
@@ -525,28 +483,15 @@ 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[] {
const entries = visibleNavDestinations(
return 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,
...(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),
);
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
}
/**
+94 -32
View File
@@ -27,18 +27,11 @@ 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";
@@ -335,6 +328,45 @@ 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";
@@ -493,17 +525,9 @@ 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,
previews,
),
[navModules, mounted, sharedRecord, sections, previews],
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
[navModules, mounted, sharedRecord, sections],
);
// Collapsed = the user's stored choice, or — with no stored choice — a
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
@@ -733,21 +757,59 @@ export function SidebarNav() {
}}
>
<div className={RAIL_LIST_GAP}>
{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}
/>
))}
{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>
);
})}
</div>
</nav>
-114
View File
@@ -1,114 +0,0 @@
"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>
);
}
@@ -1,60 +0,0 @@
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,12 +170,19 @@ 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);
@@ -242,7 +249,6 @@ 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
@@ -1,116 +0,0 @@
"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,10 +124,6 @@ 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>> = {
@@ -162,7 +158,6 @@ 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,7 +26,6 @@ import {
CloudSun,
Download,
FileHeart,
FlaskConical,
Gauge,
Info,
KeyRound,
@@ -275,12 +274,6 @@ 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",
@@ -1,62 +0,0 @@
/**
* 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);
});
});
@@ -1,85 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,22 +0,0 @@
"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
@@ -1,145 +0,0 @@
"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
@@ -1,119 +0,0 @@
"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
@@ -1,126 +0,0 @@
"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
@@ -1,132 +0,0 @@
"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
@@ -1,74 +0,0 @@
/**
* 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",
};
@@ -1,200 +0,0 @@
"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>
);
}
@@ -1,86 +0,0 @@
"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>
);
}
@@ -1,153 +0,0 @@
"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
@@ -1,98 +0,0 @@
"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
@@ -1,100 +0,0 @@
"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
@@ -1,119 +0,0 @@
"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>
);
}
@@ -1,79 +0,0 @@
"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
@@ -1,104 +0,0 @@
"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>
);
}
@@ -1,31 +0,0 @@
"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
@@ -1,146 +0,0 @@
"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
@@ -1,37 +0,0 @@
"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,
});
}