Files
HealthLog/src/lib/cycle/prediction-cache.ts
T
Marc-André BombeckandClaude Opus 4.8 5f485ca780 feat(cycle): add cycle-tracking API surface + offline sync
Add the `/api/cycle/*` capture, calendar, history, and settings routes
plus the offline-sync delta extension for the v1.15 cycle-tracking
feature, all against the locked iOS contract.

- Enable flag: add nullable `cycleTrackingEnabled` to CycleProfile
  (migration 0132). A null toggle derives from gender (FEMALE → on).
  `isCycleEnabled()` is the single gate resolver; every `/api/cycle/*`
  route 403s `{ errorCode: "cycle.disabled" }` when off. The resolved
  flag surfaces additively on GET /api/auth/me.
- Routes (apiHandler + Zod + envelope + gate + annotate + auditLog):
  POST/PATCH/DELETE day-logs (single), POST day-logs/bulk (idempotent,
  capped 500, rate-limited 60/min, per-entry inserted/updated/duplicate/
  skipped), POST period boundary, GET calendar (runs the deterministic
  engine, debounced stale-while-revalidate cache, goal-gated fertile
  window), GET cycles (history + median/MAD stats), GET profile,
  DELETE cycles, PATCH /api/auth/me/cycle-prefs (deep-merge).
- Day-log note encrypts at rest (notesEncrypted); intent fields stay
  queryable plaintext. Upsert keys on (userId, source, externalId) when
  externalId present, else (userId, date).
- Offline sync: extend GET /api/sync/changes + the cursor with the
  `cycleDays` + `cycles` domains (CURSOR_VERSION → 2). cycleDays
  tombstones key on externalId else id; cycles on id.
- OpenAPI: register every route + DTO; regenerate the committed spec.
- i18n: cycle prediction disclaimer across all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:57:12 +02:00

81 lines
2.8 KiB
TypeScript

/**
* Cycle prediction cache — debounced stale-while-revalidate (the v1.8.7
* insight-assessment cache pattern, ios-contract §2.D).
*
* `GET /api/cycle/calendar` runs the pure engine synchronously (it is
* cheap) for the live forecast it returns, AND persists that forecast to
* the `CyclePrediction` cache row so the Coach snapshot + notifications
* read a stable materialised value without re-running the engine. The
* persist is debounced: a re-write only lands when the prior row is older
* than the debounce window OR the forecast actually changed, so a tight
* read loop never thrashes the row.
*/
import { prisma } from "@/lib/db";
import type { CyclePredictionResult } from "@/lib/cycle/types";
/** Don't re-persist an unchanged forecast more often than this. */
const PREDICTION_DEBOUNCE_MS = 6 * 60 * 60 * 1000; // 6h
/**
* Persist (or refresh) the cached forecast for a user, debounced. A
* no-op when a fresh-enough identical row already exists. Best-effort:
* never throws into the read path.
*/
export async function persistPredictionCache(
userId: string,
result: CyclePredictionResult,
now: Date = new Date(),
): Promise<void> {
try {
const existing = await prisma.cyclePrediction.findUnique({
where: { userId },
select: {
method: true,
nextPeriodStart: true,
nextPeriodStartLow: true,
nextPeriodStartHigh: true,
confidence: true,
cyclesObserved: true,
generatedAt: true,
},
});
const unchanged =
existing !== null &&
existing.method === result.method &&
existing.nextPeriodStart === result.nextPeriodStart &&
existing.nextPeriodStartLow === result.nextPeriodStartLow &&
existing.nextPeriodStartHigh === result.nextPeriodStartHigh &&
existing.confidence === result.confidence &&
existing.cyclesObserved === result.cyclesObserved;
const fresh =
existing !== null &&
now.getTime() - existing.generatedAt.getTime() < PREDICTION_DEBOUNCE_MS;
// Skip the write only when the row is BOTH unchanged AND fresh.
if (unchanged && fresh) return;
const data = {
method: result.method,
nextPeriodStart: result.nextPeriodStart,
nextPeriodStartLow: result.nextPeriodStartLow,
nextPeriodStartHigh: result.nextPeriodStartHigh,
fertileWindowStart: result.fertileWindowStart,
fertileWindowEnd: result.fertileWindowEnd,
predictedOvulation: result.predictedOvulation,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
generatedAt: now,
};
await prisma.cyclePrediction.upsert({
where: { userId },
create: { userId, ...data },
update: data,
});
} catch {
/* best-effort cache write — the live engine result is authoritative */
}
}