mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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>
81 lines
2.8 KiB
TypeScript
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 */
|
|
}
|
|
}
|