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

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

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 120,
"total": 141446,
"count": 121,
"total": 142301,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -24,6 +24,7 @@
"components/insights/derived/vitals-dashboard.tsx": 1033,
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1086,
"components/layout/sidebar-nav.tsx": 855,
"components/measurement-reminders/vorsorge-section.tsx": 1536,
"components/measurements/measurement-form.tsx": 815,
"components/measurements/measurement-list.tsx": 2019,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 408,
"total": 144385,
"total": 144450,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -9,7 +9,7 @@
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 217,
"app/api/analytics/route.ts :: buildAnalyticsResponse": 446,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/me/route.ts :: apiHandler(arg)": 384,
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 499,
"app/api/auth/register/route.ts :: apiHandler(arg)": 275,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
@@ -171,9 +171,9 @@
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393,
"components/layout/auth-shell.tsx :: AuthShell": 520,
"components/layout/bottom-nav.tsx :: BottomNav": 229,
"components/layout/bottom-nav.tsx :: BottomNav": 262,
"components/layout/capture-picker.tsx :: CapturePicker": 235,
"components/layout/sidebar-nav.tsx :: SidebarNav": 342,
"components/layout/sidebar-nav.tsx :: SidebarNav": 372,
"components/layout/top-bar.tsx :: TopBar": 212,
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
+3 -2
View File
@@ -1,7 +1,7 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 523,
"total": 5494,
"count": 524,
"total": 5495,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
@@ -273,6 +273,7 @@
"components/medications/__tests__/use-medication-intake.test.ts": 1,
"components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx": 1,
"components/mood/__tests__/mood-list-url-sync.test.tsx": 1,
"components/settings/__tests__/settings-shell.test.tsx": 1,
"components/settings/integrations/__tests__/dawarich-card.test.tsx": 1,
"components/timeline/__tests__/medication-rows.test.ts": 1,
"lib/__tests__/acting-account-resolver.test.ts": 5,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2953,
"total": 10417,
"total": 10418,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2806,7 +2806,7 @@
"lib/rollups/mood-rollups.ts": 4,
"lib/rollups/tiered-context.ts": 6,
"lib/score-config/rows.ts": 1,
"lib/settings/section-slugs.ts": 19,
"lib/settings/section-slugs.ts": 20,
"lib/sharing/__tests__/account-access-parse.test.ts": 3,
"lib/sharing/__tests__/account-access.test.ts": 2,
"lib/sharing/__tests__/account-label.test.ts": 1,
+2 -3
View File
@@ -3,9 +3,8 @@
*
* The workout-route switch is off for new accounts, which is what its detail
* says rather than "off by default": an account that already held tracks at
* the upgrade started with it on (migration 0434). The Topics preview switch
* this file also held describes pages that now exist, so its copy is in the
* present tense like every other switch.
* the upgrade started with it on (migration 0434). (The Topics preview switch
* this file also held is unlisted until its pages ship, and its copy with it.)
*/
import { describe, expect, it } from "vitest";
@@ -54,8 +54,6 @@ const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
"/settings/export",
"/settings/advanced",
"/settings/privacy",
// Preview features (Settings > System).
"/settings/preview",
"/settings/about",
// The per-module personalisation slugs before the layout hub.
"/settings/dashboard",
+1 -15
View File
@@ -1074,17 +1074,6 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "record",
why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.",
},
// The topic pages (preview) lay several sections side by side, like the
// timeline, so they stay whole-record reads. The preview switch they check
// is the ACTOR's own row and decides nothing about the record.
"app/api/topics/route.ts": {
domain: "record",
why: "The topic overview: a headline value, a rule-based sentence and a sparkline per topic, computed from the record's daily values, lab markers, screener totals and cycle starts. Read-only, no model, scoped to the resolved user, every signal and block narrowed to the record's module switches and to the sections the grant covers.",
},
"app/api/topics/[topic]/route.ts": {
domain: "record",
why: "One topic page: signals with usual ranges, side-by-side series, relationships, trends and the topic's blocks. Read-only, no model, scoped to the resolved user, narrowed per signal and block like the overview.",
},
"app/api/workouts/route.ts": {
domain: "measurements",
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
@@ -2208,11 +2197,8 @@ const ACTOR_ROUTES: Record<string, string> = {
* side). The activity goals route does not: setting or changing goals stays
* owner-only, and a delegate sees the goals only as the calendar reports them.
* 270 -> 273.
*
* The topic overview and the topic page (preview) join the record list as
* whole-record reads, like the timeline. 273 -> 275.
*/
const FROZEN_ENTRY_COUNT = 275;
const FROZEN_ENTRY_COUNT = 273;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+14 -28
View File
@@ -44,7 +44,6 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolvePreviewFeatures } from "@/lib/settings/preview-features";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
@@ -94,21 +93,6 @@ const SERVER_FEATURES = {
lifeCircumstances: true,
} as const;
/**
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
* 500s the whole profile fetch (the field just reads null and the user
* re-enters it).
*/
function decryptInsuranceNumber(encrypted: string | null): string | null {
if (!encrypted) return null;
try {
return decrypt(encrypted);
} catch {
return null;
}
}
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -254,11 +238,10 @@ export const GET = apiHandler(async () => {
}),
// The encrypted insurance number is not on the per-request user
// (`SESSION_USER_SELECT`); it is read here, for the one response that
// shows it. The preview opt-ins ride the same point read of the caller's
// own row.
// shows it.
prisma.user.findUnique({
where: { id: user.id },
select: { insuranceNumberEncrypted: true, previewFeaturesJson: true },
select: { insuranceNumberEncrypted: true },
}),
]);
@@ -306,9 +289,18 @@ export const GET = apiHandler(async () => {
sectionsOpen(sections, "cycle") &&
isCycleEnabled(record?.gender ?? null, cycleProfile);
const insuranceNumber = decryptInsuranceNumber(
identity?.insuranceNumberEncrypted ?? null,
);
// v1.7.0 — patient-identity fields for the health-record export. The
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
// one row never 500s the whole profile fetch (the field just reads
// null and the user re-enters it).
let insuranceNumber: string | null = null;
if (identity?.insuranceNumberEncrypted) {
try {
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
} catch {
insuranceNumber = null;
}
}
return apiSuccess({
id: user.id,
@@ -455,12 +447,6 @@ export const GET = apiHandler(async () => {
// `offIsReal` whether the lowest option is a real off or "Minimal", and
// `source` which layer decided. Clients never recompute it.
coachReasoning,
// The caller's own preview opt-ins (Settings > System > Preview
// features), resolved: every flag present, off unless switched on. The
// navigation offers the Topics entry from `topics`. The ACTOR's, like the
// Coach preferences above, so it does not move under a switch: a preview is
// chrome of the person reading, never a property of the record on screen.
previewFeatures: resolvePreviewFeatures(identity?.previewFeaturesJson),
// v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu,
// `active` is the record this session is inside (null when it is in its
// own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds
-80
View File
@@ -1,80 +0,0 @@
/**
* `GET /api/topics/{topic}` — one topic page (preview).
*
* The topic's signals with their usual ranges, the side-by-side series for the
* chosen window, the relationships between its signals (14 paired days before
* anything is said), the 90-day trends, the signals it could show but holds
* nothing for, and its extra blocks (training load, lab markers with their
* trend, screener trends, today's place in the cycle). Every figure is
* computed on the server from existing reads; no model is involved.
*
* 404 `topics.preview_off` when the viewer has not switched the preview on,
* `topics.not_available` when the record's module switches do not offer the
* topic. Admission declares the whole record, like the overview.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { hasTopicsPreview, loadTopicDetail } from "@/lib/topics/service";
import {
topicDetailQuerySchema,
topicPathSchema,
} from "@/lib/validations/topics";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ topic: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user, actor, grantId } = await requireRecordAuth("read", "record");
annotate({ action: { name: "topics.detail.read" } });
if (!(await hasTopicsPreview(actor.id))) {
return apiError("Topics are a preview that is switched off", 404, {
errorCode: "topics.preview_off",
});
}
const path = topicPathSchema.safeParse(await params);
if (!path.success) {
return apiError("No such topic", 404, { errorCode: "topics.not_found" });
}
const query = topicDetailQuerySchema.safeParse({
range: request.nextUrl.searchParams.get("range") ?? undefined,
});
if (!query.success) return returnAllZodIssues(query.error, 422);
const rl = await checkAnalyticsReadRateLimit(actor.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const detail = await loadTopicDetail({
userId: user.id,
grantId,
topic: path.data.topic,
range: query.data.range,
now: new Date(),
});
if (!detail) {
return apiError("This topic is not available for this record", 404, {
errorCode: "topics.not_available",
});
}
annotate({
meta: {
topic: detail.key,
topic_range: detail.range,
topic_status: detail.status,
topic_relationships_linked: detail.relationships.filter(
(r) => r.status === "linked",
).length,
},
});
return apiSuccess(detail);
},
);
-54
View File
@@ -1,54 +0,0 @@
/**
* `GET /api/topics` — the topic overview (preview).
*
* One tile per topic the record holds data for: a headline value, a rule-based
* sentence and a sparkline, computed on the server from the same daily values
* the single-metric pages draw. No model is involved and no AI gate applies.
*
* The preview is the viewer's own opt-in (Settings > System > Preview
* features): with it off the route answers 404 `topics.preview_off`, the same
* as for a path that does not exist. The topics, signals and blocks follow the
* RECORD's module switches; a topic whose every module is off is named in
* `hiddenByModule`, an offered topic with nothing recorded in `empty`.
*
* Admission declares the whole record, like the timeline: a topic lays several
* sections side by side, so a grant scoped to some of them does not open it.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { hasTopicsPreview, loadTopicOverview } from "@/lib/topics/service";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user, actor, grantId } = await requireRecordAuth("read", "record");
annotate({ action: { name: "topics.overview.read" } });
if (!(await hasTopicsPreview(actor.id))) {
return apiError("Topics are a preview that is switched off", 404, {
errorCode: "topics.preview_off",
});
}
// The shared analytics-read budget, spent by the person reading.
const rl = await checkAnalyticsReadRateLimit(actor.id);
if (!rl.allowed) {
return apiError("Too many analytics requests. Please retry later.", 429);
}
const overview = await loadTopicOverview({
userId: user.id,
grantId,
now: new Date(),
});
annotate({
meta: {
topics_shown: overview.topics.length,
topics_empty: overview.empty.length,
topics_hidden: overview.hiddenByModule.length,
},
});
return apiSuccess(overview);
});
-24
View File
@@ -1,24 +0,0 @@
import { notFound } from "next/navigation";
import { TopicDetailView } from "@/components/topics/topic-detail";
import { TopicsPreviewGate } from "@/components/topics/topics-preview-gate";
import { isTopicKey } from "@/lib/topics/registry";
/**
* `/topics/<topic>` (preview): one topic page. A name that is not a topic is
* a 404 here, before anything renders; the preview switch is checked by the
* gate, the record's modules by the route behind the page.
*/
export default async function TopicPage({
params,
}: {
params: Promise<{ topic: string }>;
}) {
const { topic } = await params;
if (!isTopicKey(topic)) notFound();
return (
<TopicsPreviewGate>
<TopicDetailView topic={topic} />
</TopicsPreviewGate>
);
}
-14
View File
@@ -1,14 +0,0 @@
import { TopicsOverview } from "@/components/topics/topics-overview";
import { TopicsPreviewGate } from "@/components/topics/topics-preview-gate";
/**
* `/topics` (preview): the topic overview. Exists only for a person who
* switched the preview on; see `TopicsPreviewGate`.
*/
export default function TopicsPage() {
return (
<TopicsPreviewGate>
<TopicsOverview />
</TopicsPreviewGate>
);
}
-1
View File
@@ -58,4 +58,3 @@ export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
export { WorkoutHrChart } from "./workout-hr-chart";
export { WorkoutElevationChart } from "./workout-elevation-chart";
export { CoachResultChart } from "@/components/insights/coach-panel/result-chart";
export { TopicSideBySideChart } from "@/components/topics/topic-side-by-side-chart";
@@ -12,7 +12,6 @@ function index(over: Partial<PaletteIndexInput> = {}) {
t,
modules: {},
navModules: {},
previews: null,
inSharedRecord: false,
sections: null,
isAdmin: false,
@@ -28,16 +27,6 @@ function index(over: Partial<PaletteIndexInput> = {}) {
const ids = (entries: ReturnType<typeof index>) => entries.map((e) => e.id);
describe("buildPaletteIndex — previews", () => {
it("offers the Topics page only while the person switched the preview on", () => {
expect(ids(index())).not.toContain("nav:/topics");
expect(ids(index({ previews: { topics: false } }))).not.toContain(
"nav:/topics",
);
expect(ids(index({ previews: { topics: true } }))).toContain("nav:/topics");
});
});
describe("buildPaletteIndex — modules", () => {
it("a switched-off module takes its page, its Insights page and its cards along", () => {
const off = { mood: false, timeline: false, environment: false };
@@ -18,7 +18,6 @@ import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { PreviewFeatures } from "@/lib/settings/preview-features";
import type { ShareDomain } from "@/lib/sharing/scope";
import {
@@ -75,8 +74,6 @@ export interface PaletteIndexInput {
modules: SurfaceModuleMap | undefined;
/** The same map with `coach` following the AI capability (`useNavModules`). */
navModules: SurfaceModuleMap | undefined;
/** The person's own preview opt-ins; null offers no preview page. */
previews: PreviewFeatures | null;
inSharedRecord: boolean;
/** The shared record's covered sections, when there is one. */
sections: ReadonlyArray<ShareDomain> | null;
@@ -124,7 +121,6 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] {
true,
input.inSharedRecord,
input.sections,
input.previews,
);
const navHrefs = new Set(nav.map((d) => d.href));
for (const d of nav) {
@@ -10,7 +10,6 @@ import { useAiCapability } from "@/hooks/use-ai-capability";
import { useAuth } from "@/hooks/use-auth";
import { useModuleEnabledState } from "@/hooks/use-module-toggle";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
buildPaletteIndex,
@@ -33,7 +32,6 @@ export function usePaletteIndex(): PaletteEntry[] {
const { t } = useTranslations();
const { user } = useAuth();
const navModules = useNavModules();
const previews = usePreviewFeatures();
const capabilities = useRecordCapabilities();
const { inSharedRecord, sections, recordKind, level } = capabilities;
const coach = useAiCapability("coach");
@@ -66,7 +64,6 @@ export function usePaletteIndex(): PaletteEntry[] {
t,
modules: user?.modules,
navModules,
previews,
inSharedRecord,
sections,
isAdmin: user?.role === "ADMIN",
@@ -88,7 +85,6 @@ export function usePaletteIndex(): PaletteEntry[] {
user?.modules,
user?.role,
navModules,
previews,
inSharedRecord,
sections,
recordKind,
+37 -6
View File
@@ -6,7 +6,6 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { labTrendLines } from "./lab-trend-lines";
import type { BiomarkerTrendDto } from "./types";
/**
@@ -28,12 +27,44 @@ export function LabTrendCard({
const fmt = useFormatters();
if (!trend || trend.status !== "ok") return null;
const { slope: slopeLine, projection: projectionLine } = labTrendLines(
trend,
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
const per = (v: number) => fmt.number(Math.abs(v), digits);
const direction =
trend.slopePerYear > 0
? "rising"
: trend.slopePerYear < 0
? "falling"
: "flat";
const slopeLine = t(`labs.trend.slope.${direction}`, {
amount: per(trend.slopePerYear),
unit,
t,
fmt,
);
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
});
const projectionLine =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
date: fmt.dateShortSmartCalendar(trend.exitDate),
})
: trend.projection === "no_band"
? null
: trend.projection === "stale"
? t("labs.trend.projection.stale", {
date: fmt.dateShortSmartCalendar(trend.lastAt),
})
: trend.projection === "beyond_horizon"
? t(
trend.horizonCapped
? "labs.trend.projection.beyond_horizon_capped"
: "labs.trend.projection.beyond_horizon",
{
years: fmt.number(
trend.horizonYears,
trend.horizonCapped ? 0 : 1,
),
},
)
: t(`labs.trend.projection.${trend.projection}`);
return (
<Card data-slot="lab-trend">
-60
View File
@@ -1,60 +0,0 @@
import type { Formatters } from "@/lib/format-locale";
import type { BiomarkerTrendDto } from "./types";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* The two sentences a lab trend reads as: the slope across the readings, and
* where the straight line would meet the reference band (or why it is not
* drawn). Shared by the marker page's trend card and the metabolism topic, so
* one trend is never worded two ways.
*/
export function labTrendLines(
trend: Extract<BiomarkerTrendDto, { status: "ok" }>,
unit: string,
t: Translate,
fmt: Formatters,
): { slope: string; projection: string | null } {
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
const direction =
trend.slopePerYear > 0
? "rising"
: trend.slopePerYear < 0
? "falling"
: "flat";
const slope = t(`labs.trend.slope.${direction}`, {
amount: fmt.number(Math.abs(trend.slopePerYear), digits),
unit,
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
});
const projection =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
date: fmt.dateShortSmartCalendar(trend.exitDate),
})
: trend.projection === "no_band"
? null
: trend.projection === "stale"
? t("labs.trend.projection.stale", {
date: fmt.dateShortSmartCalendar(trend.lastAt),
})
: trend.projection === "beyond_horizon"
? t(
trend.horizonCapped
? "labs.trend.projection.beyond_horizon_capped"
: "labs.trend.projection.beyond_horizon",
{
years: fmt.number(
trend.horizonYears,
trend.horizonCapped ? 0 : 1,
),
},
)
: t(`labs.trend.projection.${trend.projection}`);
return { slope, projection };
}
@@ -105,31 +105,13 @@ describe("the destination list under a switch", () => {
});
it("changes nothing for a session in its own record", () => {
const own = visibleNavDestinations(ALL_MODULES, true, false, null, {
topics: true,
}).map((d) => d.href);
const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href);
expect(own).toEqual(
NAV_DESTINATIONS.filter((d) => !d.sharedRecordOnly).map((d) => d.href),
);
expect(own).not.toContain("/profile");
});
it("offers Topics inside a whole-record share only with the viewer's own preview on", () => {
const share = (previews: { topics: boolean } | null) =>
visibleNavDestinations(ALL_MODULES, true, true, null, previews).map(
(d) => d.href,
);
expect(share({ topics: true })).toContain("/topics");
expect(share({ topics: false })).not.toContain("/topics");
expect(share(null)).not.toContain("/topics");
// A grant scoped to sections never opens a page that lays them side by side.
expect(
visibleNavDestinations(ALL_MODULES, true, true, ["measurements"], {
topics: true,
}).map((d) => d.href),
).not.toContain("/topics");
});
it("offers the read-only profile summary only inside a shared record", () => {
expect(hrefsInSharedRecord()).toContain("/profile");
});
@@ -215,42 +215,6 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (feature-only h
});
});
describe("a preview entry (Topics)", () => {
it("exists only while the person reading switched the preview on", () => {
const hrefs = (previews: { topics: boolean } | null, mounted = true) =>
visibleNavDestinations({}, mounted, false, null, previews).map(
(d) => d.href,
);
expect(hrefs(null)).not.toContain("/topics");
expect(hrefs({ topics: false })).not.toContain("/topics");
expect(hrefs({ topics: true })).toContain("/topics");
// Fail-closed before mount, so SSR and the first paint agree.
expect(hrefs({ topics: true }, false)).not.toContain("/topics");
});
it("sits directly below Insights in the sidebar", () => {
const hrefs = visibleNavDestinations({}, true, false, null, {
topics: true,
}).map((d) => d.href);
expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics");
});
it("leads the phone's More hub with its badge and never takes a bottom-bar slot", () => {
const hub = mobileMoreHubDestinations({
modules: {},
previews: { topics: true },
});
expect(hub[0]).toMatchObject({ href: "/topics", preview: "topics" });
expect(BOTTOM_NAV_PRIMARY_SLOT_HREFS).not.toContain("/topics");
expect(
mobileMoreHubDestinations({
modules: {},
previews: { topics: false },
}).map((d) => d.href),
).not.toContain("/topics");
});
});
describe("isNavDestinationActive most-specific resolution", () => {
it("matches the dashboard only on an exact path", () => {
expect(isNavDestinationActive("/", "/")).toBe(true);
+44 -70
View File
@@ -21,8 +21,6 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { PreviewBadge } from "@/components/topics/preview-badge";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
@@ -44,8 +42,6 @@ interface NavLink {
href: string;
tKey: string;
icon: LucideIcon;
/** A preview entry; its tile carries the Beta badge. */
preview?: string;
}
// v1.12.x — iOS-parity bottom bar (additive middle-path).
@@ -115,7 +111,6 @@ export function BottomNav() {
// The Coach entry follows the `coach` AI capability (a Coach that cannot
// answer is not offered); the sidebar reads the same map.
const navModules = useNavModules();
const previews = usePreviewFeatures();
const moreHub = useMemo<ReadonlyArray<NavLink>>(
() =>
mobileMoreHubDestinations({
@@ -123,9 +118,8 @@ export function BottomNav() {
mounted,
sharedRecord,
sections,
previews,
}),
[navModules, mounted, sharedRecord, sections, previews],
[navModules, mounted, sharedRecord, sections],
);
// Every slot but Home asks two things: does sharing cover it, and does its
@@ -295,72 +289,52 @@ export function BottomNav() {
second line instead of clipping the last character at the tile
edge. */}
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
{moreHub.map((item) => (
<MoreHubTile
key={item.href}
item={item}
active={isActiveLink(item.href)}
onNavigate={() => setMoreOpen(false)}
/>
))}
{moreHub.map((item) => {
const isActive = isActiveLink(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMoreOpen(false)}
aria-current={isActive ? "page" : undefined}
// v1.12 — `min-h-14` matches the capture-picker tiles so
// the equivalent tappable rows share one height at this
// tier (and the larger row is a more comfortable target).
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
// to hand the label more horizontal room without changing
// the shared tile height.
className={cn(
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
isActive
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{/* v1.18.10 — show the FULL label, never an ellipsis
that hides meaning. The W7 `truncate` clipped long words
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
readable label, not a balanced tile. Shrink the type to
`text-xs` so long single-word locales (de/es/fr/it) fit on
one line where they can, and allow a 2-line `line-clamp-2`
fallback (with `[overflow-wrap:anywhere]` so a long word
breaks rather than overflowing) for the rare label that
still cannot fit one line. `min-w-0` lets the flex child
shrink below its content width. v1.22.1 — with the wider
text area above, "Benachrichtigungen" wraps fully inside
two lines down to a 320 px viewport. */}
<span
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
title={t(item.tKey)}
>
{t(item.tKey)}
</span>
</Link>
);
})}
</div>
</SheetContent>
</Sheet>
</>
);
}
/** One destination tile in the More sheet. */
function MoreHubTile({
item,
active,
onNavigate,
}: {
item: NavLink;
active: boolean;
onNavigate: () => void;
}) {
const { t } = useTranslations();
return (
<Link
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
// v1.12 — `min-h-14` matches the capture-picker tiles so
// the equivalent tappable rows share one height at this
// tier (and the larger row is a more comfortable target).
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
// to hand the label more horizontal room without changing
// the shared tile height.
className={cn(
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
active
? "text-primary bg-primary/5"
: "text-foreground hover:bg-accent/40",
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{/* v1.18.10 — show the FULL label, never an ellipsis
that hides meaning. The W7 `truncate` clipped long words
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
readable label, not a balanced tile. Shrink the type to
`text-xs` so long single-word locales (de/es/fr/it) fit on
one line where they can, and allow a 2-line `line-clamp-2`
fallback (with `[overflow-wrap:anywhere]` so a long word
breaks rather than overflowing) for the rare label that
still cannot fit one line. `min-w-0` lets the flex child
shrink below its content width. v1.22.1 — with the wider
text area above, "Benachrichtigungen" wraps fully inside
two lines down to a 320 px viewport. */}
<span
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
title={t(item.tKey)}
>
{t(item.tKey)}
</span>
{item.preview ? (
<PreviewBadge label={t("nav.previewBadge")} className="ml-auto" />
) : null}
</Link>
);
}
+4 -10
View File
@@ -29,7 +29,6 @@ import {
} from "@/components/layout/nav-model";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
createShortcutReader,
@@ -104,7 +103,6 @@ export function openCapturePicker(): void {
function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
const { user } = useAuth();
const navModules = useNavModules();
const previews = usePreviewFeatures();
const capabilities = useRecordCapabilities();
const { inSharedRecord, sections, recordKind, level } = capabilities;
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
@@ -113,14 +111,10 @@ function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
.length > 0;
const navHrefs = useMemo(
() =>
visibleNavDestinations(
navModules,
true,
inSharedRecord,
sections,
previews,
).map((d) => d.href),
[navModules, inSharedRecord, sections, previews],
visibleNavDestinations(navModules, true, inSharedRecord, sections).map(
(d) => d.href,
),
[navModules, inSharedRecord, sections],
);
const settingsHref = useMemo(
() =>
+3 -58
View File
@@ -8,7 +8,6 @@ import {
FlaskConical,
History,
Home,
Layers,
Lightbulb,
MessagesSquare,
Pill,
@@ -30,10 +29,6 @@ import {
type SettingsRecordContext,
} from "@/lib/record-settings/classification";
import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record";
import type {
PreviewFeatureKey,
PreviewFeatures,
} from "@/lib/settings/preview-features";
import type { ShareDomain } from "@/lib/sharing/scope";
/**
@@ -83,14 +78,6 @@ export interface NavDestination {
sharedRecord?: boolean;
/** A record-only view, intentionally absent from the actor's own nav. */
sharedRecordOnly?: boolean;
/**
* A preview the person switches on for themselves (Settings >
* System > Preview features). The entry exists only while that flag is on
* for the person reading, carries a Beta badge, and leads the phone's More
* hub so a freshly switched-on preview is found. Fail-closed: hidden until
* the account payload has said yes.
*/
preview?: PreviewFeatureKey;
}
/**
@@ -263,21 +250,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
icon: Lightbulb,
tourId: "nav-insights",
},
// Topics (preview): overview pages that bring related signals
// together and link into the single-metric Insights pages, which stay
// exactly as they are. Directly below Insights in the sidebar; on a phone it
// lands in the More hub (first), never in the bottom bar. Only while the
// person reading switched the preview on. A record read across sections,
// like the timeline: a delegate holding the whole record sees it (with their
// own preview on), a scoped grant never does.
{
href: "/topics",
sharedRecord: true,
tKey: "nav.topics",
icon: Layers,
tourId: "nav-topics",
preview: "topics",
},
// v1.17.1 — the Coach finally gets a single labeled nav home. It
// was reachable from seven scattered entry points (FAB, hero CTA, empty
// states, per-metric icons …) but nowhere in the nav, so a new user
@@ -405,11 +377,7 @@ function isNavDestinationVisible(
mounted = true,
sharedRecord = false,
sections: readonly ShareDomain[] | null = null,
previews: PreviewFeatures | null = null,
): boolean {
// A preview entry exists only once the payload has said the person reading
// switched it on: fail-closed before mount and while the payload is absent.
if (d.preview && (!mounted || previews?.[d.preview] !== true)) return false;
if (
sharedRecord &&
(d.sharedRecord !== true ||
@@ -472,17 +440,9 @@ export function visibleNavDestinations(
mounted = true,
sharedRecord = false,
sections: readonly ShareDomain[] | null = null,
previews: PreviewFeatures | null = null,
): NavDestination[] {
return NAV_DESTINATIONS.filter((d) =>
isNavDestinationVisible(
d,
modules,
mounted,
sharedRecord,
sections,
previews,
),
isNavDestinationVisible(d, modules, mounted, sharedRecord, sections),
);
}
@@ -503,8 +463,6 @@ export interface MobileMoreHubEntry {
href: string;
tKey: string;
icon: LucideIcon;
/** Set for a preview entry, which the tile marks with a Beta badge. */
preview?: PreviewFeatureKey;
}
/**
@@ -525,28 +483,15 @@ export function mobileMoreHubDestinations(opts: {
sharedRecord?: boolean;
/** Server-resolved scope for the active shared record. */
sections?: readonly ShareDomain[] | null;
/** The person's own preview opt-ins; absent hides every preview. */
previews?: PreviewFeatures | null;
}): MobileMoreHubEntry[] {
const entries = visibleNavDestinations(
return visibleNavDestinations(
opts.modules,
opts.mounted ?? true,
opts.sharedRecord ?? false,
opts.sections ?? null,
opts.previews ?? null,
)
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
.map((d) => ({
href: d.href,
tKey: d.tKey,
icon: d.icon,
...(d.preview ? { preview: d.preview } : {}),
}));
// A preview someone just switched on leads the hub, where it is found; the
// rest keep model order. `Array.prototype.sort` is stable.
return entries.sort(
(a, b) => Number(b.preview !== undefined) - Number(a.preview !== undefined),
);
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
}
/**
+94 -32
View File
@@ -27,18 +27,11 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import {
RAIL_LIST_GAP,
RAIL_SHORT_PY,
railRowClass,
SidebarNavItem,
} from "@/components/layout/sidebar-rail";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useNavModules } from "@/hooks/use-nav-modules";
import { usePreviewFeatures } from "@/hooks/use-preview-features";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTheme } from "@/components/providers";
@@ -335,6 +328,45 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
);
}
/**
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
* the browser roughly 700–860 px, and the full destination list plus the
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
* On short windows the rows close their gaps first, then step their height
* down; tall windows keep the roomy default. Desktop-only by construction —
* the rail is hidden below `md`, so the phone layout never sees these steps.
*
* Steps (rows are full-width, so the click target is the whole row):
* - default: 40 px rows, 4 px apart
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
* - max-height 820 px: 36 px rows
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
* where the alternative is rows hidden behind a scroll edge
*/
// Disjoint height ranges, not nested max-heights: two arbitrary media
// variants on one property cascade in stylesheet order, not by how narrow
// the query is, so a nested pair let the 36 px step win on a 720 px window.
// The height steps apply to a mouse only. Under a finger (a touch tablet)
// the rows keep the 44 px touch floor and the list scrolls behind its fades
// instead. A phone held sideways never sees the rail: it keeps the phone
// shell (`shell-mobile`).
const RAIL_ROW_HEIGHT =
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
/** Rail row, collapsed (icon only) or expanded (icon + label). */
function railRowClass(collapsed: boolean): string {
return cn(
"flex items-center rounded-lg transition-colors",
RAIL_ROW_HEIGHT,
collapsed
? "justify-center px-2.5"
: "gap-3 px-3 py-1.5 text-sm font-medium",
);
}
/** Height of the fade at a scroll edge that has more content beyond it. */
const RAIL_FADE = "1.5rem";
@@ -493,17 +525,9 @@ export function SidebarNav() {
// The Coach entry follows the `coach` AI capability, like every other
// Coach entry point; the rest of the map is `modules` unchanged.
const navModules = useNavModules();
const previews = usePreviewFeatures();
const visibleNavItems = useMemo(
() =>
visibleNavDestinations(
navModules,
mounted,
sharedRecord,
sections,
previews,
),
[navModules, mounted, sharedRecord, sections, previews],
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
[navModules, mounted, sharedRecord, sections],
);
// Collapsed = the user's stored choice, or — with no stored choice — a
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
@@ -733,21 +757,59 @@ export function SidebarNav() {
}}
>
<div className={RAIL_LIST_GAP}>
{visibleNavItems.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
label={t(item.tKey)}
badge={item.preview ? t("nav.previewBadge") : null}
active={isNavDestinationActive(
item.href,
pathname,
visibleNavItems,
)}
collapsed={collapsed}
intent={item.href === "/medications" ? medsIntent : undefined}
/>
))}
{visibleNavItems.map((item) => {
const isActive = isNavDestinationActive(
item.href,
pathname,
visibleNavItems,
);
const label = t(item.tKey);
if (collapsed) {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
railRowClass(true),
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
railRowClass(false),
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
{label}
</Link>
);
})}
</div>
</nav>
-114
View File
@@ -1,114 +0,0 @@
"use client";
/**
* The rail's rows: the height steps every row shares and one destination
* row, collapsed or expanded. Split from `sidebar-nav.tsx`, which keeps the
* shell, the footer and the user menu.
*/
import Link from "next/link";
import type { NavDestination } from "@/components/layout/nav-model";
import { PreviewBadge } from "@/components/topics/preview-badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
import { cn } from "@/lib/utils";
/**
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
* the browser roughly 700–860 px, and the full destination list plus the
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
* On short windows the rows close their gaps first, then step their height
* down; tall windows keep the roomy default. Desktop-only by construction —
* the rail is hidden below `md`, so the phone layout never sees these steps.
*
* Steps (rows are full-width, so the click target is the whole row):
* - default: 40 px rows, 4 px apart
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
* - max-height 820 px: 36 px rows
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
* where the alternative is rows hidden behind a scroll edge
*/
// Disjoint height ranges, not nested max-heights: two arbitrary media
// variants on one property cascade in stylesheet order, not by how narrow
// the query is, so a nested pair let the 36 px step win on a 720 px window.
// The height steps apply to a mouse only. Under a finger (a touch tablet)
// the rows keep the 44 px touch floor and the list scrolls behind its fades
// instead. A phone held sideways never sees the rail: it keeps the phone
// shell (`shell-mobile`).
const RAIL_ROW_HEIGHT =
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
export const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
export const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
/** Rail row, collapsed (icon only) or expanded (icon + label). */
export function railRowClass(collapsed: boolean): string {
return cn(
"flex items-center rounded-lg transition-colors",
RAIL_ROW_HEIGHT,
collapsed
? "justify-center px-2.5"
: "gap-3 px-3 py-1.5 text-sm font-medium",
);
}
/** One destination in the rail: an icon row when collapsed, icon and label when expanded. */
export function SidebarNavItem({
item,
label,
badge,
active,
collapsed,
intent,
}: {
item: NavDestination;
label: string;
/** The Beta mark of a preview entry, or null. */
badge: string | null;
active: boolean;
collapsed: boolean;
/** The medications prefetch handlers, on that one row. */
intent?: ReturnType<typeof medicationsPrefetchIntentProps>;
}) {
const className = cn(
railRowClass(collapsed),
active ? "bg-primary/10 text-primary" : "text-foreground hover:bg-accent",
);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
data-tour-id={item.tourId}
{...intent}
className={className}
>
<item.icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
data-tour-id={item.tourId}
{...intent}
className={className}
>
<item.icon className="h-4 w-4" />
{label}
{badge ? <PreviewBadge label={badge} className="ml-auto" /> : null}
</Link>
);
}
@@ -1,60 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { PreviewSection } from "../preview-section";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
});
if (topics !== undefined) {
client.setQueryData(queryKeys.previewFeatures(), { topics });
}
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>
<PreviewSection />
</QueryClientProvider>
</I18nProvider>,
);
}
const switchState = (html: string, state: "true" | "false") =>
new RegExp(
`data-slot="settings-preview-topics-switch"[^>]*aria-checked="${state}"|aria-checked="${state}"[^>]*data-slot="settings-preview-topics-switch"`,
).test(html);
describe("<PreviewSection>", () => {
it("shows the Topics switch off by default, saved on change", () => {
const html = render(false);
expect(switchState(html, "false")).toBe(true);
expect(html).toContain("Topics");
expect(html).toContain('data-slot="preview-badge"');
// A switch, no Save: one save model per card.
expect(html).not.toContain('type="submit"');
// Nothing to open while it is off.
expect(html).not.toContain('data-slot="settings-preview-topics-open"');
});
it("reads on once the person switched it on and offers the way in", () => {
const html = render(true);
expect(switchState(html, "true")).toBe(true);
expect(html).toMatch(
/data-slot="settings-preview-topics-open"[^>]*href="\/topics"|href="\/topics"[^>]*data-slot="settings-preview-topics-open"/,
);
});
it("names the switch by its visible title and leaks no raw key", () => {
const html = render(false, "de");
expect(html).toContain('aria-labelledby="settings-preview-topics-title"');
expect(html).toContain("Themen");
expect(html).not.toContain("settings.preview.");
expect(html).not.toContain("nav.previewBadge");
});
});
@@ -170,12 +170,19 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
// encryption / session / activity pieces into one pane, after `advanced`.
"privacy",
// Per-user preview opt-ins, in the System group before About.
"preview",
"about",
]);
});
it("lists no Preview features page until the topic pages ship", () => {
// The stored flag and its API stay; the page and its nav entry return
// with the first preview the switch actually turns on.
expect(isSettingsSectionSlug("preview")).toBe(false);
expect(SETTINGS_SECTIONS.some((s) => s.slug === ("preview" as never))).toBe(
false,
);
});
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
for (const slug of SETTINGS_SECTION_SLUGS) {
expect(isSettingsSectionSlug(slug)).toBe(true);
@@ -242,7 +249,6 @@ describe("<SettingsShell>", () => {
gesundheitsakte: "data",
privacy: "data",
advanced: "data",
preview: "system",
about: "system",
});
// Every routable section has a nav entry, and nothing else does.
-116
View File
@@ -1,116 +0,0 @@
"use client";
/**
* Settings > System > Preview features.
*
* One switch per preview feature, each off until the person turns it on, each
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
* preview adds something beside what is there and never replaces a page.
*
* The switch is the person's own and stays theirs while they act on somebody
* else's record. Saving refreshes the account payload too, because the
* navigation reads the flag from there: the Topics entry appears or goes the
* moment the switch moves.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Layers } from "lucide-react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { PreviewBadge } from "@/components/topics/preview-badge";
import { InlineAction } from "@/components/ui/inline-action";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Switch } from "@/components/ui/switch";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_PREVIEW_FEATURES,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
export function PreviewSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const key = queryKeys.previewFeatures();
const query = useQuery({
queryKey: key,
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
});
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
const toggle = useMutation({
mutationKey: key,
mutationFn: (topics: boolean) =>
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
onMutate: async (topics) => {
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<PreviewFeatures>(key);
queryClient.setQueryData<PreviewFeatures>(key, {
...(previous ?? DEFAULT_PREVIEW_FEATURES),
topics,
});
return { previous };
},
onSuccess: (saved, topics) => {
if (saved) queryClient.setQueryData(key, saved);
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
toastWrittenOutcome(
"success",
topics
? t("settings.preview.topics.savedOn")
: t("settings.preview.topics.savedOff"),
);
},
onError: (failure, _topics, context) => {
queryClient.setQueryData(key, context?.previous);
showErrorToast(t("settings.savingError"), { cause: failure });
},
});
return (
<SettingsCard data-slot="settings-preview-topics">
<SettingsCardHeader
anchor="preview-topics"
icon={Layers}
title={t("settings.preview.topics.title")}
titleId="settings-preview-topics-title"
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
description={t("settings.preview.topics.description")}
status={
<Switch
data-slot="settings-preview-topics-switch"
checked={features.topics}
disabled={query.isLoading || toggle.isPending}
onCheckedChange={(next) => toggle.mutate(next)}
aria-labelledby="settings-preview-topics-title"
/>
}
/>
{query.isError ? (
<QueryErrorRow
inset
error={query.error}
slot="settings-preview-error"
message={t("settings.preview.loadError")}
onRetry={() => void query.refetch()}
/>
) : null}
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
{features.topics ? (
<p className="text-sm">
<InlineAction asChild tone="primary" size="sm">
<Link href="/topics" data-slot="settings-preview-topics-open">
{t("settings.preview.topics.open")}
</Link>
</InlineAction>
</p>
) : null}
</SettingsCard>
);
}
@@ -124,10 +124,6 @@ const LOADERS: Record<
import("@/components/settings/privacy-section").then(
(m) => m.PrivacySection,
),
preview: () =>
import("@/components/settings/preview-section").then(
(m) => m.PreviewSection,
),
};
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
@@ -162,7 +158,6 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
};
/**
@@ -26,7 +26,6 @@ import {
CloudSun,
Download,
FileHeart,
FlaskConical,
Gauge,
Info,
KeyRound,
@@ -275,12 +274,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
group: "data",
},
// ── System ──
{
slug: "preview",
titleKey: "settings.sections.preview.title",
icon: FlaskConical,
group: "system",
},
{
slug: "about",
titleKey: "settings.sections.about.title",
@@ -1,62 +0,0 @@
/**
* The topic pages build several message keys at run time (one per topic,
* range, state, tone, verdict). The call-site coverage guard sees literal keys
* only, so this holds every built key against every locale.
*/
import { describe, expect, it } from "vitest";
import { locales } from "@/lib/i18n/config";
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
import {
topicDescriptionKey,
topicSourcesKey,
topicTitleKey,
} from "../topic-meta";
function resolve(bundle: unknown, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
bundle,
);
}
const BUILT_KEYS = [
...TOPIC_KEYS.flatMap((key) => [
topicTitleKey(key),
topicDescriptionKey(key),
topicSourcesKey(key),
]),
...TOPIC_RANGES.map((range) => `topics.range.${range}`),
...["within", "above", "below", "learning"].map((s) => `topics.state.${s}`),
...["steady", "watch", "learning"].map((s) => `topics.tone.${s}`),
...["improved", "worsened", "within_threshold"].map(
(v) => `topics.screeners.${v}`,
),
...["above", "below"].map((s) => `topics.labs.${s}`),
...["SameDay", "NextDay"].flatMap((lag) =>
["Higher", "Lower"].map(
(dir) => `topics.relationships.correlation${lag}${dir}`,
),
),
];
describe("topic message keys", () => {
it.each(locales)("every built key resolves to text in %s", async (locale) => {
const bundle = (await import(`../../../../messages/${locale}.json`))
.default;
const missing = BUILT_KEYS.filter(
(key) => typeof resolve(bundle, key) !== "string",
);
expect(missing).toEqual([]);
});
it("checks a non-empty set", () => {
expect(BUILT_KEYS.length).toBeGreaterThan(40);
});
});
@@ -1,85 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { TopicOverviewEntry } from "@/lib/topics/build";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import { TopicTile } from "../topic-tile";
primeLocales("de");
function render(entry: TopicOverviewEntry, locale: "en" | "de" = "en") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, enabled: false } },
});
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>
<TopicTile entry={entry} />
</QueryClientProvider>
</I18nProvider>,
);
}
const heart: TopicOverviewEntry = {
key: "heart",
headline: {
kind: "signal",
key: "RESTING_HEART_RATE",
value: 59,
day: "2026-10-22",
},
summary: {
code: "outside",
signal: "RESTING_HEART_RATE",
direction: "above",
count: null,
total: null,
},
tone: "watch",
sparkline: [58, 59, 61],
recorded: 2,
total: 4,
};
describe("<TopicTile>", () => {
it("links the visible title to the topic and words the server's sentence", () => {
const html = render(heart);
expect(html).toMatch(
/href="\/topics\/heart"[^>]*data-slot="topic-tile-link"[^>]*>Heart<|data-slot="topic-tile-link"[^>]*href="\/topics\/heart"[^>]*>Heart</,
);
expect(html).toContain('data-code="outside"');
expect(html).toContain("above your usual range");
expect(html).toContain('data-tone="watch"');
expect(html).toContain("2 of 4");
expect(html).toMatch(/>59(\.0)?</);
});
it("speaks German and leaks no raw key", () => {
const html = render(heart, "de");
expect(html).toContain(">Herz<");
expect(html).toContain("über deinem üblichen Bereich");
expect(html).not.toMatch(/topics\.[a-z]/);
});
it("says a block headline in words, without a sparkline", () => {
const html = render({
...heart,
key: "metabolism",
headline: { kind: "labs", inRange: 11, total: 12 },
summary: {
code: "labs",
signal: null,
direction: null,
count: 11,
total: 12,
},
tone: "watch",
sparkline: [],
});
expect(html).toContain("11/12");
expect(html).toContain("11 of 12 lab values");
});
});
-29
View File
@@ -1,29 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
/**
* The "Beta" mark beside a preview: the nav entry, the More tile, the page
* title and the Settings switch. A status, so a `Badge`, in the one accent a
* preview carries. Its text is part of the accessible name of whatever it
* sits in, which is what a visible label should be.
*/
export function PreviewBadge({
label,
className,
}: {
label: string;
className?: string;
}) {
return (
<Badge
variant="outline"
data-slot="preview-badge"
className={cn(
"border-primary/40 text-primary text-2xs px-1.5 py-0 tracking-wide uppercase",
className,
)}
>
{label}
</Badge>
);
}
-22
View File
@@ -1,22 +0,0 @@
"use client";
import { FlaskConical } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
/**
* The line every topic page ends on: this is a preview, it may change, and
* the single-metric pages are untouched. Neutral, with no link out.
*/
export function PreviewNote() {
const { t } = useTranslations();
return (
<p
className="text-muted-foreground flex items-start gap-2 text-xs"
data-slot="topics-preview-note"
>
<FlaskConical className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{t("topics.previewNote")}</span>
</p>
);
}
-145
View File
@@ -1,145 +0,0 @@
"use client";
import Link from "next/link";
import { Brain, Droplets } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { InlineAction } from "@/components/ui/inline-action";
import { ScorePill } from "@/components/ui/score-pill";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
import type { TopicCycleSummary } from "@/lib/topics/read";
import { INSTRUMENT_LABEL, TOPIC_EXTRA_HREF } from "./topic-meta";
const VERDICT_TONE = {
improved: "success",
worsened: "warning",
within_threshold: "neutral",
} as const;
/**
* The mind topic's screener block: each instrument's latest total and how it
* moved since the first check-in, measured against the change studies treat
* as meaningful (the check-in page's own trend).
*/
export function TopicScreenersCard({
screeners,
}: {
screeners: AssessmentTrend[];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
return (
<Card data-slot="topic-screeners">
<CardHeader>
<TileHeader
icon={Brain}
titleAs="h2"
title={t("topics.screeners.title")}
/>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{screeners.length === 0 ? (
<p className="text-sm" data-slot="topic-screeners-none">
{t("topics.screeners.none")}
</p>
) : (
<ul className="flex flex-col gap-3">
{screeners.map((trend) => (
<li
key={trend.instrument}
className="flex flex-wrap items-center justify-between gap-2"
data-slot="topic-screener"
data-instrument={trend.instrument}
>
<div className="min-w-0">
<p className="text-sm font-medium">
{INSTRUMENT_LABEL[trend.instrument] ?? trend.instrument}{" "}
<span className="tabular-nums">
{fmt.number(trend.latest.score, 0)}
</span>
</p>
<p className="text-muted-foreground text-xs">
{t("topics.screeners.checkIns", { count: trend.n })}
</p>
</div>
{trend.sinceFirst ? (
<ScorePill tone={VERDICT_TONE[trend.sinceFirst]}>
{t(`topics.screeners.${trend.sinceFirst}`)}
</ScorePill>
) : (
<span className="text-muted-foreground text-xs">
{t("topics.screeners.single")}
</span>
)}
</li>
))}
</ul>
)}
<InlineAction asChild tone="primary" size="xs">
<Link href={TOPIC_EXTRA_HREF.screeners} className="self-start">
{t("topics.screeners.open")}
</Link>
</InlineAction>
</CardContent>
</Card>
);
}
/**
* The cycle topic's block: today's cycle day and phase as the cycle page
* decides them, and the lengths of the completed cycles on record.
*/
export function TopicCycleCard({ cycle }: { cycle: TopicCycleSummary }) {
const { t } = useTranslations();
const fmt = useFormatters();
return (
<Card data-slot="topic-cycle">
<CardHeader>
<TileHeader
icon={Droplets}
titleAs="h2"
title={t("topics.cycle.title")}
/>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{cycle.today ? (
<p className="text-sm font-medium">
{cycle.today.phase
? t("topics.cycle.todayWithPhase", {
day: cycle.today.dayOfCycle,
phase: t(`cycle.phase.${cycle.today.phase}`),
})
: t("topics.cycle.today", { day: cycle.today.dayOfCycle })}
</p>
) : null}
{cycle.averageLength !== null ? (
<p className="text-sm">
{t("topics.cycle.average", {
days: fmt.number(cycle.averageLength, 1),
count: cycle.lengths.length,
})}
</p>
) : (
<p className="text-sm" data-slot="topic-cycle-needs">
{t("topics.cycle.needs", { count: cycle.periodsLogged })}
</p>
)}
{cycle.lengths.length > 0 ? (
<p className="text-muted-foreground text-xs tabular-nums">
{t("topics.cycle.lengths", {
lengths: cycle.lengths.map((l) => fmt.integer(l)).join(", "),
})}
</p>
) : null}
<InlineAction asChild tone="primary" size="xs">
<Link href={TOPIC_EXTRA_HREF.cycle} className="self-start">
{t("topics.cycle.open")}
</Link>
</InlineAction>
</CardContent>
</Card>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import { useState } from "react";
import { TrainingLoadCard } from "@/components/workouts/training-load-card";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Segmented } from "@/components/ui/segmented";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import {
DEFAULT_TOPIC_RANGE,
TOPIC_RANGES,
type TopicKey,
type TopicRange,
} from "@/lib/topics/registry";
import { PreviewBadge } from "./preview-badge";
import { PreviewNote } from "./preview-note";
import { TopicCycleCard, TopicScreenersCard } from "./topic-blocks";
import { TopicLabsCard } from "./topic-labs-card";
import { TopicLinksCard, TopicMissingCard } from "./topic-links-card";
import { topicDescriptionKey, topicTitleKey } from "./topic-meta";
import { TopicRelationshipsCard } from "./topic-relationships-card";
import { TopicSideBySideCard } from "./topic-side-by-side-card";
import { TopicTodayCard } from "./topic-today-card";
import { TopicTrendsCard } from "./topic-trends-card";
import { useTopicDetail } from "./use-topics";
function DetailSkeleton() {
return (
<div className="space-y-6" aria-hidden="true">
<Skeleton className="h-40 rounded-xl" />
<Skeleton className="h-80 rounded-xl" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-64 rounded-xl" />
<Skeleton className="h-64 rounded-xl" />
</div>
</div>
);
}
/**
* `/topics/<topic>`: the topic's signals today, side by side over a chosen
* window, how they move together, their trends, the topic's blocks, the
* single-metric pages behind it and what it holds nothing for yet.
*/
export function TopicDetailView({ topic }: { topic: TopicKey }) {
const { t } = useTranslations();
const [range, setRange] = useState<TopicRange>(DEFAULT_TOPIC_RANGE);
const query = useTopicDetail(topic, range, true);
const detail = query.data?.key === topic ? query.data : undefined;
return (
<div className="space-y-6" data-slot="topic-detail" data-topic={topic}>
<PageHeader
title={t(topicTitleKey(topic))}
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
description={t(topicDescriptionKey(topic))}
backLink={{
href: "/topics",
label: t("topics.detail.back"),
dataSlot: "topic-back",
}}
/>
<Segmented
options={TOPIC_RANGES.map((value) => ({
value,
label: t(`topics.range.${value}`),
}))}
value={range}
onChange={setRange}
label={t("topics.range.label")}
slot="topic-range"
size="sm"
/>
{query.isError ? (
<QueryErrorCard
error={query.error}
title={t("topics.loadError")}
onRetry={() => void query.refetch()}
/>
) : !detail ? (
<DetailSkeleton />
) : (
<div
className="space-y-6"
data-slot="topic-detail-body"
data-status={detail.status}
>
<TopicTodayCard detail={detail} />
<TopicSideBySideCard detail={detail} />
{detail.relationships.length > 0 ||
detail.trends.length > 0 ||
detail.training?.status === "ok" ? (
<div className="grid items-start gap-4 md:grid-cols-2">
<TopicRelationshipsCard relationships={detail.relationships} />
<div className="flex flex-col gap-4">
<TopicTrendsCard trends={detail.trends} />
<TrainingLoadCard trainingLoad={detail.training ?? undefined} />
</div>
</div>
) : null}
{detail.labs ? <TopicLabsCard labs={detail.labs} /> : null}
{detail.screeners ? (
<TopicScreenersCard screeners={detail.screeners} />
) : null}
{detail.cycle ? <TopicCycleCard cycle={detail.cycle} /> : null}
<div className="grid items-start gap-4 md:grid-cols-2">
<TopicLinksCard detail={detail} />
<TopicMissingCard detail={detail} />
</div>
</div>
)}
<PreviewNote />
</div>
);
}
-126
View File
@@ -1,126 +0,0 @@
"use client";
import Link from "next/link";
import { FlaskConical, TrendingUp } from "lucide-react";
import { labTrendLines } from "@/components/labs/lab-trend-lines";
import { useLabNumber } from "@/components/labs/use-lab-format";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { ScorePill } from "@/components/ui/score-pill";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import type { TopicLabMarker, TopicLabPanel } from "@/lib/topics/labs";
function MarkerTile({ marker }: { marker: TopicLabMarker }) {
const { t } = useTranslations();
const labNumber = useLabNumber();
const range = formatReferenceRange(marker.low, marker.high, (v) =>
labNumber(v),
);
const outside = marker.status === "above" || marker.status === "below";
return (
<Link
href={`/labs/${marker.biomarkerId}`}
data-slot="topic-lab-marker"
data-status={marker.status}
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-w-0 flex-col gap-1 rounded-lg border p-3 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="text-foreground truncate text-xs font-medium">
{marker.name}
</span>
<span className="flex items-baseline gap-1">
<span className="text-lg font-semibold tabular-nums">
{labNumber(marker.latest)}
</span>
<span className="text-muted-foreground text-xs">{marker.unit}</span>
</span>
{range ? (
<span className="text-muted-foreground text-xs">{range}</span>
) : null}
{outside ? (
<ScorePill tone="warning" className="self-start">
{t(`topics.labs.${marker.status}`)}
</ScorePill>
) : null}
</Link>
);
}
/**
* The metabolism topic's lab block: each marker's newest reading against the
* window it is judged by, then what the markers' trend lines say. A trend
* needs three readings spanning a year; markers short of that are named in
* one sentence instead of being given a line.
*/
export function TopicLabsCard({ labs }: { labs: TopicLabPanel }) {
const { t } = useTranslations();
const fmt = useFormatters();
const trending = labs.markers.filter((m) => m.trend.status === "ok");
const short = labs.markers.filter((m) => m.trend.status !== "ok");
return (
<Card data-slot="topic-labs">
<CardHeader>
<TileHeader
icon={FlaskConical}
titleAs="h2"
title={t("topics.labs.title")}
right={
labs.latestOn ? (
<span className="text-muted-foreground text-xs">
{fmt.dateShortSmartCalendar(labs.latestOn)}
</span>
) : null
}
/>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{labs.markers.map((marker) => (
<MarkerTile key={marker.biomarkerId} marker={marker} />
))}
</div>
<section
className="flex flex-col gap-2"
aria-labelledby="topic-lab-trends-heading"
>
<h3
id="topic-lab-trends-heading"
className="text-foreground flex items-center gap-2 text-sm font-semibold"
>
<TrendingUp className="size-4" aria-hidden="true" />
{t("topics.labs.trendsTitle")}
</h3>
{trending.map((marker) => {
if (marker.trend.status !== "ok") return null;
const lines = labTrendLines(marker.trend, marker.unit, t, fmt);
return (
<div
key={marker.biomarkerId}
data-slot="topic-lab-trend"
data-projection={marker.trend.projection}
>
<p className="text-sm font-medium">{marker.name}</p>
<p className="text-sm">{lines.slope}</p>
{lines.projection ? (
<p className="text-sm">{lines.projection}</p>
) : null}
</div>
);
})}
{short.length > 0 ? (
<p
className="text-muted-foreground text-xs"
data-slot="topic-lab-trend-short"
>
{t("topics.labs.trendNeeds", {
markers: short.map((m) => m.name).join(", "),
})}
</p>
) : null}
</section>
</CardContent>
</Card>
);
}
-132
View File
@@ -1,132 +0,0 @@
"use client";
import Link from "next/link";
import { ChevronRight, Info, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import type { TopicDetail } from "@/lib/topics/build";
import { TOPIC_EXTRA_HREF, topicSourcesKey } from "./topic-meta";
import { useSignalFormat } from "./use-signal-format";
interface PageLink {
href: string;
label: string;
value: string | null;
}
/**
* The single-metric pages behind the topic, unchanged and one tap away: a
* topic is an overview, never a replacement for any of them.
*/
export function TopicLinksCard({ detail }: { detail: TopicDetail }) {
const { t } = useTranslations();
const format = useSignalFormat();
const links: PageLink[] = [];
const seen = new Set<string>();
const add = (link: PageLink) => {
if (seen.has(link.href)) return;
seen.add(link.href);
links.push(link);
};
for (const signal of detail.signals) {
if (!signal.href || !signal.latest) continue;
add({
href: signal.href,
label: format.label(signal.key),
value: format.value(signal.key, signal.latest.value),
});
}
if (detail.labs) {
add({ href: TOPIC_EXTRA_HREF.labs, label: t("nav.labs"), value: null });
}
if (detail.training?.status === "ok") {
add({
href: TOPIC_EXTRA_HREF.training,
label: t("topics.links.workouts"),
value: null,
});
}
if (links.length === 0) return null;
return (
<Card data-slot="topic-links">
<CardHeader>
<TileHeader
icon={Lightbulb}
titleAs="h2"
title={t("topics.links.title")}
/>
</CardHeader>
<CardContent>
<ul className="divide-border flex flex-col divide-y">
{links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
data-slot="topic-link"
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 items-center gap-3 rounded-md py-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span className="min-w-0 flex-1 text-sm font-medium">
{link.label}
</span>
{link.value ? (
<span className="text-muted-foreground text-xs tabular-nums">
{link.value}
</span>
) : null}
<ChevronRight
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
/**
* What the topic could show but holds nothing for yet, and where such values
* come from. Never a zero, never an empty chart.
*/
export function TopicMissingCard({ detail }: { detail: TopicDetail }) {
const { t } = useTranslations();
const format = useSignalFormat();
const { inSharedRecord } = useRecordCapabilities();
if (detail.missing.length === 0) return null;
return (
<Card
data-slot="topic-missing"
className="border-dashed shadow-none"
density="compact"
>
<CardContent className="flex flex-col gap-2">
<p className="flex items-start gap-2 text-sm font-medium">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span>
{t("topics.missing.title", {
signals: detail.missing.map((k) => format.label(k)).join(", "),
})}
</span>
</p>
<p className="text-muted-foreground text-xs">
{t(topicSourcesKey(detail.key))}
</p>
{inSharedRecord ? null : (
<Button asChild variant="outline" size="sm" className="self-start">
<Link href="/settings/integrations">
{t("topics.missing.connect")}
</Link>
</Button>
)}
</CardContent>
</Card>
);
}
-74
View File
@@ -1,74 +0,0 @@
/**
* Client-side presentation of the topics: the glyph per topic, the message
* keys of its name and description, and where each extra block's own page
* lives. Kept apart from the server registry so the browser bundle carries
* no read code; the keys themselves come from `@/lib/topics/registry`.
*/
import {
Activity,
Brain,
Droplets,
FlaskConical,
Gauge,
Heart,
Moon,
Scale,
type LucideIcon,
} from "lucide-react";
import type { TopicExtra, TopicKey } from "@/lib/topics/registry";
export const TOPIC_ICONS: Readonly<Record<TopicKey, LucideIcon>> = {
heart: Heart,
body: Scale,
"blood-pressure": Gauge,
sleep: Moon,
activity: Activity,
metabolism: FlaskConical,
mind: Brain,
cycle: Droplets,
};
/** Message-key segment per topic (`topics.names.<id>.title`). */
export const TOPIC_MESSAGE_ID: Readonly<Record<TopicKey, string>> = {
heart: "heart",
body: "body",
"blood-pressure": "bloodPressure",
sleep: "sleep",
activity: "activity",
metabolism: "metabolism",
mind: "mind",
cycle: "cycle",
};
export function topicTitleKey(key: TopicKey): string {
return `topics.names.${TOPIC_MESSAGE_ID[key]}.title`;
}
export function topicDescriptionKey(key: TopicKey): string {
return `topics.names.${TOPIC_MESSAGE_ID[key]}.description`;
}
/** Where to record what an empty topic is missing (`topics.empty.<id>`). */
export function topicSourcesKey(key: TopicKey): string {
return `topics.sources.${TOPIC_MESSAGE_ID[key]}`;
}
/** The page each extra block opens. */
export const TOPIC_EXTRA_HREF: Readonly<Record<TopicExtra, string>> = {
training: "/insights/workouts",
labs: "/labs",
screeners: "/mental-wellbeing",
cycle: "/cycle",
};
export function topicHref(key: TopicKey): string {
return `/topics/${key}`;
}
/** The screener names as the check-in page writes them. */
export const INSTRUMENT_LABEL: Readonly<Record<string, string>> = {
PHQ9: "PHQ-9",
GAD7: "GAD-7",
WHO5: "WHO-5",
};
@@ -1,200 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { Info, Link2 } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TopicRelationship } from "@/lib/topics/stats";
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
const ScatterCorrelationChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.ScatterCorrelationChart,
})),
{ ssr: false, loading: () => <Skeleton className="h-44 w-full" /> },
);
const STATUS_DOT = {
linked: "bg-chart-1",
no_clear_link: "bg-muted-foreground",
insufficient: "bg-border",
} as const;
/** The values' own span with a tenth of it on each side, so dots sit inside. */
function paddedDomain(values: number[]): [number, number] {
const min = Math.min(...values);
const max = Math.max(...values);
const pad = (max - min) * 0.1 || Math.abs(max) * 0.05 || 1;
return [min - pad, max + pad];
}
/** The one sentence a relationship reads as, from the server's figures. */
function useRelationshipSentence() {
const { t } = useTranslations();
const fmt = useFormatters();
const format = useSignalFormat();
return (rel: TopicRelationship): { text: string; meta: string | null } => {
const x = format.label(rel.x);
const y = format.label(rel.y);
if (rel.status === "insufficient") {
return {
text: t("topics.relationships.insufficient", {
x,
y,
count: rel.pairedDays,
required: rel.requiredDays,
}),
meta: null,
};
}
if (rel.status === "no_clear_link") {
return {
text: t("topics.relationships.noClearLink", {
x,
y,
count: rel.pairedDays,
}),
meta: null,
};
}
if (rel.kind === "split" && rel.split) {
const higher = rel.split.difference > 0;
return {
text: t(
higher
? "topics.relationships.splitHigher"
: "topics.relationships.splitLower",
{
x,
y,
threshold:
rel.x === "SLEEP_DURATION" && rel.split.threshold % 60 === 0
? t("common.durationClockHours", {
time: fmt.number(rel.split.threshold / 60, 0),
})
: format.value(rel.x, rel.split.threshold),
amount: format.amount(rel.y, rel.split.difference),
},
),
meta: t("topics.relationships.splitDays", {
below: rel.split.belowDays,
other: rel.split.otherDays,
}),
};
}
const higher = (rel.r ?? 0) > 0;
const key = rel.lagDays === 1 ? "NextDay" : "SameDay";
return {
text: t(
`topics.relationships.correlation${key}${higher ? "Higher" : "Lower"}`,
{ x, y, r: fmt.number(rel.r ?? 0, 2) },
),
meta: t("topics.relationships.pairedDays", { count: rel.pairedDays }),
};
};
}
/**
* How the topic's signals move together: one statement per pair, each with
* the number of days it rests on, and a scatter for the first pair that holds
* a pattern. Fourteen paired days before anything is said; never a cause.
*/
export function TopicRelationshipsCard({
relationships,
}: {
relationships: TopicRelationship[];
}) {
const { t } = useTranslations();
const format = useSignalFormat();
const axis = useSignalAxis();
const sentence = useRelationshipSentence();
if (relationships.length === 0) return null;
// A scatter only reads for a correlation: a split's x is two groups.
const featured = relationships.find(
(r) =>
r.kind === "correlation" && r.status === "linked" && r.points.length > 0,
);
const scatter = featured
? featured.points.map((p) => ({
x: axis.toAxis(featured.x, p.x),
y: axis.toAxis(featured.y, p.y),
}))
: [];
return (
<Card data-slot="topic-relationships">
<CardHeader>
<TileHeader
icon={Link2}
titleAs="h2"
title={t("topics.relationships.title")}
/>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{featured ? (
<figure className="flex flex-col gap-1">
<div aria-hidden="true">
<ScatterCorrelationChart
data={scatter}
fill="var(--chart-1)"
xAxis={{
dataKey: "x",
name: format.label(featured.x),
domain: paddedDomain(scatter.map((p) => p.x)),
tickFormatter: (v) => axis.tick(featured.x, v),
}}
yAxis={{
dataKey: "y",
name: format.label(featured.y),
domain: paddedDomain(scatter.map((p) => p.y)),
tickFormatter: (v) => axis.tick(featured.y, v),
}}
height={176}
/>
</div>
<figcaption className="text-muted-foreground text-xs">
{t("topics.relationships.scatterCaption", {
x: format.label(featured.x),
y: format.label(featured.y),
})}
</figcaption>
</figure>
) : null}
<ul className="flex flex-col gap-3">
{relationships.map((rel) => {
const { text, meta } = sentence(rel);
return (
<li
key={rel.id}
className="flex items-start gap-2"
data-slot="topic-relationship"
data-relationship={rel.id}
data-status={rel.status}
>
<span
aria-hidden="true"
className={`mt-1.5 size-2 shrink-0 rounded-full ${STATUS_DOT[rel.status]}`}
/>
<div className="min-w-0">
<p className="text-sm">{text}</p>
{meta ? (
<p className="text-muted-foreground text-xs">{meta}</p>
) : null}
</div>
</li>
);
})}
</ul>
<p className="text-muted-foreground flex items-start gap-2 text-xs">
<Info className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>{t("topics.relationships.footnote")}</span>
</p>
</CardContent>
</Card>
);
}
@@ -1,86 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { Layers } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TopicDetail } from "@/lib/topics/build";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
// Recharts loads through the one shared chart chunk, never in this route's own.
const TopicSideBySideChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.TopicSideBySideChart,
})),
{ ssr: false, loading: () => <Skeleton className="h-72 w-full" /> },
);
const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"];
/**
* The topic's signals on one time axis for the chosen window, each against
* its usual range. Sleep draws in hours on the axis and reads as hours and
* minutes in the tooltip; every other type in the person's display unit.
*/
export function TopicSideBySideCard({ detail }: { detail: TopicDetail }) {
const { t } = useTranslations();
const fmt = useFormatters();
const format = useSignalFormat();
const { toAxis, fromAxis, tick } = useSignalAxis();
if (detail.series.length === 0) return null;
const days = daysBetweenDateKeys(detail.from, detail.to) + 1;
const series = detail.series.map((s, i) => ({
key: s.key,
label: format.label(s.key),
color: SERIES_COLORS[i % SERIES_COLORS.length],
points: s.points.map((p) => ({
x: daysBetweenDateKeys(detail.from, p.day),
y: toAxis(s.key, p.value),
})),
band: s.usual
? { low: toAxis(s.key, s.usual.low), high: toAxis(s.key, s.usual.high) }
: null,
}));
const names = series.map((s) => s.label).join(", ");
return (
<Card data-slot="topic-side-by-side">
<CardHeader>
<TileHeader
icon={Layers}
titleAs="h2"
title={t("topics.chart.title")}
/>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-muted-foreground text-xs">
{t("topics.chart.legend")}
</p>
<div
role="img"
aria-label={t("topics.chart.label", { signals: names })}
>
<TopicSideBySideChart
series={series}
days={days}
formatDay={(x) =>
fmt.dateShortSmartCalendar(shiftDateKey(detail.from, x))
}
formatValue={(key, value) =>
format.value(key, fromAxis(key, value))
}
formatTick={tick}
/>
</div>
</CardContent>
</Card>
);
}
@@ -1,153 +0,0 @@
"use client";
import {
CartesianGrid,
Line,
LineChart,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { RichChartTooltip } from "@/components/charts/chart-tooltip";
export interface SideBySideSeries {
key: string;
label: string;
/** A `var(--chart-N)` expression. */
color: string;
/** Day index from the window's first day, and the value in display units. */
points: Array<{ x: number; y: number }>;
/** The usual range in display units, shaded behind the line. */
band: { low: number; high: number } | null;
}
const AXIS_TICK = { fontSize: 11, fill: "var(--muted-foreground)" };
const ROW_HEIGHT = 88;
const AXIS_HEIGHT = 24;
/**
* Up to three signals stacked on one time axis, each with its usual range
* shaded, so a reader sees two lines move together before reading that they
* do. Each row keeps its own scale; the axis, the grid and the band follow the
* metric charts. Values arrive in display units, already converted.
*/
export function TopicSideBySideChart({
series,
days,
formatDay,
formatValue,
formatTick,
}: {
series: SideBySideSeries[];
/** Days in the window; the axis runs from 0 to `days - 1`. */
days: number;
formatDay: (x: number) => string;
/** A value with its unit, for the tooltip. */
formatValue: (key: string, value: number) => string;
/** A bare axis number. */
formatTick: (key: string, value: number) => string;
}) {
const ticks = [0, Math.round((days - 1) / 2), days - 1];
return (
<div className="flex flex-col gap-2">
{series.map((s, index) => {
const last = index === series.length - 1;
return (
<div key={s.key} data-slot="topic-chart-row" data-signal={s.key}>
<p className="text-foreground flex items-center gap-2 text-xs font-medium">
<span
aria-hidden="true"
className="inline-block h-0.5 w-3 rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.label}
</p>
<div style={{ height: ROW_HEIGHT + (last ? AXIS_HEIGHT : 0) }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={s.points}
margin={{ top: 6, right: 8, bottom: 0, left: 0 }}
syncId="topic-side-by-side"
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
vertical={false}
/>
<XAxis
type="number"
dataKey="x"
domain={[0, Math.max(1, days - 1)]}
ticks={ticks}
hide={!last}
height={last ? AXIS_HEIGHT : 0}
tick={AXIS_TICK}
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatDay(Number(value))}
/>
<YAxis
domain={["auto", "auto"]}
width={44}
tick={AXIS_TICK}
tickLine={false}
axisLine={false}
tickCount={3}
tickFormatter={(value) =>
typeof value === "number" ? formatTick(s.key, value) : ""
}
/>
{s.band ? (
<ReferenceArea
y1={s.band.low}
y2={s.band.high}
fill="var(--success)"
fillOpacity={0.12}
strokeOpacity={0}
ifOverflow="extendDomain"
/>
) : null}
<Tooltip
cursor={{ stroke: "var(--border)" }}
content={({ active, payload }) => {
const point = payload?.[0]?.payload as
{ x: number; y: number } | undefined;
if (!active || !point) return null;
return (
<RichChartTooltip
active
label={formatDay(point.x)}
rows={[
{
name: s.label,
value: formatValue(s.key, point.y),
color: s.color,
},
]}
/>
);
}}
/>
<Line
type="monotone"
dataKey="y"
name={s.label}
stroke={s.color}
strokeWidth={2}
dot={s.points.length <= 31 ? { r: 2 } : false}
isAnimationActive={false}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
})}
</div>
);
}
-98
View File
@@ -1,98 +0,0 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TopicHeadline, TopicSummary } from "@/lib/topics/build";
import { cn } from "@/lib/utils";
import { INSTRUMENT_LABEL } from "./topic-meta";
import { useSignalFormat } from "./use-signal-format";
/**
* The topic's one sentence, worded from the server's code. The rule that
* picked the code lives in `src/lib/topics/build.ts`; this only says it.
*/
export function useSummarySentence(summary: TopicSummary): string {
const { t } = useTranslations();
const signals = useSignalFormat();
switch (summary.code) {
case "outside":
return t(
summary.direction === "below"
? "topics.summary.outsideBelow"
: "topics.summary.outsideAbove",
{ signal: summary.signal ? signals.label(summary.signal) : "" },
);
case "labs":
return t("topics.summary.labs", {
count: summary.count ?? 0,
total: summary.total ?? 0,
});
case "cycleDay":
return t("topics.summary.cycleDay", { day: summary.count ?? 0 });
case "steady":
return summary.count === summary.total
? t("topics.summary.steadyAll")
: t("topics.summary.steadySome", {
count: summary.count ?? 0,
total: summary.total ?? 0,
});
case "learning":
return t("topics.summary.learning");
default:
return t("topics.summary.empty");
}
}
export function TopicSummaryText({
summary,
className,
}: {
summary: TopicSummary;
className?: string;
}) {
const sentence = useSummarySentence(summary);
return (
<p
data-slot="topic-summary"
data-code={summary.code}
className={cn("text-foreground", className)}
>
{sentence}
</p>
);
}
/** The headline as a value and a unit, for whichever kind the server sent. */
export function useHeadline(
headline: TopicHeadline | null,
): { value: string; unit: string } | null {
const { t } = useTranslations();
const fmt = useFormatters();
const signals = useSignalFormat();
if (!headline) return null;
switch (headline.kind) {
case "signal":
return {
value: signals.number(headline.key, headline.value),
unit: signals.unit(headline.key),
};
case "labs":
return {
value: t("topics.headline.labsValue", {
count: headline.inRange,
total: headline.total,
}),
unit: t("topics.headline.labsUnit"),
};
case "cycleDay":
return {
value: t("topics.headline.cycleDay", { day: headline.dayOfCycle }),
unit: "",
};
case "screener":
return {
value: `${INSTRUMENT_LABEL[headline.instrument] ?? headline.instrument} ${fmt.number(headline.score, 0)}`,
unit: "",
};
}
}
-100
View File
@@ -1,100 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { ScorePill } from "@/components/ui/score-pill";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import type { TopicOverviewEntry } from "@/lib/topics/build";
import { TOPIC_ICONS, topicHref, topicTitleKey } from "./topic-meta";
import { TopicSummaryText, useHeadline } from "./topic-summary";
// The sparkline loads through the shared chart runtime, so the overview's own
// chunk carries no recharts; the tile owns the fixed band it draws in.
const DeltaSparkline = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.DeltaSparkline,
})),
{ ssr: false, loading: () => null },
);
const TONE_PILL = {
steady: "success",
watch: "warning",
learning: "neutral",
} as const;
/**
* One topic on the overview: its name (the link), the headline value, the
* rule-based sentence, the last 30 days as a sparkline, and how many of the
* topic's signals hold a value. The whole card is the target, through the
* title link stretched over it, so the link's name is the visible title.
*/
export function TopicTile({ entry }: { entry: TopicOverviewEntry }) {
const { t } = useTranslations();
const headline = useHeadline(entry.headline);
const title = t(topicTitleKey(entry.key));
const points = entry.sparkline.map((v, i) => ({ i, v }));
return (
<Card
data-slot="topic-tile"
data-topic={entry.key}
data-tone={entry.tone}
className="hover:bg-accent/40 relative gap-2 transition-colors md:gap-3"
>
<CardHeader>
<TileHeader
icon={TOPIC_ICONS[entry.key]}
titleAs="h2"
title={
<Link
href={topicHref(entry.key)}
data-slot="topic-tile-link"
className="focus-visible:ring-ring/50 rounded-sm after:absolute after:inset-0 after:rounded-xl focus-visible:ring-2 focus-visible:outline-none"
>
{title}
</Link>
}
/>
</CardHeader>
<CardContent className="flex min-w-0 flex-1 flex-col gap-2">
{headline ? (
<p className="text-foreground flex items-baseline gap-1.5">
<span className="text-2xl font-semibold tabular-nums">
{headline.value}
</span>
{headline.unit ? (
<span className="text-muted-foreground text-sm">
{headline.unit}
</span>
) : null}
</p>
) : null}
<TopicSummaryText summary={entry.summary} className="text-sm" />
{points.length >= 2 ? (
<div className="h-10" aria-hidden="true">
<DeltaSparkline data={points} strokeVar="var(--chart-1)" />
</div>
) : null}
<div className="mt-auto flex flex-wrap items-center justify-between gap-2">
<ScorePill tone={TONE_PILL[entry.tone]}>
{t(`topics.tone.${entry.tone}`)}
</ScorePill>
{entry.recorded > 0 ? (
<span className="text-muted-foreground text-xs">
{t("topics.recorded", {
count: entry.recorded,
total: entry.total,
})}
</span>
) : null}
</div>
</CardContent>
</Card>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import Link from "next/link";
import { ChevronRight, Sun } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { InlineAction } from "@/components/ui/inline-action";
import { ScorePill } from "@/components/ui/score-pill";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TopicDetail, TopicSignal } from "@/lib/topics/build";
import { TopicSummaryText } from "./topic-summary";
import { useSignalFormat } from "./use-signal-format";
const STATE_TONE = {
within: "success",
above: "warning",
below: "warning",
learning: "neutral",
} as const;
function SignalReadout({
signal,
todayKey,
}: {
signal: TopicSignal;
todayKey: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const format = useSignalFormat();
if (!signal.latest) return null;
const label = format.label(signal.key);
const unit = format.unit(signal.key);
const usual = signal.usual;
return (
<div
className="flex min-w-0 flex-col gap-1"
data-slot="topic-signal"
data-signal={signal.key}
data-state={signal.state}
>
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="text-foreground flex items-baseline gap-1">
<span className="text-xl font-semibold tabular-nums">
{format.number(signal.key, signal.latest.value)}
</span>
{unit ? (
<span className="text-muted-foreground text-xs">{unit}</span>
) : null}
</dd>
<dd className="text-muted-foreground text-xs">
{usual
? t("topics.today.usual", {
low: format.number(signal.key, usual.low),
high: format.number(signal.key, usual.high),
})
: t("topics.today.learning")}
</dd>
{signal.latest.day !== todayKey ? (
<dd className="text-muted-foreground text-xs">
{t("topics.today.asOf", {
date: fmt.dateShortSmartCalendar(signal.latest.day),
})}
</dd>
) : null}
<dd className="flex flex-wrap items-center gap-2">
{signal.state !== "none" ? (
<ScorePill tone={STATE_TONE[signal.state]}>
{t(`topics.state.${signal.state}`)}
</ScorePill>
) : null}
{signal.href ? (
<InlineAction asChild tone="primary" size="xs">
<Link href={signal.href}>
{t("topics.today.details")}
<span className="sr-only">: {label}</span>
<ChevronRight className="size-3" aria-hidden="true" />
</Link>
</InlineAction>
) : null}
</dd>
</div>
);
}
/**
* The topic today: its one sentence, then each recorded signal with its
* latest value, its usual range and a way into its own page.
*/
export function TopicTodayCard({ detail }: { detail: TopicDetail }) {
const { t } = useTranslations();
const recorded = detail.signals.filter((s) => s.latest !== null);
return (
<Card data-slot="topic-today">
<CardHeader>
<TileHeader icon={Sun} titleAs="h2" title={t("topics.today.title")} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<TopicSummaryText
summary={detail.summary}
className="text-base font-medium"
/>
{recorded.length > 0 ? (
<dl className="grid grid-cols-2 gap-x-3 gap-y-4 md:grid-cols-4">
{recorded.map((signal) => (
<SignalReadout
key={signal.key}
signal={signal}
todayKey={detail.to}
/>
))}
</dl>
) : null}
</CardContent>
</Card>
);
}
@@ -1,79 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { TrendingUp } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import type { TopicTrend } from "@/lib/topics/stats";
import { useSignalFormat } from "./use-signal-format";
const DeltaSparkline = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.DeltaSparkline,
})),
{ ssr: false, loading: () => null },
);
/**
* Each recorded signal over the trailing 90 days: the change the fitted line
* amounts to, beside its weekly means. Below two weeks of days a row says how
* many it has rather than drawing a slope through a handful of points.
*/
export function TopicTrendsCard({ trends }: { trends: TopicTrend[] }) {
const { t } = useTranslations();
const format = useSignalFormat();
if (trends.length === 0) return null;
return (
<Card data-slot="topic-trends">
<CardHeader>
<TileHeader
icon={TrendingUp}
titleAs="h2"
title={t("topics.trends.title")}
/>
</CardHeader>
<CardContent>
<ul className="divide-border flex flex-col divide-y">
{trends.map((trend) => {
const points = trend.weekly.map((p, i) => ({ i, v: p.value }));
return (
<li
key={trend.key}
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
data-slot="topic-trend"
data-signal={trend.key}
data-status={trend.status}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{format.label(trend.key)}
</p>
<p className="text-muted-foreground text-xs">
{trend.status === "ok"
? t("topics.trends.window")
: t("topics.trends.insufficient", { count: trend.days })}
</p>
</div>
{trend.status === "ok" && points.length >= 2 ? (
<div className="h-8 w-20 shrink-0" aria-hidden="true">
<DeltaSparkline data={points} strokeVar="var(--chart-2)" />
</div>
) : null}
{trend.status === "ok" && trend.change !== null ? (
<span className="shrink-0 text-sm font-semibold tabular-nums">
{format.delta(trend.key, trend.change)}
</span>
) : null}
</li>
);
})}
</ul>
</CardContent>
</Card>
);
}
-104
View File
@@ -1,104 +0,0 @@
"use client";
import Link from "next/link";
import { Layers } from "lucide-react";
import { EmptyState } from "@/components/ui/empty-state";
import { InlineAction } from "@/components/ui/inline-action";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import type { TopicKey } from "@/lib/topics/registry";
import { PreviewBadge } from "./preview-badge";
import { PreviewNote } from "./preview-note";
import { topicTitleKey } from "./topic-meta";
import { TopicTile } from "./topic-tile";
import { useTopicOverview } from "./use-topics";
function useTopicList() {
const { t } = useTranslations();
return (keys: readonly TopicKey[]) =>
keys.map((key) => t(topicTitleKey(key))).join(", ");
}
/**
* `/topics`: one tile per topic the record holds data for. Topics with
* nothing recorded yet and topics whose module is off are named once each,
* at the end, with where to change that; neither gets an empty tile.
*/
export function TopicsOverview() {
const { t } = useTranslations();
const list = useTopicList();
const { inSharedRecord } = useRecordCapabilities();
const query = useTopicOverview(true);
const data = query.data;
return (
<div className="space-y-6" data-slot="topics-overview">
<PageHeader
title={t("topics.overview.title")}
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
description={t("topics.overview.description")}
/>
{query.isError ? (
<QueryErrorCard
error={query.error}
title={t("topics.loadError")}
onRetry={() => void query.refetch()}
/>
) : !data ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-56 rounded-xl" />
))}
</div>
) : data.topics.length === 0 ? (
<EmptyState
icon={<Layers className="size-6" aria-hidden="true" />}
title={t("topics.overview.emptyTitle")}
description={t("topics.overview.emptyDescription")}
/>
) : (
<div
className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
data-slot="topics-grid"
>
{data.topics.map((entry) => (
<TopicTile key={entry.key} entry={entry} />
))}
</div>
)}
{data && data.empty.length > 0 ? (
<p
className="text-muted-foreground text-xs"
data-slot="topics-empty-list"
>
{t("topics.overview.notRecorded", { topics: list(data.empty) })}
</p>
) : null}
{data && data.hiddenByModule.length > 0 ? (
<p
className="text-muted-foreground text-xs"
data-slot="topics-hidden-list"
>
{t("topics.overview.hiddenByModule", {
topics: list(data.hiddenByModule),
})}{" "}
{inSharedRecord ? null : (
<InlineAction asChild tone="muted" size="xs">
<Link href="/settings/modules">
{t("topics.overview.modulesLink")}
</Link>
</InlineAction>
)}
</p>
) : null}
<PreviewNote />
</div>
);
}
@@ -1,31 +0,0 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { useAuth } from "@/hooks/use-auth";
/**
* The Topics area exists only for a person who switched the preview on.
*
* Until the account payload answers, the page shows the shell's loading
* state; signed out, it goes to sign-in like every page; with the preview off
* (an old bookmark, a link from someone else) it leaves for Insights, where
* the person's pages are. The routes refuse on their own (404
* `topics.preview_off`), so this is the friendly end, not the guard.
*/
export function TopicsPreviewGate({ children }: { children: ReactNode }) {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const enabled = user?.previewFeatures?.topics === true;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) router.replace("/auth/login");
else if (!enabled) router.replace("/insights");
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) return <PageAuthGate />;
return <>{children}</>;
}
-146
View File
@@ -1,146 +0,0 @@
"use client";
/**
* How a topic signal reads on screen: its name, a value in the person's own
* units, and a signed change. Measurement types go through the same unit
* display every metric page uses (`useUnitDisplay`), so a topic never shows a
* number its metric page would write differently; sleep reads as hours and
* minutes, the mood score as its 1–5 value.
*/
import { useMemo } from "react";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision";
import { MOOD_SIGNAL } from "@/lib/topics/registry";
/**
* Whole numbers for counts (steps, minutes) and for the vitals every device
* reads whole (pulse, blood pressure, HRV, SpO2); one decimal for weight and
* temperature; a converted unit at its transform's precision. The same
* answer the Today overview and the server-side texts give.
*/
const WHOLE_VITALS = new Set([
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"HRV_RMSSD",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"OXYGEN_SATURATION",
"RESPIRATORY_RATE",
"WALKING_HEART_RATE_AVERAGE",
]);
function signalDigits(key: string, transformDecimals: number): number {
if (rawDisplayFractionDigits(key) === 0) return 0;
if (WHOLE_VITALS.has(key))
return vitalDisplayDecimals(key, transformDecimals);
return transformDecimals;
}
export interface SignalFormat {
label: (key: string) => string;
/** The value with its unit, as one string. */
value: (key: string, raw: number) => string;
/** The number alone, in display units. */
number: (key: string, raw: number) => string;
unit: (key: string) => string;
/** A signed change with its unit ("+1.2 kg", "−4 ms"). */
delta: (key: string, raw: number) => string;
/** The size of a change with its unit, unsigned ("1.2 kg"). */
amount: (key: string, raw: number) => string;
}
export function useSignalFormat(): SignalFormat {
const { t } = useTranslations();
const fmt = useFormatters();
const units = useUnitDisplay();
return useMemo(() => {
const label = (key: string) => {
if (key === MOOD_SIGNAL) return t("topics.signals.mood");
const labelKey =
MEASUREMENT_TYPE_LABEL_KEYS[
key as keyof typeof MEASUREMENT_TYPE_LABEL_KEYS
];
return labelKey ? t(labelKey) : key;
};
const number = (key: string, raw: number) => {
if (key === MOOD_SIGNAL) return fmt.number(raw, 1);
if (key === "SLEEP_DURATION") return formatDurationMinutes(raw, t);
return fmt.number(
units.toDisplay(key, raw),
signalDigits(key, units.decimalsFor(key)),
);
};
const unit = (key: string) => {
if (key === MOOD_SIGNAL) return t("topics.units.moodScale");
if (key === "SLEEP_DURATION") return "";
return units.unitFor(key);
};
const value = (key: string, raw: number) => {
const u = unit(key);
return u ? `${number(key, raw)} ${u}` : number(key, raw);
};
const amount = (key: string, raw: number) => {
const magnitude = Math.abs(raw);
if (key === "SLEEP_DURATION") return formatDurationMinutes(magnitude, t);
const shown =
key === MOOD_SIGNAL
? fmt.number(magnitude, 1)
: fmt.number(
Math.abs(units.toDisplayDelta(key, magnitude)),
signalDigits(key, units.decimalsFor(key)),
);
const u = unit(key);
return u ? `${shown} ${u}` : shown;
};
const delta = (key: string, raw: number) => {
const sign = raw > 0 ? "+" : raw < 0 ? "−" : "±";
return `${sign}${amount(key, raw)}`;
};
return { label, value, number, unit, delta, amount };
}, [t, fmt, units]);
}
export interface SignalAxis {
/** A stored value in the unit a chart axis draws (sleep in hours). */
toAxis: (key: string, raw: number) => number;
/** Back from the axis unit to the stored value. */
fromAxis: (key: string, value: number) => number;
/** A bare axis tick. */
tick: (key: string, value: number) => string;
}
/** How a signal draws on a chart axis, in the person's own units. */
export function useSignalAxis(): SignalAxis {
const fmt = useFormatters();
const units = useUnitDisplay();
return useMemo(() => {
const plain = (key: string) =>
key === MOOD_SIGNAL || key === "SLEEP_DURATION";
return {
toAxis: (key, raw) =>
key === "SLEEP_DURATION"
? raw / 60
: key === MOOD_SIGNAL
? raw
: units.toDisplay(key, raw),
fromAxis: (key, value) =>
key === "SLEEP_DURATION"
? value * 60
: key === MOOD_SIGNAL
? value
: units.fromDisplay(key, value),
tick: (key, value) =>
fmt.number(
value,
plain(key) ? 1 : signalDigits(key, units.decimalsFor(key)),
),
};
}, [fmt, units]);
}
-37
View File
@@ -1,37 +0,0 @@
"use client";
/**
* The two topic reads. Each answer is the server's, rendered as given; the
* failed read hands its `ApiError` through untouched so the surface's
* read-failure primitive decides what to offer.
*/
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { TopicDetail, TopicOverview } from "@/lib/topics/build";
import type { TopicKey, TopicRange } from "@/lib/topics/registry";
export function useTopicOverview(enabled: boolean) {
return useQuery({
queryKey: queryKeys.topicsOverview(),
queryFn: () => apiGet<TopicOverview>("/api/topics"),
enabled,
});
}
export function useTopicDetail(
topic: TopicKey,
range: TopicRange,
enabled: boolean,
) {
return useQuery({
queryKey: queryKeys.topicDetail(topic, range),
queryFn: () =>
apiGet<TopicDetail>(
`/api/topics/${encodeURIComponent(topic)}?range=${encodeURIComponent(range)}`,
),
enabled,
placeholderData: (previous) => previous,
});
}
-14
View File
@@ -24,10 +24,6 @@ import { storeTimezone } from "@/lib/timezone-mirror";
import type { ModuleKey } from "@/lib/modules/registry";
import type { AiCapabilities } from "@/lib/ai/capabilities/types";
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
import {
resolvePreviewFeatures,
type PreviewFeatures,
} from "@/lib/settings/preview-features";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
@@ -258,14 +254,6 @@ export interface AuthUser {
* server image that does not publish it.
*/
coachReasoning?: CoachReasoningState | null;
/**
* The person's own preview opt-ins, resolved on the server: every
* flag present, off unless switched on in Settings > System > Preview
* features. Read through `usePreviewFeature`. Optional so existing
* fixtures stay valid; `fetchMe` coerces an absent block (an older server)
* to every flag off.
*/
previewFeatures?: PreviewFeatures;
/**
* v1.36.0 — account sharing, resolved server-side. `accounts` is the
* switcher's menu, `active` is the record this browser is inside (null when
@@ -489,8 +477,6 @@ export async function fetchMe(): Promise<AuthUser> {
data.coachReasoning && typeof data.coachReasoning === "object"
? data.coachReasoning
: null,
// An older server publishes no opt-ins; every preview stays off.
previewFeatures: resolvePreviewFeatures(data.previewFeatures),
accountAccess,
accountAccessStatus,
};
-14
View File
@@ -1,14 +0,0 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import type { PreviewFeatures } from "@/lib/settings/preview-features";
/**
* The person's own preview opt-ins, off the cached account payload, or null
* while it has not answered. Every reader treats null as "off": a preview
* appears only once the server has said yes, never on a guess.
*/
export function usePreviewFeatures(): PreviewFeatures | null {
const { user } = useAuth();
return user?.previewFeatures ?? null;
}
-2
View File
@@ -51,8 +51,6 @@ const WHOLE_RECORD_ROUTE_FAMILIES = [
"/achievements",
// v1.42 — the timeline lays every section side by side.
"/timeline",
// So does a topic page (preview).
"/topics",
] as const;
interface RouteFamily {
-1
View File
@@ -452,7 +452,6 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"tokens.measurements.ceiling_reached",
"tokens.workouts.ceiling_reached",
],
topics: ["topics.not_available", "topics.not_found", "topics.preview_off"],
vaccination: [
"vaccination.booster-invalid",
"vaccination.booster-no-antigen",
@@ -44,7 +44,6 @@ import {
} from "@/lib/validations/managed-profile-handover";
import { onboardingStateResource } from "./onboarding";
import { aiAccountBlock, moduleAccessMap } from "./profile";
import { previewFeaturesSchema } from "./settings";
import {
dataEnvelope,
errorEnvelope,
@@ -563,11 +562,6 @@ const accountPayload = z
ai: aiAccountBlock,
// v1.41 — enumerated for the reason `ai` is: the settings decide on it.
coachReasoning: coachReasoningBlock,
// Enumerated because the navigation decides on it: the Topics
// entry exists only while the person reading has switched it on. The
// caller's own opt-in, never the record's, so it does not move under a
// switch. The same object `GET /api/settings/preview-features` answers.
previewFeatures: previewFeaturesSchema,
recordSession: recordSessionState.nullable(),
// Enumerated because a client DECIDES on it rather than displays it: the
// medication surface hides the server-side reminder switch when
+2 -4
View File
@@ -77,7 +77,7 @@ const biomarkerRow = z
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
});
export const biomarkerTrendSchema = z
const labTrend = z
.union([
z.object({
status: z.literal("ok"),
@@ -140,9 +140,7 @@ export const biomarkerTrendSchema = z
const biomarkerDetail = biomarkerRow
.extend({
trend: biomarkerTrendSchema
.optional()
.describe("v1.44 — absent on older servers."),
trend: labTrend.optional().describe("v1.44 — absent on older servers."),
})
.meta({ id: "BiomarkerDetail" });
-4
View File
@@ -56,7 +56,6 @@ import { cyclePaths } from "./cycle";
import { dashboardWidgetPaths } from "./dashboard";
import { dayPaths } from "./day";
import { searchPaths } from "./search";
import { topicPaths } from "./topics";
import { biomarkerPaths } from "./biomarkers";
import { inboundDocumentPaths } from "./documents";
import { documentSourcePaths } from "./document-sources";
@@ -205,9 +204,6 @@ const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...lifeCircumstancesPaths,
...dawarichPaths,
...relationLinkPaths,
// The topic pages, a preview each account switches on for itself
// (appended, spread order is load-bearing).
...topicPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
+2 -2
View File
@@ -115,7 +115,7 @@ const changeVerdict = z
.enum(["improved", "worsened", "within_threshold"])
.nullable();
export const assessmentTrendSchema = z
const assessmentTrend = z
.object({
instrument: z.enum(["PHQ9", "GAD7", "WHO5"]),
n: z.number().int(),
@@ -143,7 +143,7 @@ const listResponse = z
.object({
assessments: z.array(assessmentRow),
trends: z
.array(assessmentTrendSchema)
.array(assessmentTrend)
.optional()
.describe(
"v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.",
+2 -2
View File
@@ -72,12 +72,12 @@ const moduleListLayoutSchema = z
"Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
});
export const previewFeaturesSchema = z
const previewFeaturesSchema = z
.object({
topics: z
.boolean()
.describe(
"The Topics area (`/api/topics`): overview pages that bring related signals together above the single-metric Insights pages, which stay as they are. Off by default.",
"Topic overview pages above the single-metric Insights pages. Off by default.",
),
})
.meta({
-324
View File
@@ -1,324 +0,0 @@
/**
* OpenAPI route table for the topic pages (preview): `/api/topics` and
* `/api/topics/{topic}`.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* schemas are the routes' own (`@/lib/validations/topics`); the response
* shapes mirror `src/lib/topics/build.ts` and reuse the published lab-trend,
* training-load and screener-trend components rather than restating them.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
import {
topicDetailQuerySchema,
topicPathSchema,
} from "@/lib/validations/topics";
import { biomarkerTrendSchema } from "./biomarkers";
import { assessmentTrendSchema } from "./mental-health";
import {
dataEnvelope,
errorEnvelope,
measurementTypeEnum,
recordRefusal,
stdResponses,
} from "./shared";
import { trainingLoadSchema } from "./workouts";
const dayKey = z.string().describe("A local calendar day, `YYYY-MM-DD`.");
const topicKey = z.enum(TOPIC_KEYS).meta({
id: "TopicKey",
description: "A topic, as its URL slug.",
});
const signalKey = z.union([measurementTypeEnum, z.literal("MOOD")]).meta({
id: "TopicSignalKey",
description:
"A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5). Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended); clients convert for display as they do for the metric pages.",
});
const dayValue = z.object({ day: dayKey, value: z.number() });
const usualRange = z
.object({
low: z.number(),
high: z.number(),
center: z.number(),
days: z.number().int().describe("Days the band was built from."),
})
.meta({
id: "TopicUsualRange",
description:
"The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never narrower than the measurement's own wobble. Needs 7 days, else null.",
});
const signal = z
.object({
key: signalKey,
latest: dayValue.nullable(),
usual: usualRange.nullable(),
state: z
.enum(["within", "above", "below", "learning", "none"])
.describe(
"`latest` against `usual`: `learning` while the range needs more days, `none` without a value.",
),
href: z
.string()
.nullable()
.describe("The in-app single-metric page of this signal."),
})
.meta({ id: "TopicSignal" });
const summary = z
.object({
code: z.enum([
"outside",
"labs",
"cycleDay",
"steady",
"learning",
"empty",
]),
signal: signalKey.nullable(),
direction: z.enum(["above", "below"]).nullable(),
count: z.number().int().nullable(),
total: z.number().int().nullable(),
})
.meta({
id: "TopicSummary",
description:
"The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside` names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window (`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range (`count` of `total` recorded); `learning` while no range is established; `empty` without any data.",
});
const headline = z
.discriminatedUnion("kind", [
z.object({
kind: z.literal("signal"),
key: signalKey,
value: z.number(),
day: dayKey,
}),
z.object({
kind: z.literal("labs"),
inRange: z.number().int(),
total: z.number().int(),
}),
z.object({ kind: z.literal("cycleDay"), dayOfCycle: z.number().int() }),
z.object({
kind: z.literal("screener"),
instrument: z.string(),
score: z.number(),
}),
])
.meta({ id: "TopicHeadline" });
const overviewEntry = z
.object({
key: topicKey,
headline: headline.nullable(),
summary,
tone: z.enum(["steady", "watch", "learning"]),
sparkline: z
.array(z.number())
.describe(
"The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block.",
),
recorded: z.number().int(),
total: z.number().int(),
})
.meta({ id: "TopicOverviewEntry" });
const overviewSchema = z
.object({
topics: z.array(overviewEntry),
empty: z
.array(topicKey)
.describe("Offered topics with nothing recorded yet."),
hiddenByModule: z
.array(topicKey)
.describe("Topics whose every module is switched off for the record."),
})
.meta({ id: "TopicOverview" });
const relationship = z
.object({
id: z.string(),
x: signalKey,
y: signalKey,
lagDays: z
.union([z.literal(0), z.literal(1)])
.describe("`x` on day d is held against `y` on day d + `lagDays`."),
kind: z.enum(["correlation", "split"]),
pairedDays: z.number().int(),
requiredDays: z
.number()
.int()
.describe("Paired days needed before anything is said: 14."),
status: z
.enum(["insufficient", "no_clear_link", "linked"])
.describe(
"`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause.",
),
r: z.number().nullable(),
pValue: z.number().nullable(),
split: z
.object({
threshold: z.number(),
belowDays: z.number().int(),
otherDays: z.number().int(),
belowMean: z.number(),
otherMean: z.number(),
difference: z.number(),
})
.nullable()
.describe(
"For a split: the mean of `y` on days `x` sat below `threshold` against the other days.",
),
points: z
.array(z.object({ x: z.number(), y: z.number() }))
.describe(
"Paired values for a scatter, at most 120; empty when insufficient.",
),
})
.meta({ id: "TopicRelationship" });
const trend = z
.object({
key: signalKey,
status: z.enum(["ok", "insufficient"]),
days: z.number().int(),
change: z
.number()
.nullable()
.describe(
"The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days.",
),
weekly: z
.array(dayValue)
.describe("Weekly means, each keyed by its Monday."),
})
.meta({ id: "TopicTrend" });
const labMarker = z
.object({
biomarkerId: z.string(),
name: z.string(),
unit: z.string(),
latest: z.number(),
takenOn: dayKey,
low: z.number().nullable(),
high: z.number().nullable(),
status: z.enum(["in-range", "below", "above", "unknown"]),
readings: z.number().int(),
trend: biomarkerTrendSchema,
})
.meta({ id: "TopicLabMarker" });
const labPanel = z
.object({
markers: z.array(labMarker).describe("At most 24, newest reading first."),
inRange: z.number().int(),
withRange: z.number().int(),
latestOn: dayKey.nullable(),
})
.meta({ id: "TopicLabPanel" });
const cycleSummary = z
.object({
today: z
.object({
dayOfCycle: z.number().int(),
phase: z.string().nullable(),
})
.nullable(),
lengths: z.array(z.number().int()),
averageLength: z.number().nullable(),
periodsLogged: z.number().int(),
})
.meta({ id: "TopicCycleSummary" });
const detailSchema = z
.object({
key: topicKey,
range: z.enum(TOPIC_RANGES),
from: dayKey,
to: dayKey,
status: z.enum(["ready", "partial", "empty"]),
summary,
signals: z.array(signal),
series: z.array(
z.object({
key: signalKey,
usual: usualRange.nullable(),
points: z.array(dayValue),
}),
),
relationships: z.array(relationship),
trends: z.array(trend),
missing: z
.array(signalKey)
.describe("Signals the topic could show that hold no value yet."),
labs: labPanel.nullable(),
training: trainingLoadSchema.nullable(),
screeners: z.array(assessmentTrendSchema).nullable(),
cycle: cycleSummary.nullable(),
})
.meta({ id: "TopicDetail" });
const PREVIEW_OFF =
"`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview features). The preview is per account and off by default.";
export const topicPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/topics": {
get: {
tags: ["Insights"],
summary: "Topic overview (preview)",
description:
"One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the record's module switches. Reads across sections, so a grant scoped to some of them is refused.",
responses: {
"200": {
description: "The overview.",
content: {
"application/json": {
schema: dataEnvelope(overviewSchema, "TopicOverviewEnvelope"),
},
},
},
"404": {
description: PREVIEW_OFF,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...recordRefusal(),
},
},
},
"/api/topics/{topic}": {
get: {
tags: ["Insights"],
summary: "One topic (preview)",
description:
"A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's cycle day. No model, no AI gate.",
requestParams: { path: topicPathSchema, query: topicDetailQuerySchema },
responses: {
"200": {
description: "The topic.",
content: {
"application/json": {
schema: dataEnvelope(detailSchema, "TopicDetailEnvelope"),
},
},
},
"404": {
description: `${PREVIEW_OFF}\n\n\`topics.not_found\`: no topic by that name. \`topics.not_available\`: the record's module switches do not offer this topic.`,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...recordRefusal(),
},
},
},
};
+2 -2
View File
@@ -122,7 +122,7 @@ const trainingLoadPoint = z.object({
),
});
export const trainingLoadSchema = z
const trainingLoad = z
.union([
z.object({
status: z.literal("ok"),
@@ -174,7 +174,7 @@ const workoutListResponse = z
offset: z.number().int().nonnegative(),
droppedDuplicates: z.number().int().nonnegative(),
}),
trainingLoad: trainingLoadSchema
trainingLoad: trainingLoad
.optional()
.describe(
"v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers.",
-9
View File
@@ -49,7 +49,6 @@ import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
import { activityKeys } from "./activity";
import { provenanceKeys } from "./provenance";
import { topicKeys } from "./topics";
export const queryKeys = {
...authKeys,
@@ -65,7 +64,6 @@ export const queryKeys = {
...workoutKeys,
...activityKeys,
...provenanceKeys,
...topicKeys,
...settingsKeys,
...cycleKeys,
...measurementReminderKeys,
@@ -167,7 +165,6 @@ export const measurementDependentKeys = [
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
queryKeys.topicsRoot(),
];
/**
@@ -202,7 +199,6 @@ export const moodDependentKeys = [
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
queryKeys.topicsRoot(),
];
/**
@@ -258,7 +254,6 @@ export const medicationDependentKeys = [
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
queryKeys.topicsRoot(),
// v1.43 — a taken supplement dose adds to the day's micronutrients
// (`source = SUPPLEMENT`), so a dose write evicts the nutrient reads.
queryKeys.nutrientsRoot(),
@@ -279,7 +274,6 @@ export const cycleDependentKeys = [
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
queryKeys.topicsRoot(),
];
/**
@@ -303,7 +297,6 @@ export const illnessDependentKeys = [
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
queryKeys.topicsRoot(),
];
/**
@@ -337,7 +330,6 @@ export const encounterDependentKeys = [
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
queryKeys.topicsRoot(),
];
/**
@@ -365,7 +357,6 @@ export const vaccinationDependentKeys = [
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
queryKeys.topicsRoot(),
];
/**
-3
View File
@@ -44,9 +44,6 @@ export const settingsKeys = {
*/
moduleListLayout: () => ["settings", "module-list-layout"] as const,
/** The per-user preview-feature opt-ins behind `/api/settings/preview-features`. */
previewFeatures: () => ["settings", "preview-features"] as const,
/**
* v1.35.0 — which pillars count toward the Health Score, behind
* `GET/PATCH /api/auth/me/health-score-config`. Its own cell rather than
-11
View File
@@ -1,11 +0,0 @@
/**
* Query keys — the topic pages (preview).
* Part of the centralized factory; aggregated in `./index.ts`.
*/
export const topicKeys = {
/** Every topic read; a write to any record table the topics read evicts it. */
topicsRoot: () => ["topics"] as const,
topicsOverview: () => ["topics", "overview"] as const,
topicDetail: (topic: string, range: string) =>
["topics", "detail", topic, range] as const,
};
@@ -98,7 +98,6 @@ export const SETTINGS_DESTINATION_INVENTORY = {
export: PERSONAL,
advanced: PERSONAL,
privacy: PERSONAL,
preview: PERSONAL,
about: PERSONAL,
} as const satisfies Record<
SettingsSectionSlug,
+4 -5
View File
@@ -6,11 +6,10 @@
* adds it beside what is there and never replaces an existing page.
*
* Flags:
* - `topics`: the Topics area (`/topics`, `/api/topics`), overview pages
* that bring related signals together above the single-metric Insights
* pages, which stay exactly as they are. Switched in Settings > System >
* Preview features, published on `/api/auth/me` as `previewFeatures`, read
* by the navigation and refused by the topic routes while off.
* - `topics`: topic overview pages above the single-metric Insights pages.
* Stored, served by `/api/settings/preview-features` and carried by the
* backup. The Settings > System switch is not listed until the topic pages
* ship, because nothing reads the flag yet.
*
* Unknown keys in a stored blob are dropped on read, so a flag retired later
* disappears cleanly and a malformed blob reads as everything off.
+4 -4
View File
@@ -114,10 +114,10 @@ export const SETTINGS_SECTION_SLUGS = [
// pieces into one coherent privacy pane. Sits next to `advanced` (the
// destructive-action home) and before `about`.
"privacy",
// "Preview features": per-user opt-in to things finished enough to
// try and not yet the default (the Topics area first). Sits in the System
// group before About.
"preview",
// v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not
// listed until the first preview it switches, the topic pages, ships: a
// switch that changes nothing is a promise the page cannot keep. The stored
// flag, `/api/settings/preview-features` and its backup round-trip stay.
"about",
] as const;
-161
View File
@@ -1,161 +0,0 @@
import { describe, expect, it } from "vitest";
import { shiftDateKey } from "@/lib/tz/format";
import { buildDetail, buildOverview, summarise } from "../build";
import type { TopicInputs } from "../read";
import { TOPICS, type TopicSignalKey } from "../registry";
import type { TopicVisibility } from "../visibility";
const TODAY = "2026-10-23";
function series(n: number, f: (i: number) => number) {
const out = new Map<string, number>();
for (let i = 0; i < n; i++) out.set(shiftDateKey(TODAY, -(n - 1 - i)), f(i));
return out;
}
function inputs(
entries: Array<[TopicSignalKey, Map<string, number>]>,
extra: Partial<TopicInputs> = {},
): TopicInputs {
return {
series: new Map(entries),
labs: null,
training: null,
screeners: null,
cycle: null,
...extra,
};
}
const everything: TopicVisibility = { modules: {}, sees: () => true };
describe("summarise", () => {
it("names the first signal outside its usual range", () => {
const data = inputs([
["RESTING_HEART_RATE", series(30, (i) => (i === 29 ? 75 : 58 + (i % 2)))],
]);
const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY);
expect(detail.summary).toMatchObject({
code: "outside",
signal: "RESTING_HEART_RATE",
direction: "above",
});
});
it("is still learning below a week, and empty without anything", () => {
expect(
summarise(
[
{
key: "WEIGHT",
latest: { day: TODAY, value: 80 },
usual: null,
state: "learning",
href: null,
},
],
inputs([]),
).code,
).toBe("learning");
expect(summarise([], inputs([])).code).toBe("empty");
});
});
describe("buildOverview", () => {
it("gives a tile to a topic with data and names the empty and the hidden ones", () => {
const data = inputs([["WEIGHT", series(20, () => 80)]]);
const overview = buildOverview(
["heart", "body"],
["cycle"],
data,
everything,
TODAY,
);
expect(overview.topics.map((t) => t.key)).toEqual(["body"]);
expect(overview.empty).toEqual(["heart"]);
expect(overview.hiddenByModule).toEqual(["cycle"]);
const body = overview.topics[0];
expect(body.headline).toMatchObject({ kind: "signal", key: "WEIGHT" });
expect(body.sparkline).toHaveLength(20);
expect(body.recorded).toBe(1);
expect(body.total).toBe(TOPICS.body.signals.length);
expect(body.tone).toBe("steady");
});
});
describe("buildDetail", () => {
it("lists what it could show but holds nothing for, and says it is partial", () => {
const data = inputs([["RESTING_HEART_RATE", series(20, () => 58)]]);
const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY);
expect(detail.status).toBe("partial");
expect(detail.missing).toContain("HEART_RATE_VARIABILITY");
expect(detail.series.map((s) => s.key)).toEqual(["RESTING_HEART_RATE"]);
expect(detail.from).toBe(shiftDateKey(TODAY, -29));
expect(detail.series[0].points).toHaveLength(20);
});
it("never names a pair or a chart row whose module is off", () => {
const sleepOff: TopicVisibility = {
modules: { sleep: false },
sees: () => true,
};
const data = inputs([
["RESTING_HEART_RATE", series(30, () => 58)],
["SLEEP_DURATION", series(30, () => 420)],
]);
const detail = buildDetail(TOPICS.heart, "30d", data, sleepOff, TODAY);
expect(detail.series.map((s) => s.key)).not.toContain("SLEEP_DURATION");
expect(
detail.relationships.some(
(r) => r.x === "SLEEP_DURATION" || r.y === "SLEEP_DURATION",
),
).toBe(false);
});
it("states a below-threshold pair with its count instead of a figure", () => {
const data = inputs([
["SLEEP_DURATION", series(10, () => 400)],
["RESTING_HEART_RATE", series(10, () => 60)],
]);
const detail = buildDetail(TOPICS.heart, "30d", data, everything, TODAY);
const pair = detail.relationships.find(
(r) => r.id === "short-nights-resting-hr",
);
expect(pair).toMatchObject({ status: "insufficient", pairedDays: 10 });
});
it("does not name a pair one side of which holds nothing", () => {
const data = inputs([["ACTIVITY_STEPS", series(30, () => 8000)]]);
const detail = buildDetail(
TOPICS.metabolism,
"30d",
data,
everything,
TODAY,
);
expect(detail.relationships).toEqual([]);
});
it("drops a block the grant does not open", () => {
const noLabs: TopicVisibility = {
modules: {},
sees: (scope) => scope !== "labs",
};
const labs = {
markers: [],
inRange: 0,
withRange: 0,
latestOn: null,
};
const detail = buildDetail(
TOPICS.metabolism,
"30d",
inputs([], { labs }),
noLabs,
TODAY,
);
expect(detail.labs).toBeNull();
});
});
-83
View File
@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import { completedCycleLengths } from "../read";
import { shapeLabPanel } from "../labs";
const NOW = new Date("2026-10-23T12:00:00Z");
function reading(biomarkerId: string, value: number, takenAt: string) {
return {
biomarkerId,
value,
takenAt: new Date(takenAt),
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
sourceUnit: null,
};
}
describe("shapeLabPanel", () => {
const markers = [
{ id: "ldl", name: "LDL", unit: "mg/dL", lowerBound: 0, upperBound: 130 },
{
id: "vitd",
name: "Vitamin D",
unit: "ng/mL",
lowerBound: 30,
upperBound: 100,
},
{
id: "none",
name: "Unused",
unit: "x",
lowerBound: null,
upperBound: null,
},
];
it("judges the newest reading and carries the marker's trend", () => {
const panel = shapeLabPanel(
markers,
[
reading("ldl", 98, "2026-10-01T08:00:00Z"),
reading("ldl", 120, "2025-09-01T08:00:00Z"),
reading("ldl", 140, "2024-08-01T08:00:00Z"),
reading("vitd", 24, "2026-09-10T08:00:00Z"),
],
NOW,
);
expect(panel).not.toBeNull();
expect(panel!.markers.map((m) => m.biomarkerId)).toEqual(["ldl", "vitd"]);
const ldl = panel!.markers[0];
expect(ldl).toMatchObject({ latest: 98, status: "in-range", readings: 3 });
expect(ldl.trend.status).toBe("ok");
const vitd = panel!.markers[1];
expect(vitd.status).toBe("below");
// One reading is not a trend; the panel says so rather than drawing one.
expect(vitd.trend).toMatchObject({ status: "insufficient", n: 1 });
expect(panel).toMatchObject({
inRange: 1,
withRange: 2,
latestOn: "2026-10-01",
});
});
it("is absent without a single numeric reading", () => {
expect(shapeLabPanel(markers, [], NOW)).toBeNull();
});
});
describe("completedCycleLengths", () => {
it("measures between logged starts and skips gaps in logging", () => {
expect(
completedCycleLengths([
"2026-06-01",
"2026-06-29",
"2026-07-28",
"2026-10-20",
]),
).toEqual([28, 29]);
expect(completedCycleLengths(["2026-06-01"])).toEqual([]);
});
});
-222
View File
@@ -1,222 +0,0 @@
import { describe, expect, it } from "vitest";
import { shiftDateKey } from "@/lib/tz/format";
import {
RELATIONSHIP_MIN_PAIRED_DAYS,
type TopicRelationshipDef,
} from "../registry";
import {
computeRelationship,
computeTrend,
latestValue,
pairDays,
stateAgainstRange,
usualRange,
welchPValue,
windowValues,
} from "../stats";
const TODAY = "2026-10-23";
/** `n` consecutive days ending today, valued by `f(i)` (i = 0 oldest). */
function series(n: number, f: (i: number) => number, end = TODAY) {
const out = new Map<string, number>();
for (let i = 0; i < n; i++) {
out.set(shiftDateKey(end, -(n - 1 - i)), f(i));
}
return out;
}
describe("windowValues / latestValue", () => {
it("keeps the window's days in order and ignores a day after today", () => {
const s = new Map([
["2026-10-20", 3],
["2026-10-18", 1],
["2026-10-24", 9],
]);
expect(windowValues(s, "2026-10-18", TODAY).map((p) => p.day)).toEqual([
"2026-10-18",
"2026-10-20",
]);
expect(latestValue(s, TODAY)).toEqual({ day: "2026-10-20", value: 3 });
expect(latestValue(undefined, TODAY)).toBeNull();
});
});
describe("usualRange", () => {
it("says nothing below a week of days", () => {
expect(
usualRange(
"PULSE",
series(6, () => 60),
TODAY,
),
).toBeNull();
});
it("builds the personal band from a week or more", () => {
const band = usualRange(
"RESTING_HEART_RATE",
series(30, (i) => 58 + (i % 3)),
TODAY,
);
expect(band).not.toBeNull();
expect(band!.days).toBe(30);
expect(band!.low).toBeLessThanOrEqual(58);
expect(band!.high).toBeGreaterThanOrEqual(60);
});
it("reads only the trailing 90 days", () => {
const old = series(30, () => 100, shiftDateKey(TODAY, -200));
expect(usualRange("WEIGHT", old, TODAY)).toBeNull();
});
});
describe("stateAgainstRange", () => {
const band = { low: 55, high: 62, center: 58, days: 30 };
it("places a value against the band, and says when it cannot", () => {
expect(stateAgainstRange(null, band)).toBe("none");
expect(stateAgainstRange({ day: TODAY, value: 60 }, null)).toBe("learning");
expect(stateAgainstRange({ day: TODAY, value: 60 }, band)).toBe("within");
expect(stateAgainstRange({ day: TODAY, value: 70 }, band)).toBe("above");
expect(stateAgainstRange({ day: TODAY, value: 50 }, band)).toBe("below");
});
});
describe("computeTrend", () => {
it("draws no slope through fewer than 14 days", () => {
const trend = computeTrend(
"WEIGHT",
series(10, (i) => 80 - i),
TODAY,
);
expect(trend.status).toBe("insufficient");
expect(trend.change).toBeNull();
expect(trend.days).toBe(10);
});
it("reports the fitted change across the window", () => {
// One kilogram down every ten days, for ninety days.
const trend = computeTrend(
"WEIGHT",
series(90, (i) => 85 - i / 10),
TODAY,
);
expect(trend.status).toBe("ok");
expect(trend.change).toBeCloseTo(-8.9, 1);
expect(trend.weekly.length).toBeGreaterThanOrEqual(13);
expect(trend.weekly[0].value).toBeGreaterThan(trend.weekly.at(-1)!.value);
});
});
describe("pairDays", () => {
it("holds x on day d against y on day d + lag, inside the window", () => {
const x = series(5, (i) => i);
const y = series(5, (i) => 10 + i);
const sameDay = pairDays(x, y, 0, shiftDateKey(TODAY, -4), TODAY);
expect(sameDay).toHaveLength(5);
const nextDay = pairDays(x, y, 1, shiftDateKey(TODAY, -4), TODAY);
expect(nextDay).toHaveLength(4);
expect(nextDay[0]).toMatchObject({ x: 0, y: 11 });
});
});
describe("computeRelationship", () => {
const corr: TopicRelationshipDef = {
id: "a-b",
x: "HEART_RATE_VARIABILITY",
y: "RESTING_HEART_RATE",
lagDays: 0,
kind: "correlation",
};
const from = shiftDateKey(TODAY, -89);
it("says nothing but the count below 14 paired days", () => {
const n = RELATIONSHIP_MIN_PAIRED_DAYS - 1;
const rel = computeRelationship(
corr,
series(n, (i) => 50 + i),
series(n, (i) => 70 - i),
from,
TODAY,
);
expect(rel.status).toBe("insufficient");
expect(rel.pairedDays).toBe(n);
expect(rel.requiredDays).toBe(14);
expect(rel.r).toBeNull();
expect(rel.points).toEqual([]);
});
it("reports a clear negative link with its r and its days", () => {
const rel = computeRelationship(
corr,
series(30, (i) => 50 + i),
series(30, (i) => 70 - i + (i % 2) * 0.5),
from,
TODAY,
);
expect(rel.status).toBe("linked");
expect(rel.r).toBeLessThan(-0.9);
expect(rel.pairedDays).toBe(30);
expect(rel.points).toHaveLength(30);
});
it("calls an unrelated pair what it is", () => {
const noise = [3, 9, 1, 7, 5, 2, 8, 4, 6, 0, 9, 2, 7, 1, 5, 8, 3, 6, 0, 4];
const rel = computeRelationship(
corr,
series(20, (i) => i),
series(20, (i) => noise[i]),
from,
TODAY,
);
expect(rel.status).toBe("no_clear_link");
expect(rel.pairedDays).toBe(20);
});
it("compares the days below a threshold with the others", () => {
const split: TopicRelationshipDef = {
id: "short-nights",
x: "SLEEP_DURATION",
y: "RESTING_HEART_RATE",
lagDays: 0,
kind: "split",
threshold: 420,
};
const sleep = series(28, (i) => (i % 4 === 0 ? 360 : 480));
const rhr = series(28, (i) => (i % 4 === 0 ? 63 : 58) + (i % 3) * 0.2);
const rel = computeRelationship(split, sleep, rhr, from, TODAY);
expect(rel.status).toBe("linked");
expect(rel.split).toMatchObject({ belowDays: 7, otherDays: 21 });
expect(rel.split!.difference).toBeGreaterThan(4);
});
it("will not compare a split whose one side is too thin", () => {
const split: TopicRelationshipDef = {
id: "short-nights",
x: "SLEEP_DURATION",
y: "MOOD",
lagDays: 0,
kind: "split",
threshold: 420,
};
const sleep = series(20, (i) => (i < 2 ? 360 : 480));
const rel = computeRelationship(
split,
sleep,
series(20, () => 3),
from,
TODAY,
);
expect(rel.status).toBe("insufficient");
expect(rel.pairedDays).toBe(20);
});
});
describe("welchPValue", () => {
it("is 1 for identical groups and small for well separated ones", () => {
expect(welchPValue([1, 1, 1], [1, 1, 1])).toBe(1);
expect(welchPValue([1, 2, 1, 2, 1], [9, 8, 9, 8, 9])).toBeLessThan(0.001);
});
});
@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import { TOPICS } from "../registry";
import {
isSignalVisible,
offeredTopics,
signalsToRead,
visibleExtras,
} from "../visibility";
const sees = () => true;
describe("topic visibility", () => {
it("offers every topic when nothing is switched off", () => {
expect(offeredTopics({ modules: {}, sees })).toEqual([
"heart",
"body",
"blood-pressure",
"sleep",
"activity",
"metabolism",
"mind",
"cycle",
]);
});
it("follows the modules: cycle only with the cycle module, mind with either of its two", () => {
const off = offeredTopics({
modules: { cycle: false, mood: false, mentalHealth: false, sleep: false },
sees,
});
expect(off).not.toContain("cycle");
expect(off).not.toContain("mind");
expect(off).not.toContain("sleep");
expect(off).toContain("heart");
expect(offeredTopics({ modules: { mood: false }, sees })).toContain("mind");
});
it("never reads a signal or a block whose module is off or whose section is closed", () => {
const ctx = {
modules: { sleep: false, workouts: false },
sees: (scope: string) => scope !== "mind",
};
expect(isSignalVisible("SLEEP_DURATION", ctx)).toBe(false);
expect(isSignalVisible("MOOD", ctx)).toBe(false);
expect(isSignalVisible("PULSE", ctx)).toBe(true);
expect([...signalsToRead(TOPICS.heart, ctx)]).not.toContain(
"SLEEP_DURATION",
);
expect([...visibleExtras(TOPICS.heart, ctx)]).toEqual([]);
});
});
-386
View File
@@ -1,386 +0,0 @@
/**
* Assemble the topic overview and a topic page from what `read.ts` returned.
* Pure: every input arrives as an argument, so a fixture covers every state.
*
* The summary sentence is a rule, never model text: the first signal outside
* its usual range names itself; otherwise the lab panel or the cycle day
* speaks for its topic; otherwise the count of signals inside their range;
* otherwise "still learning". The client words the code; the server only
* decides which one applies.
*/
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
import type { TrainingLoad } from "@/lib/insights/training-load";
import { shiftDateKey } from "@/lib/tz/format";
import type { TopicLabPanel } from "./labs";
import type { TopicCycleSummary, TopicInputs } from "./read";
import {
MOOD_SIGNAL,
SPARKLINE_POINTS,
TOPIC_BASE_WINDOW_DAYS,
TOPIC_RANGE_DAYS,
TOPICS,
type TopicDefinition,
type TopicKey,
type TopicRange,
type TopicSignalKey,
} from "./registry";
import {
computeRelationship,
computeTrend,
latestValue,
stateAgainstRange,
usualRange,
windowValues,
type DayValue,
type SignalState,
type TopicRelationship,
type TopicTrend,
type UsualRange,
} from "./stats";
import {
isExtraVisible,
isSignalVisible,
visibleSignals,
type TopicVisibility,
} from "./visibility";
export interface TopicSignal {
key: TopicSignalKey;
latest: DayValue | null;
usual: UsualRange | null;
state: SignalState;
/** The single-metric page this signal opens, when it has one. */
href: string | null;
}
export type TopicSummaryCode =
"outside" | "labs" | "cycleDay" | "steady" | "learning" | "empty";
export interface TopicSummary {
code: TopicSummaryCode;
signal: TopicSignalKey | null;
direction: "above" | "below" | null;
count: number | null;
total: number | null;
}
export type TopicHeadline =
| { kind: "signal"; key: TopicSignalKey; value: number; day: string }
| { kind: "labs"; inRange: number; total: number }
| { kind: "cycleDay"; dayOfCycle: number }
| { kind: "screener"; instrument: string; score: number };
export type TopicTone = "steady" | "watch" | "learning";
export interface TopicOverviewEntry {
key: TopicKey;
headline: TopicHeadline | null;
summary: TopicSummary;
tone: TopicTone;
/** The headline signal's last values, oldest first (empty for a block headline). */
sparkline: number[];
/** Signals of the topic's list that hold a value. */
recorded: number;
/** Signals of the topic's list the record could hold. */
total: number;
}
export interface TopicOverview {
topics: TopicOverviewEntry[];
/** Offered topics with nothing recorded yet. */
empty: TopicKey[];
/** Topics whose every module is switched off. */
hiddenByModule: TopicKey[];
}
export interface TopicSeries {
key: TopicSignalKey;
usual: UsualRange | null;
points: DayValue[];
}
export interface TopicDetail {
key: TopicKey;
range: TopicRange;
from: string;
to: string;
status: "ready" | "partial" | "empty";
summary: TopicSummary;
signals: TopicSignal[];
series: TopicSeries[];
relationships: TopicRelationship[];
trends: TopicTrend[];
/** Signals the topic could show that hold no value yet. */
missing: TopicSignalKey[];
labs: TopicLabPanel | null;
training: TrainingLoad | null;
screeners: AssessmentTrend[] | null;
cycle: TopicCycleSummary | null;
}
function signalHref(key: TopicSignalKey): string | null {
if (key === MOOD_SIGNAL) return "/insights/mood";
return insightsHrefForMetric(key);
}
export function buildSignals(
keys: readonly TopicSignalKey[],
inputs: TopicInputs,
todayKey: string,
): TopicSignal[] {
return keys.map((key) => {
const series = inputs.series.get(key);
const latest = latestValue(series, todayKey);
const usual = usualRange(key, series, todayKey);
return {
key,
latest,
usual,
state: stateAgainstRange(latest, usual),
href: signalHref(key),
};
});
}
function hasBlockData(inputs: TopicInputs): boolean {
return (
inputs.labs !== null ||
(inputs.screeners !== null && inputs.screeners.length > 0) ||
inputs.cycle !== null ||
(inputs.training !== null && inputs.training.status === "ok")
);
}
/** The rule that picks the topic's one sentence. */
export function summarise(
signals: readonly TopicSignal[],
inputs: TopicInputs,
): TopicSummary {
const none: TopicSummary = {
code: "empty",
signal: null,
direction: null,
count: null,
total: null,
};
const recorded = signals.filter((s) => s.latest !== null);
if (recorded.length === 0 && !hasBlockData(inputs)) return none;
const outside = recorded.find(
(s) => s.state === "above" || s.state === "below",
);
if (outside) {
return {
...none,
code: "outside",
signal: outside.key,
direction: outside.state === "above" ? "above" : "below",
};
}
if (inputs.labs && inputs.labs.withRange > 0) {
return {
...none,
code: "labs",
count: inputs.labs.inRange,
total: inputs.labs.withRange,
};
}
if (inputs.cycle?.today) {
return { ...none, code: "cycleDay", count: inputs.cycle.today.dayOfCycle };
}
const within = recorded.filter((s) => s.state === "within");
if (within.length > 0) {
return {
...none,
code: "steady",
count: within.length,
total: recorded.length,
};
}
return { ...none, code: "learning" };
}
function toneOf(summary: TopicSummary, labs: TopicLabPanel | null): TopicTone {
if (summary.code === "outside") return "watch";
if (summary.code === "labs") {
return labs && labs.inRange < labs.withRange ? "watch" : "steady";
}
if (summary.code === "steady" || summary.code === "cycleDay") return "steady";
return "learning";
}
function headlineOf(
signals: readonly TopicSignal[],
inputs: TopicInputs,
): TopicHeadline | null {
const first = signals.find((s) => s.latest !== null);
if (first?.latest) {
return {
kind: "signal",
key: first.key,
value: first.latest.value,
day: first.latest.day,
};
}
if (inputs.labs) {
return {
kind: "labs",
inRange: inputs.labs.inRange,
total: inputs.labs.withRange,
};
}
if (inputs.cycle?.today) {
return { kind: "cycleDay", dayOfCycle: inputs.cycle.today.dayOfCycle };
}
const screener = inputs.screeners?.[0];
if (screener) {
return {
kind: "screener",
instrument: screener.instrument,
score: screener.latest.score,
};
}
return null;
}
/** The topic's slice of the shared read, with blocks it may not show removed. */
export function inputsForTopic(
def: TopicDefinition,
inputs: TopicInputs,
ctx: TopicVisibility,
): TopicInputs {
const allowed = (extra: "labs" | "training" | "screeners" | "cycle") =>
def.extras.includes(extra) && isExtraVisible(extra, ctx);
return {
series: inputs.series,
labs: allowed("labs") ? inputs.labs : null,
training: allowed("training") ? inputs.training : null,
screeners: allowed("screeners") ? inputs.screeners : null,
cycle: allowed("cycle") ? inputs.cycle : null,
};
}
export function buildOverviewEntry(
def: TopicDefinition,
inputs: TopicInputs,
ctx: TopicVisibility,
todayKey: string,
): TopicOverviewEntry | null {
const own = inputsForTopic(def, inputs, ctx);
const signals = buildSignals(visibleSignals(def, ctx), own, todayKey);
const summary = summarise(signals, own);
if (summary.code === "empty") return null;
const headline = headlineOf(signals, own);
const sparkline =
headline?.kind === "signal"
? windowValues(
own.series.get(headline.key),
shiftDateKey(todayKey, -(SPARKLINE_POINTS - 1)),
todayKey,
).map((p) => p.value)
: [];
return {
key: def.key,
headline,
summary,
tone: toneOf(summary, own.labs),
sparkline,
recorded: signals.filter((s) => s.latest !== null).length,
total: signals.length,
};
}
export function buildOverview(
offered: readonly TopicKey[],
hiddenByModule: readonly TopicKey[],
inputs: TopicInputs,
ctx: TopicVisibility,
todayKey: string,
): TopicOverview {
const topics: TopicOverviewEntry[] = [];
const empty: TopicKey[] = [];
for (const key of offered) {
const entry = buildOverviewEntry(TOPICS[key], inputs, ctx, todayKey);
if (entry) topics.push(entry);
else empty.push(key);
}
return { topics, empty, hiddenByModule: [...hiddenByModule] };
}
/** First day of the read a topic page needs for its range. */
export function detailReadStart(range: TopicRange, todayKey: string): string {
const days = Math.max(TOPIC_RANGE_DAYS[range], TOPIC_BASE_WINDOW_DAYS);
return shiftDateKey(todayKey, -days);
}
export function buildDetail(
def: TopicDefinition,
range: TopicRange,
inputs: TopicInputs,
ctx: TopicVisibility,
todayKey: string,
): TopicDetail {
const own = inputsForTopic(def, inputs, ctx);
const signalKeys = visibleSignals(def, ctx);
const signals = buildSignals(signalKeys, own, todayKey);
const summary = summarise(signals, own);
const from = shiftDateKey(todayKey, -(TOPIC_RANGE_DAYS[range] - 1));
const pairFrom = detailReadStart(range, todayKey);
const series: TopicSeries[] = [];
for (const key of def.chart) {
if (!isSignalVisible(key, ctx)) continue;
const values = own.series.get(key);
const points = windowValues(values, from, todayKey);
if (points.length === 0) continue;
series.push({ key, usual: usualRange(key, values, todayKey), points });
}
// A pair shows when both sides may be shown and both hold values: that is
// when "needs 14 paired days, you have n" tells the reader something. A
// pair with a switched-off or never-recorded side is not named.
const relationships = def.relationships
.filter((rel) => isSignalVisible(rel.x, ctx) && isSignalVisible(rel.y, ctx))
.filter((rel) => own.series.has(rel.x) && own.series.has(rel.y))
.map((rel) =>
computeRelationship(
rel,
own.series.get(rel.x),
own.series.get(rel.y),
pairFrom,
todayKey,
),
);
const trends = signals
.filter((s) => s.latest !== null)
.map((s) => computeTrend(s.key, own.series.get(s.key), todayKey));
const missing = signals.filter((s) => s.latest === null).map((s) => s.key);
const status =
summary.code === "empty"
? "empty"
: missing.length > 0
? "partial"
: "ready";
return {
key: def.key,
range,
from,
to: todayKey,
status,
summary,
signals,
series,
relationships,
trends,
missing,
labs: own.labs,
training: own.training,
screeners: own.screeners,
cycle: own.cycle,
};
}
-164
View File
@@ -1,164 +0,0 @@
/**
* The lab block of the metabolism topic: each marker's latest reading against
* the reference window in force for it, and the marker's trend line.
*
* The window is the one the labs list judges the reading by
* (`resolveEffectiveReferenceRange`: the range the report printed, else the
* marker's catalog band); the trend is `computeLabTrend` over every numeric
* reading of the marker against the marker's band, exactly as the marker page
* computes it. A trend needs three readings spanning a year; below that it is
* `insufficient` and the topic says what is missing.
*/
import { prisma } from "@/lib/db";
import { computeLabTrend, type LabTrend } from "@/lib/labs/lab-trend";
import {
classifyReferenceRange,
resolveEffectiveReferenceRange,
type ReferenceRangeStatus,
} from "@/lib/labs/reference-range";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** Markers the panel shows, the most recently measured first. */
export const LAB_PANEL_MAX_MARKERS = 24;
/** Readings the panel reads; far above any real history, the cap falls on the oldest. */
const LAB_READ_CAP = 4000;
export interface TopicLabMarker {
biomarkerId: string;
name: string;
unit: string;
latest: number;
/** Calendar day of the newest reading. */
takenOn: string;
low: number | null;
high: number | null;
status: ReferenceRangeStatus;
readings: number;
trend: LabTrend;
}
export interface TopicLabPanel {
markers: TopicLabMarker[];
/** Markers whose latest reading sits inside its window. */
inRange: number;
/** Markers with a window to sit inside. */
withRange: number;
/** Calendar day of the newest reading across the panel. */
latestOn: string | null;
}
interface MarkerRow {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
}
interface ReadingRow {
biomarkerId: string | null;
value: number | null;
takenAt: Date;
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
sourceUnit: string | null;
}
/** Shape the panel from the rows. Pure; `readings` arrive newest first. */
export function shapeLabPanel(
markers: readonly MarkerRow[],
readings: readonly ReadingRow[],
now: Date,
): TopicLabPanel | null {
const byMarker = new Map<string, ReadingRow[]>();
for (const row of readings) {
if (row.biomarkerId === null || row.value === null) continue;
const list = byMarker.get(row.biomarkerId) ?? [];
list.push(row);
byMarker.set(row.biomarkerId, list);
}
const shaped: TopicLabMarker[] = [];
for (const marker of markers) {
const rows = byMarker.get(marker.id);
if (!rows || rows.length === 0) continue;
const newest = rows.reduce((a, b) => (b.takenAt > a.takenAt ? b : a));
const window = resolveEffectiveReferenceRange(
marker.lowerBound,
marker.upperBound,
newest,
);
const latest = newest.value as number;
shaped.push({
biomarkerId: marker.id,
name: marker.name,
unit: marker.unit,
latest,
takenOn: dateOnlyKey(newest.takenAt),
low: window.low,
high: window.high,
status: classifyReferenceRange(latest, window.low, window.high),
readings: rows.length,
trend: computeLabTrend(
rows.map((r) => ({ takenAt: r.takenAt, value: r.value as number })),
{ low: marker.lowerBound, high: marker.upperBound },
now,
),
});
}
if (shaped.length === 0) return null;
shaped.sort((a, b) =>
a.takenOn === b.takenOn
? a.name.localeCompare(b.name)
: a.takenOn < b.takenOn
? 1
: -1,
);
const panel = shaped.slice(0, LAB_PANEL_MAX_MARKERS);
const withWindow = panel.filter((m) => m.status !== "unknown");
return {
markers: panel,
inRange: withWindow.filter((m) => m.status === "in-range").length,
withRange: withWindow.length,
latestOn: panel[0]?.takenOn ?? null,
};
}
export async function readLabPanel(
userId: string,
now: Date,
): Promise<TopicLabPanel | null> {
const [markers, readings] = await Promise.all([
prisma.biomarker.findMany({
where: { userId, hidden: false },
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
},
}),
prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
value: { not: null },
biomarkerId: { not: null },
},
orderBy: { takenAt: "desc" },
take: LAB_READ_CAP,
select: {
biomarkerId: true,
value: true,
takenAt: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
sourceUnit: true,
},
}),
]);
return shapeLabPanel(markers, readings, now);
}
-225
View File
@@ -1,225 +0,0 @@
/**
* The reads behind the topic pages. Every one is a read the single-metric
* pages already make, so a topic never shows a value its metric page would
* not:
*
* - measurement days: `readLocalDailyStats`, the canonical-source local-day
* value the charts draw (a cumulative type's day total, a pulse-like
* type's mean of hourly means), in one query for every type a page needs;
* - sleep: the night's time asleep as the sleep page reconstructs it
* (`readNightCells`), keyed by the day the night ended;
* - mood: the day's mean of its entries' 1–5 score;
* - the extra blocks: the training load the workouts page shows, the lab
* markers with the trend the marker page shows, the screener trends of the
* check-in page, and today's place in the cycle as the cycle page decides
* it.
*
* The caller decides what may be read (module switches, the grant's
* sections); a signal or block it leaves out is never queried.
*/
import type { MeasurementType } from "@/generated/prisma/enums";
import { readTodayCycle } from "@/lib/cycle/today-verdict";
import { readLocalDailyStats } from "@/lib/day/daily-stats";
import { prisma } from "@/lib/db";
import {
readTrainingLoad,
type TrainingLoad,
} from "@/lib/insights/training-load";
import {
computeAssessmentTrends,
TREND_INSTRUMENTS,
type AssessmentTrend,
} from "@/lib/mental-health/assessment-trend";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { readNightCells } from "@/lib/timeline/series";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { readLabPanel, type TopicLabPanel } from "./labs";
import { MOOD_SIGNAL, type TopicExtra, type TopicSignalKey } from "./registry";
import type { DaySeries } from "./stats";
/** Screener administrations the trends read; the cap falls on the oldest. */
const SCREENER_READ_CAP = 1500;
/** Cycle lengths the cycle block reports, newest kept. */
const CYCLE_LENGTHS_SHOWN = 6;
/** A plausible cycle length; anything outside is a gap in logging. */
const CYCLE_LENGTH_MIN_DAYS = 15;
const CYCLE_LENGTH_MAX_DAYS = 60;
export interface TopicCycleSummary {
/** Today's cycle day and phase, or null while the cycle is still learned. */
today: { dayOfCycle: number; phase: string | null } | null;
/** Lengths of the completed cycles, oldest first, at most six. */
lengths: number[];
/** Mean of `lengths`, or null with none. */
averageLength: number | null;
/** Logged period starts on record. */
periodsLogged: number;
}
export interface TopicReadRequest {
userId: string;
tz: string;
todayKey: string;
/** First local day to read (inclusive); the last is `todayKey`. */
fromKey: string;
signals: ReadonlySet<TopicSignalKey>;
extras: ReadonlySet<TopicExtra>;
now: Date;
}
export interface TopicInputs {
series: Map<TopicSignalKey, DaySeries>;
labs: TopicLabPanel | null;
training: TrainingLoad | null;
screeners: AssessmentTrend[] | null;
cycle: TopicCycleSummary | null;
}
async function readMeasurementDays(
req: TopicReadRequest,
types: MeasurementType[],
priorityJson: unknown,
): Promise<Map<TopicSignalKey, DaySeries>> {
const out = new Map<TopicSignalKey, DaySeries>();
if (types.length === 0) return out;
const stats = await readLocalDailyStats({
userId: req.userId,
types,
from: startOfLocalDayKey(req.fromKey, req.tz),
to: startOfLocalDayKey(shiftDateKey(req.todayKey, 1), req.tz),
tz: req.tz,
priorityJson,
});
for (const [type, days] of stats) {
if (days.size > 0) out.set(type, days);
}
return out;
}
async function readSleepNights(
req: TopicReadRequest,
priorityJson: unknown,
): Promise<DaySeries | null> {
const cells = await readNightCells({
userId: req.userId,
from: req.fromKey,
to: req.todayKey,
tz: req.tz,
priorityJson,
});
if (cells.size === 0) return null;
const out = new Map<string, number>();
for (const [day, cell] of cells) out.set(day, cell.value);
return out;
}
async function readMoodDays(req: TopicReadRequest): Promise<DaySeries | null> {
const rows = await prisma.moodEntry.groupBy({
by: ["date"],
where: {
userId: req.userId,
deletedAt: null,
date: { gte: req.fromKey, lte: req.todayKey },
},
_avg: { score: true },
});
const out = new Map<string, number>();
for (const row of rows) {
if (row._avg.score !== null) out.set(row.date, row._avg.score);
}
return out.size > 0 ? out : null;
}
async function readScreenerTrends(userId: string): Promise<AssessmentTrend[]> {
const rows = await prisma.mentalHealthAssessment.findMany({
where: { userId, deletedAt: null, instrument: { in: TREND_INSTRUMENTS } },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: SCREENER_READ_CAP,
select: { id: true, instrument: true, totalScore: true, takenAt: true },
});
return computeAssessmentTrends(rows);
}
/** Lengths between consecutive logged period starts, plausible ones only. */
export function completedCycleLengths(starts: readonly string[]): number[] {
const sorted = [...starts].sort();
const lengths: number[] = [];
for (let i = 1; i < sorted.length; i++) {
const length = daysBetweenDateKeys(sorted[i - 1], sorted[i]);
if (length >= CYCLE_LENGTH_MIN_DAYS && length <= CYCLE_LENGTH_MAX_DAYS) {
lengths.push(length);
}
}
return lengths.slice(-CYCLE_LENGTHS_SHOWN);
}
async function readCycleSummary(
req: TopicReadRequest,
): Promise<TopicCycleSummary | null> {
const [today, cycles] = await Promise.all([
readTodayCycle(req.userId, req.tz, req.now),
prisma.menstrualCycle.findMany({
where: { userId: req.userId, deletedAt: null, isPredicted: false },
orderBy: { startDate: "asc" },
select: { startDate: true },
}),
]);
const starts = cycles
.map((c) => c.startDate)
.filter((day) => day <= req.todayKey);
if (starts.length === 0 && today === null) return null;
const lengths = completedCycleLengths(starts);
const averageLength =
lengths.length > 0
? Math.round((lengths.reduce((s, v) => s + v, 0) / lengths.length) * 10) /
10
: null;
return {
today: today ? { dayOfCycle: today.dayOfCycle, phase: today.phase } : null,
lengths,
averageLength,
periodsLogged: starts.length,
};
}
/** Read everything one request needs, in parallel. */
export async function readTopicInputs(
req: TopicReadRequest,
): Promise<TopicInputs> {
const wantsSleep = req.signals.has("SLEEP_DURATION");
const wantsMood = req.signals.has(MOOD_SIGNAL);
const types = [...req.signals].filter(
(key): key is MeasurementType =>
key !== MOOD_SIGNAL && key !== "SLEEP_DURATION",
);
const needsPriority = types.length > 0 || wantsSleep;
const priorityJson = needsPriority
? await loadUserSourcePriority(req.userId)
: null;
const [measurements, sleep, mood, labs, training, screeners, cycle] =
await Promise.all([
readMeasurementDays(req, types, priorityJson),
wantsSleep ? readSleepNights(req, priorityJson) : Promise.resolve(null),
wantsMood ? readMoodDays(req) : Promise.resolve(null),
req.extras.has("labs")
? readLabPanel(req.userId, req.now)
: Promise.resolve(null),
req.extras.has("training")
? readTrainingLoad(req.userId, req.now)
: Promise.resolve(null),
req.extras.has("screeners")
? readScreenerTrends(req.userId)
: Promise.resolve(null),
req.extras.has("cycle") ? readCycleSummary(req) : Promise.resolve(null),
]);
const series = measurements;
if (sleep) series.set("SLEEP_DURATION", sleep);
if (mood) series.set(MOOD_SIGNAL, mood);
return { series, labs, training, screeners, cycle };
}
-321
View File
@@ -1,321 +0,0 @@
/**
* The topic pages (preview): which signals each topic brings together, which
* pairs it checks against each other and which extra blocks it carries.
*
* A topic is an overview layer above the single-metric Insights pages. It
* never owns a number of its own: every value is the same daily value the
* metric page draws, and every topic links into those pages. This file is the
* one declaration; the server builds from it (`build.ts`) and the browser
* imports only the keys and labels, so it carries constants and types only.
*
* Module ownership is not restated here. A measurement signal follows its
* type's owner (`moduleForMeasurementType`), the mood signal follows `mood`,
* and the extra blocks name their module in {@link TOPIC_EXTRA_MODULE}. A
* topic is offered when at least one of its {@link TopicDefinition.modules}
* is on (an empty list means it rides core signals and is always offered);
* inside it, each signal and block shows only when its own owner is on.
*/
import type { MeasurementType } from "@/generated/prisma/enums";
import type { ModuleKey } from "@/lib/modules/registry";
/** The topics, in the overview's reading order. Also the URL slugs. */
export const TOPIC_KEYS = [
"heart",
"body",
"blood-pressure",
"sleep",
"activity",
"metabolism",
"mind",
"cycle",
] as const;
export type TopicKey = (typeof TOPIC_KEYS)[number];
export function isTopicKey(value: unknown): value is TopicKey {
return (
typeof value === "string" &&
(TOPIC_KEYS as readonly string[]).includes(value)
);
}
/** The mood score (1 to 5, the day's mean of its entries) as a signal. */
export const MOOD_SIGNAL = "MOOD";
/** A daily series a topic reads: a measurement type or the mood score. */
export type TopicSignalKey = MeasurementType | typeof MOOD_SIGNAL;
/** The extra blocks a topic may carry beside its signals. */
export type TopicExtra = "training" | "labs" | "screeners" | "cycle";
/** The module each extra block follows. */
export const TOPIC_EXTRA_MODULE: Readonly<Record<TopicExtra, ModuleKey>> = {
training: "workouts",
labs: "labs",
screeners: "mentalHealth",
cycle: "cycle",
};
/**
* One pair a topic checks. `x` on day d is held against `y` on day
* d + `lagDays`. A sleep night is keyed by the day it ended, so "the night
* before" and "that morning" share a key and need no lag.
*
* `correlation` reports Pearson's r; `split` compares the mean of `y` on the
* days `x` sat below `threshold` (in `x`'s stored unit) with the other days.
*/
export interface TopicRelationshipDef {
id: string;
x: TopicSignalKey;
y: TopicSignalKey;
lagDays: 0 | 1;
kind: "correlation" | "split";
threshold?: number;
}
export interface TopicDefinition {
key: TopicKey;
/** Any one of these on offers the topic; empty means core signals carry it. */
modules: readonly ModuleKey[];
/** Display order. The first one with data is the topic's headline. */
signals: readonly TopicSignalKey[];
/** The side-by-side chart, top to bottom (at most three). */
chart: readonly TopicSignalKey[];
relationships: readonly TopicRelationshipDef[];
extras: readonly TopicExtra[];
}
/** Seven hours, in the minutes `SLEEP_DURATION` is stored in. */
const SHORT_NIGHT_MINUTES = 420;
export const TOPICS: Readonly<Record<TopicKey, TopicDefinition>> = {
heart: {
key: "heart",
modules: [],
signals: [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"VO2_MAX",
"PULSE",
],
chart: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY", "SLEEP_DURATION"],
relationships: [
{
id: "short-nights-resting-hr",
x: "SLEEP_DURATION",
y: "RESTING_HEART_RATE",
lagDays: 0,
kind: "split",
threshold: SHORT_NIGHT_MINUTES,
},
{
id: "hrv-resting-hr",
x: "HEART_RATE_VARIABILITY",
y: "RESTING_HEART_RATE",
lagDays: 0,
kind: "correlation",
},
{
id: "exercise-next-day-hrv",
x: "EXERCISE_MINUTES",
y: "HEART_RATE_VARIABILITY",
lagDays: 1,
kind: "correlation",
},
],
extras: ["training"],
},
body: {
key: "body",
modules: [],
signals: [
"WEIGHT",
"BODY_FAT",
"BODY_MASS_INDEX",
"MUSCLE_MASS",
"WAIST_CIRCUMFERENCE",
"VISCERAL_FAT",
],
chart: ["WEIGHT", "BODY_FAT", "MUSCLE_MASS"],
relationships: [
{
id: "active-energy-next-day-weight",
x: "ACTIVE_ENERGY_BURNED",
y: "WEIGHT",
lagDays: 1,
kind: "correlation",
},
],
extras: [],
},
"blood-pressure": {
key: "blood-pressure",
modules: [],
signals: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
chart: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
relationships: [
{
id: "short-nights-systolic",
x: "SLEEP_DURATION",
y: "BLOOD_PRESSURE_SYS",
lagDays: 0,
kind: "split",
threshold: SHORT_NIGHT_MINUTES,
},
{
id: "steps-next-day-systolic",
x: "ACTIVITY_STEPS",
y: "BLOOD_PRESSURE_SYS",
lagDays: 1,
kind: "correlation",
},
],
extras: [],
},
sleep: {
key: "sleep",
modules: ["sleep"],
signals: [
"SLEEP_DURATION",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"BREATHING_DISTURBANCES",
],
chart: ["SLEEP_DURATION", "RESPIRATORY_RATE", "HEART_RATE_VARIABILITY"],
relationships: [
{
id: "exercise-sleep",
x: "EXERCISE_MINUTES",
y: "SLEEP_DURATION",
lagDays: 1,
kind: "correlation",
},
{
id: "sleep-mood",
x: "SLEEP_DURATION",
y: MOOD_SIGNAL,
lagDays: 0,
kind: "correlation",
},
],
extras: [],
},
activity: {
key: "activity",
modules: [],
signals: [
"ACTIVITY_STEPS",
"EXERCISE_MINUTES",
"ACTIVE_ENERGY_BURNED",
"WALKING_RUNNING_DISTANCE",
"FLIGHTS_CLIMBED",
],
chart: ["ACTIVITY_STEPS", "EXERCISE_MINUTES", "ACTIVE_ENERGY_BURNED"],
relationships: [
{
id: "steps-sleep",
x: "ACTIVITY_STEPS",
y: "SLEEP_DURATION",
lagDays: 1,
kind: "correlation",
},
{
id: "exercise-next-day-resting-hr",
x: "EXERCISE_MINUTES",
y: "RESTING_HEART_RATE",
lagDays: 1,
kind: "correlation",
},
],
extras: ["training"],
},
metabolism: {
key: "metabolism",
modules: ["glucose", "labs"],
signals: ["BLOOD_GLUCOSE"],
chart: ["BLOOD_GLUCOSE"],
relationships: [
{
id: "steps-glucose",
x: "ACTIVITY_STEPS",
y: "BLOOD_GLUCOSE",
lagDays: 0,
kind: "correlation",
},
{
id: "short-nights-glucose",
x: "SLEEP_DURATION",
y: "BLOOD_GLUCOSE",
lagDays: 0,
kind: "split",
threshold: SHORT_NIGHT_MINUTES,
},
],
extras: ["labs"],
},
mind: {
key: "mind",
modules: ["mood", "mentalHealth"],
signals: [MOOD_SIGNAL, "PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE"],
chart: [MOOD_SIGNAL, "SLEEP_DURATION", "ACTIVITY_STEPS"],
relationships: [
{
id: "short-nights-mood",
x: "SLEEP_DURATION",
y: MOOD_SIGNAL,
lagDays: 0,
kind: "split",
threshold: SHORT_NIGHT_MINUTES,
},
{
id: "steps-mood",
x: "ACTIVITY_STEPS",
y: MOOD_SIGNAL,
lagDays: 0,
kind: "correlation",
},
],
extras: ["screeners"],
},
cycle: {
key: "cycle",
modules: ["cycle"],
signals: ["WRIST_TEMPERATURE", "RESTING_HEART_RATE"],
chart: ["WRIST_TEMPERATURE", "RESTING_HEART_RATE"],
relationships: [],
extras: ["cycle"],
},
};
/** The selectable windows of a topic page. */
export const TOPIC_RANGES = ["7d", "30d", "90d", "1y"] as const;
export type TopicRange = (typeof TOPIC_RANGES)[number];
export const DEFAULT_TOPIC_RANGE: TopicRange = "30d";
export const TOPIC_RANGE_DAYS: Readonly<Record<TopicRange, number>> = {
"7d": 7,
"30d": 30,
"90d": 90,
"1y": 365,
};
/** A relationship says nothing below this many paired days. */
export const RELATIONSHIP_MIN_PAIRED_DAYS = 14;
/** Each side of a `split` needs this many days to be compared at all. */
export const SPLIT_MIN_DAYS_PER_SIDE = 3;
/** The usual range needs this many days of the trailing window. */
export const USUAL_RANGE_MIN_DAYS = 7;
/** The trailing window the usual range, the trends and the pairs read. */
export const TOPIC_BASE_WINDOW_DAYS = 90;
/** A trend over the base window needs this many days with a value. */
export const TREND_MIN_DAYS = 14;
/** Points a relationship ships for its scatter, newest kept. */
export const RELATIONSHIP_MAX_POINTS = 120;
/** Points in an overview tile's sparkline. */
export const SPARKLINE_POINTS = 30;
-127
View File
@@ -1,127 +0,0 @@
/**
* The topic routes' one entry: who may see the topics, what the record may
* show, and the reads and assembly behind both answers.
*
* Two people are in play and they are kept apart. The preview switch is the
* VIEWER's (`actorId`): a preview is chrome of the person reading, so it is
* their opt-in that decides whether the topics exist for them. Everything the
* page shows is the RECORD's (`userId`): its module switches decide which
* topics, signals and blocks are offered, and the sections the viewer's grant
* opens decide which of them may be read at all.
*/
import { prisma } from "@/lib/db";
import { resolveModuleMap } from "@/lib/modules/gate";
import { resolvePreviewFeatures } from "@/lib/settings/preview-features";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
buildDetail,
buildOverview,
detailReadStart,
type TopicDetail,
type TopicOverview,
} from "./build";
import { readTopicInputs } from "./read";
import {
TOPIC_BASE_WINDOW_DAYS,
TOPICS,
TOPIC_KEYS,
type TopicExtra,
type TopicKey,
type TopicRange,
type TopicSignalKey,
} from "./registry";
import {
isExtraVisible,
isTopicOffered,
offeredTopics,
signalsToRead,
visibleExtras,
visibleSignals,
type TopicVisibility,
} from "./visibility";
/** Whether the viewer switched the topics preview on for themselves. */
export async function hasTopicsPreview(actorId: string): Promise<boolean> {
const row = await prisma.user.findUnique({
where: { id: actorId },
select: { previewFeaturesJson: true },
});
return resolvePreviewFeatures(row?.previewFeaturesJson).topics;
}
async function visibilityFor(
userId: string,
grantId: string | null,
): Promise<TopicVisibility> {
const [modules, sees] = await Promise.all([
resolveModuleMap(userId),
actingDomainVisibility(prisma, grantId),
]);
return { modules, sees };
}
async function today(userId: string, now: Date) {
const tz = await resolveUserTimezone(userId);
return { tz, todayKey: userDayKey(now, tz) };
}
export async function loadTopicOverview(args: {
userId: string;
grantId: string | null;
now: Date;
}): Promise<TopicOverview> {
const ctx = await visibilityFor(args.userId, args.grantId);
const { tz, todayKey } = await today(args.userId, args.now);
const offered = offeredTopics(ctx);
const hidden = TOPIC_KEYS.filter((key) => !offered.includes(key));
// The overview needs each offered topic's own list and its blocks, not its
// chart or pairs; the training load stays on the topic page.
const signals = new Set<TopicSignalKey>();
const extras = new Set<TopicExtra>();
for (const key of offered) {
const def = TOPICS[key];
for (const signal of visibleSignals(def, ctx)) signals.add(signal);
for (const extra of def.extras) {
if (extra !== "training" && isExtraVisible(extra, ctx)) extras.add(extra);
}
}
const inputs = await readTopicInputs({
userId: args.userId,
tz,
todayKey,
fromKey: shiftDateKey(todayKey, -(TOPIC_BASE_WINDOW_DAYS - 1)),
signals,
extras,
now: args.now,
});
return buildOverview(offered, hidden, inputs, ctx, todayKey);
}
/** One topic page, or null when the record's switches do not offer it. */
export async function loadTopicDetail(args: {
userId: string;
grantId: string | null;
topic: TopicKey;
range: TopicRange;
now: Date;
}): Promise<TopicDetail | null> {
const ctx = await visibilityFor(args.userId, args.grantId);
const def = TOPICS[args.topic];
if (!isTopicOffered(def, ctx)) return null;
const { tz, todayKey } = await today(args.userId, args.now);
const inputs = await readTopicInputs({
userId: args.userId,
tz,
todayKey,
fromKey: detailReadStart(args.range, todayKey),
signals: signalsToRead(def, ctx),
extras: visibleExtras(def, ctx),
now: args.now,
});
return buildDetail(def, args.range, inputs, ctx, todayKey);
}
-367
View File
@@ -1,367 +0,0 @@
/**
* The arithmetic of the topic pages, over day-keyed series. Pure: the reads
* live in `read.ts`, the assembly in `build.ts`.
*
* Nothing here invents a figure. A usual range needs a week of days, a trend
* two weeks, a relationship fourteen paired days; below that the answer says
* how many days there are and how many it needs, and carries no number that
* would read as a finding.
*
* - Usual range: the personal band the Insights tiles call "typical for
* you", median ± 3 scaled MAD of the daily values (`buildBaselineBand`),
* over the trailing {@link TOPIC_BASE_WINDOW_DAYS} days.
* - Trend: the least-squares slope of the daily values over the same
* window, reported as the change it amounts to across the window, beside
* weekly means for the sparkline.
* - Relationship: Pearson's r (two-sided p from the t statistic, the one
* correlation the app reports) or, for a `split`, the difference of two
* means with a Welch t test. A finding needs p < 0.05; otherwise it reads
* as "no clear link over n days". Never a cause.
*/
import type { MeasurementType } from "@/generated/prisma/enums";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
import {
MAX_P_VALUE,
pearson,
twoSidedPFromT,
} from "@/lib/insights/correlations";
import { linearFit } from "@/lib/stats/linear-fit";
import { variance } from "@/lib/stats/spread";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
import {
MOOD_SIGNAL,
RELATIONSHIP_MAX_POINTS,
RELATIONSHIP_MIN_PAIRED_DAYS,
SPLIT_MIN_DAYS_PER_SIDE,
TOPIC_BASE_WINDOW_DAYS,
TREND_MIN_DAYS,
USUAL_RANGE_MIN_DAYS,
type TopicRelationshipDef,
type TopicSignalKey,
} from "./registry";
/** One signal's values, keyed by local day (`YYYY-MM-DD`). */
export type DaySeries = ReadonlyMap<string, number>;
export interface DayValue {
day: string;
value: number;
}
export interface UsualRange {
low: number;
high: number;
center: number;
/** Days the band was built from. */
days: number;
}
export type SignalState = "within" | "above" | "below" | "learning" | "none";
export interface TopicTrend {
key: TopicSignalKey;
status: "ok" | "insufficient";
/** Days with a value in the trend window. */
days: number;
/** The fitted change across the window, in the signal's stored unit. */
change: number | null;
/** Weekly means, oldest first; a week without a value is left out. */
weekly: DayValue[];
}
export interface SplitComparison {
threshold: number;
belowDays: number;
otherDays: number;
belowMean: number;
otherMean: number;
/** `belowMean − otherMean`, in `y`'s stored unit. */
difference: number;
}
export interface TopicRelationship {
id: string;
x: TopicSignalKey;
y: TopicSignalKey;
lagDays: 0 | 1;
kind: "correlation" | "split";
pairedDays: number;
requiredDays: number;
/**
* `insufficient`: below the paired-day floor (or one side of a split is
* too thin). `no_clear_link`: enough days, no difference a test can tell
* from chance. `linked`: p < 0.05, reported as a pattern, never a cause.
*/
status: "insufficient" | "no_clear_link" | "linked";
r: number | null;
pValue: number | null;
split: SplitComparison | null;
/** Paired values for the scatter, newest kept; empty when insufficient. */
points: Array<{ x: number; y: number }>;
}
const round = (value: number, digits: number) => {
const f = 10 ** digits;
return Math.round(value * f) / f;
};
/** The days of `series` in `[fromKey, toKey]`, oldest first. */
export function windowValues(
series: DaySeries | undefined,
fromKey: string,
toKey: string,
): DayValue[] {
if (!series) return [];
const out: DayValue[] = [];
for (const [day, value] of series) {
if (day >= fromKey && day <= toKey && Number.isFinite(value)) {
out.push({ day, value });
}
}
return out.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
/** The newest day with a value on or before `todayKey`. */
export function latestValue(
series: DaySeries | undefined,
todayKey: string,
): DayValue | null {
if (!series) return null;
let best: DayValue | null = null;
for (const [day, value] of series) {
if (day > todayKey || !Number.isFinite(value)) continue;
if (best === null || day > best.day) best = { day, value };
}
return best;
}
/** The first day of the trailing base window that ends on `todayKey`. */
export function baseWindowStart(todayKey: string): string {
return shiftDateKey(todayKey, -(TOPIC_BASE_WINDOW_DAYS - 1));
}
/**
* The personal usual range over the trailing base window, or null below
* {@link USUAL_RANGE_MIN_DAYS} days. The mood score has no measurement type
* and keeps the raw band edges.
*/
export function usualRange(
key: TopicSignalKey,
series: DaySeries | undefined,
todayKey: string,
): UsualRange | null {
const values = windowValues(series, baseWindowStart(todayKey), todayKey).map(
(p) => p.value,
);
if (values.length < USUAL_RANGE_MIN_DAYS) return null;
const type = key === MOOD_SIGNAL ? null : (key as MeasurementType);
const band = buildBaselineBand(values, type);
if (!band) return null;
return {
low: round(band.low, 2),
high: round(band.high, 2),
center: round(band.center, 2),
days: band.sampleDays,
};
}
/** Where a value sits against the usual range. */
export function stateAgainstRange(
latest: DayValue | null,
range: UsualRange | null,
): SignalState {
if (latest === null) return "none";
if (range === null) return "learning";
if (latest.value > range.high) return "above";
if (latest.value < range.low) return "below";
return "within";
}
/** The Monday-anchored start of the week a day falls in (ISO weeks). */
function weekStart(day: string): string {
const [y, m, d] = day.split("-").map(Number);
const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
const sinceMonday = (weekday + 6) % 7;
return shiftDateKey(day, -sinceMonday);
}
/**
* The slope of the daily values over the trailing base window, as the change
* it amounts to across the window, beside weekly means.
*/
export function computeTrend(
key: TopicSignalKey,
series: DaySeries | undefined,
todayKey: string,
): TopicTrend {
const fromKey = baseWindowStart(todayKey);
const points = windowValues(series, fromKey, todayKey);
const weeks = new Map<string, { sum: number; n: number }>();
for (const p of points) {
const week = weekStart(p.day);
const cell = weeks.get(week) ?? { sum: 0, n: 0 };
cell.sum += p.value;
cell.n += 1;
weeks.set(week, cell);
}
const weekly = [...weeks.entries()]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([day, cell]) => ({ day, value: round(cell.sum / cell.n, 2) }));
if (points.length < TREND_MIN_DAYS) {
return {
key,
status: "insufficient",
days: points.length,
change: null,
weekly,
};
}
const fit = linearFit(
points.map((p) => daysBetweenDateKeys(fromKey, p.day)),
points.map((p) => p.value),
);
if (!fit) {
return {
key,
status: "insufficient",
days: points.length,
change: null,
weekly,
};
}
return {
key,
status: "ok",
days: points.length,
change: round(fit.slope * (TOPIC_BASE_WINDOW_DAYS - 1), 2),
weekly,
};
}
/** `x` on day d beside `y` on day d + lag, both inside the window. */
export function pairDays(
x: DaySeries | undefined,
y: DaySeries | undefined,
lagDays: number,
fromKey: string,
toKey: string,
): Array<{ day: string; x: number; y: number }> {
if (!x || !y) return [];
const out: Array<{ day: string; x: number; y: number }> = [];
for (const { day, value } of windowValues(x, fromKey, toKey)) {
const target = shiftDateKey(day, lagDays);
if (target > toKey) continue;
const yValue = y.get(target);
if (yValue === undefined || !Number.isFinite(yValue)) continue;
out.push({ day, x: value, y: yValue });
}
return out;
}
function mean(values: readonly number[]): number {
return values.reduce((s, v) => s + v, 0) / values.length;
}
/** Welch's two-sided p for a difference of two means. */
export function welchPValue(
a: readonly number[],
b: readonly number[],
): number {
const ma = mean(a);
const mb = mean(b);
const va = (variance(a, "sample") ?? 0) / a.length;
const vb = (variance(b, "sample") ?? 0) / b.length;
const se = Math.sqrt(va + vb);
if (se === 0) return ma === mb ? 1 : 0;
const t = Math.abs(ma - mb) / se;
const df =
(va + vb) ** 2 /
((va * va) / Math.max(1, a.length - 1) +
(vb * vb) / Math.max(1, b.length - 1));
return twoSidedPFromT(t, df);
}
function compareSplit(
pairs: ReadonlyArray<{ x: number; y: number }>,
threshold: number,
): { split: SplitComparison; pValue: number } | null {
const below = pairs.filter((p) => p.x < threshold).map((p) => p.y);
const other = pairs.filter((p) => p.x >= threshold).map((p) => p.y);
if (below.length < SPLIT_MIN_DAYS_PER_SIDE) return null;
if (other.length < SPLIT_MIN_DAYS_PER_SIDE) return null;
const belowMean = mean(below);
const otherMean = mean(other);
return {
split: {
threshold,
belowDays: below.length,
otherDays: other.length,
belowMean: round(belowMean, 2),
otherMean: round(otherMean, 2),
difference: round(belowMean - otherMean, 2),
},
pValue: welchPValue(below, other),
};
}
/**
* One relationship over `[fromKey, toKey]`. Below the paired-day floor it
* reports the count and nothing else.
*/
export function computeRelationship(
def: TopicRelationshipDef,
x: DaySeries | undefined,
y: DaySeries | undefined,
fromKey: string,
toKey: string,
): TopicRelationship {
const pairs = pairDays(x, y, def.lagDays, fromKey, toKey);
const base: TopicRelationship = {
id: def.id,
x: def.x,
y: def.y,
lagDays: def.lagDays,
kind: def.kind,
pairedDays: pairs.length,
requiredDays: RELATIONSHIP_MIN_PAIRED_DAYS,
status: "insufficient",
r: null,
pValue: null,
split: null,
points: [],
};
if (pairs.length < RELATIONSHIP_MIN_PAIRED_DAYS) return base;
const points = pairs
.slice(-RELATIONSHIP_MAX_POINTS)
.map((p) => ({ x: round(p.x, 2), y: round(p.y, 2) }));
if (def.kind === "split") {
const compared = compareSplit(pairs, def.threshold ?? 0);
if (!compared) return base;
return {
...base,
status: compared.pValue < MAX_P_VALUE ? "linked" : "no_clear_link",
pValue: round(compared.pValue, 4),
split: compared.split,
points,
};
}
const result = pearson({
xs: pairs.map((p) => p.x),
ys: pairs.map((p) => p.y),
minPairs: RELATIONSHIP_MIN_PAIRED_DAYS,
});
if (result.status !== "ok") {
// A flat series (no variance) is enough days and no link to report.
return { ...base, status: "no_clear_link", points };
}
return {
...base,
status: result.pValue < MAX_P_VALUE ? "linked" : "no_clear_link",
r: round(result.r, 2),
pValue: round(result.pValue, 4),
points,
};
}
-107
View File
@@ -1,107 +0,0 @@
/**
* What a topic may show for the record on screen: the record's module
* switches and the sections the viewer's grant opens.
*
* Both are asked of every signal and every block, not once per topic, so a
* switched-off module disappears from every topic it would otherwise appear
* in (sleep from the heart chart, mood from the sleep pairs) and a section the
* grant does not open is never read.
*/
import type { MeasurementType } from "@/generated/prisma/enums";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import type { ModuleKey } from "@/lib/modules/registry";
import type { SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareScope } from "@/lib/sharing/scope";
import {
MOOD_SIGNAL,
TOPIC_EXTRA_MODULE,
TOPICS,
TOPIC_KEYS,
type TopicDefinition,
type TopicExtra,
type TopicKey,
type TopicSignalKey,
} from "./registry";
export interface TopicVisibility {
/** The record's resolved module map; only an explicit `false` hides. */
modules: SurfaceModuleMap;
/** Whether the viewer's grant opens a section (always true on one's own). */
sees: (scope: ShareScope) => boolean;
}
const EXTRA_SECTION: Readonly<Record<TopicExtra, ShareScope>> = {
training: "measurements",
labs: "labs",
screeners: "mind",
cycle: "cycle",
};
function moduleOn(key: ModuleKey | null, modules: SurfaceModuleMap): boolean {
return key === null || modules[key] !== false;
}
export function signalModule(key: TopicSignalKey): ModuleKey | null {
if (key === MOOD_SIGNAL) return "mood";
return moduleForMeasurementType(key as MeasurementType);
}
export function isSignalVisible(
key: TopicSignalKey,
ctx: TopicVisibility,
): boolean {
const section: ShareScope = key === MOOD_SIGNAL ? "mind" : "measurements";
return moduleOn(signalModule(key), ctx.modules) && ctx.sees(section);
}
export function isExtraVisible(
extra: TopicExtra,
ctx: TopicVisibility,
): boolean {
return (
moduleOn(TOPIC_EXTRA_MODULE[extra], ctx.modules) &&
ctx.sees(EXTRA_SECTION[extra])
);
}
/** Whether the topic is offered at all under the record's module switches. */
export function isTopicOffered(
def: TopicDefinition,
ctx: TopicVisibility,
): boolean {
if (def.modules.length === 0) return true;
return def.modules.some((key) => ctx.modules[key] !== false);
}
export function offeredTopics(ctx: TopicVisibility): TopicKey[] {
return TOPIC_KEYS.filter((key) => isTopicOffered(TOPICS[key], ctx));
}
/** The signals a topic's own list shows, in display order. */
export function visibleSignals(
def: TopicDefinition,
ctx: TopicVisibility,
): TopicSignalKey[] {
return def.signals.filter((key) => isSignalVisible(key, ctx));
}
/** Every signal a topic page reads: its list, its chart and its pairs. */
export function signalsToRead(
def: TopicDefinition,
ctx: TopicVisibility,
): Set<TopicSignalKey> {
const keys = new Set<TopicSignalKey>([...def.signals, ...def.chart]);
for (const rel of def.relationships) {
keys.add(rel.x);
keys.add(rel.y);
}
return new Set([...keys].filter((key) => isSignalVisible(key, ctx)));
}
export function visibleExtras(
def: TopicDefinition,
ctx: TopicVisibility,
): Set<TopicExtra> {
return new Set(def.extras.filter((extra) => isExtraVisible(extra, ctx)));
}
-28
View File
@@ -1,28 +0,0 @@
/**
* Request schemas of the topic routes (preview): the topic in the path and
* the window of a topic page. Shared by the routes and the OpenAPI registry.
*/
import { z } from "zod/v4";
import {
DEFAULT_TOPIC_RANGE,
TOPIC_KEYS,
TOPIC_RANGES,
} from "@/lib/topics/registry";
export const topicKeySchema = z
.enum(TOPIC_KEYS)
.describe("The topic, as its URL slug.");
export const topicPathSchema = z.object({ topic: topicKeySchema });
export const topicDetailQuerySchema = z.object({
range: z
.enum(TOPIC_RANGES)
.default(DEFAULT_TOPIC_RANGE)
.describe(
"The window the side-by-side series cover. The usual ranges, the trends and the relationships always read at least the trailing 90 days.",
),
});
export type TopicDetailQuery = z.infer<typeof topicDetailQuerySchema>;