mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'release/v1.46.0' into feat/v146-ui
# Conflicts: # src/__tests__/baselines/source-file-lines.json # src/__tests__/baselines/source-function-lines.json
This commit is contained in:
commit
0a88155aec
100 files changed
+259
-8727
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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` —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -58,4 +58,3 @@ export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
|
||||
export { WorkoutHrChart } from "./workout-hr-chart";
|
||||
export { WorkoutElevationChart } from "./workout-elevation-chart";
|
||||
export { CoachResultChart } from "@/components/insights/coach-panel/result-chart";
|
||||
export { TopicSideBySideChart } from "@/components/topics/topic-side-by-side-chart";
|
||||
@@ -12,7 +12,6 @@ function index(over: Partial<PaletteIndexInput> = {}) {
|
||||
t,
|
||||
modules: {},
|
||||
navModules: {},
|
||||
previews: null,
|
||||
inSharedRecord: false,
|
||||
sections: null,
|
||||
isAdmin: false,
|
||||
@@ -28,16 +27,6 @@ function index(over: Partial<PaletteIndexInput> = {}) {
|
||||
|
||||
const ids = (entries: ReturnType<typeof index>) => entries.map((e) => e.id);
|
||||
|
||||
describe("buildPaletteIndex — previews", () => {
|
||||
it("offers the Topics page only while the person switched the preview on", () => {
|
||||
expect(ids(index())).not.toContain("nav:/topics");
|
||||
expect(ids(index({ previews: { topics: false } }))).not.toContain(
|
||||
"nav:/topics",
|
||||
);
|
||||
expect(ids(index({ previews: { topics: true } }))).toContain("nav:/topics");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaletteIndex — modules", () => {
|
||||
it("a switched-off module takes its page, its Insights page and its cards along", () => {
|
||||
const off = { mood: false, timeline: false, environment: false };
|
||||
|
||||
@@ -18,7 +18,6 @@ import { SETTINGS_SECTIONS } from "@/components/settings/settings-shell";
|
||||
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
|
||||
import { MODULE_REGISTRY, type ModuleKey } from "@/lib/modules/registry";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import type { PreviewFeatures } from "@/lib/settings/preview-features";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
import {
|
||||
@@ -75,8 +74,6 @@ export interface PaletteIndexInput {
|
||||
modules: SurfaceModuleMap | undefined;
|
||||
/** The same map with `coach` following the AI capability (`useNavModules`). */
|
||||
navModules: SurfaceModuleMap | undefined;
|
||||
/** The person's own preview opt-ins; null offers no preview page. */
|
||||
previews: PreviewFeatures | null;
|
||||
inSharedRecord: boolean;
|
||||
/** The shared record's covered sections, when there is one. */
|
||||
sections: ReadonlyArray<ShareDomain> | null;
|
||||
@@ -124,7 +121,6 @@ export function buildPaletteIndex(input: PaletteIndexInput): PaletteEntry[] {
|
||||
true,
|
||||
input.inSharedRecord,
|
||||
input.sections,
|
||||
input.previews,
|
||||
);
|
||||
const navHrefs = new Set(nav.map((d) => d.href));
|
||||
for (const d of nav) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabledState } from "@/hooks/use-module-toggle";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
buildPaletteIndex,
|
||||
@@ -33,7 +32,6 @@ export function usePaletteIndex(): PaletteEntry[] {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord, sections, recordKind, level } = capabilities;
|
||||
const coach = useAiCapability("coach");
|
||||
@@ -66,7 +64,6 @@ export function usePaletteIndex(): PaletteEntry[] {
|
||||
t,
|
||||
modules: user?.modules,
|
||||
navModules,
|
||||
previews,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
isAdmin: user?.role === "ADMIN",
|
||||
@@ -88,7 +85,6 @@ export function usePaletteIndex(): PaletteEntry[] {
|
||||
user?.modules,
|
||||
user?.role,
|
||||
navModules,
|
||||
previews,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
recordKind,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { labTrendLines } from "./lab-trend-lines";
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
|
||||
/**
|
||||
@@ -28,12 +27,44 @@ export function LabTrendCard({
|
||||
const fmt = useFormatters();
|
||||
if (!trend || trend.status !== "ok") return null;
|
||||
|
||||
const { slope: slopeLine, projection: projectionLine } = labTrendLines(
|
||||
trend,
|
||||
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
|
||||
const per = (v: number) => fmt.number(Math.abs(v), digits);
|
||||
const direction =
|
||||
trend.slopePerYear > 0
|
||||
? "rising"
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slopeLine = t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit,
|
||||
t,
|
||||
fmt,
|
||||
);
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const projectionLine =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
date: fmt.dateShortSmartCalendar(trend.exitDate),
|
||||
})
|
||||
: trend.projection === "no_band"
|
||||
? null
|
||||
: trend.projection === "stale"
|
||||
? t("labs.trend.projection.stale", {
|
||||
date: fmt.dateShortSmartCalendar(trend.lastAt),
|
||||
})
|
||||
: trend.projection === "beyond_horizon"
|
||||
? t(
|
||||
trend.horizonCapped
|
||||
? "labs.trend.projection.beyond_horizon_capped"
|
||||
: "labs.trend.projection.beyond_horizon",
|
||||
{
|
||||
years: fmt.number(
|
||||
trend.horizonYears,
|
||||
trend.horizonCapped ? 0 : 1,
|
||||
),
|
||||
},
|
||||
)
|
||||
: t(`labs.trend.projection.${trend.projection}`);
|
||||
|
||||
return (
|
||||
<Card data-slot="lab-trend">
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* The two sentences a lab trend reads as: the slope across the readings, and
|
||||
* where the straight line would meet the reference band (or why it is not
|
||||
* drawn). Shared by the marker page's trend card and the metabolism topic, so
|
||||
* one trend is never worded two ways.
|
||||
*/
|
||||
export function labTrendLines(
|
||||
trend: Extract<BiomarkerTrendDto, { status: "ok" }>,
|
||||
unit: string,
|
||||
t: Translate,
|
||||
fmt: Formatters,
|
||||
): { slope: string; projection: string | null } {
|
||||
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
|
||||
const direction =
|
||||
trend.slopePerYear > 0
|
||||
? "rising"
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slope = t(`labs.trend.slope.${direction}`, {
|
||||
amount: fmt.number(Math.abs(trend.slopePerYear), digits),
|
||||
unit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const projection =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
date: fmt.dateShortSmartCalendar(trend.exitDate),
|
||||
})
|
||||
: trend.projection === "no_band"
|
||||
? null
|
||||
: trend.projection === "stale"
|
||||
? t("labs.trend.projection.stale", {
|
||||
date: fmt.dateShortSmartCalendar(trend.lastAt),
|
||||
})
|
||||
: trend.projection === "beyond_horizon"
|
||||
? t(
|
||||
trend.horizonCapped
|
||||
? "labs.trend.projection.beyond_horizon_capped"
|
||||
: "labs.trend.projection.beyond_horizon",
|
||||
{
|
||||
years: fmt.number(
|
||||
trend.horizonYears,
|
||||
trend.horizonCapped ? 0 : 1,
|
||||
),
|
||||
},
|
||||
)
|
||||
: t(`labs.trend.projection.${trend.projection}`);
|
||||
return { slope, projection };
|
||||
}
|
||||
@@ -105,31 +105,13 @@ describe("the destination list under a switch", () => {
|
||||
});
|
||||
|
||||
it("changes nothing for a session in its own record", () => {
|
||||
const own = visibleNavDestinations(ALL_MODULES, true, false, null, {
|
||||
topics: true,
|
||||
}).map((d) => d.href);
|
||||
const own = visibleNavDestinations(ALL_MODULES, true).map((d) => d.href);
|
||||
expect(own).toEqual(
|
||||
NAV_DESTINATIONS.filter((d) => !d.sharedRecordOnly).map((d) => d.href),
|
||||
);
|
||||
expect(own).not.toContain("/profile");
|
||||
});
|
||||
|
||||
it("offers Topics inside a whole-record share only with the viewer's own preview on", () => {
|
||||
const share = (previews: { topics: boolean } | null) =>
|
||||
visibleNavDestinations(ALL_MODULES, true, true, null, previews).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
expect(share({ topics: true })).toContain("/topics");
|
||||
expect(share({ topics: false })).not.toContain("/topics");
|
||||
expect(share(null)).not.toContain("/topics");
|
||||
// A grant scoped to sections never opens a page that lays them side by side.
|
||||
expect(
|
||||
visibleNavDestinations(ALL_MODULES, true, true, ["measurements"], {
|
||||
topics: true,
|
||||
}).map((d) => d.href),
|
||||
).not.toContain("/topics");
|
||||
});
|
||||
|
||||
it("offers the read-only profile summary only inside a shared record", () => {
|
||||
expect(hrefsInSharedRecord()).toContain("/profile");
|
||||
});
|
||||
|
||||
@@ -215,42 +215,6 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (feature-only h
|
||||
});
|
||||
});
|
||||
|
||||
describe("a preview entry (Topics)", () => {
|
||||
it("exists only while the person reading switched the preview on", () => {
|
||||
const hrefs = (previews: { topics: boolean } | null, mounted = true) =>
|
||||
visibleNavDestinations({}, mounted, false, null, previews).map(
|
||||
(d) => d.href,
|
||||
);
|
||||
expect(hrefs(null)).not.toContain("/topics");
|
||||
expect(hrefs({ topics: false })).not.toContain("/topics");
|
||||
expect(hrefs({ topics: true })).toContain("/topics");
|
||||
// Fail-closed before mount, so SSR and the first paint agree.
|
||||
expect(hrefs({ topics: true }, false)).not.toContain("/topics");
|
||||
});
|
||||
|
||||
it("sits directly below Insights in the sidebar", () => {
|
||||
const hrefs = visibleNavDestinations({}, true, false, null, {
|
||||
topics: true,
|
||||
}).map((d) => d.href);
|
||||
expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics");
|
||||
});
|
||||
|
||||
it("leads the phone's More hub with its badge and never takes a bottom-bar slot", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: {},
|
||||
previews: { topics: true },
|
||||
});
|
||||
expect(hub[0]).toMatchObject({ href: "/topics", preview: "topics" });
|
||||
expect(BOTTOM_NAV_PRIMARY_SLOT_HREFS).not.toContain("/topics");
|
||||
expect(
|
||||
mobileMoreHubDestinations({
|
||||
modules: {},
|
||||
previews: { topics: false },
|
||||
}).map((d) => d.href),
|
||||
).not.toContain("/topics");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNavDestinationActive most-specific resolution", () => {
|
||||
it("matches the dashboard only on an exact path", () => {
|
||||
expect(isNavDestinationActive("/", "/")).toBe(true);
|
||||
|
||||
@@ -21,8 +21,6 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { PreviewBadge } from "@/components/topics/preview-badge";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
@@ -44,8 +42,6 @@ interface NavLink {
|
||||
href: string;
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
/** A preview entry; its tile carries the Beta badge. */
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
// v1.12.x — iOS-parity bottom bar (additive middle-path).
|
||||
@@ -115,7 +111,6 @@ export function BottomNav() {
|
||||
// The Coach entry follows the `coach` AI capability (a Coach that cannot
|
||||
// answer is not offered); the sidebar reads the same map.
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const moreHub = useMemo<ReadonlyArray<NavLink>>(
|
||||
() =>
|
||||
mobileMoreHubDestinations({
|
||||
@@ -123,9 +118,8 @@ export function BottomNav() {
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
}),
|
||||
[navModules, mounted, sharedRecord, sections, previews],
|
||||
[navModules, mounted, sharedRecord, sections],
|
||||
);
|
||||
|
||||
// Every slot but Home asks two things: does sharing cover it, and does its
|
||||
@@ -295,72 +289,52 @@ export function BottomNav() {
|
||||
second line instead of clipping the last character at the tile
|
||||
edge. */}
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
|
||||
{moreHub.map((item) => (
|
||||
<MoreHubTile
|
||||
key={item.href}
|
||||
item={item}
|
||||
active={isActiveLink(item.href)}
|
||||
onNavigate={() => setMoreOpen(false)}
|
||||
/>
|
||||
))}
|
||||
{moreHub.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
// v1.12 — `min-h-14` matches the capture-picker tiles so
|
||||
// the equivalent tappable rows share one height at this
|
||||
// tier (and the larger row is a more comfortable target).
|
||||
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
|
||||
// to hand the label more horizontal room without changing
|
||||
// the shared tile height.
|
||||
className={cn(
|
||||
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
|
||||
isActive
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-foreground hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
{/* v1.18.10 — show the FULL label, never an ellipsis
|
||||
that hides meaning. The W7 `truncate` clipped long words
|
||||
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
|
||||
readable label, not a balanced tile. Shrink the type to
|
||||
`text-xs` so long single-word locales (de/es/fr/it) fit on
|
||||
one line where they can, and allow a 2-line `line-clamp-2`
|
||||
fallback (with `[overflow-wrap:anywhere]` so a long word
|
||||
breaks rather than overflowing) for the rare label that
|
||||
still cannot fit one line. `min-w-0` lets the flex child
|
||||
shrink below its content width. v1.22.1 — with the wider
|
||||
text area above, "Benachrichtigungen" wraps fully inside
|
||||
two lines down to a 320 px viewport. */}
|
||||
<span
|
||||
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
|
||||
title={t(item.tKey)}
|
||||
>
|
||||
{t(item.tKey)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One destination tile in the More sheet. */
|
||||
function MoreHubTile({
|
||||
item,
|
||||
active,
|
||||
onNavigate,
|
||||
}: {
|
||||
item: NavLink;
|
||||
active: boolean;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
// v1.12 — `min-h-14` matches the capture-picker tiles so
|
||||
// the equivalent tappable rows share one height at this
|
||||
// tier (and the larger row is a more comfortable target).
|
||||
// v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`)
|
||||
// to hand the label more horizontal room without changing
|
||||
// the shared tile height.
|
||||
className={cn(
|
||||
"border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors",
|
||||
active
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-foreground hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
{/* v1.18.10 — show the FULL label, never an ellipsis
|
||||
that hides meaning. The W7 `truncate` clipped long words
|
||||
("Benachrichtigungen" → "Benachrichtigun…"); the goal is a
|
||||
readable label, not a balanced tile. Shrink the type to
|
||||
`text-xs` so long single-word locales (de/es/fr/it) fit on
|
||||
one line where they can, and allow a 2-line `line-clamp-2`
|
||||
fallback (with `[overflow-wrap:anywhere]` so a long word
|
||||
breaks rather than overflowing) for the rare label that
|
||||
still cannot fit one line. `min-w-0` lets the flex child
|
||||
shrink below its content width. v1.22.1 — with the wider
|
||||
text area above, "Benachrichtigungen" wraps fully inside
|
||||
two lines down to a 320 px viewport. */}
|
||||
<span
|
||||
className="line-clamp-2 min-w-0 text-xs leading-tight font-medium [overflow-wrap:anywhere]"
|
||||
title={t(item.tKey)}
|
||||
>
|
||||
{t(item.tKey)}
|
||||
</span>
|
||||
{item.preview ? (
|
||||
<PreviewBadge label={t("nav.previewBadge")} className="ml-auto" />
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from "@/components/layout/nav-model";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
createShortcutReader,
|
||||
@@ -104,7 +103,6 @@ export function openCapturePicker(): void {
|
||||
function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
|
||||
const { user } = useAuth();
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const { inSharedRecord, sections, recordKind, level } = capabilities;
|
||||
const manageableDomains = user?.accountAccess?.active?.manageableDomains;
|
||||
@@ -113,14 +111,10 @@ function useShortcutOffer(): ShortcutOffer & { canCapture: boolean } {
|
||||
.length > 0;
|
||||
const navHrefs = useMemo(
|
||||
() =>
|
||||
visibleNavDestinations(
|
||||
navModules,
|
||||
true,
|
||||
inSharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
).map((d) => d.href),
|
||||
[navModules, inSharedRecord, sections, previews],
|
||||
visibleNavDestinations(navModules, true, inSharedRecord, sections).map(
|
||||
(d) => d.href,
|
||||
),
|
||||
[navModules, inSharedRecord, sections],
|
||||
);
|
||||
const settingsHref = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
FlaskConical,
|
||||
History,
|
||||
Home,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
MessagesSquare,
|
||||
Pill,
|
||||
@@ -30,10 +29,6 @@ import {
|
||||
type SettingsRecordContext,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { isSharedRecordPathPresentable } from "@/lib/navigation/shared-record";
|
||||
import type {
|
||||
PreviewFeatureKey,
|
||||
PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
/**
|
||||
@@ -83,14 +78,6 @@ export interface NavDestination {
|
||||
sharedRecord?: boolean;
|
||||
/** A record-only view, intentionally absent from the actor's own nav. */
|
||||
sharedRecordOnly?: boolean;
|
||||
/**
|
||||
* A preview the person switches on for themselves (Settings >
|
||||
* System > Preview features). The entry exists only while that flag is on
|
||||
* for the person reading, carries a Beta badge, and leads the phone's More
|
||||
* hub so a freshly switched-on preview is found. Fail-closed: hidden until
|
||||
* the account payload has said yes.
|
||||
*/
|
||||
preview?: PreviewFeatureKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,21 +250,6 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
|
||||
icon: Lightbulb,
|
||||
tourId: "nav-insights",
|
||||
},
|
||||
// Topics (preview): overview pages that bring related signals
|
||||
// together and link into the single-metric Insights pages, which stay
|
||||
// exactly as they are. Directly below Insights in the sidebar; on a phone it
|
||||
// lands in the More hub (first), never in the bottom bar. Only while the
|
||||
// person reading switched the preview on. A record read across sections,
|
||||
// like the timeline: a delegate holding the whole record sees it (with their
|
||||
// own preview on), a scoped grant never does.
|
||||
{
|
||||
href: "/topics",
|
||||
sharedRecord: true,
|
||||
tKey: "nav.topics",
|
||||
icon: Layers,
|
||||
tourId: "nav-topics",
|
||||
preview: "topics",
|
||||
},
|
||||
// v1.17.1 — the Coach finally gets a single labeled nav home. It
|
||||
// was reachable from seven scattered entry points (FAB, hero CTA, empty
|
||||
// states, per-metric icons …) but nowhere in the nav, so a new user
|
||||
@@ -405,11 +377,7 @@ function isNavDestinationVisible(
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
sections: readonly ShareDomain[] | null = null,
|
||||
previews: PreviewFeatures | null = null,
|
||||
): boolean {
|
||||
// A preview entry exists only once the payload has said the person reading
|
||||
// switched it on: fail-closed before mount and while the payload is absent.
|
||||
if (d.preview && (!mounted || previews?.[d.preview] !== true)) return false;
|
||||
if (
|
||||
sharedRecord &&
|
||||
(d.sharedRecord !== true ||
|
||||
@@ -472,17 +440,9 @@ export function visibleNavDestinations(
|
||||
mounted = true,
|
||||
sharedRecord = false,
|
||||
sections: readonly ShareDomain[] | null = null,
|
||||
previews: PreviewFeatures | null = null,
|
||||
): NavDestination[] {
|
||||
return NAV_DESTINATIONS.filter((d) =>
|
||||
isNavDestinationVisible(
|
||||
d,
|
||||
modules,
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
),
|
||||
isNavDestinationVisible(d, modules, mounted, sharedRecord, sections),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -503,8 +463,6 @@ export interface MobileMoreHubEntry {
|
||||
href: string;
|
||||
tKey: string;
|
||||
icon: LucideIcon;
|
||||
/** Set for a preview entry, which the tile marks with a Beta badge. */
|
||||
preview?: PreviewFeatureKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -525,28 +483,15 @@ export function mobileMoreHubDestinations(opts: {
|
||||
sharedRecord?: boolean;
|
||||
/** Server-resolved scope for the active shared record. */
|
||||
sections?: readonly ShareDomain[] | null;
|
||||
/** The person's own preview opt-ins; absent hides every preview. */
|
||||
previews?: PreviewFeatures | null;
|
||||
}): MobileMoreHubEntry[] {
|
||||
const entries = visibleNavDestinations(
|
||||
return visibleNavDestinations(
|
||||
opts.modules,
|
||||
opts.mounted ?? true,
|
||||
opts.sharedRecord ?? false,
|
||||
opts.sections ?? null,
|
||||
opts.previews ?? null,
|
||||
)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({
|
||||
href: d.href,
|
||||
tKey: d.tKey,
|
||||
icon: d.icon,
|
||||
...(d.preview ? { preview: d.preview } : {}),
|
||||
}));
|
||||
// A preview someone just switched on leads the hub, where it is found; the
|
||||
// rest keep model order. `Array.prototype.sort` is stable.
|
||||
return entries.sort(
|
||||
(a, b) => Number(b.preview !== undefined) - Number(a.preview !== undefined),
|
||||
);
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,18 +27,11 @@ import {
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RAIL_LIST_GAP,
|
||||
RAIL_SHORT_PY,
|
||||
railRowClass,
|
||||
SidebarNavItem,
|
||||
} from "@/components/layout/sidebar-rail";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useNavModules } from "@/hooks/use-nav-modules";
|
||||
import { usePreviewFeatures } from "@/hooks/use-preview-features";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTheme } from "@/components/providers";
|
||||
@@ -335,6 +328,45 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
|
||||
* the browser roughly 700–860 px, and the full destination list plus the
|
||||
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
|
||||
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
|
||||
* On short windows the rows close their gaps first, then step their height
|
||||
* down; tall windows keep the roomy default. Desktop-only by construction —
|
||||
* the rail is hidden below `md`, so the phone layout never sees these steps.
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
*/
|
||||
// Disjoint height ranges, not nested max-heights: two arbitrary media
|
||||
// variants on one property cascade in stylesheet order, not by how narrow
|
||||
// the query is, so a nested pair let the 36 px step win on a 720 px window.
|
||||
// The height steps apply to a mouse only. Under a finger (a touch tablet)
|
||||
// the rows keep the 44 px touch floor and the list scrolls behind its fades
|
||||
// instead. A phone held sideways never sees the rail: it keeps the phone
|
||||
// shell (`shell-mobile`).
|
||||
const RAIL_ROW_HEIGHT =
|
||||
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
|
||||
const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
|
||||
const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
|
||||
|
||||
/** Rail row, collapsed (icon only) or expanded (icon + label). */
|
||||
function railRowClass(collapsed: boolean): string {
|
||||
return cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
RAIL_ROW_HEIGHT,
|
||||
collapsed
|
||||
? "justify-center px-2.5"
|
||||
: "gap-3 px-3 py-1.5 text-sm font-medium",
|
||||
);
|
||||
}
|
||||
|
||||
/** Height of the fade at a scroll edge that has more content beyond it. */
|
||||
const RAIL_FADE = "1.5rem";
|
||||
|
||||
@@ -493,17 +525,9 @@ export function SidebarNav() {
|
||||
// The Coach entry follows the `coach` AI capability, like every other
|
||||
// Coach entry point; the rest of the map is `modules` unchanged.
|
||||
const navModules = useNavModules();
|
||||
const previews = usePreviewFeatures();
|
||||
const visibleNavItems = useMemo(
|
||||
() =>
|
||||
visibleNavDestinations(
|
||||
navModules,
|
||||
mounted,
|
||||
sharedRecord,
|
||||
sections,
|
||||
previews,
|
||||
),
|
||||
[navModules, mounted, sharedRecord, sections, previews],
|
||||
() => visibleNavDestinations(navModules, mounted, sharedRecord, sections),
|
||||
[navModules, mounted, sharedRecord, sections],
|
||||
);
|
||||
// Collapsed = the user's stored choice, or — with no stored choice — a
|
||||
// viewport default: tablet widths (md–lg, e.g. an iPad held upright)
|
||||
@@ -733,21 +757,59 @@ export function SidebarNav() {
|
||||
}}
|
||||
>
|
||||
<div className={RAIL_LIST_GAP}>
|
||||
{visibleNavItems.map((item) => (
|
||||
<SidebarNavItem
|
||||
key={item.href}
|
||||
item={item}
|
||||
label={t(item.tKey)}
|
||||
badge={item.preview ? t("nav.previewBadge") : null}
|
||||
active={isNavDestinationActive(
|
||||
item.href,
|
||||
pathname,
|
||||
visibleNavItems,
|
||||
)}
|
||||
collapsed={collapsed}
|
||||
intent={item.href === "/medications" ? medsIntent : undefined}
|
||||
/>
|
||||
))}
|
||||
{visibleNavItems.map((item) => {
|
||||
const isActive = isNavDestinationActive(
|
||||
item.href,
|
||||
pathname,
|
||||
visibleNavItems,
|
||||
);
|
||||
const label = t(item.tKey);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
className={cn(
|
||||
railRowClass(true),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
className={cn(
|
||||
railRowClass(false),
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The rail's rows: the height steps every row shares and one destination
|
||||
* row, collapsed or expanded. Split from `sidebar-nav.tsx`, which keeps the
|
||||
* shell, the footer and the user menu.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
|
||||
import type { NavDestination } from "@/components/layout/nav-model";
|
||||
import { PreviewBadge } from "@/components/topics/preview-badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
|
||||
* the browser roughly 700–860 px, and the full destination list plus the
|
||||
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
|
||||
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
|
||||
* On short windows the rows close their gaps first, then step their height
|
||||
* down; tall windows keep the roomy default. Desktop-only by construction —
|
||||
* the rail is hidden below `md`, so the phone layout never sees these steps.
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
*/
|
||||
// Disjoint height ranges, not nested max-heights: two arbitrary media
|
||||
// variants on one property cascade in stylesheet order, not by how narrow
|
||||
// the query is, so a nested pair let the 36 px step win on a 720 px window.
|
||||
// The height steps apply to a mouse only. Under a finger (a touch tablet)
|
||||
// the rows keep the 44 px touch floor and the list scrolls behind its fades
|
||||
// instead. A phone held sideways never sees the rail: it keeps the phone
|
||||
// shell (`shell-mobile`).
|
||||
const RAIL_ROW_HEIGHT =
|
||||
"min-h-10 pointer-coarse:min-h-11 [@media(hover:hover)_and_(pointer:fine)_and_(740px<height<=820px)]:min-h-9 [@media(hover:hover)_and_(pointer:fine)_and_(height<=740px)]:min-h-8";
|
||||
export const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
|
||||
export const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
|
||||
|
||||
/** Rail row, collapsed (icon only) or expanded (icon + label). */
|
||||
export function railRowClass(collapsed: boolean): string {
|
||||
return cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
RAIL_ROW_HEIGHT,
|
||||
collapsed
|
||||
? "justify-center px-2.5"
|
||||
: "gap-3 px-3 py-1.5 text-sm font-medium",
|
||||
);
|
||||
}
|
||||
|
||||
/** One destination in the rail: an icon row when collapsed, icon and label when expanded. */
|
||||
export function SidebarNavItem({
|
||||
item,
|
||||
label,
|
||||
badge,
|
||||
active,
|
||||
collapsed,
|
||||
intent,
|
||||
}: {
|
||||
item: NavDestination;
|
||||
label: string;
|
||||
/** The Beta mark of a preview entry, or null. */
|
||||
badge: string | null;
|
||||
active: boolean;
|
||||
collapsed: boolean;
|
||||
/** The medications prefetch handlers, on that one row. */
|
||||
intent?: ReturnType<typeof medicationsPrefetchIntentProps>;
|
||||
}) {
|
||||
const className = cn(
|
||||
railRowClass(collapsed),
|
||||
active ? "bg-primary/10 text-primary" : "text-foreground hover:bg-accent",
|
||||
);
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...intent}
|
||||
className={className}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-tour-id={item.tourId}
|
||||
{...intent}
|
||||
className={className}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{label}
|
||||
{badge ? <PreviewBadge label={badge} className="ml-auto" /> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PreviewSection } from "../preview-section";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
function render(topics: boolean | undefined, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnMount: false } },
|
||||
});
|
||||
if (topics !== undefined) {
|
||||
client.setQueryData(queryKeys.previewFeatures(), { topics });
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>
|
||||
<PreviewSection />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const switchState = (html: string, state: "true" | "false") =>
|
||||
new RegExp(
|
||||
`data-slot="settings-preview-topics-switch"[^>]*aria-checked="${state}"|aria-checked="${state}"[^>]*data-slot="settings-preview-topics-switch"`,
|
||||
).test(html);
|
||||
|
||||
describe("<PreviewSection>", () => {
|
||||
it("shows the Topics switch off by default, saved on change", () => {
|
||||
const html = render(false);
|
||||
expect(switchState(html, "false")).toBe(true);
|
||||
expect(html).toContain("Topics");
|
||||
expect(html).toContain('data-slot="preview-badge"');
|
||||
// A switch, no Save: one save model per card.
|
||||
expect(html).not.toContain('type="submit"');
|
||||
// Nothing to open while it is off.
|
||||
expect(html).not.toContain('data-slot="settings-preview-topics-open"');
|
||||
});
|
||||
|
||||
it("reads on once the person switched it on and offers the way in", () => {
|
||||
const html = render(true);
|
||||
expect(switchState(html, "true")).toBe(true);
|
||||
expect(html).toMatch(
|
||||
/data-slot="settings-preview-topics-open"[^>]*href="\/topics"|href="\/topics"[^>]*data-slot="settings-preview-topics-open"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("names the switch by its visible title and leaks no raw key", () => {
|
||||
const html = render(false, "de");
|
||||
expect(html).toContain('aria-labelledby="settings-preview-topics-title"');
|
||||
expect(html).toContain("Themen");
|
||||
expect(html).not.toContain("settings.preview.");
|
||||
expect(html).not.toContain("nav.previewBadge");
|
||||
});
|
||||
});
|
||||
@@ -170,12 +170,19 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// v1.23 — "Data & Privacy" assembles the export / deletion / retention /
|
||||
// encryption / session / activity pieces into one pane, after `advanced`.
|
||||
"privacy",
|
||||
// Per-user preview opt-ins, in the System group before About.
|
||||
"preview",
|
||||
"about",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists no Preview features page until the topic pages ship", () => {
|
||||
// The stored flag and its API stay; the page and its nav entry return
|
||||
// with the first preview the switch actually turns on.
|
||||
expect(isSettingsSectionSlug("preview")).toBe(false);
|
||||
expect(SETTINGS_SECTIONS.some((s) => s.slug === ("preview" as never))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
|
||||
for (const slug of SETTINGS_SECTION_SLUGS) {
|
||||
expect(isSettingsSectionSlug(slug)).toBe(true);
|
||||
@@ -242,7 +249,6 @@ describe("<SettingsShell>", () => {
|
||||
gesundheitsakte: "data",
|
||||
privacy: "data",
|
||||
advanced: "data",
|
||||
preview: "system",
|
||||
about: "system",
|
||||
});
|
||||
// Every routable section has a nav entry, and nothing else does.
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings > System > Preview features.
|
||||
*
|
||||
* One switch per preview feature, each off until the person turns it on, each
|
||||
* saved the moment it changes (`PATCH /api/settings/preview-features`). A
|
||||
* preview adds something beside what is there and never replaces a page.
|
||||
*
|
||||
* The switch is the person's own and stays theirs while they act on somebody
|
||||
* else's record. Saving refreshes the account payload too, because the
|
||||
* navigation reads the flag from there: the Topics entry appears or goes the
|
||||
* moment the switch moves.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { PreviewBadge } from "@/components/topics/preview-badge";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DEFAULT_PREVIEW_FEATURES,
|
||||
type PreviewFeatures,
|
||||
} from "@/lib/settings/preview-features";
|
||||
|
||||
export function PreviewSection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const key = queryKeys.previewFeatures();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => apiGet<PreviewFeatures>("/api/settings/preview-features"),
|
||||
});
|
||||
const features = query.data ?? DEFAULT_PREVIEW_FEATURES;
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationKey: key,
|
||||
mutationFn: (topics: boolean) =>
|
||||
apiPatch<PreviewFeatures>("/api/settings/preview-features", { topics }),
|
||||
onMutate: async (topics) => {
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<PreviewFeatures>(key);
|
||||
queryClient.setQueryData<PreviewFeatures>(key, {
|
||||
...(previous ?? DEFAULT_PREVIEW_FEATURES),
|
||||
topics,
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onSuccess: (saved, topics) => {
|
||||
if (saved) queryClient.setQueryData(key, saved);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
topics
|
||||
? t("settings.preview.topics.savedOn")
|
||||
: t("settings.preview.topics.savedOff"),
|
||||
);
|
||||
},
|
||||
onError: (failure, _topics, context) => {
|
||||
queryClient.setQueryData(key, context?.previous);
|
||||
showErrorToast(t("settings.savingError"), { cause: failure });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-preview-topics">
|
||||
<SettingsCardHeader
|
||||
anchor="preview-topics"
|
||||
icon={Layers}
|
||||
title={t("settings.preview.topics.title")}
|
||||
titleId="settings-preview-topics-title"
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t("settings.preview.topics.description")}
|
||||
status={
|
||||
<Switch
|
||||
data-slot="settings-preview-topics-switch"
|
||||
checked={features.topics}
|
||||
disabled={query.isLoading || toggle.isPending}
|
||||
onCheckedChange={(next) => toggle.mutate(next)}
|
||||
aria-labelledby="settings-preview-topics-title"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{query.isError ? (
|
||||
<QueryErrorRow
|
||||
inset
|
||||
error={query.error}
|
||||
slot="settings-preview-error"
|
||||
message={t("settings.preview.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-sm">{t("settings.preview.topics.detail")}</p>
|
||||
{features.topics ? (
|
||||
<p className="text-sm">
|
||||
<InlineAction asChild tone="primary" size="sm">
|
||||
<Link href="/topics" data-slot="settings-preview-topics-open">
|
||||
{t("settings.preview.topics.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</p>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -124,10 +124,6 @@ const LOADERS: Record<
|
||||
import("@/components/settings/privacy-section").then(
|
||||
(m) => m.PrivacySection,
|
||||
),
|
||||
preview: () =>
|
||||
import("@/components/settings/preview-section").then(
|
||||
(m) => m.PreviewSection,
|
||||
),
|
||||
};
|
||||
|
||||
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
@@ -162,7 +158,6 @@ const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
|
||||
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
|
||||
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
|
||||
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
|
||||
preview: dynamic(LOADERS.preview, { loading: () => <SectionLoading /> }),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
CloudSun,
|
||||
Download,
|
||||
FileHeart,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Info,
|
||||
KeyRound,
|
||||
@@ -275,12 +274,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
group: "data",
|
||||
},
|
||||
// ── System ──
|
||||
{
|
||||
slug: "preview",
|
||||
titleKey: "settings.sections.preview.title",
|
||||
icon: FlaskConical,
|
||||
group: "system",
|
||||
},
|
||||
{
|
||||
slug: "about",
|
||||
titleKey: "settings.sections.about.title",
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* The topic pages build several message keys at run time (one per topic,
|
||||
* range, state, tone, verdict). The call-site coverage guard sees literal keys
|
||||
* only, so this holds every built key against every locale.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
|
||||
|
||||
import {
|
||||
topicDescriptionKey,
|
||||
topicSourcesKey,
|
||||
topicTitleKey,
|
||||
} from "../topic-meta";
|
||||
|
||||
function resolve(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) =>
|
||||
node && typeof node === "object"
|
||||
? (node as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
const BUILT_KEYS = [
|
||||
...TOPIC_KEYS.flatMap((key) => [
|
||||
topicTitleKey(key),
|
||||
topicDescriptionKey(key),
|
||||
topicSourcesKey(key),
|
||||
]),
|
||||
...TOPIC_RANGES.map((range) => `topics.range.${range}`),
|
||||
...["within", "above", "below", "learning"].map((s) => `topics.state.${s}`),
|
||||
...["steady", "watch", "learning"].map((s) => `topics.tone.${s}`),
|
||||
...["improved", "worsened", "within_threshold"].map(
|
||||
(v) => `topics.screeners.${v}`,
|
||||
),
|
||||
...["above", "below"].map((s) => `topics.labs.${s}`),
|
||||
...["SameDay", "NextDay"].flatMap((lag) =>
|
||||
["Higher", "Lower"].map(
|
||||
(dir) => `topics.relationships.correlation${lag}${dir}`,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
describe("topic message keys", () => {
|
||||
it.each(locales)("every built key resolves to text in %s", async (locale) => {
|
||||
const bundle = (await import(`../../../../messages/${locale}.json`))
|
||||
.default;
|
||||
const missing = BUILT_KEYS.filter(
|
||||
(key) => typeof resolve(bundle, key) !== "string",
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks a non-empty set", () => {
|
||||
expect(BUILT_KEYS.length).toBeGreaterThan(40);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { TopicOverviewEntry } from "@/lib/topics/build";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
import { TopicTile } from "../topic-tile";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
function render(entry: TopicOverviewEntry, locale: "en" | "de" = "en") {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>
|
||||
<TopicTile entry={entry} />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const heart: TopicOverviewEntry = {
|
||||
key: "heart",
|
||||
headline: {
|
||||
kind: "signal",
|
||||
key: "RESTING_HEART_RATE",
|
||||
value: 59,
|
||||
day: "2026-10-22",
|
||||
},
|
||||
summary: {
|
||||
code: "outside",
|
||||
signal: "RESTING_HEART_RATE",
|
||||
direction: "above",
|
||||
count: null,
|
||||
total: null,
|
||||
},
|
||||
tone: "watch",
|
||||
sparkline: [58, 59, 61],
|
||||
recorded: 2,
|
||||
total: 4,
|
||||
};
|
||||
|
||||
describe("<TopicTile>", () => {
|
||||
it("links the visible title to the topic and words the server's sentence", () => {
|
||||
const html = render(heart);
|
||||
expect(html).toMatch(
|
||||
/href="\/topics\/heart"[^>]*data-slot="topic-tile-link"[^>]*>Heart<|data-slot="topic-tile-link"[^>]*href="\/topics\/heart"[^>]*>Heart</,
|
||||
);
|
||||
expect(html).toContain('data-code="outside"');
|
||||
expect(html).toContain("above your usual range");
|
||||
expect(html).toContain('data-tone="watch"');
|
||||
expect(html).toContain("2 of 4");
|
||||
expect(html).toMatch(/>59(\.0)?</);
|
||||
});
|
||||
|
||||
it("speaks German and leaks no raw key", () => {
|
||||
const html = render(heart, "de");
|
||||
expect(html).toContain(">Herz<");
|
||||
expect(html).toContain("über deinem üblichen Bereich");
|
||||
expect(html).not.toMatch(/topics\.[a-z]/);
|
||||
});
|
||||
|
||||
it("says a block headline in words, without a sparkline", () => {
|
||||
const html = render({
|
||||
...heart,
|
||||
key: "metabolism",
|
||||
headline: { kind: "labs", inRange: 11, total: 12 },
|
||||
summary: {
|
||||
code: "labs",
|
||||
signal: null,
|
||||
direction: null,
|
||||
count: 11,
|
||||
total: 12,
|
||||
},
|
||||
tone: "watch",
|
||||
sparkline: [],
|
||||
});
|
||||
expect(html).toContain("11/12");
|
||||
expect(html).toContain("11 of 12 lab values");
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The "Beta" mark beside a preview: the nav entry, the More tile, the page
|
||||
* title and the Settings switch. A status, so a `Badge`, in the one accent a
|
||||
* preview carries. Its text is part of the accessible name of whatever it
|
||||
* sits in, which is what a visible label should be.
|
||||
*/
|
||||
export function PreviewBadge({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-slot="preview-badge"
|
||||
className={cn(
|
||||
"border-primary/40 text-primary text-2xs px-1.5 py-0 tracking-wide uppercase",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The line every topic page ends on: this is a preview, it may change, and
|
||||
* the single-metric pages are untouched. Neutral, with no link out.
|
||||
*/
|
||||
export function PreviewNote() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground flex items-start gap-2 text-xs"
|
||||
data-slot="topics-preview-note"
|
||||
>
|
||||
<FlaskConical className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("topics.previewNote")}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Brain, Droplets } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
|
||||
import type { TopicCycleSummary } from "@/lib/topics/read";
|
||||
|
||||
import { INSTRUMENT_LABEL, TOPIC_EXTRA_HREF } from "./topic-meta";
|
||||
|
||||
const VERDICT_TONE = {
|
||||
improved: "success",
|
||||
worsened: "warning",
|
||||
within_threshold: "neutral",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The mind topic's screener block: each instrument's latest total and how it
|
||||
* moved since the first check-in, measured against the change studies treat
|
||||
* as meaningful (the check-in page's own trend).
|
||||
*/
|
||||
export function TopicScreenersCard({
|
||||
screeners,
|
||||
}: {
|
||||
screeners: AssessmentTrend[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
return (
|
||||
<Card data-slot="topic-screeners">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Brain}
|
||||
titleAs="h2"
|
||||
title={t("topics.screeners.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{screeners.length === 0 ? (
|
||||
<p className="text-sm" data-slot="topic-screeners-none">
|
||||
{t("topics.screeners.none")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{screeners.map((trend) => (
|
||||
<li
|
||||
key={trend.instrument}
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
data-slot="topic-screener"
|
||||
data-instrument={trend.instrument}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
{INSTRUMENT_LABEL[trend.instrument] ?? trend.instrument}{" "}
|
||||
<span className="tabular-nums">
|
||||
{fmt.number(trend.latest.score, 0)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("topics.screeners.checkIns", { count: trend.n })}
|
||||
</p>
|
||||
</div>
|
||||
{trend.sinceFirst ? (
|
||||
<ScorePill tone={VERDICT_TONE[trend.sinceFirst]}>
|
||||
{t(`topics.screeners.${trend.sinceFirst}`)}
|
||||
</ScorePill>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("topics.screeners.single")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={TOPIC_EXTRA_HREF.screeners} className="self-start">
|
||||
{t("topics.screeners.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cycle topic's block: today's cycle day and phase as the cycle page
|
||||
* decides them, and the lengths of the completed cycles on record.
|
||||
*/
|
||||
export function TopicCycleCard({ cycle }: { cycle: TopicCycleSummary }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
return (
|
||||
<Card data-slot="topic-cycle">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Droplets}
|
||||
titleAs="h2"
|
||||
title={t("topics.cycle.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{cycle.today ? (
|
||||
<p className="text-sm font-medium">
|
||||
{cycle.today.phase
|
||||
? t("topics.cycle.todayWithPhase", {
|
||||
day: cycle.today.dayOfCycle,
|
||||
phase: t(`cycle.phase.${cycle.today.phase}`),
|
||||
})
|
||||
: t("topics.cycle.today", { day: cycle.today.dayOfCycle })}
|
||||
</p>
|
||||
) : null}
|
||||
{cycle.averageLength !== null ? (
|
||||
<p className="text-sm">
|
||||
{t("topics.cycle.average", {
|
||||
days: fmt.number(cycle.averageLength, 1),
|
||||
count: cycle.lengths.length,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm" data-slot="topic-cycle-needs">
|
||||
{t("topics.cycle.needs", { count: cycle.periodsLogged })}
|
||||
</p>
|
||||
)}
|
||||
{cycle.lengths.length > 0 ? (
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("topics.cycle.lengths", {
|
||||
lengths: cycle.lengths.map((l) => fmt.integer(l)).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={TOPIC_EXTRA_HREF.cycle} className="self-start">
|
||||
{t("topics.cycle.open")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { TrainingLoadCard } from "@/components/workouts/training-load-card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
DEFAULT_TOPIC_RANGE,
|
||||
TOPIC_RANGES,
|
||||
type TopicKey,
|
||||
type TopicRange,
|
||||
} from "@/lib/topics/registry";
|
||||
|
||||
import { PreviewBadge } from "./preview-badge";
|
||||
import { PreviewNote } from "./preview-note";
|
||||
import { TopicCycleCard, TopicScreenersCard } from "./topic-blocks";
|
||||
import { TopicLabsCard } from "./topic-labs-card";
|
||||
import { TopicLinksCard, TopicMissingCard } from "./topic-links-card";
|
||||
import { topicDescriptionKey, topicTitleKey } from "./topic-meta";
|
||||
import { TopicRelationshipsCard } from "./topic-relationships-card";
|
||||
import { TopicSideBySideCard } from "./topic-side-by-side-card";
|
||||
import { TopicTodayCard } from "./topic-today-card";
|
||||
import { TopicTrendsCard } from "./topic-trends-card";
|
||||
import { useTopicDetail } from "./use-topics";
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" aria-hidden="true">
|
||||
<Skeleton className="h-40 rounded-xl" />
|
||||
<Skeleton className="h-80 rounded-xl" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
<Skeleton className="h-64 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/topics/<topic>`: the topic's signals today, side by side over a chosen
|
||||
* window, how they move together, their trends, the topic's blocks, the
|
||||
* single-metric pages behind it and what it holds nothing for yet.
|
||||
*/
|
||||
export function TopicDetailView({ topic }: { topic: TopicKey }) {
|
||||
const { t } = useTranslations();
|
||||
const [range, setRange] = useState<TopicRange>(DEFAULT_TOPIC_RANGE);
|
||||
const query = useTopicDetail(topic, range, true);
|
||||
const detail = query.data?.key === topic ? query.data : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="topic-detail" data-topic={topic}>
|
||||
<PageHeader
|
||||
title={t(topicTitleKey(topic))}
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t(topicDescriptionKey(topic))}
|
||||
backLink={{
|
||||
href: "/topics",
|
||||
label: t("topics.detail.back"),
|
||||
dataSlot: "topic-back",
|
||||
}}
|
||||
/>
|
||||
<Segmented
|
||||
options={TOPIC_RANGES.map((value) => ({
|
||||
value,
|
||||
label: t(`topics.range.${value}`),
|
||||
}))}
|
||||
value={range}
|
||||
onChange={setRange}
|
||||
label={t("topics.range.label")}
|
||||
slot="topic-range"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{query.isError ? (
|
||||
<QueryErrorCard
|
||||
error={query.error}
|
||||
title={t("topics.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : !detail ? (
|
||||
<DetailSkeleton />
|
||||
) : (
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-slot="topic-detail-body"
|
||||
data-status={detail.status}
|
||||
>
|
||||
<TopicTodayCard detail={detail} />
|
||||
<TopicSideBySideCard detail={detail} />
|
||||
{detail.relationships.length > 0 ||
|
||||
detail.trends.length > 0 ||
|
||||
detail.training?.status === "ok" ? (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
<TopicRelationshipsCard relationships={detail.relationships} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<TopicTrendsCard trends={detail.trends} />
|
||||
<TrainingLoadCard trainingLoad={detail.training ?? undefined} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.labs ? <TopicLabsCard labs={detail.labs} /> : null}
|
||||
{detail.screeners ? (
|
||||
<TopicScreenersCard screeners={detail.screeners} />
|
||||
) : null}
|
||||
{detail.cycle ? <TopicCycleCard cycle={detail.cycle} /> : null}
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
<TopicLinksCard detail={detail} />
|
||||
<TopicMissingCard detail={detail} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PreviewNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FlaskConical, TrendingUp } from "lucide-react";
|
||||
|
||||
import { labTrendLines } from "@/components/labs/lab-trend-lines";
|
||||
import { useLabNumber } from "@/components/labs/use-lab-format";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import type { TopicLabMarker, TopicLabPanel } from "@/lib/topics/labs";
|
||||
|
||||
function MarkerTile({ marker }: { marker: TopicLabMarker }) {
|
||||
const { t } = useTranslations();
|
||||
const labNumber = useLabNumber();
|
||||
const range = formatReferenceRange(marker.low, marker.high, (v) =>
|
||||
labNumber(v),
|
||||
);
|
||||
const outside = marker.status === "above" || marker.status === "below";
|
||||
return (
|
||||
<Link
|
||||
href={`/labs/${marker.biomarkerId}`}
|
||||
data-slot="topic-lab-marker"
|
||||
data-status={marker.status}
|
||||
className="border-border hover:bg-accent/40 focus-visible:ring-ring/50 flex min-w-0 flex-col gap-1 rounded-lg border p-3 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-foreground truncate text-xs font-medium">
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{labNumber(marker.latest)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
</span>
|
||||
{range ? (
|
||||
<span className="text-muted-foreground text-xs">{range}</span>
|
||||
) : null}
|
||||
{outside ? (
|
||||
<ScorePill tone="warning" className="self-start">
|
||||
{t(`topics.labs.${marker.status}`)}
|
||||
</ScorePill>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The metabolism topic's lab block: each marker's newest reading against the
|
||||
* window it is judged by, then what the markers' trend lines say. A trend
|
||||
* needs three readings spanning a year; markers short of that are named in
|
||||
* one sentence instead of being given a line.
|
||||
*/
|
||||
export function TopicLabsCard({ labs }: { labs: TopicLabPanel }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const trending = labs.markers.filter((m) => m.trend.status === "ok");
|
||||
const short = labs.markers.filter((m) => m.trend.status !== "ok");
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-labs">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={FlaskConical}
|
||||
titleAs="h2"
|
||||
title={t("topics.labs.title")}
|
||||
right={
|
||||
labs.latestOn ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateShortSmartCalendar(labs.latestOn)}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
{labs.markers.map((marker) => (
|
||||
<MarkerTile key={marker.biomarkerId} marker={marker} />
|
||||
))}
|
||||
</div>
|
||||
<section
|
||||
className="flex flex-col gap-2"
|
||||
aria-labelledby="topic-lab-trends-heading"
|
||||
>
|
||||
<h3
|
||||
id="topic-lab-trends-heading"
|
||||
className="text-foreground flex items-center gap-2 text-sm font-semibold"
|
||||
>
|
||||
<TrendingUp className="size-4" aria-hidden="true" />
|
||||
{t("topics.labs.trendsTitle")}
|
||||
</h3>
|
||||
{trending.map((marker) => {
|
||||
if (marker.trend.status !== "ok") return null;
|
||||
const lines = labTrendLines(marker.trend, marker.unit, t, fmt);
|
||||
return (
|
||||
<div
|
||||
key={marker.biomarkerId}
|
||||
data-slot="topic-lab-trend"
|
||||
data-projection={marker.trend.projection}
|
||||
>
|
||||
<p className="text-sm font-medium">{marker.name}</p>
|
||||
<p className="text-sm">{lines.slope}</p>
|
||||
{lines.projection ? (
|
||||
<p className="text-sm">{lines.projection}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{short.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topic-lab-trend-short"
|
||||
>
|
||||
{t("topics.labs.trendNeeds", {
|
||||
markers: short.map((m) => m.name).join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Info, Lightbulb } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail } from "@/lib/topics/build";
|
||||
|
||||
import { TOPIC_EXTRA_HREF, topicSourcesKey } from "./topic-meta";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
interface PageLink {
|
||||
href: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-metric pages behind the topic, unchanged and one tap away: a
|
||||
* topic is an overview, never a replacement for any of them.
|
||||
*/
|
||||
export function TopicLinksCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const links: PageLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (link: PageLink) => {
|
||||
if (seen.has(link.href)) return;
|
||||
seen.add(link.href);
|
||||
links.push(link);
|
||||
};
|
||||
for (const signal of detail.signals) {
|
||||
if (!signal.href || !signal.latest) continue;
|
||||
add({
|
||||
href: signal.href,
|
||||
label: format.label(signal.key),
|
||||
value: format.value(signal.key, signal.latest.value),
|
||||
});
|
||||
}
|
||||
if (detail.labs) {
|
||||
add({ href: TOPIC_EXTRA_HREF.labs, label: t("nav.labs"), value: null });
|
||||
}
|
||||
if (detail.training?.status === "ok") {
|
||||
add({
|
||||
href: TOPIC_EXTRA_HREF.training,
|
||||
label: t("topics.links.workouts"),
|
||||
value: null,
|
||||
});
|
||||
}
|
||||
if (links.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-links">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Lightbulb}
|
||||
titleAs="h2"
|
||||
title={t("topics.links.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-border flex flex-col divide-y">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
data-slot="topic-link"
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 items-center gap-3 rounded-md py-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
{link.label}
|
||||
</span>
|
||||
{link.value ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{link.value}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the topic could show but holds nothing for yet, and where such values
|
||||
* come from. Never a zero, never an empty chart.
|
||||
*/
|
||||
export function TopicMissingCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
if (detail.missing.length === 0) return null;
|
||||
return (
|
||||
<Card
|
||||
data-slot="topic-missing"
|
||||
className="border-dashed shadow-none"
|
||||
density="compact"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<p className="flex items-start gap-2 text-sm font-medium">
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
{t("topics.missing.title", {
|
||||
signals: detail.missing.map((k) => format.label(k)).join(", "),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(topicSourcesKey(detail.key))}
|
||||
</p>
|
||||
{inSharedRecord ? null : (
|
||||
<Button asChild variant="outline" size="sm" className="self-start">
|
||||
<Link href="/settings/integrations">
|
||||
{t("topics.missing.connect")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Client-side presentation of the topics: the glyph per topic, the message
|
||||
* keys of its name and description, and where each extra block's own page
|
||||
* lives. Kept apart from the server registry so the browser bundle carries
|
||||
* no read code; the keys themselves come from `@/lib/topics/registry`.
|
||||
*/
|
||||
import {
|
||||
Activity,
|
||||
Brain,
|
||||
Droplets,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Heart,
|
||||
Moon,
|
||||
Scale,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { TopicExtra, TopicKey } from "@/lib/topics/registry";
|
||||
|
||||
export const TOPIC_ICONS: Readonly<Record<TopicKey, LucideIcon>> = {
|
||||
heart: Heart,
|
||||
body: Scale,
|
||||
"blood-pressure": Gauge,
|
||||
sleep: Moon,
|
||||
activity: Activity,
|
||||
metabolism: FlaskConical,
|
||||
mind: Brain,
|
||||
cycle: Droplets,
|
||||
};
|
||||
|
||||
/** Message-key segment per topic (`topics.names.<id>.title`). */
|
||||
export const TOPIC_MESSAGE_ID: Readonly<Record<TopicKey, string>> = {
|
||||
heart: "heart",
|
||||
body: "body",
|
||||
"blood-pressure": "bloodPressure",
|
||||
sleep: "sleep",
|
||||
activity: "activity",
|
||||
metabolism: "metabolism",
|
||||
mind: "mind",
|
||||
cycle: "cycle",
|
||||
};
|
||||
|
||||
export function topicTitleKey(key: TopicKey): string {
|
||||
return `topics.names.${TOPIC_MESSAGE_ID[key]}.title`;
|
||||
}
|
||||
|
||||
export function topicDescriptionKey(key: TopicKey): string {
|
||||
return `topics.names.${TOPIC_MESSAGE_ID[key]}.description`;
|
||||
}
|
||||
|
||||
/** Where to record what an empty topic is missing (`topics.empty.<id>`). */
|
||||
export function topicSourcesKey(key: TopicKey): string {
|
||||
return `topics.sources.${TOPIC_MESSAGE_ID[key]}`;
|
||||
}
|
||||
|
||||
/** The page each extra block opens. */
|
||||
export const TOPIC_EXTRA_HREF: Readonly<Record<TopicExtra, string>> = {
|
||||
training: "/insights/workouts",
|
||||
labs: "/labs",
|
||||
screeners: "/mental-wellbeing",
|
||||
cycle: "/cycle",
|
||||
};
|
||||
|
||||
export function topicHref(key: TopicKey): string {
|
||||
return `/topics/${key}`;
|
||||
}
|
||||
|
||||
/** The screener names as the check-in page writes them. */
|
||||
export const INSTRUMENT_LABEL: Readonly<Record<string, string>> = {
|
||||
PHQ9: "PHQ-9",
|
||||
GAD7: "GAD-7",
|
||||
WHO5: "WHO-5",
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Info, Link2 } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicRelationship } from "@/lib/topics/stats";
|
||||
|
||||
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.ScatterCorrelationChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-44 w-full" /> },
|
||||
);
|
||||
|
||||
const STATUS_DOT = {
|
||||
linked: "bg-chart-1",
|
||||
no_clear_link: "bg-muted-foreground",
|
||||
insufficient: "bg-border",
|
||||
} as const;
|
||||
|
||||
/** The values' own span with a tenth of it on each side, so dots sit inside. */
|
||||
function paddedDomain(values: number[]): [number, number] {
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const pad = (max - min) * 0.1 || Math.abs(max) * 0.05 || 1;
|
||||
return [min - pad, max + pad];
|
||||
}
|
||||
|
||||
/** The one sentence a relationship reads as, from the server's figures. */
|
||||
function useRelationshipSentence() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
return (rel: TopicRelationship): { text: string; meta: string | null } => {
|
||||
const x = format.label(rel.x);
|
||||
const y = format.label(rel.y);
|
||||
if (rel.status === "insufficient") {
|
||||
return {
|
||||
text: t("topics.relationships.insufficient", {
|
||||
x,
|
||||
y,
|
||||
count: rel.pairedDays,
|
||||
required: rel.requiredDays,
|
||||
}),
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
if (rel.status === "no_clear_link") {
|
||||
return {
|
||||
text: t("topics.relationships.noClearLink", {
|
||||
x,
|
||||
y,
|
||||
count: rel.pairedDays,
|
||||
}),
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
if (rel.kind === "split" && rel.split) {
|
||||
const higher = rel.split.difference > 0;
|
||||
return {
|
||||
text: t(
|
||||
higher
|
||||
? "topics.relationships.splitHigher"
|
||||
: "topics.relationships.splitLower",
|
||||
{
|
||||
x,
|
||||
y,
|
||||
threshold:
|
||||
rel.x === "SLEEP_DURATION" && rel.split.threshold % 60 === 0
|
||||
? t("common.durationClockHours", {
|
||||
time: fmt.number(rel.split.threshold / 60, 0),
|
||||
})
|
||||
: format.value(rel.x, rel.split.threshold),
|
||||
amount: format.amount(rel.y, rel.split.difference),
|
||||
},
|
||||
),
|
||||
meta: t("topics.relationships.splitDays", {
|
||||
below: rel.split.belowDays,
|
||||
other: rel.split.otherDays,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const higher = (rel.r ?? 0) > 0;
|
||||
const key = rel.lagDays === 1 ? "NextDay" : "SameDay";
|
||||
return {
|
||||
text: t(
|
||||
`topics.relationships.correlation${key}${higher ? "Higher" : "Lower"}`,
|
||||
{ x, y, r: fmt.number(rel.r ?? 0, 2) },
|
||||
),
|
||||
meta: t("topics.relationships.pairedDays", { count: rel.pairedDays }),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How the topic's signals move together: one statement per pair, each with
|
||||
* the number of days it rests on, and a scatter for the first pair that holds
|
||||
* a pattern. Fourteen paired days before anything is said; never a cause.
|
||||
*/
|
||||
export function TopicRelationshipsCard({
|
||||
relationships,
|
||||
}: {
|
||||
relationships: TopicRelationship[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
const axis = useSignalAxis();
|
||||
const sentence = useRelationshipSentence();
|
||||
if (relationships.length === 0) return null;
|
||||
// A scatter only reads for a correlation: a split's x is two groups.
|
||||
const featured = relationships.find(
|
||||
(r) =>
|
||||
r.kind === "correlation" && r.status === "linked" && r.points.length > 0,
|
||||
);
|
||||
const scatter = featured
|
||||
? featured.points.map((p) => ({
|
||||
x: axis.toAxis(featured.x, p.x),
|
||||
y: axis.toAxis(featured.y, p.y),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-relationships">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Link2}
|
||||
titleAs="h2"
|
||||
title={t("topics.relationships.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{featured ? (
|
||||
<figure className="flex flex-col gap-1">
|
||||
<div aria-hidden="true">
|
||||
<ScatterCorrelationChart
|
||||
data={scatter}
|
||||
fill="var(--chart-1)"
|
||||
xAxis={{
|
||||
dataKey: "x",
|
||||
name: format.label(featured.x),
|
||||
domain: paddedDomain(scatter.map((p) => p.x)),
|
||||
tickFormatter: (v) => axis.tick(featured.x, v),
|
||||
}}
|
||||
yAxis={{
|
||||
dataKey: "y",
|
||||
name: format.label(featured.y),
|
||||
domain: paddedDomain(scatter.map((p) => p.y)),
|
||||
tickFormatter: (v) => axis.tick(featured.y, v),
|
||||
}}
|
||||
height={176}
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="text-muted-foreground text-xs">
|
||||
{t("topics.relationships.scatterCaption", {
|
||||
x: format.label(featured.x),
|
||||
y: format.label(featured.y),
|
||||
})}
|
||||
</figcaption>
|
||||
</figure>
|
||||
) : null}
|
||||
<ul className="flex flex-col gap-3">
|
||||
{relationships.map((rel) => {
|
||||
const { text, meta } = sentence(rel);
|
||||
return (
|
||||
<li
|
||||
key={rel.id}
|
||||
className="flex items-start gap-2"
|
||||
data-slot="topic-relationship"
|
||||
data-relationship={rel.id}
|
||||
data-status={rel.status}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`mt-1.5 size-2 shrink-0 rounded-full ${STATUS_DOT[rel.status]}`}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">{text}</p>
|
||||
{meta ? (
|
||||
<p className="text-muted-foreground text-xs">{meta}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground flex items-start gap-2 text-xs">
|
||||
<Info className="mt-px size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>{t("topics.relationships.footnote")}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail } from "@/lib/topics/build";
|
||||
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { useSignalAxis, useSignalFormat } from "./use-signal-format";
|
||||
|
||||
// Recharts loads through the one shared chart chunk, never in this route's own.
|
||||
const TopicSideBySideChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.TopicSideBySideChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <Skeleton className="h-72 w-full" /> },
|
||||
);
|
||||
|
||||
const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"];
|
||||
|
||||
/**
|
||||
* The topic's signals on one time axis for the chosen window, each against
|
||||
* its usual range. Sleep draws in hours on the axis and reads as hours and
|
||||
* minutes in the tooltip; every other type in the person's display unit.
|
||||
*/
|
||||
export function TopicSideBySideCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
const { toAxis, fromAxis, tick } = useSignalAxis();
|
||||
if (detail.series.length === 0) return null;
|
||||
|
||||
const days = daysBetweenDateKeys(detail.from, detail.to) + 1;
|
||||
|
||||
const series = detail.series.map((s, i) => ({
|
||||
key: s.key,
|
||||
label: format.label(s.key),
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length],
|
||||
points: s.points.map((p) => ({
|
||||
x: daysBetweenDateKeys(detail.from, p.day),
|
||||
y: toAxis(s.key, p.value),
|
||||
})),
|
||||
band: s.usual
|
||||
? { low: toAxis(s.key, s.usual.low), high: toAxis(s.key, s.usual.high) }
|
||||
: null,
|
||||
}));
|
||||
const names = series.map((s) => s.label).join(", ");
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-side-by-side">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Layers}
|
||||
titleAs="h2"
|
||||
title={t("topics.chart.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("topics.chart.legend")}
|
||||
</p>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t("topics.chart.label", { signals: names })}
|
||||
>
|
||||
<TopicSideBySideChart
|
||||
series={series}
|
||||
days={days}
|
||||
formatDay={(x) =>
|
||||
fmt.dateShortSmartCalendar(shiftDateKey(detail.from, x))
|
||||
}
|
||||
formatValue={(key, value) =>
|
||||
format.value(key, fromAxis(key, value))
|
||||
}
|
||||
formatTick={tick}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { RichChartTooltip } from "@/components/charts/chart-tooltip";
|
||||
|
||||
export interface SideBySideSeries {
|
||||
key: string;
|
||||
label: string;
|
||||
/** A `var(--chart-N)` expression. */
|
||||
color: string;
|
||||
/** Day index from the window's first day, and the value in display units. */
|
||||
points: Array<{ x: number; y: number }>;
|
||||
/** The usual range in display units, shaded behind the line. */
|
||||
band: { low: number; high: number } | null;
|
||||
}
|
||||
|
||||
const AXIS_TICK = { fontSize: 11, fill: "var(--muted-foreground)" };
|
||||
const ROW_HEIGHT = 88;
|
||||
const AXIS_HEIGHT = 24;
|
||||
|
||||
/**
|
||||
* Up to three signals stacked on one time axis, each with its usual range
|
||||
* shaded, so a reader sees two lines move together before reading that they
|
||||
* do. Each row keeps its own scale; the axis, the grid and the band follow the
|
||||
* metric charts. Values arrive in display units, already converted.
|
||||
*/
|
||||
export function TopicSideBySideChart({
|
||||
series,
|
||||
days,
|
||||
formatDay,
|
||||
formatValue,
|
||||
formatTick,
|
||||
}: {
|
||||
series: SideBySideSeries[];
|
||||
/** Days in the window; the axis runs from 0 to `days - 1`. */
|
||||
days: number;
|
||||
formatDay: (x: number) => string;
|
||||
/** A value with its unit, for the tooltip. */
|
||||
formatValue: (key: string, value: number) => string;
|
||||
/** A bare axis number. */
|
||||
formatTick: (key: string, value: number) => string;
|
||||
}) {
|
||||
const ticks = [0, Math.round((days - 1) / 2), days - 1];
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{series.map((s, index) => {
|
||||
const last = index === series.length - 1;
|
||||
return (
|
||||
<div key={s.key} data-slot="topic-chart-row" data-signal={s.key}>
|
||||
<p className="text-foreground flex items-center gap-2 text-xs font-medium">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.label}
|
||||
</p>
|
||||
<div style={{ height: ROW_HEIGHT + (last ? AXIS_HEIGHT : 0) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={s.points}
|
||||
margin={{ top: 6, right: 8, bottom: 0, left: 0 }}
|
||||
syncId="topic-side-by-side"
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
domain={[0, Math.max(1, days - 1)]}
|
||||
ticks={ticks}
|
||||
hide={!last}
|
||||
height={last ? AXIS_HEIGHT : 0}
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatDay(Number(value))}
|
||||
/>
|
||||
<YAxis
|
||||
domain={["auto", "auto"]}
|
||||
width={44}
|
||||
tick={AXIS_TICK}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickCount={3}
|
||||
tickFormatter={(value) =>
|
||||
typeof value === "number" ? formatTick(s.key, value) : ""
|
||||
}
|
||||
/>
|
||||
{s.band ? (
|
||||
<ReferenceArea
|
||||
y1={s.band.low}
|
||||
y2={s.band.high}
|
||||
fill="var(--success)"
|
||||
fillOpacity={0.12}
|
||||
strokeOpacity={0}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
cursor={{ stroke: "var(--border)" }}
|
||||
content={({ active, payload }) => {
|
||||
const point = payload?.[0]?.payload as
|
||||
{ x: number; y: number } | undefined;
|
||||
if (!active || !point) return null;
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDay(point.x)}
|
||||
rows={[
|
||||
{
|
||||
name: s.label,
|
||||
value: formatValue(s.key, point.y),
|
||||
color: s.color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="y"
|
||||
name={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={s.points.length <= 31 ? { r: 2 } : false}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicHeadline, TopicSummary } from "@/lib/topics/build";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { INSTRUMENT_LABEL } from "./topic-meta";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
/**
|
||||
* The topic's one sentence, worded from the server's code. The rule that
|
||||
* picked the code lives in `src/lib/topics/build.ts`; this only says it.
|
||||
*/
|
||||
export function useSummarySentence(summary: TopicSummary): string {
|
||||
const { t } = useTranslations();
|
||||
const signals = useSignalFormat();
|
||||
switch (summary.code) {
|
||||
case "outside":
|
||||
return t(
|
||||
summary.direction === "below"
|
||||
? "topics.summary.outsideBelow"
|
||||
: "topics.summary.outsideAbove",
|
||||
{ signal: summary.signal ? signals.label(summary.signal) : "" },
|
||||
);
|
||||
case "labs":
|
||||
return t("topics.summary.labs", {
|
||||
count: summary.count ?? 0,
|
||||
total: summary.total ?? 0,
|
||||
});
|
||||
case "cycleDay":
|
||||
return t("topics.summary.cycleDay", { day: summary.count ?? 0 });
|
||||
case "steady":
|
||||
return summary.count === summary.total
|
||||
? t("topics.summary.steadyAll")
|
||||
: t("topics.summary.steadySome", {
|
||||
count: summary.count ?? 0,
|
||||
total: summary.total ?? 0,
|
||||
});
|
||||
case "learning":
|
||||
return t("topics.summary.learning");
|
||||
default:
|
||||
return t("topics.summary.empty");
|
||||
}
|
||||
}
|
||||
|
||||
export function TopicSummaryText({
|
||||
summary,
|
||||
className,
|
||||
}: {
|
||||
summary: TopicSummary;
|
||||
className?: string;
|
||||
}) {
|
||||
const sentence = useSummarySentence(summary);
|
||||
return (
|
||||
<p
|
||||
data-slot="topic-summary"
|
||||
data-code={summary.code}
|
||||
className={cn("text-foreground", className)}
|
||||
>
|
||||
{sentence}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The headline as a value and a unit, for whichever kind the server sent. */
|
||||
export function useHeadline(
|
||||
headline: TopicHeadline | null,
|
||||
): { value: string; unit: string } | null {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const signals = useSignalFormat();
|
||||
if (!headline) return null;
|
||||
switch (headline.kind) {
|
||||
case "signal":
|
||||
return {
|
||||
value: signals.number(headline.key, headline.value),
|
||||
unit: signals.unit(headline.key),
|
||||
};
|
||||
case "labs":
|
||||
return {
|
||||
value: t("topics.headline.labsValue", {
|
||||
count: headline.inRange,
|
||||
total: headline.total,
|
||||
}),
|
||||
unit: t("topics.headline.labsUnit"),
|
||||
};
|
||||
case "cycleDay":
|
||||
return {
|
||||
value: t("topics.headline.cycleDay", { day: headline.dayOfCycle }),
|
||||
unit: "",
|
||||
};
|
||||
case "screener":
|
||||
return {
|
||||
value: `${INSTRUMENT_LABEL[headline.instrument] ?? headline.instrument} ${fmt.number(headline.score, 0)}`,
|
||||
unit: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicOverviewEntry } from "@/lib/topics/build";
|
||||
|
||||
import { TOPIC_ICONS, topicHref, topicTitleKey } from "./topic-meta";
|
||||
import { TopicSummaryText, useHeadline } from "./topic-summary";
|
||||
|
||||
// The sparkline loads through the shared chart runtime, so the overview's own
|
||||
// chunk carries no recharts; the tile owns the fixed band it draws in.
|
||||
const DeltaSparkline = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
const TONE_PILL = {
|
||||
steady: "success",
|
||||
watch: "warning",
|
||||
learning: "neutral",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One topic on the overview: its name (the link), the headline value, the
|
||||
* rule-based sentence, the last 30 days as a sparkline, and how many of the
|
||||
* topic's signals hold a value. The whole card is the target, through the
|
||||
* title link stretched over it, so the link's name is the visible title.
|
||||
*/
|
||||
export function TopicTile({ entry }: { entry: TopicOverviewEntry }) {
|
||||
const { t } = useTranslations();
|
||||
const headline = useHeadline(entry.headline);
|
||||
const title = t(topicTitleKey(entry.key));
|
||||
const points = entry.sparkline.map((v, i) => ({ i, v }));
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="topic-tile"
|
||||
data-topic={entry.key}
|
||||
data-tone={entry.tone}
|
||||
className="hover:bg-accent/40 relative gap-2 transition-colors md:gap-3"
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={TOPIC_ICONS[entry.key]}
|
||||
titleAs="h2"
|
||||
title={
|
||||
<Link
|
||||
href={topicHref(entry.key)}
|
||||
data-slot="topic-tile-link"
|
||||
className="focus-visible:ring-ring/50 rounded-sm after:absolute after:inset-0 after:rounded-xl focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{headline ? (
|
||||
<p className="text-foreground flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{headline.value}
|
||||
</span>
|
||||
{headline.unit ? (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{headline.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
<TopicSummaryText summary={entry.summary} className="text-sm" />
|
||||
{points.length >= 2 ? (
|
||||
<div className="h-10" aria-hidden="true">
|
||||
<DeltaSparkline data={points} strokeVar="var(--chart-1)" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-2">
|
||||
<ScorePill tone={TONE_PILL[entry.tone]}>
|
||||
{t(`topics.tone.${entry.tone}`)}
|
||||
</ScorePill>
|
||||
{entry.recorded > 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("topics.recorded", {
|
||||
count: entry.recorded,
|
||||
total: entry.total,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Sun } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicDetail, TopicSignal } from "@/lib/topics/build";
|
||||
|
||||
import { TopicSummaryText } from "./topic-summary";
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const STATE_TONE = {
|
||||
within: "success",
|
||||
above: "warning",
|
||||
below: "warning",
|
||||
learning: "neutral",
|
||||
} as const;
|
||||
|
||||
function SignalReadout({
|
||||
signal,
|
||||
todayKey,
|
||||
}: {
|
||||
signal: TopicSignal;
|
||||
todayKey: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const format = useSignalFormat();
|
||||
if (!signal.latest) return null;
|
||||
const label = format.label(signal.key);
|
||||
const unit = format.unit(signal.key);
|
||||
const usual = signal.usual;
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-1"
|
||||
data-slot="topic-signal"
|
||||
data-signal={signal.key}
|
||||
data-state={signal.state}
|
||||
>
|
||||
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||
<dd className="text-foreground flex items-baseline gap-1">
|
||||
<span className="text-xl font-semibold tabular-nums">
|
||||
{format.number(signal.key, signal.latest.value)}
|
||||
</span>
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground text-xs">{unit}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{usual
|
||||
? t("topics.today.usual", {
|
||||
low: format.number(signal.key, usual.low),
|
||||
high: format.number(signal.key, usual.high),
|
||||
})
|
||||
: t("topics.today.learning")}
|
||||
</dd>
|
||||
{signal.latest.day !== todayKey ? (
|
||||
<dd className="text-muted-foreground text-xs">
|
||||
{t("topics.today.asOf", {
|
||||
date: fmt.dateShortSmartCalendar(signal.latest.day),
|
||||
})}
|
||||
</dd>
|
||||
) : null}
|
||||
<dd className="flex flex-wrap items-center gap-2">
|
||||
{signal.state !== "none" ? (
|
||||
<ScorePill tone={STATE_TONE[signal.state]}>
|
||||
{t(`topics.state.${signal.state}`)}
|
||||
</ScorePill>
|
||||
) : null}
|
||||
{signal.href ? (
|
||||
<InlineAction asChild tone="primary" size="xs">
|
||||
<Link href={signal.href}>
|
||||
{t("topics.today.details")}
|
||||
<span className="sr-only">: {label}</span>
|
||||
<ChevronRight className="size-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The topic today: its one sentence, then each recorded signal with its
|
||||
* latest value, its usual range and a way into its own page.
|
||||
*/
|
||||
export function TopicTodayCard({ detail }: { detail: TopicDetail }) {
|
||||
const { t } = useTranslations();
|
||||
const recorded = detail.signals.filter((s) => s.latest !== null);
|
||||
return (
|
||||
<Card data-slot="topic-today">
|
||||
<CardHeader>
|
||||
<TileHeader icon={Sun} titleAs="h2" title={t("topics.today.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<TopicSummaryText
|
||||
summary={detail.summary}
|
||||
className="text-base font-medium"
|
||||
/>
|
||||
{recorded.length > 0 ? (
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-4 md:grid-cols-4">
|
||||
{recorded.map((signal) => (
|
||||
<SignalReadout
|
||||
key={signal.key}
|
||||
signal={signal}
|
||||
todayKey={detail.to}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicTrend } from "@/lib/topics/stats";
|
||||
|
||||
import { useSignalFormat } from "./use-signal-format";
|
||||
|
||||
const DeltaSparkline = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.DeltaSparkline,
|
||||
})),
|
||||
{ ssr: false, loading: () => null },
|
||||
);
|
||||
|
||||
/**
|
||||
* Each recorded signal over the trailing 90 days: the change the fitted line
|
||||
* amounts to, beside its weekly means. Below two weeks of days a row says how
|
||||
* many it has rather than drawing a slope through a handful of points.
|
||||
*/
|
||||
export function TopicTrendsCard({ trends }: { trends: TopicTrend[] }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useSignalFormat();
|
||||
if (trends.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="topic-trends">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={TrendingUp}
|
||||
titleAs="h2"
|
||||
title={t("topics.trends.title")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-border flex flex-col divide-y">
|
||||
{trends.map((trend) => {
|
||||
const points = trend.weekly.map((p, i) => ({ i, v: p.value }));
|
||||
return (
|
||||
<li
|
||||
key={trend.key}
|
||||
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
data-slot="topic-trend"
|
||||
data-signal={trend.key}
|
||||
data-status={trend.status}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{format.label(trend.key)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{trend.status === "ok"
|
||||
? t("topics.trends.window")
|
||||
: t("topics.trends.insufficient", { count: trend.days })}
|
||||
</p>
|
||||
</div>
|
||||
{trend.status === "ok" && points.length >= 2 ? (
|
||||
<div className="h-8 w-20 shrink-0" aria-hidden="true">
|
||||
<DeltaSparkline data={points} strokeVar="var(--chart-2)" />
|
||||
</div>
|
||||
) : null}
|
||||
{trend.status === "ok" && trend.change !== null ? (
|
||||
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{format.delta(trend.key, trend.change)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TopicKey } from "@/lib/topics/registry";
|
||||
|
||||
import { PreviewBadge } from "./preview-badge";
|
||||
import { PreviewNote } from "./preview-note";
|
||||
import { topicTitleKey } from "./topic-meta";
|
||||
import { TopicTile } from "./topic-tile";
|
||||
import { useTopicOverview } from "./use-topics";
|
||||
|
||||
function useTopicList() {
|
||||
const { t } = useTranslations();
|
||||
return (keys: readonly TopicKey[]) =>
|
||||
keys.map((key) => t(topicTitleKey(key))).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* `/topics`: one tile per topic the record holds data for. Topics with
|
||||
* nothing recorded yet and topics whose module is off are named once each,
|
||||
* at the end, with where to change that; neither gets an empty tile.
|
||||
*/
|
||||
export function TopicsOverview() {
|
||||
const { t } = useTranslations();
|
||||
const list = useTopicList();
|
||||
const { inSharedRecord } = useRecordCapabilities();
|
||||
const query = useTopicOverview(true);
|
||||
const data = query.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="topics-overview">
|
||||
<PageHeader
|
||||
title={t("topics.overview.title")}
|
||||
titleAccessory={<PreviewBadge label={t("nav.previewBadge")} />}
|
||||
description={t("topics.overview.description")}
|
||||
/>
|
||||
|
||||
{query.isError ? (
|
||||
<QueryErrorCard
|
||||
error={query.error}
|
||||
title={t("topics.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
) : !data ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-56 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : data.topics.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Layers className="size-6" aria-hidden="true" />}
|
||||
title={t("topics.overview.emptyTitle")}
|
||||
description={t("topics.overview.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"
|
||||
data-slot="topics-grid"
|
||||
>
|
||||
{data.topics.map((entry) => (
|
||||
<TopicTile key={entry.key} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.empty.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topics-empty-list"
|
||||
>
|
||||
{t("topics.overview.notRecorded", { topics: list(data.empty) })}
|
||||
</p>
|
||||
) : null}
|
||||
{data && data.hiddenByModule.length > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="topics-hidden-list"
|
||||
>
|
||||
{t("topics.overview.hiddenByModule", {
|
||||
topics: list(data.hiddenByModule),
|
||||
})}{" "}
|
||||
{inSharedRecord ? null : (
|
||||
<InlineAction asChild tone="muted" size="xs">
|
||||
<Link href="/settings/modules">
|
||||
{t("topics.overview.modulesLink")}
|
||||
</Link>
|
||||
</InlineAction>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<PreviewNote />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* The Topics area exists only for a person who switched the preview on.
|
||||
*
|
||||
* Until the account payload answers, the page shows the shell's loading
|
||||
* state; signed out, it goes to sign-in like every page; with the preview off
|
||||
* (an old bookmark, a link from someone else) it leaves for Insights, where
|
||||
* the person's pages are. The routes refuse on their own (404
|
||||
* `topics.preview_off`), so this is the friendly end, not the guard.
|
||||
*/
|
||||
export function TopicsPreviewGate({ children }: { children: ReactNode }) {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const enabled = user?.previewFeatures?.topics === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) router.replace("/auth/login");
|
||||
else if (!enabled) router.replace("/insights");
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) return <PageAuthGate />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* How a topic signal reads on screen: its name, a value in the person's own
|
||||
* units, and a signed change. Measurement types go through the same unit
|
||||
* display every metric page uses (`useUnitDisplay`), so a topic never shows a
|
||||
* number its metric page would write differently; sleep reads as hours and
|
||||
* minutes, the mood score as its 1–5 value.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys";
|
||||
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
|
||||
import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision";
|
||||
import { MOOD_SIGNAL } from "@/lib/topics/registry";
|
||||
|
||||
/**
|
||||
* Whole numbers for counts (steps, minutes) and for the vitals every device
|
||||
* reads whole (pulse, blood pressure, HRV, SpO2); one decimal for weight and
|
||||
* temperature; a converted unit at its transform's precision. The same
|
||||
* answer the Today overview and the server-side texts give.
|
||||
*/
|
||||
const WHOLE_VITALS = new Set([
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"HRV_RMSSD",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"OXYGEN_SATURATION",
|
||||
"RESPIRATORY_RATE",
|
||||
"WALKING_HEART_RATE_AVERAGE",
|
||||
]);
|
||||
|
||||
function signalDigits(key: string, transformDecimals: number): number {
|
||||
if (rawDisplayFractionDigits(key) === 0) return 0;
|
||||
if (WHOLE_VITALS.has(key))
|
||||
return vitalDisplayDecimals(key, transformDecimals);
|
||||
return transformDecimals;
|
||||
}
|
||||
|
||||
export interface SignalFormat {
|
||||
label: (key: string) => string;
|
||||
/** The value with its unit, as one string. */
|
||||
value: (key: string, raw: number) => string;
|
||||
/** The number alone, in display units. */
|
||||
number: (key: string, raw: number) => string;
|
||||
unit: (key: string) => string;
|
||||
/** A signed change with its unit ("+1.2 kg", "−4 ms"). */
|
||||
delta: (key: string, raw: number) => string;
|
||||
/** The size of a change with its unit, unsigned ("1.2 kg"). */
|
||||
amount: (key: string, raw: number) => string;
|
||||
}
|
||||
|
||||
export function useSignalFormat(): SignalFormat {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const units = useUnitDisplay();
|
||||
|
||||
return useMemo(() => {
|
||||
const label = (key: string) => {
|
||||
if (key === MOOD_SIGNAL) return t("topics.signals.mood");
|
||||
const labelKey =
|
||||
MEASUREMENT_TYPE_LABEL_KEYS[
|
||||
key as keyof typeof MEASUREMENT_TYPE_LABEL_KEYS
|
||||
];
|
||||
return labelKey ? t(labelKey) : key;
|
||||
};
|
||||
const number = (key: string, raw: number) => {
|
||||
if (key === MOOD_SIGNAL) return fmt.number(raw, 1);
|
||||
if (key === "SLEEP_DURATION") return formatDurationMinutes(raw, t);
|
||||
return fmt.number(
|
||||
units.toDisplay(key, raw),
|
||||
signalDigits(key, units.decimalsFor(key)),
|
||||
);
|
||||
};
|
||||
const unit = (key: string) => {
|
||||
if (key === MOOD_SIGNAL) return t("topics.units.moodScale");
|
||||
if (key === "SLEEP_DURATION") return "";
|
||||
return units.unitFor(key);
|
||||
};
|
||||
const value = (key: string, raw: number) => {
|
||||
const u = unit(key);
|
||||
return u ? `${number(key, raw)} ${u}` : number(key, raw);
|
||||
};
|
||||
const amount = (key: string, raw: number) => {
|
||||
const magnitude = Math.abs(raw);
|
||||
if (key === "SLEEP_DURATION") return formatDurationMinutes(magnitude, t);
|
||||
const shown =
|
||||
key === MOOD_SIGNAL
|
||||
? fmt.number(magnitude, 1)
|
||||
: fmt.number(
|
||||
Math.abs(units.toDisplayDelta(key, magnitude)),
|
||||
signalDigits(key, units.decimalsFor(key)),
|
||||
);
|
||||
const u = unit(key);
|
||||
return u ? `${shown} ${u}` : shown;
|
||||
};
|
||||
const delta = (key: string, raw: number) => {
|
||||
const sign = raw > 0 ? "+" : raw < 0 ? "−" : "±";
|
||||
return `${sign}${amount(key, raw)}`;
|
||||
};
|
||||
return { label, value, number, unit, delta, amount };
|
||||
}, [t, fmt, units]);
|
||||
}
|
||||
|
||||
export interface SignalAxis {
|
||||
/** A stored value in the unit a chart axis draws (sleep in hours). */
|
||||
toAxis: (key: string, raw: number) => number;
|
||||
/** Back from the axis unit to the stored value. */
|
||||
fromAxis: (key: string, value: number) => number;
|
||||
/** A bare axis tick. */
|
||||
tick: (key: string, value: number) => string;
|
||||
}
|
||||
|
||||
/** How a signal draws on a chart axis, in the person's own units. */
|
||||
export function useSignalAxis(): SignalAxis {
|
||||
const fmt = useFormatters();
|
||||
const units = useUnitDisplay();
|
||||
return useMemo(() => {
|
||||
const plain = (key: string) =>
|
||||
key === MOOD_SIGNAL || key === "SLEEP_DURATION";
|
||||
return {
|
||||
toAxis: (key, raw) =>
|
||||
key === "SLEEP_DURATION"
|
||||
? raw / 60
|
||||
: key === MOOD_SIGNAL
|
||||
? raw
|
||||
: units.toDisplay(key, raw),
|
||||
fromAxis: (key, value) =>
|
||||
key === "SLEEP_DURATION"
|
||||
? value * 60
|
||||
: key === MOOD_SIGNAL
|
||||
? value
|
||||
: units.fromDisplay(key, value),
|
||||
tick: (key, value) =>
|
||||
fmt.number(
|
||||
value,
|
||||
plain(key) ? 1 : signalDigits(key, units.decimalsFor(key)),
|
||||
),
|
||||
};
|
||||
}, [fmt, units]);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The two topic reads. Each answer is the server's, rendered as given; the
|
||||
* failed read hands its `ApiError` through untouched so the surface's
|
||||
* read-failure primitive decides what to offer.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { TopicDetail, TopicOverview } from "@/lib/topics/build";
|
||||
import type { TopicKey, TopicRange } from "@/lib/topics/registry";
|
||||
|
||||
export function useTopicOverview(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.topicsOverview(),
|
||||
queryFn: () => apiGet<TopicOverview>("/api/topics"),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopicDetail(
|
||||
topic: TopicKey,
|
||||
range: TopicRange,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.topicDetail(topic, range),
|
||||
queryFn: () =>
|
||||
apiGet<TopicDetail>(
|
||||
`/api/topics/${encodeURIComponent(topic)}?range=${encodeURIComponent(range)}`,
|
||||
),
|
||||
enabled,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user