mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
No user-facing change. Eight of the largest source files split along their natural seams into focused modules, every move verbatim with stable import paths: - mood analytics calculators (mood-tag-influence / mood-crosstab / mood-better-days / mood-patterns; hub 2229 → 770 lines) - insight status-invalidation machinery (status-invalidation.ts) - feature extraction (feature-blocks / signals-of-day) - doctor report types + pure helpers (doctor-report-types / doctor-report-helpers; collector 1756 → 1002) - Google Health mapping layer (mappers.ts; client 2153 → 1022, acyclic client → mappers with type-only back-imports) - coach chat bubbles + read-aloud controls (chat-bubble / read-aloud; message-thread 1713 → 464) - Telegram webhook handlers to lib (route 1307 → 92 — handler thin, logic in lib; rate-limit-before-secret ordering byte-identical) - coach snapshot cache, series helpers and the four largest per-metric blocks (snapshot-cache / snapshot-series / snapshot-blocks; 2691 → 1896) The glucose-panel-window structural test follows the moved alias; two unused imports left by the feature split are removed.
89 lines
3.2 KiB
TypeScript
89 lines
3.2 KiB
TypeScript
/**
|
|
* In-memory result cache for the Coach snapshot builder.
|
|
*
|
|
* v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The
|
|
* snapshot reads only persisted data; a single chat conversation sends
|
|
* 2-4 turns within a minute and the snapshot would otherwise rebuild
|
|
* from the same rows each turn. Caching the result for 60s shaves the
|
|
* ~10 measurement reads + the GLP-1 / mood / intake side-fetches off
|
|
* every turn after the first, which `.planning/round-v1433-audit-perf.md`
|
|
* §3.3 estimates at 200-800 ms of server-side tail.
|
|
*
|
|
* Scope is part of the cache key so a switch from `last30days` to
|
|
* `last7days` (or a different `sources` set) computes fresh. The map
|
|
* is bounded at 64 entries — a multi-tenant deployment with a few
|
|
* active power users sits well inside that ceiling even if each cycles
|
|
* through several scopes per minute.
|
|
*
|
|
* Split out of `snapshot.ts` so the builder file carries only the
|
|
* assembly logic; the public surface (`buildCoachSnapshot`,
|
|
* `__resetCoachSnapshotCacheForTests`) stays importable from
|
|
* `./snapshot` via re-export.
|
|
*/
|
|
import type { CoachSnapshotResult } from "./snapshot";
|
|
import type { CoachScope, CoachScopeWindow } from "./types";
|
|
|
|
/** Default window when the caller doesn't pass a scope. */
|
|
export const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
|
|
|
|
const SNAPSHOT_TTL_MS = 60_000;
|
|
const SNAPSHOT_LRU_MAX = 64;
|
|
const snapshotCache = new Map<
|
|
string,
|
|
{ expiresAt: number; result: CoachSnapshotResult }
|
|
>();
|
|
|
|
export function snapshotCacheKey(
|
|
userId: string,
|
|
scope: CoachScope | undefined,
|
|
): string {
|
|
// v1.7.0 — when the request pins an explicit source list, key on it.
|
|
// Otherwise the source set is derived from the user's saved
|
|
// `dataClusters`, which we don't read here (the cache must stay
|
|
// I/O-free on a hit) — key on a stable `clusters` marker instead.
|
|
// A cluster change is reflected on the next cache miss (≤60 s), the
|
|
// same staleness window every other pref change already tolerates.
|
|
const window = scope?.window ?? DEFAULT_WINDOW;
|
|
const sourceList =
|
|
scope?.sources && scope.sources.length > 0
|
|
? Array.from(scope.sources).sort().join(",")
|
|
: "clusters";
|
|
return `${userId}|${window}|${sourceList}`;
|
|
}
|
|
|
|
export function readSnapshotCache(key: string): CoachSnapshotResult | null {
|
|
const entry = snapshotCache.get(key);
|
|
if (!entry) return null;
|
|
if (entry.expiresAt <= Date.now()) {
|
|
snapshotCache.delete(key);
|
|
return null;
|
|
}
|
|
// Touch for LRU — re-insert moves to the end of the Map's iteration order.
|
|
snapshotCache.delete(key);
|
|
snapshotCache.set(key, entry);
|
|
return entry.result;
|
|
}
|
|
|
|
export function writeSnapshotCache(
|
|
key: string,
|
|
result: CoachSnapshotResult,
|
|
): void {
|
|
if (snapshotCache.size >= SNAPSHOT_LRU_MAX) {
|
|
// Evict the oldest entry — JS Map iteration order is insertion order,
|
|
// so the first key is the least-recently inserted/touched.
|
|
const oldest = snapshotCache.keys().next().value;
|
|
if (oldest !== undefined) {
|
|
snapshotCache.delete(oldest);
|
|
}
|
|
}
|
|
snapshotCache.set(key, {
|
|
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
|
|
result,
|
|
});
|
|
}
|
|
|
|
/** Clear the snapshot cache. Test-only escape hatch. */
|
|
export function __resetCoachSnapshotCacheForTests(): void {
|
|
snapshotCache.clear();
|
|
}
|