mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
commit
0a88155aec
100 files changed
+259
-8727
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user