feat(topics): compute topic overviews and topic pages on the server

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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 08:18:43 +02:00
1 parent 14d0184b63
commit cc04e0944d
30 files changed
+3937 -35

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
/**
* 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>;