mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).
Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.
The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
/**
|
|
* 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)));
|
|
}
|