Merge branch 'main' into feat/encounter-phase-2

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:11:50 +02:00
commit f28c6df861
43 files changed
+5333 -7

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
/**
* `GET /api/mood/prognosis` — the day's two readings, side by side.
*
* The person's own rating leads and is returned exactly as it was stored. The
* forecast rides beside it with its band, the number of days it was built
* from, and how old it is. The two are never merged: the distance between them
* is the whole finding, and a single blended number would destroy it.
*
* **This route declares `record`, not `mind`, and that is the security
* decision on it.** The payload is not only mood: the fit reads the day's
* level-B context AND the figures the sleep, activity and recovery modules
* hold, and the contributions it publishes name those features one by one — a
* reader can see that sleep or steps or resting heart rate carried weight on a
* given day, and roughly how much. Declared `mind` it would be a hole with a
* mood-shaped label on it: a delegate holding only the mind section would
* receive a derived view of two sections the owner had declined to share. The
* convention for a read that crosses sections is `record`
* (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest`
* are the precedents) and a scoped grant never reaches one, so a scoped
* delegate loses the forecast entirely rather than receiving a filtered
* version of it. A filtered version would be worse: a contribution list with
* the linked features quietly removed would tell the reader that the model
* weighted only the mood fields, which is false.
*
* Nothing here writes. The rows come from the nightly job and the user cannot
* overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts`
* holds that, and this file is one of the ones it scans.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
// The parameter is declared although the handler body reads nothing off it,
// and the reason is the exported type rather than the runtime. `apiHandler`
// infers the type of `GET` from this callback, so a zero-argument handler
// would type `GET` as taking no arguments — and Next.js, and the integration
// tests, call it WITH the request. The wide event's method and path, and the
// read-vs-write check `requireRecordAuth` runs, both come from the request the
// wrapper receives from Next.js (not from this parameter), and both fall back
// to `GET` when no request is present — so an absent request keeps a read a
// read rather than escalating it.
export const GET = apiHandler(async (_request: NextRequest) => {
// `record`: see the file header. A scoped grant is refused here by
// construction rather than served a narrowed payload.
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
const reading = await readMoodPrognosis(user.id, new Date(), user.timezone);
annotate({
action: { name: "mood.prognosis.read" },
meta: reading.present
? {
present: true,
day: reading.date,
fit_rows: reading.n,
age_days: reading.ageDays,
current: reading.current,
provisional: reading.provisional,
deviation: reading.deviation,
}
: { present: false, reason: reading.reason, entries: reading.entries },
});
return apiSuccess(reading);
});
+21
View File
@@ -36,6 +36,21 @@ const MoodChart = dynamic(
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* The day's two readings. Deferred like every other block on this route that
* is not the first paint: it reads its own endpoint and renders below the
* calendar, so nothing about it belongs in the eager graph of a page whose
* first paint is a heatmap. This route sits close to its bundle ceiling and
* every addition to it goes through the same treatment.
*/
const MoodPrognosisCard = dynamic(
() =>
import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({
default: mod.MoodPrognosisCard,
})),
{ ssr: false, loading: () => null },
);
interface ComprehensiveMoodData {
moodSummary: { count: number } | null;
}
@@ -137,6 +152,12 @@ export default function InsightsStimmungPage() {
<MetricTargetSummary slug="mood" />
{/* The day's own rating beside what the account's past days imply. It
sits high because the self-assessment is the leading value on this
page, and directly under the line chart because that is where a
reader has just seen the rating it leads with. */}
<MoodPrognosisCard />
{/* The better-days Einschätzung sits directly under the Ziel card,
ahead of the classification tiles and breakdowns. */}
<MoodInsightsSections region="assessment" />