Files
HealthLog/src/lib/cycle/import-accumulator.ts
T
Marc-André BombeckandClaude Opus 4.8 c21131e26e feat(cycle): second-audit feature, QoL, and wiring close
Close the second-round audit UI cluster:

- Cycle reminders: add the missing notification-event labels so the
  period-soon / period-confirm toggles read correctly on the
  notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
  length, confirmed-ovulation chip) — the cycles were fetched but only
  the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
  solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
  the nullable PATCH); period start/end work on any selected date for
  retroactive correction; "end period" shows only while one is open.
  Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
  in before the gated page is reachable; wire the cycle data-purge button
  to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
  the grid; tighten the luteal prior bound to the engine clamp; mute the
  BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.

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

286 lines
10 KiB
TypeScript

/**
* Cycle-import accumulator — folds the reproductive HealthKit samples a
* `export.xml` parse emits into one merged `CycleDayLog` per user-local day.
*
* The streaming importer (`import-apple-health-export.ts`) sees one
* `<Record>` per HK sample. Several reproductive samples can share a day
* (flow + cervical mucus + a symptom). The `CycleDayLog` is one canonical
* row per `(user, date)`, so we merge same-day samples in memory and flush
* one upsert per day at the end of the parse.
*
* Dedup / idempotency: each merged day writes with `source:APPLE_HEALTH` and
* a synthetic per-day `externalId` (`hkcycle:<YYYY-MM-DD>`), so a re-import
* upserts the same row on the `(userId, source, externalId)` NULL-distinct
* key — first-write-wins at the day grain, last-writer-wins on the field
* set (the day-log write helper's contract). This mirrors the cumulative
* `stats:<HKType>:<day>` convention the measurement importer already uses.
*
* A contraceptive STATUS sample additionally nudges the `CycleProfile`
* goal toward AVOID_PREGNANCY — but only when the goal is still on its
* GENERAL_HEALTH default, so an explicit user choice is never clobbered.
*/
import { prisma } from "@/lib/db";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { auditLog } from "@/lib/auth/audit";
import {
mapHkCycleSample,
isCycleHkIdentifier,
type CycleDayLogFields,
} from "@/lib/cycle/healthkit-mapping";
import type { CycleDayLogInput } from "@/lib/validations/cycle";
import type { ContraceptiveKind } from "@/generated/prisma/client";
/** Synthetic per-day externalId prefix for HealthKit-imported cycle rows. */
export const HK_CYCLE_DAY_EXTERNAL_PREFIX = "hkcycle:";
/** A zeroed cycle-import result — the no-cycle / aborted-flush fallback. */
export const EMPTY_CYCLE_IMPORT_STATS: CycleImportStats = {
samplesConsumed: 0,
daysUpserted: 0,
daysInserted: 0,
goalNudged: false,
};
/** Per-day merge state held during the parse. */
interface DayBucket {
fields: CycleDayLogFields;
symptomKeys: Set<string>;
/** A contraceptive method seen this day → triggers the profile nudge. */
contraceptiveNudge: ContraceptiveKind | null;
}
/** Terminal stats the importer folds into its `ImportJobResult`. */
export interface CycleImportStats {
/** Reproductive samples consumed into a day-bucket. */
samplesConsumed: number;
/** Day-logs upserted at flush. */
daysUpserted: number;
/** Day-logs that the write created (vs refreshed). */
daysInserted: number;
/** Whether a CycleProfile goal nudge fired. */
goalNudged: boolean;
}
/**
* In-memory fold of the reproductive HK samples for one user's import.
* Construct once per parse; `consume()` per matching `<Record>`; `flush()`
* once at the end.
*/
export class CycleImportAccumulator {
private readonly byDay = new Map<string, DayBucket>();
private samplesConsumed = 0;
private gated: boolean | null = null;
constructor(
private readonly userId: string,
private readonly userTimezone: string,
) {}
/** Does this importer own the inbound HK identifier? */
static handles(hkIdentifier: string): boolean {
return isCycleHkIdentifier(hkIdentifier);
}
/** True once at least one reproductive sample folded into a day-bucket. */
hasSamples(): boolean {
return this.byDay.size > 0;
}
/**
* Fold one reproductive HK sample into its day-bucket. `dayKey` is the
* already-resolved user-local `YYYY-MM-DD`. `rawValue` is the sample's
* `value` attribute (symbolic name or integer string). `protectionUsed`
* is the resolved SexualActivity protection metadata (or undefined).
* Returns true when the sample was consumed (recognised + routed).
*/
consume(
hkIdentifier: string,
dayKey: string,
rawValue: string | undefined,
protectionUsed?: boolean,
): boolean {
const route = mapHkCycleSample(hkIdentifier, rawValue, protectionUsed);
if (route.kind === "skip") return false;
const bucket = this.bucketFor(dayKey);
this.mergeFields(bucket, route.fields);
if (route.kind === "day-log+profile" && route.profile.contraceptive) {
bucket.contraceptiveNudge = route.profile.contraceptive;
}
this.samplesConsumed += 1;
return true;
}
private bucketFor(dayKey: string): DayBucket {
let bucket = this.byDay.get(dayKey);
if (!bucket) {
bucket = { fields: {}, symptomKeys: new Set(), contraceptiveNudge: null };
this.byDay.set(dayKey, bucket);
}
return bucket;
}
/** Last-writer-wins merge of a sample's fields into the day-bucket. */
private mergeFields(bucket: DayBucket, fields: CycleDayLogFields): void {
if (fields.symptomKey) bucket.symptomKeys.add(fields.symptomKey);
if (fields.flow !== undefined) bucket.fields.flow = fields.flow;
if (fields.intermenstrualBleeding !== undefined) {
bucket.fields.intermenstrualBleeding = fields.intermenstrualBleeding;
}
if (fields.ovulationTest !== undefined) {
bucket.fields.ovulationTest = fields.ovulationTest;
}
if (fields.cervicalMucus !== undefined) {
bucket.fields.cervicalMucus = fields.cervicalMucus;
}
if (fields.sexualActivity !== undefined) {
bucket.fields.sexualActivity = fields.sexualActivity;
}
if (fields.protectedSex !== undefined) {
bucket.fields.protectedSex = fields.protectedSex;
}
if (fields.pregnancyTest !== undefined) {
bucket.fields.pregnancyTest = fields.pregnancyTest;
}
if (fields.progesteroneTest !== undefined) {
bucket.fields.progesteroneTest = fields.progesteroneTest;
}
if (fields.contraceptive !== undefined) {
bucket.fields.contraceptive = fields.contraceptive;
}
}
/**
* Resolve whether cycle tracking is enabled for the user (gender-derived
* or the explicit toggle). Cached for the parse lifetime. The importer
* skips the whole cycle fold when this is false so a non-cycle account's
* Apple Health export never silently provisions cycle rows.
*/
async isEnabled(): Promise<boolean> {
if (this.gated !== null) return this.gated;
const [user, profile] = await Promise.all([
prisma.user.findUnique({
where: { id: this.userId },
select: { gender: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: this.userId },
select: { cycleTrackingEnabled: true },
}),
]);
// The single source-of-truth gate (no profile auto-create on import).
this.gated = isCycleEnabled(user?.gender, profile);
return this.gated;
}
/**
* Upsert one merged `CycleDayLog` per accumulated day. No-op when nothing
* was consumed. Returns the terminal stats for the importer result.
*/
async flush(): Promise<CycleImportStats> {
const stats: CycleImportStats = {
samplesConsumed: this.samplesConsumed,
daysUpserted: 0,
daysInserted: 0,
goalNudged: false,
};
if (this.byDay.size === 0) return stats;
let contraceptiveNudge: ContraceptiveKind | null = null;
// Hoist cycle attribution out of the per-day loop: load the user's
// (non-deleted) cycle start dates once and resolve the owning span in
// memory (latest start <= date) rather than a query per day.
const cycleRows = await prisma.menstrualCycle.findMany({
where: { userId: this.userId, deletedAt: null },
orderBy: { startDate: "asc" },
select: { id: true, startDate: true },
});
const owningCycleId = (date: string): string | null => {
let id: string | null = null;
for (const c of cycleRows) {
if (c.startDate <= date) id = c.id;
else break;
}
return id;
};
// Resolve the invariant encryption flag once for the whole flush, instead
// of re-reading the profile per upserted day (QA M-3). Default ON if the
// profile somehow vanished between the gate check and here.
const encProfile = await prisma.cycleProfile.findUnique({
where: { userId: this.userId },
select: { sensitiveCategoryEncryption: true },
});
const encryptSensitive = encProfile?.sensitiveCategoryEncryption ?? true;
for (const [dayKey, bucket] of this.byDay.entries()) {
const symptoms =
bucket.symptomKeys.size > 0
? Array.from(bucket.symptomKeys).map((key) => ({ key }))
: undefined;
const entry: CycleDayLogInput = {
date: dayKey,
...bucket.fields,
...(symptoms ? { symptoms } : {}),
source: "APPLE_HEALTH",
externalId: `${HK_CYCLE_DAY_EXTERNAL_PREFIX}${dayKey}`,
// The day-log write helper requires a `loggedAt`; anchor it to noon
// of the day so it is stable across re-imports (the row's measured
// moment is the day, not a wall-clock instant from the sample).
loggedAt: `${dayKey}T12:00:00.000Z`,
};
const cycleId = owningCycleId(dayKey);
const result = await upsertCycleDayLog(
this.userId,
entry,
this.userTimezone,
cycleId,
encryptSensitive,
);
stats.daysUpserted += 1;
if (!result.existed) stats.daysInserted += 1;
// The most-recent contraceptive method across the import drives the
// single goal nudge (applied once after the day loop).
if (bucket.contraceptiveNudge) {
contraceptiveNudge = bucket.contraceptiveNudge;
}
}
// Goal nudge: an active contraceptive method moves a still-default
// GENERAL_HEALTH profile to AVOID_PREGNANCY. NONE/UNSPECIFIED do not
// assert avoidance intent, so they leave the goal untouched.
if (
contraceptiveNudge &&
contraceptiveNudge !== "NONE" &&
contraceptiveNudge !== "UNSPECIFIED"
) {
const updated = await prisma.cycleProfile.updateMany({
where: { userId: this.userId, goal: "GENERAL_HEALTH" },
data: { goal: "AVOID_PREGNANCY" },
});
if (updated.count > 0) {
stats.goalNudged = true;
// A server-side mutation of an intent-revealing setting must not be
// invisible — record it on the audit trail.
await auditLog("cycle.profile.goal-nudged", {
userId: this.userId,
details: {
from: "GENERAL_HEALTH",
to: "AVOID_PREGNANCY",
via: "apple-health-import",
},
});
}
}
return stats;
}
}