mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
perf(illness): compute the recovery gap lazily and gate it on signal density
The illness list load triggered the cross-episode correlation fan-out on every mount, so the page waited on up to 24 per-episode reads before it painted. Move that work behind an explicit opt-in: the insights endpoint takes an includeRecoveryGap query param (default off), the list reads the count breakdown on a single fast query, and the recovery gap is computed only when the viewer opens the Analyse expansion. Tighten the typical-gap gate against coincidental data. A gap now counts only from an episode that cleared a per-episode measurement floor (MIN_GAP_MEASUREMENT_DAYS), at least MIN_EPISODES_FOR_TYPICAL_GAP such episodes must qualify, and the median magnitude must reach MIN_TYPICAL_GAP_MAGNITUDE_DAYS — a 0/+-1-day gap is the coincidental baseline and is withheld as noise.
This commit is contained in:
1 parent
a5b3715a46
commit
1de63f2b5c
15 files changed
+313
-84
No files matched your search
@@ -5,10 +5,18 @@
|
||||
*
|
||||
* RETROSPECTIVE ONLY — it summarises past episodes. It is NEVER a predictor or
|
||||
* diagnoser; the recurrence figure is a count, not a forecast. Server-
|
||||
* authoritative + gated: the "typical gap" is withheld below the min-sample
|
||||
* floor (asserts nothing thin). Per-episode gaps come from the same coverage-
|
||||
* gated correlation engine the per-episode route uses, so a thin episode
|
||||
* contributes no gap rather than a wrong one. Born-gated + owner-scoped.
|
||||
* authoritative + gated: the "typical gap" is withheld below the signal-
|
||||
* density floor (asserts nothing thin). Per-episode gaps come from the same
|
||||
* coverage-gated correlation engine the per-episode route uses, so a thin
|
||||
* episode contributes no gap rather than a wrong one. Born-gated + owner-
|
||||
* scoped.
|
||||
*
|
||||
* LAZY BY DEFAULT — the recovery-gap is the only expensive part (a bounded
|
||||
* per-episode correlation fan-out). The illness LIST loads with
|
||||
* `includeRecoveryGap` unset, so it pays only for a single count query and
|
||||
* paints fast; the gap is computed solely when the user opens the explicit
|
||||
* "Analyse" expansion (`includeRecoveryGap=true`). With it off the summary
|
||||
* returns the count breakdown and a null typical-gap.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import pLimit from "p-limit";
|
||||
@@ -40,9 +48,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = illnessInsightsQuerySchema.safeParse({
|
||||
windowDays:
|
||||
new URL(request.url).searchParams.get("windowDays") ?? undefined,
|
||||
windowDays: params.get("windowDays") ?? undefined,
|
||||
includeRecoveryGap: params.get("includeRecoveryGap") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
@@ -51,6 +60,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const windowDays = parsed.data.windowDays ?? 365;
|
||||
const includeRecoveryGap = parsed.data.includeRecoveryGap ?? false;
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
const tz = resolveUserTimezone(user.timezone ?? null);
|
||||
@@ -67,45 +77,72 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Compute the recovery-gap only for resolved, non-chronic episodes (the
|
||||
// only ones that CAN have a gap), bounded to MAX_GAP_COMPUTATIONS most
|
||||
// recent. Each gap comes from the coverage-gated engine — a thin episode
|
||||
// yields null and simply doesn't contribute to the typical-gap median.
|
||||
const gapCandidates = rows
|
||||
.filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
|
||||
.slice(0, MAX_GAP_COMPUTATIONS);
|
||||
const limit = pLimit(EPISODE_GAP_CONCURRENCY);
|
||||
const gapEntries = await Promise.all(
|
||||
gapCandidates.map((r) =>
|
||||
limit(async (): Promise<[string, number | null]> => {
|
||||
const derived = await computeEpisodeCorrelation(
|
||||
user.id,
|
||||
{
|
||||
id: r.id,
|
||||
onsetAt: r.onsetAt,
|
||||
resolvedAt: r.resolvedAt,
|
||||
lifecycle: r.lifecycle,
|
||||
// The recovery-gap is the only expensive part. LIST loads skip it
|
||||
// entirely (`includeRecoveryGap` off) so the page paints on the single
|
||||
// `findMany` above; the explicit "Analyse" expansion turns it on and pays
|
||||
// the bounded fan-out. Per-episode gap + coverage come from the coverage-
|
||||
// gated engine — a thin episode yields null/low coverage and is filtered
|
||||
// out of the typical-gap median by the aggregate's signal-density gates.
|
||||
const gapById = new Map<
|
||||
string,
|
||||
{ recoveryGapDays: number | null; gapMeasurementDays: number }
|
||||
>();
|
||||
if (includeRecoveryGap) {
|
||||
const gapCandidates = rows
|
||||
.filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
|
||||
.slice(0, MAX_GAP_COMPUTATIONS);
|
||||
const limit = pLimit(EPISODE_GAP_CONCURRENCY);
|
||||
const gapEntries = await Promise.all(
|
||||
gapCandidates.map((r) =>
|
||||
limit(
|
||||
async (): Promise<
|
||||
[
|
||||
string,
|
||||
{ recoveryGapDays: number | null; gapMeasurementDays: number },
|
||||
]
|
||||
> => {
|
||||
const derived = await computeEpisodeCorrelation(
|
||||
user.id,
|
||||
{
|
||||
id: r.id,
|
||||
onsetAt: r.onsetAt,
|
||||
resolvedAt: r.resolvedAt,
|
||||
lifecycle: r.lifecycle,
|
||||
},
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
return [
|
||||
r.id,
|
||||
{
|
||||
recoveryGapDays:
|
||||
derived.status === "ok"
|
||||
? derived.value.recoveryGapDays
|
||||
: null,
|
||||
// `historyDays` = distinct episode days with a banded vital,
|
||||
// present on both the `ok` and `insufficient` arms.
|
||||
gapMeasurementDays: derived.coverage.historyDays,
|
||||
},
|
||||
];
|
||||
},
|
||||
tz,
|
||||
now,
|
||||
);
|
||||
return [
|
||||
r.id,
|
||||
derived.status === "ok" ? derived.value.recoveryGapDays : null,
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
const gapById = new Map<string, number | null>(gapEntries);
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const [id, entry] of gapEntries) gapById.set(id, entry);
|
||||
}
|
||||
|
||||
const episodes: RetrospectiveEpisode[] = rows.map((r) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
onsetDay: dayKeyForUserTz(r.onsetAt, tz),
|
||||
resolved: r.resolvedAt !== null,
|
||||
recoveryGapDays: gapById.get(r.id) ?? null,
|
||||
lifecycle: r.lifecycle,
|
||||
}));
|
||||
const episodes: RetrospectiveEpisode[] = rows.map((r) => {
|
||||
const gap = gapById.get(r.id);
|
||||
return {
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
onsetDay: dayKeyForUserTz(r.onsetAt, tz),
|
||||
resolved: r.resolvedAt !== null,
|
||||
recoveryGapDays: gap?.recoveryGapDays ?? null,
|
||||
gapMeasurementDays: gap?.gapMeasurementDays ?? 0,
|
||||
lifecycle: r.lifecycle,
|
||||
};
|
||||
});
|
||||
|
||||
const summary = summarizeIllnessRetrospective(episodes);
|
||||
|
||||
@@ -115,6 +152,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
episode_count: summary.episodeCount,
|
||||
gap_sample_size: summary.gapSampleSize,
|
||||
typical_gap_days: summary.typicalRecoveryGapDays,
|
||||
include_recovery_gap: includeRecoveryGap,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user