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

@@ -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>
);
}