mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Opening an Insights category page froze the whole UI: the per-metric status card fired a cold, uncapped LLM fetch on mount with no client abort, so the request hung up to the server's 60s ceiling and the pending/resolve re-renders thrashed the tab strip and ate touch events. Timeouts were never negatively cached, so every visit re-blocked. Fix the class holistically: - Bound every status GET on the client. The shared use-insight-status hook and the inline medication-compliance query now wrap fetch in an 8s AbortController and stop retrying inline; navigation can no longer await an uncapped round-trip. - Make the status routes read-only. On a cache miss the generator enqueues an out-of-band pg-boss job (insight-status-generate) and returns a preparing envelope; the client polls a bounded interval until the worker warms the cache. Models the dashboard snapshot's ready/preparing pattern. The queue is registered in the worker allQueues loop + a boss.work handler, with a guard test. - Fix the nightly warm-pass regression: warm every supported locale (the client reads against the active UI locale, not the persisted User.locale) and warm on a cached comprehensive outcome too (its write still evicts the per-status caches). Add a short-TTL negative cache on the timeout path so a transient provider stall no longer makes every subsequent visit re-block or re-enqueue. - Add loading.tsx for the insights segment + the root so any future RSC await streams a skeleton instead of blocking the transition. - Stabilise the advisor regenerate callback so a status-query flip can't re-render the memoised tab strip mid-gesture. New i18n key insights.assessmentPreparing across all six locales.
295 lines
7.7 KiB
TypeScript
295 lines
7.7 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { getBmiSystemPrompt, getBmiUserPrompt } from "@/lib/ai/prompts/bmi";
|
|
import { classifyBMI } from "@/lib/analytics/classifications";
|
|
import { getNoKeyBmiStatusText } from "@/lib/insights/no-key-fallbacks";
|
|
import {
|
|
formatPreviousContextForPrompt,
|
|
getPreviousInsightContext,
|
|
} from "@/lib/insights/memory";
|
|
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
|
import {
|
|
buildGradedSeriesWithRollups,
|
|
scaleGradedSeries,
|
|
degradeStatusSnapshotToBudget,
|
|
} from "@/lib/insights/graded-series";
|
|
import {
|
|
type SupportedLocale,
|
|
normalizeLocale,
|
|
normalizeSummaryText,
|
|
parseSummaryFromContent,
|
|
persistStatusInsight,
|
|
round,
|
|
summarizeSeries,
|
|
} from "@/lib/insights/status-shared";
|
|
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
|
import {
|
|
readFreshStatusText,
|
|
resolveReadOnlyStatusMiss,
|
|
} from "@/lib/insights/status-cache";
|
|
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
|
|
|
export async function generateBmiStatusForUser(
|
|
userId: string,
|
|
options?: {
|
|
locale?: string | null;
|
|
force?: boolean;
|
|
/** v1.8.3 — read-only navigation path; see weight-status for the rationale. */
|
|
readOnly?: boolean;
|
|
},
|
|
): Promise<{
|
|
hasProvider: boolean;
|
|
text: string | null;
|
|
cached: boolean;
|
|
updatedAt: string | null;
|
|
preparing?: boolean;
|
|
}> {
|
|
const locale = normalizeLocale(options?.locale);
|
|
const force = options?.force === true;
|
|
const readOnly = options?.readOnly === true;
|
|
const cacheAction = `insights.bmi-status.${locale}`;
|
|
const todayKey = toBerlinDayKey(new Date());
|
|
|
|
const cached = await readFreshStatusText({
|
|
userId,
|
|
cacheAction,
|
|
todayKey,
|
|
force,
|
|
});
|
|
if (cached) {
|
|
return {
|
|
hasProvider: true,
|
|
text: cached.text,
|
|
cached: true,
|
|
updatedAt: cached.updatedAt,
|
|
};
|
|
}
|
|
|
|
if (readOnly) {
|
|
const outcome = await resolveReadOnlyStatusMiss({
|
|
userId,
|
|
metric: "bmi",
|
|
locale,
|
|
});
|
|
if (outcome === "no-provider") {
|
|
return {
|
|
hasProvider: false,
|
|
text: getNoKeyBmiStatusText(locale),
|
|
cached: true,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
return {
|
|
hasProvider: true,
|
|
text: null,
|
|
cached: false,
|
|
updatedAt: null,
|
|
preparing: true,
|
|
};
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
heightCm: true,
|
|
},
|
|
});
|
|
|
|
if (!user?.heightCm || user.heightCm <= 0) {
|
|
return {
|
|
hasProvider: true,
|
|
text:
|
|
locale === "de"
|
|
? "Für die BMI-Einschätzung fehlen aktuell Größenangaben im Profil."
|
|
: "BMI assessment currently requires height data in the profile.",
|
|
cached: true,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
|
|
// v1.4.28 FB-D2 — cap the snapshot input (weight runs at most once
|
|
// per day for typical users; 365 covers a full year while the
|
|
// downstream payload budget trims further).
|
|
const measurements = await prisma.measurement
|
|
.findMany({
|
|
where: {
|
|
userId,
|
|
type: "WEIGHT",
|
|
},
|
|
orderBy: { measuredAt: "desc" },
|
|
take: 365,
|
|
select: {
|
|
value: true,
|
|
measuredAt: true,
|
|
},
|
|
})
|
|
.then((rows) => rows.reverse());
|
|
|
|
const now = new Date();
|
|
const heightFactor = (user.heightCm / 100) ** 2;
|
|
|
|
const bmiPoints = measurements.map((measurement) => ({
|
|
measuredAt: measurement.measuredAt,
|
|
value: round(measurement.value / heightFactor, 2),
|
|
}));
|
|
// `applyPayloadBudget` daily buckets drive the latest/previous focus;
|
|
// the compact graded series is what reaches the prompt.
|
|
const weightSeries = applyPayloadBudget(bmiPoints, { now });
|
|
// BMI has no rollup tier of its own — it is weight ÷ height², a linear
|
|
// transform by the per-user height factor. Read the WEIGHT tier (recent
|
|
// / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR)
|
|
// and scale by 1 / heightFactor; the scaled series is exact.
|
|
const weightGraded = await buildGradedSeriesWithRollups(
|
|
userId,
|
|
"WEIGHT",
|
|
now,
|
|
);
|
|
const bmiGraded = scaleGradedSeries(weightGraded, 1 / heightFactor, 2);
|
|
const bmiSeries = {
|
|
daily: weightSeries.daily,
|
|
monthly: weightSeries.monthly,
|
|
};
|
|
|
|
// daily[0] = newest bucket (lowest dayOffset).
|
|
const latestBmi = bmiSeries.daily[0] ?? null;
|
|
const previousBmi = bmiSeries.daily[1] ?? null;
|
|
const latestClassification = latestBmi ? classifyBMI(latestBmi.value) : null;
|
|
|
|
const oldestMeasurement =
|
|
measurements.length > 0 ? measurements[0].measuredAt : null;
|
|
const newestMeasurement =
|
|
measurements.length > 0
|
|
? measurements[measurements.length - 1].measuredAt
|
|
: null;
|
|
const totalSpanDays =
|
|
oldestMeasurement && newestMeasurement
|
|
? Math.round(
|
|
(newestMeasurement.getTime() - oldestMeasurement.getTime()) /
|
|
(24 * 60 * 60 * 1000),
|
|
)
|
|
: 0;
|
|
const newestMeasurementDaysAgo = newestMeasurement
|
|
? Math.round(
|
|
(Date.now() - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000),
|
|
)
|
|
: null;
|
|
|
|
const snapshot = {
|
|
locale,
|
|
generatedForDay: todayKey,
|
|
focus: "bmi",
|
|
dataCoverage: {
|
|
totalMeasurements: measurements.length,
|
|
totalSpanDays,
|
|
newestMeasurementDaysAgo,
|
|
},
|
|
bmi: {
|
|
summary: summarizeSeries(
|
|
bmiSeries.daily.map((bucket) => ({ value: bucket.value })),
|
|
),
|
|
series: bmiGraded,
|
|
latestDayFocus: latestBmi
|
|
? {
|
|
dayOffset: latestBmi.dayOffset,
|
|
value: latestBmi.value,
|
|
classification: latestClassification,
|
|
deltaToPreviousDailyPoint:
|
|
previousBmi == null
|
|
? null
|
|
: round(latestBmi.value - previousBmi.value, 2),
|
|
}
|
|
: null,
|
|
target: {
|
|
greenMin: 18.5,
|
|
greenMax: 24.9,
|
|
},
|
|
},
|
|
};
|
|
|
|
const shed = degradeStatusSnapshotToBudget(
|
|
snapshot as unknown as Record<string, unknown>,
|
|
);
|
|
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
|
|
|
annotate({
|
|
action: { name: cacheAction },
|
|
meta: {
|
|
payload_size_bytes: snapshotJson.length,
|
|
...(shed.length > 0 ? { snapshot_shed: shed } : {}),
|
|
},
|
|
});
|
|
|
|
const previousContext = await getPreviousInsightContext(
|
|
userId,
|
|
"bmi-status",
|
|
locale,
|
|
12,
|
|
);
|
|
const previousContextBlock = formatPreviousContextForPrompt(
|
|
previousContext,
|
|
locale,
|
|
);
|
|
|
|
const outcome = await runStatusCompletion({
|
|
userId,
|
|
cacheAction,
|
|
systemPrompt: getBmiSystemPrompt(locale),
|
|
userPrompt: getBmiUserPrompt(
|
|
snapshotJson,
|
|
todayKey,
|
|
locale,
|
|
previousContextBlock,
|
|
),
|
|
temperature: 0.3,
|
|
maxTokens: 1000,
|
|
});
|
|
|
|
if (outcome.kind === "none") {
|
|
return {
|
|
hasProvider: false,
|
|
text: getNoKeyBmiStatusText(locale),
|
|
cached: true,
|
|
updatedAt: null,
|
|
};
|
|
}
|
|
if (outcome.kind === "timeout" || outcome.kind === "error") {
|
|
return returnTimeoutFallback({
|
|
cacheAction,
|
|
reason: outcome.kind,
|
|
userId,
|
|
todayKey,
|
|
stubText: getNoKeyBmiStatusText(locale),
|
|
});
|
|
}
|
|
|
|
const summary = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
|
if (!summary) {
|
|
throw new Error("Bmi-status summary was empty after normalization");
|
|
}
|
|
|
|
const updatedAt = await persistStatusInsight({
|
|
userId,
|
|
cacheAction,
|
|
todayKey,
|
|
locale,
|
|
text: summary,
|
|
providerType: outcome.providerType,
|
|
model: outcome.model,
|
|
tokensUsed: outcome.tokensUsed,
|
|
});
|
|
|
|
return {
|
|
hasProvider: true,
|
|
text: summary,
|
|
cached: false,
|
|
updatedAt,
|
|
};
|
|
}
|
|
|
|
export function resolveBmiStatusLocale(
|
|
locale: string | null | undefined,
|
|
): SupportedLocale {
|
|
return normalizeLocale(locale);
|
|
}
|