mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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>
286 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|