mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
/**
|
|
* Rest Mode — the server-authoritative "an episode is active" context (v1.18.1, P4).
|
|
*
|
|
* CORE PRINCIPLE: an active illness/condition episode ANNOTATES, it never
|
|
* PENALISES. Rest Mode never changes a measured number — it only frames the
|
|
* narrative around it: the health score keeps its value but carries a "you
|
|
* were unwell" note, recovery keeps its number, achievement streaks freeze
|
|
* (rather than break) across the days you were ill, cadence-nudges pause, and
|
|
* a coincident vital deviation reads as illness-explained instead of an
|
|
* unexplained anomaly.
|
|
*
|
|
* This module is the ONE place that resolves "is the account in Rest Mode
|
|
* right now, and which episodes back it" — so every surface annotates from
|
|
* the same fact. It is server-authoritative: the resolved `RestModeContext`
|
|
* is the DTO the iOS client mirrors (it renders Rest Mode, never recomputes
|
|
* the suppression). The illness module gate is honoured: a disabled / opted-
|
|
* out account is never in Rest Mode (the gate short-circuits before any read).
|
|
*
|
|
* "Active" = an `IllnessEpisode` with `resolvedAt = null` (still ongoing),
|
|
* not soft-deleted, whose `onsetAt` is on or before the reference instant.
|
|
* CHRONIC_ONGOING episodes are intentionally INCLUDED here — an ongoing
|
|
* chronic condition is precisely the case where a measured dip should be
|
|
* framed, not penalised. (They are excluded only from the recovery-GAP math,
|
|
* which is P3 and lives elsewhere.)
|
|
*/
|
|
import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client";
|
|
import { prisma as defaultPrisma } from "@/lib/db";
|
|
import { isIllnessEnabled } from "@/lib/illness/gate";
|
|
import { memoizePerRequest } from "@/lib/request-cache";
|
|
|
|
/** Minimal shape the Rest Mode resolver needs from an episode row. */
|
|
export type ActiveIllnessEpisode = Pick<
|
|
IllnessEpisode,
|
|
"id" | "label" | "type" | "lifecycle" | "onsetAt"
|
|
>;
|
|
|
|
/**
|
|
* The resolved Rest Mode annotation a surface attaches to its result. Carries
|
|
* NO measured value — it is pure narrative context. `active` is the single
|
|
* flag a renderer branches on; `since` is the earliest active onset (the
|
|
* "unwell since" anchor); `episodes` is the small set backing it (label +
|
|
* type only — no decrypted note, no free-text leaks into a score payload).
|
|
*
|
|
* This is the server-authoritative shape iOS mirrors verbatim.
|
|
*/
|
|
export interface RestModeContext {
|
|
/** True when ≥ 1 illness episode is active as of the reference instant. */
|
|
active: boolean;
|
|
/** ISO onset of the earliest active episode, or null when none. */
|
|
since: string | null;
|
|
/** Count of active episodes (usually 1; ≥ 2 when conditions overlap). */
|
|
episodeCount: number;
|
|
/** The active episodes, label + type + onset only — never a decrypted note. */
|
|
episodes: Array<{
|
|
id: string;
|
|
label: string;
|
|
type: string;
|
|
lifecycle: string;
|
|
onsetAt: string;
|
|
}>;
|
|
}
|
|
|
|
/** The neutral, honest empty state — not in Rest Mode. */
|
|
export const REST_MODE_INACTIVE: RestModeContext = {
|
|
active: false,
|
|
since: null,
|
|
episodeCount: 0,
|
|
episodes: [],
|
|
};
|
|
|
|
/**
|
|
* Read the active illness episodes for an account as of `asOf`. Gated through
|
|
* the illness module: a disabled / not-opted-in account returns `[]` without
|
|
* touching the episode table. The reference instant is injectable so the
|
|
* resolver is deterministic for tests and so a nightly job can resolve "as of
|
|
* the scored day".
|
|
*/
|
|
export async function getActiveIllnessEpisodes(
|
|
userId: string,
|
|
asOf: Date = new Date(),
|
|
client: PrismaClient = defaultPrisma,
|
|
): Promise<ActiveIllnessEpisode[]> {
|
|
// Several surfaces resolve Rest Mode for the same `(userId, asOf)` inside
|
|
// one request — the snapshot builder, the recovery score it builds, the
|
|
// analytics route, and coincident-deviation — each firing an identical
|
|
// `findMany`. Dedupe them to a single read per request. An explicit client
|
|
// (a test stub or a transaction) bypasses the memo so its query is never
|
|
// served a default-client cached result.
|
|
if (client !== defaultPrisma)
|
|
return readActiveIllnessEpisodes(userId, asOf, client);
|
|
return memoizePerRequest(
|
|
`illness-active-episodes:${userId}:${asOf.getTime()}`,
|
|
() => readActiveIllnessEpisodes(userId, asOf, client),
|
|
);
|
|
}
|
|
|
|
async function readActiveIllnessEpisodes(
|
|
userId: string,
|
|
asOf: Date,
|
|
client: PrismaClient,
|
|
): Promise<ActiveIllnessEpisode[]> {
|
|
if (!(await isIllnessEnabled(userId))) return [];
|
|
return client.illnessEpisode.findMany({
|
|
where: {
|
|
userId,
|
|
deletedAt: null,
|
|
resolvedAt: null,
|
|
onsetAt: { lte: asOf },
|
|
},
|
|
select: {
|
|
id: true,
|
|
label: true,
|
|
type: true,
|
|
lifecycle: true,
|
|
onsetAt: true,
|
|
},
|
|
orderBy: { onsetAt: "asc" },
|
|
});
|
|
}
|
|
|
|
/** Fold a set of active episodes into the renderable Rest Mode context. Pure. */
|
|
export function toRestModeContext(
|
|
episodes: ActiveIllnessEpisode[],
|
|
): RestModeContext {
|
|
if (episodes.length === 0) return REST_MODE_INACTIVE;
|
|
return {
|
|
active: true,
|
|
since: episodes[0].onsetAt.toISOString(),
|
|
episodeCount: episodes.length,
|
|
episodes: episodes.map((e) => ({
|
|
id: e.id,
|
|
label: e.label,
|
|
type: e.type,
|
|
lifecycle: e.lifecycle,
|
|
onsetAt: e.onsetAt.toISOString(),
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the Rest Mode context for an account as of `asOf` — the one call a
|
|
* surface makes to learn whether to annotate. Module-gated and fail-soft: any
|
|
* resolution error reads as "not in Rest Mode" so a Rest Mode read can never
|
|
* break the score / recovery / achievements payload it annotates.
|
|
*/
|
|
export async function resolveRestMode(
|
|
userId: string,
|
|
asOf: Date = new Date(),
|
|
client: PrismaClient = defaultPrisma,
|
|
): Promise<RestModeContext> {
|
|
try {
|
|
const episodes = await getActiveIllnessEpisodes(userId, asOf, client);
|
|
return toRestModeContext(episodes);
|
|
} catch {
|
|
return REST_MODE_INACTIVE;
|
|
}
|
|
}
|