feat(cycle): import Apple Health reproductive data into cycle, add cycle export

Promote the reproductive HealthKit category types out of the
Apple-Health-import privacy-hold list and route them into cycle day-logs
instead of measurements, and surface cycle data on the three export
surfaces.

Import promotion:
- A new HealthKit -> cycle mapping module is the single source of truth for
  how MenstrualFlow, IntermenstrualBleeding, CervicalMucusQuality,
  OvulationTestResult, SexualActivity, PregnancyTestResult,
  ProgesteroneTestResult, Contraceptive and the symptom category types fold
  into CycleDayLog fields / seeded symptom keys. The XML importer
  accumulates same-day reproductive samples and writes one canonical
  day-log per day with source APPLE_HEALTH and a synthetic per-day
  externalId for idempotent re-import. The fold is gated on cycle tracking
  being enabled, so a non-cycle export never provisions cycle rows. A
  contraceptive method nudges the profile goal toward avoiding pregnancy
  only when the goal is still on its default. Basal body temperature stays
  a measurement and mirrors onto the day-log. Pregnancy/Lactation status
  and the pregnancy-bleeding types stay deferred; the cycle awareness types
  remain server-derived.

Export:
- FHIR emits LMP (LOINC 8665-2), average cycle length (64700-8), period
  length (64698-4) and the current phase as opt-in reproductive-health
  observations grouped into their own document section.
- The doctor PDF gains an opt-in cycle section (recent cycles, average
  length and variability, current phase), privacy-default off like mood.
- The full-backup export, the weekly backup worker and the off-host DR
  snapshot include the cycle tables, and the admin restore re-creates them;
  the day-log note round-trips as ciphertext and is never decrypted onto a
  report surface.

Mapping table documented in docs/api/cycle-healthkit-mapping.md. Adds unit
+ integration coverage for the mapping, the import routing, the FHIR
observations and the backup round-trip. OpenAPI regenerated. Six-locale
strings added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 10:16:54 +02:00
1 parent 7333d8e40f
commit e984ada5f1
39 files changed
+2406 -41

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
# Apple Health → cycle mapping
How HealthLog folds Apple Health reproductive samples into the cycle model.
Both the `export.xml` importer and the iOS batch contract share one mapping
table: `src/lib/cycle/healthkit-mapping.ts`. This page is the human-readable
companion — keep the two in sync.
Reproductive samples route into **cycle day-logs** (`CycleDayLog`), never into
`Measurement`. Each Apple Health day collapses into one canonical day-log per
`(user, date)`; the importer writes `source = APPLE_HEALTH` with a synthetic
per-day `externalId` (`hkcycle:<YYYY-MM-DD>`) so a re-import upserts the same
row. The fold only runs when cycle tracking is enabled for the account.
## Day-log fields
| HealthKit category type | CycleDayLog field | Values |
|---|---|---|
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
logged-but-unspecified bleeding day is at least light flow).
## Symptom category types → seeded symptom keys
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
sample whose severity is *not present* (codepoint 1) does NOT create a link.
| HealthKit category type | CycleSymptom key |
|---|---|
| `AbdominalCramps` | `cramps` |
| `Headache` | `headache` |
| `Bloating` | `bloating` |
| `Acne` | `acne` |
| `BreastPain` | `breast_tenderness` |
| `Fatigue` | `fatigue` |
| `LowerBackPain` | `back_pain` |
| `SleepChanges` | `insomnia` |
| `MoodChanges` | `mood_swings` |
| `AppetiteChanges` | `food_cravings` |
| `Nausea` | `nausea` |
## Profile-level signals
A `Contraceptive` sample asserting an active method also nudges the
`CycleProfile.goal` to `AVOID_PREGNANCY` — but only when the goal is still on
its `GENERAL_HEALTH` default, so an explicit user choice is never overwritten.
## Basal body temperature
`basalBodyTemperature` is **not** routed here. It stays a
`Measurement(BODY_TEMPERATURE)` (the system of record for the rollup tier and
the cross-source source-priority ladder) and is mirrored onto
`CycleDayLog.basalBodyTempC` for the cycle chart. Apple Watch wrist/skin
temperature already flows to `WRIST_TEMPERATURE` / `SKIN_TEMPERATURE` and feeds
the temperature-trend ovulation method directly from `Measurement`.
## Deferred
| HealthKit category type | Status |
|---|---|
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
+4
View File
@@ -829,6 +829,8 @@ paths:
type: boolean
bmi:
type: boolean
cycle:
type: boolean
locale:
type: string
enum:
@@ -955,6 +957,8 @@ paths:
type: boolean
bmi:
type: boolean
cycle:
type: boolean
resourceTypes:
maxItems: 8
type: array
+17 -1
View File
@@ -146,6 +146,7 @@
"moodSensitive": "Sensibel — standardmäßig aus.",
"compliance": "Medikamenten-Compliance",
"sleep": "Schlafdauer",
"cycle": "Menstruationszyklus",
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
"unavailableHint": "Keine Daten in diesem Zeitraum"
},
@@ -163,7 +164,22 @@
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
"wellnessRecovery": "Erholung",
"wellnessStress": "Stress",
"wellnessStrain": "Belastung"
"wellnessStrain": "Belastung",
"cycleTitle": "Menstruationszyklus",
"cycleLmp": "Letzte Periode",
"cycleAvgLength": "Durchschnittliche Zykluslänge",
"cycleAvgPeriod": "Durchschnittliche Periodendauer",
"cyclePhase": "Aktuelle Phase",
"cycleDays": "Tage",
"cycleColStart": "Periodenbeginn",
"cycleColLength": "Zykluslänge (Tage)",
"cycleColPeriod": "Periodendauer (Tage)",
"cyclePhases": {
"MENSTRUAL": "Menstruation",
"FOLLICULAR": "Follikelphase",
"OVULATORY": "Ovulation",
"LUTEAL": "Lutealphase"
}
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
+17 -1
View File
@@ -146,6 +146,7 @@
"moodSensitive": "Sensitive — off by default.",
"compliance": "Medication compliance",
"sleep": "Sleep duration",
"cycle": "Menstrual cycle",
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
"unavailableHint": "No data in this period"
},
@@ -163,7 +164,22 @@
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
"wellnessRecovery": "Recovery",
"wellnessStress": "Stress",
"wellnessStrain": "Strain"
"wellnessStrain": "Strain",
"cycleTitle": "Menstrual cycle",
"cycleLmp": "Last period",
"cycleAvgLength": "Average cycle length",
"cycleAvgPeriod": "Average period length",
"cyclePhase": "Current phase",
"cycleDays": "days",
"cycleColStart": "Period start",
"cycleColLength": "Cycle length (days)",
"cycleColPeriod": "Period length (days)",
"cyclePhases": {
"MENSTRUAL": "Menstrual",
"FOLLICULAR": "Follicular",
"OVULATORY": "Ovulatory",
"LUTEAL": "Luteal"
}
},
"errorBoundary": {
"title": "Something went wrong",
+17 -1
View File
@@ -146,6 +146,7 @@
"moodSensitive": "Sensible — desactivado por defecto.",
"compliance": "Cumplimiento de medicación",
"sleep": "Duración del sueño",
"cycle": "Ciclo menstrual",
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
"unavailableHint": "Sin datos en este periodo"
},
@@ -163,7 +164,22 @@
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
"wellnessRecovery": "Recuperación",
"wellnessStress": "Estrés",
"wellnessStrain": "Esfuerzo"
"wellnessStrain": "Esfuerzo",
"cycleTitle": "Ciclo menstrual",
"cycleLmp": "Última menstruación",
"cycleAvgLength": "Duración media del ciclo",
"cycleAvgPeriod": "Duración media del periodo",
"cyclePhase": "Fase actual",
"cycleDays": "días",
"cycleColStart": "Inicio del periodo",
"cycleColLength": "Duración del ciclo (días)",
"cycleColPeriod": "Duración del periodo (días)",
"cyclePhases": {
"MENSTRUAL": "Menstrual",
"FOLLICULAR": "Folicular",
"OVULATORY": "Ovulatoria",
"LUTEAL": "Lútea"
}
},
"errorBoundary": {
"title": "Algo ha salido mal",
+17 -1
View File
@@ -146,6 +146,7 @@
"moodSensitive": "Sensible — désactivé par défaut.",
"compliance": "Observance des médicaments",
"sleep": "Durée du sommeil",
"cycle": "Cycle menstruel",
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
"unavailableHint": "Pas de données sur cette période"
},
@@ -163,7 +164,22 @@
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
"wellnessRecovery": "Récupération",
"wellnessStress": "Stress",
"wellnessStrain": "Effort"
"wellnessStrain": "Effort",
"cycleTitle": "Cycle menstruel",
"cycleLmp": "Dernières règles",
"cycleAvgLength": "Durée moyenne du cycle",
"cycleAvgPeriod": "Durée moyenne des règles",
"cyclePhase": "Phase actuelle",
"cycleDays": "jours",
"cycleColStart": "Début des règles",
"cycleColLength": "Durée du cycle (jours)",
"cycleColPeriod": "Durée des règles (jours)",
"cyclePhases": {
"MENSTRUAL": "Menstruelle",
"FOLLICULAR": "Folliculaire",
"OVULATORY": "Ovulatoire",
"LUTEAL": "Lutéale"
}
},
"errorBoundary": {
"title": "Une erreur est survenue",
+17 -1
View File
@@ -146,6 +146,7 @@
"moodSensitive": "Sensibile — disattivato per default.",
"compliance": "Aderenza ai farmaci",
"sleep": "Durata del sonno",
"cycle": "Ciclo mestruale",
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
"unavailableHint": "Nessun dato in questo periodo"
},
@@ -163,7 +164,22 @@
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
"wellnessRecovery": "Recupero",
"wellnessStress": "Stress",
"wellnessStrain": "Sforzo"
"wellnessStrain": "Sforzo",
"cycleTitle": "Ciclo mestruale",
"cycleLmp": "Ultime mestruazioni",
"cycleAvgLength": "Durata media del ciclo",
"cycleAvgPeriod": "Durata media delle mestruazioni",
"cyclePhase": "Fase attuale",
"cycleDays": "giorni",
"cycleColStart": "Inizio mestruazioni",
"cycleColLength": "Durata del ciclo (giorni)",
"cycleColPeriod": "Durata mestruazioni (giorni)",
"cyclePhases": {
"MENSTRUAL": "Mestruale",
"FOLLICULAR": "Follicolare",
"OVULATORY": "Ovulatoria",
"LUTEAL": "Luteale"
}
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
+17 -1
View File
@@ -146,6 +146,7 @@
"moodSensitive": "Wrażliwe — domyślnie wyłączone.",
"compliance": "Przestrzeganie leków",
"sleep": "Czas snu",
"cycle": "Cykl menstruacyjny",
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
"unavailableHint": "Brak danych w tym okresie"
},
@@ -163,7 +164,22 @@
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
"wellnessRecovery": "Regeneracja",
"wellnessStress": "Stres",
"wellnessStrain": "Obciążenie"
"wellnessStrain": "Obciążenie",
"cycleTitle": "Cykl menstruacyjny",
"cycleLmp": "Ostatnia miesiączka",
"cycleAvgLength": "Średnia długość cyklu",
"cycleAvgPeriod": "Średnia długość miesiączki",
"cyclePhase": "Bieżąca faza",
"cycleDays": "dni",
"cycleColStart": "Początek miesiączki",
"cycleColLength": "Długość cyklu (dni)",
"cycleColPeriod": "Długość miesiączki (dni)",
"cyclePhases": {
"MENSTRUAL": "Menstruacyjna",
"FOLLICULAR": "Folikularna",
"OVULATORY": "Owulacyjna",
"LUTEAL": "Lutealna"
}
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -49,6 +49,7 @@ import {
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
export const dynamic = "force-dynamic";
@@ -63,6 +64,10 @@ interface RestoreResponse {
notificationChannels: number;
pushSubscriptions: number;
telegramScheduledDeletions: number;
// v1.15.0 — cycle tables wiped before recreate.
cycles: number;
cycleDayLogs: number;
cycleProfile: number;
};
}
@@ -397,6 +402,11 @@ const handler = apiHandler(
});
}
// v1.15.0 — cycle tables (profile + observed spans + day-logs +
// symptom links). Delete-then-recreate, mirroring the contract
// above. `notesEncrypted` is restored as ciphertext verbatim.
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
return {
measurements: measurements.count,
medications: meds.count,
@@ -405,6 +415,9 @@ const handler = apiHandler(
notificationChannels: channels.count,
pushSubscriptions: subs.count,
telegramScheduledDeletions: tgDel.count,
cycles: cycleCleared.cycles,
cycleDayLogs: cycleCleared.cycleDayLogs,
cycleProfile: cycleCleared.cycleProfile,
};
});
} catch (err) {
@@ -513,6 +526,8 @@ const handler = apiHandler(
medications: summary.medications,
intakeEvents: summary.intakeEvents,
moodEntries: summary.moodEntries,
cycles: summary.cycles,
cycleDayLogs: summary.cycleDayLogs,
},
},
});
@@ -216,6 +216,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
medications: summary.medications,
intakeEvents: summary.intakeEvents,
moodEntries: summary.moodEntries,
cycles: summary.cycles,
cycleDayLogs: summary.cycleDayLogs,
},
},
});
@@ -90,6 +90,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
mood: true,
compliance: true,
sleep: false,
cycle: false,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
@@ -112,6 +113,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
mood: true,
compliance: false,
sleep: false,
cycle: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
@@ -16,6 +16,10 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
cycleDayLog: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
@@ -318,6 +322,9 @@ describe("GET /api/export/full-backup", () => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
const { GET } = await import("../full-backup/route");
const res = await GET(mkReq("http://localhost/api/export/full-backup"));
+13 -1
View File
@@ -22,6 +22,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -33,7 +34,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 exports per hour", 429);
}
const [measurements, medications, intakeEvents, moodEntries] =
const [measurements, medications, intakeEvents, moodEntries, cycle] =
await Promise.all([
prisma.measurement.findMany({
// v1.4.41 W-DELETED-2 — soft-deleted rows are excluded from the
@@ -74,6 +75,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: { userId: user.id, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
}),
// v1.15.0 — cycle tables (profile + observed spans + day-logs). The
// shared helper carries `notesEncrypted` verbatim (never decrypted).
buildCycleBackupSection(prisma, user.id),
]);
// Shape mirrors the pg-boss `data-backup` worker exactly so the same
@@ -118,6 +122,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
})),
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
cycleDayLogs: cycle.cycleDayLogs,
};
await auditLog("user.export.full-backup", {
@@ -129,6 +137,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
medications: medications.length,
intakeEvents: intakeEvents.length,
moodEntries: moodEntries.length,
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
},
},
});
@@ -139,6 +149,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
export_medications_count: medications.length,
export_intake_count: intakeEvents.length,
export_mood_count: moodEntries.length,
export_cycle_count: cycle.cycles.length,
export_cycle_day_log_count: cycle.cycleDayLogs.length,
},
});
+5 -1
View File
@@ -234,6 +234,8 @@ export function BackupsSection() {
medications: number;
intakeEvents: number;
moodEntries: number;
cycles?: number;
cycleDayLogs?: number;
};
};
},
@@ -242,7 +244,9 @@ export function BackupsSection() {
data.summary.measurements +
data.summary.medications +
data.summary.intakeEvents +
data.summary.moodEntries;
data.summary.moodEntries +
(data.summary.cycles ?? 0) +
(data.summary.cycleDayLogs ?? 0);
toast.success(
t("admin.section.backups.uploadSuccess", { count: String(total) }),
);
@@ -80,6 +80,11 @@ interface SectionAvailability {
mood: boolean;
compliance: boolean;
sleep: boolean;
// v1.15.0 — cycle section. Not rendered by this legacy dialog
// (SECTION_ORDER omits it); the flagship health-record export surfaces
// the cycle toggle. Present so `keyof DoctorReportPrefs` indexing
// typechecks. Optional — the availability endpoint may not provide it.
cycle?: boolean;
}
export interface DoctorReportSubmitPayload {
@@ -541,6 +546,9 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
mood: "doctorReport.sections.mood",
compliance: "doctorReport.sections.compliance",
sleep: "doctorReport.sections.sleep",
// v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it);
// present so the `keyof DoctorReportPrefs` record stays exhaustive.
cycle: "doctorReport.sections.cycle",
};
/**
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import {
mapHkCycleSample,
isCycleHkIdentifier,
hkSymptomIsPresent,
HK_MENSTRUAL_FLOW,
HK_INTERMENSTRUAL_BLEEDING,
HK_CERVICAL_MUCUS,
HK_OVULATION_TEST,
HK_SEXUAL_ACTIVITY,
HK_PREGNANCY_TEST,
HK_PROGESTERONE_TEST,
HK_CONTRACEPTIVE,
HK_PREGNANCY,
HK_LACTATION,
} from "../healthkit-mapping";
describe("cycle/healthkit-mapping — identifier ownership", () => {
it("owns the promoted reproductive identifiers", () => {
expect(isCycleHkIdentifier(HK_MENSTRUAL_FLOW)).toBe(true);
expect(isCycleHkIdentifier(HK_OVULATION_TEST)).toBe(true);
expect(isCycleHkIdentifier(HK_CONTRACEPTIVE)).toBe(true);
expect(isCycleHkIdentifier("HKCategoryTypeIdentifierAbdominalCramps")).toBe(
true,
);
});
it("does NOT own pregnancy/lactation status (deferred — no schema target)", () => {
expect(isCycleHkIdentifier(HK_PREGNANCY)).toBe(false);
expect(isCycleHkIdentifier(HK_LACTATION)).toBe(false);
});
it("does NOT own unrelated identifiers", () => {
expect(isCycleHkIdentifier("HKQuantityTypeIdentifierStepCount")).toBe(false);
});
});
describe("cycle/healthkit-mapping — menstrual flow", () => {
it("maps the symbolic value names to FlowLevel", () => {
expect(
mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowLight"),
).toEqual({ kind: "day-log", fields: { flow: "LIGHT" } });
expect(
mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowHeavy"),
).toEqual({ kind: "day-log", fields: { flow: "HEAVY" } });
expect(
mapHkCycleSample(HK_MENSTRUAL_FLOW, "HKCategoryValueMenstrualFlowNone"),
).toEqual({ kind: "day-log", fields: { flow: "NONE" } });
});
it("maps the integer codepoints (live-API form)", () => {
expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "2")).toEqual({
kind: "day-log",
fields: { flow: "LIGHT" },
});
expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "4")).toEqual({
kind: "day-log",
fields: { flow: "HEAVY" },
});
});
it("maps HK unspecified → LIGHT (conservative boundary)", () => {
expect(
mapHkCycleSample(
HK_MENSTRUAL_FLOW,
"HKCategoryValueMenstrualFlowUnspecified",
),
).toEqual({ kind: "day-log", fields: { flow: "LIGHT" } });
});
it("skips an unrecognised flow value", () => {
expect(mapHkCycleSample(HK_MENSTRUAL_FLOW, "garbage")).toEqual({
kind: "skip",
});
});
});
describe("cycle/healthkit-mapping — ovulation + cervical mucus + tests", () => {
it("maps ovulation results", () => {
expect(
mapHkCycleSample(
HK_OVULATION_TEST,
"HKCategoryValueOvulationTestResultLuteinizingHormoneSurge",
),
).toEqual({ kind: "day-log", fields: { ovulationTest: "POSITIVE_LH_SURGE" } });
expect(mapHkCycleSample(HK_OVULATION_TEST, "4")).toEqual({
kind: "day-log",
fields: { ovulationTest: "ESTROGEN_SURGE" },
});
});
it("maps cervical mucus", () => {
expect(
mapHkCycleSample(
HK_CERVICAL_MUCUS,
"HKCategoryValueCervicalMucusQualityEggWhite",
),
).toEqual({ kind: "day-log", fields: { cervicalMucus: "EGG_WHITE" } });
});
it("maps pregnancy + progesterone test results", () => {
expect(mapHkCycleSample(HK_PREGNANCY_TEST, "2")).toEqual({
kind: "day-log",
fields: { pregnancyTest: "POSITIVE" },
});
expect(mapHkCycleSample(HK_PROGESTERONE_TEST, "1")).toEqual({
kind: "day-log",
fields: { progesteroneTest: "NEGATIVE" },
});
});
it("maps intermenstrual bleeding (presence)", () => {
expect(mapHkCycleSample(HK_INTERMENSTRUAL_BLEEDING, undefined)).toEqual({
kind: "day-log",
fields: { intermenstrualBleeding: true },
});
});
});
describe("cycle/healthkit-mapping — sexual activity + protection", () => {
it("records protectedSex from the protection metadata", () => {
expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined, true)).toEqual({
kind: "day-log",
fields: { sexualActivity: true, protectedSex: true },
});
expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined, false)).toEqual({
kind: "day-log",
fields: { sexualActivity: true, protectedSex: false },
});
});
it("leaves protectedSex null when the metadata is absent", () => {
expect(mapHkCycleSample(HK_SEXUAL_ACTIVITY, undefined)).toEqual({
kind: "day-log",
fields: { sexualActivity: true, protectedSex: null },
});
});
});
describe("cycle/healthkit-mapping — contraceptive routes to day-log + profile", () => {
it("records the method AND carries a profile nudge", () => {
expect(
mapHkCycleSample(
HK_CONTRACEPTIVE,
"HKCategoryValueContraceptiveOral",
),
).toEqual({
kind: "day-log+profile",
fields: { contraceptive: "ORAL" },
profile: { contraceptive: "ORAL" },
});
});
});
describe("cycle/healthkit-mapping — symptoms", () => {
it("maps symptom identifiers to the seeded catalogue keys", () => {
expect(
mapHkCycleSample("HKCategoryTypeIdentifierAbdominalCramps", "3"),
).toEqual({ kind: "day-log", fields: { symptomKey: "cramps" } });
expect(
mapHkCycleSample("HKCategoryTypeIdentifierLowerBackPain", "4"),
).toEqual({ kind: "day-log", fields: { symptomKey: "back_pain" } });
});
it("skips an explicit not-present symptom sample", () => {
expect(
mapHkCycleSample("HKCategoryTypeIdentifierHeadache", "1"),
).toEqual({ kind: "skip" });
expect(hkSymptomIsPresent("1")).toBe(false);
expect(hkSymptomIsPresent("HKCategoryValueSeverityNotPresent")).toBe(false);
expect(hkSymptomIsPresent("3")).toBe(true);
});
});
+310
View File
@@ -0,0 +1,310 @@
/**
* Cycle-tracking backup serialisation — the one place the full-backup
* writers (the on-demand `GET /api/export/full-backup` route AND the
* pg-boss `data-backup` worker) read the cycle tables, so the two writers
* never drift. Mirrors the `backupPayloadSchema` cycle shapes.
*
* Reads are scoped to `userId` + `deletedAt: null` and exclude predicted
* (forecast) cycle rows — only observed history round-trips. `notesEncrypted`
* is carried as the ciphertext envelope VERBATIM: the backup never decrypts
* the day-log note, so a wrong-surface plaintext leak is impossible and the
* owner's note round-trips encrypted at rest.
*/
import type { PrismaClient, Prisma } from "@/generated/prisma/client";
import type { BackupPayload } from "@/lib/validations/backup";
/** The cycle slice of a backup payload. */
export interface CycleBackupSection {
cycleProfile: {
goal: string;
cycleTrackingEnabled: boolean | null;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
predictionEnabled: boolean;
rawChartMode: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
} | null;
cycles: Array<{
startDate: string;
endDate: string | null;
periodEndDate: string | null;
lengthDays: number | null;
ovulationDate: string | null;
ovulationConfirmed: boolean;
tz: string | null;
}>;
cycleDayLogs: Array<{
date: string;
flow: string | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
ovulationTest: string | null;
cervicalMucus: string | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: string | null;
progesteroneTest: string | null;
contraceptive: string | null;
notesEncrypted: string | null;
source: string;
externalId: string | null;
tz: string | null;
symptomKeys: string[];
}>;
}
/**
* Build the cycle section of a user's full backup. Accepts any client with
* the cycle delegates (the route's global client OR the worker's local
* client) so both writers share one read.
*/
export async function buildCycleBackupSection(
prisma: Pick<
PrismaClient,
"cycleProfile" | "menstrualCycle" | "cycleDayLog"
>,
userId: string,
): Promise<CycleBackupSection> {
const [profile, cycles, dayLogs] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId } }),
prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null, isPredicted: false },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null },
orderBy: { date: "asc" },
include: { symptomLinks: { include: { symptom: { select: { key: true } } } } },
}),
]);
return {
cycleProfile: profile
? {
goal: profile.goal,
cycleTrackingEnabled: profile.cycleTrackingEnabled,
typicalCycleLength: profile.typicalCycleLength,
typicalPeriodLength: profile.typicalPeriodLength,
lutealPhaseLength: profile.lutealPhaseLength,
predictionEnabled: profile.predictionEnabled,
rawChartMode: profile.rawChartMode,
discreetNotifications: profile.discreetNotifications,
sensitiveCategoryEncryption: profile.sensitiveCategoryEncryption,
}
: null,
cycles: cycles.map((c) => ({
startDate: c.startDate,
endDate: c.endDate,
periodEndDate: c.periodEndDate,
lengthDays: c.lengthDays,
ovulationDate: c.ovulationDate,
ovulationConfirmed: c.ovulationConfirmed,
tz: c.tz,
})),
cycleDayLogs: dayLogs.map((d) => ({
date: d.date,
flow: d.flow,
intermenstrualBleeding: d.intermenstrualBleeding,
basalBodyTempC: d.basalBodyTempC,
ovulationTest: d.ovulationTest,
cervicalMucus: d.cervicalMucus,
sexualActivity: d.sexualActivity,
protectedSex: d.protectedSex,
pregnancyTest: d.pregnancyTest,
progesteroneTest: d.progesteroneTest,
contraceptive: d.contraceptive,
// Ciphertext envelope carried verbatim — never decrypted here.
notesEncrypted: d.notesEncrypted,
source: d.source,
externalId: d.externalId,
tz: d.tz,
symptomKeys: d.symptomLinks.map((l) => l.symptom.key),
})),
};
}
/* ── restore ───────────────────────────────────────────────────────── */
/** Counts the cycle restore wiped, for the audit trail. */
export interface CycleRestoreCleared {
cycles: number;
cycleDayLogs: number;
cycleProfile: number;
}
// Closed enum allow-lists. The backup schema is `.passthrough()`, so a
// malformed enum value would otherwise crash deep in `create()`. Guard the
// few enum columns up-front (mirrors the measurement-type guard in the
// restore route). Unknown values are coerced to null rather than failing
// the whole restore — a single drifted field shouldn't strand the rest.
const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]);
const OVULATION_TESTS = new Set([
"NEGATIVE",
"POSITIVE_LH_SURGE",
"ESTROGEN_SURGE",
"INDETERMINATE",
]);
const CERVICAL_MUCUS = new Set([
"DRY",
"STICKY",
"CREAMY",
"WATERY",
"EGG_WHITE",
]);
const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]);
const CONTRACEPTIVES = new Set([
"NONE",
"UNSPECIFIED",
"IMPLANT",
"INJECTION",
"IUD",
"INTRAVAGINAL_RING",
"ORAL",
"PATCH",
"EMERGENCY",
]);
const CYCLE_SOURCES = new Set(["MANUAL", "WITHINGS", "IMPORT", "APPLE_HEALTH"]);
const CYCLE_GOALS = new Set([
"GENERAL_HEALTH",
"AVOID_PREGNANCY",
"TRYING_TO_CONCEIVE",
"PERIMENOPAUSE",
"OFF",
]);
function enumOrNull<T extends string>(
value: string | null | undefined,
allow: Set<string>,
): T | null {
return value != null && allow.has(value) ? (value as T) : null;
}
/**
* Restore the cycle tables for `ownerId` from a parsed backup payload,
* inside an existing transaction. Delete-then-recreate, matching the
* measurement/mood restore contract. Symptom links are re-resolved against
* the seeded catalogue by key (unknown keys dropped). `notesEncrypted` is
* written back as the ciphertext envelope verbatim — never re-encrypted.
*
* Returns the wiped counts for the audit trail. A pre-v1.15 payload (empty
* cycle arrays + null profile) wipes nothing and recreates nothing.
*/
export async function restoreCycleData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: BackupPayload,
): Promise<CycleRestoreCleared> {
// Wipe (child links cascade off the day-log delete).
const dayLogs = await tx.cycleDayLog.deleteMany({ where: { userId: ownerId } });
const cycles = await tx.menstrualCycle.deleteMany({ where: { userId: ownerId } });
const profile = await tx.cycleProfile.deleteMany({ where: { userId: ownerId } });
// Recreate the profile (one row / user).
if (payload.cycleProfile) {
const p = payload.cycleProfile;
await tx.cycleProfile.create({
data: {
userId: ownerId,
goal: (enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH") as never,
cycleTrackingEnabled: p.cycleTrackingEnabled ?? null,
typicalCycleLength: p.typicalCycleLength ?? null,
typicalPeriodLength: p.typicalPeriodLength ?? null,
lutealPhaseLength: p.lutealPhaseLength ?? null,
predictionEnabled: p.predictionEnabled ?? true,
rawChartMode: p.rawChartMode ?? false,
discreetNotifications: p.discreetNotifications ?? false,
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true,
},
});
}
// Recreate observed cycle spans; map startDate → id so day-logs can
// re-attach to their owning span.
const cycleIdByStart = new Map<string, string>();
for (const c of payload.cycles) {
const created = await tx.menstrualCycle.create({
data: {
userId: ownerId,
startDate: c.startDate,
endDate: c.endDate ?? null,
periodEndDate: c.periodEndDate ?? null,
lengthDays: c.lengthDays ?? null,
ovulationDate: c.ovulationDate ?? null,
ovulationConfirmed: c.ovulationConfirmed ?? false,
tz: c.tz ?? null,
isPredicted: false,
},
});
cycleIdByStart.set(c.startDate, created.id);
}
// Resolve the seeded symptom catalogue once for the link re-creation.
const allKeys = Array.from(
new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])),
);
const symptomIdByKey = new Map<string, string>();
if (allKeys.length > 0) {
const rows = await tx.cycleSymptom.findMany({
where: { key: { in: allKeys }, OR: [{ userId: null }, { userId: ownerId }] },
select: { id: true, key: true },
});
for (const r of rows) symptomIdByKey.set(r.key, r.id);
}
// Recreate day-logs (with symptom links). The owning cycle is the latest
// span whose start is on/before the day (the cycle-attribution rule).
const sortedStarts = payload.cycles
.map((c) => c.startDate)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
const owningCycleId = (date: string): string | null => {
let id: string | null = null;
for (const start of sortedStarts) {
if (start <= date) id = cycleIdByStart.get(start) ?? id;
else break;
}
return id;
};
for (const d of payload.cycleDayLogs) {
const symptomIds = (d.symptomKeys ?? [])
.map((k) => symptomIdByKey.get(k))
.filter((v): v is string => v !== undefined);
await tx.cycleDayLog.create({
data: {
userId: ownerId,
date: d.date,
cycleId: owningCycleId(d.date),
flow: enumOrNull(d.flow, FLOW_LEVELS) as never,
intermenstrualBleeding: d.intermenstrualBleeding ?? false,
basalBodyTempC: d.basalBodyTempC ?? null,
ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never,
cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never,
sexualActivity: d.sexualActivity ?? false,
protectedSex: d.protectedSex ?? null,
pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never,
progesteroneTest: enumOrNull(d.progesteroneTest, HOME_TESTS) as never,
contraceptive: enumOrNull(d.contraceptive, CONTRACEPTIVES) as never,
// Ciphertext envelope written back verbatim — never re-encrypted.
notesEncrypted: d.notesEncrypted ?? null,
source: (enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL") as never,
externalId: d.externalId ?? null,
tz: d.tz ?? null,
...(symptomIds.length > 0
? {
symptomLinks: {
create: symptomIds.map((symptomId) => ({ symptomId })),
},
}
: {}),
},
});
}
return {
cycles: cycles.count,
cycleDayLogs: dayLogs.count,
cycleProfile: profile.count,
};
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Cycle export summary — the shared read both the FHIR Observation emitter
* and the doctor-PDF cycle section consume, so the two surfaces describe
* identical numbers (the source-of-truth property the export family holds).
*
* Reads cycle data scoped to `userId` + `deletedAt: null`. Computes the
* concise clinical summary: last menstrual period (LMP), recent observed
* cycles, average length + variability, average period length, and the
* current phase. Pure stats; no prediction (predictions are forecasts, not
* a clinical record of what happened).
*
* `notesEncrypted` is NEVER touched here — the export summary is
* statistics only, so no plaintext free-text ever reaches a report surface
* through this path.
*/
import { prisma } from "@/lib/db";
import { median, mad } from "@/lib/cycle/prediction";
import { phaseForDay } from "@/lib/cycle/phase";
import { dayDiff, addDays } from "@/lib/cycle/day-math";
import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types";
/** One observed cycle in the recent-cycles table. */
export interface CycleExportCycle {
/** YYYY-MM-DD period start. */
startDate: string;
/** Observed cycle length in days (start→next start), null for the open cycle. */
lengthDays: number | null;
/** Observed period (bleeding) length in days, null when unknown. */
periodLengthDays: number | null;
}
/** The concise cycle summary surfaced in FHIR + the doctor PDF. */
export interface CycleExportSummary {
/** YYYY-MM-DD of the most recent period start (LMP). */
lastPeriodStart: string | null;
/** Observed cycles within the window, newest first (capped). */
recentCycles: CycleExportCycle[];
/** Count of observed cycles used for the stats. */
observedCycleCount: number;
/** Median observed cycle length (days). Null with < 1 completed cycle. */
averageCycleLengthDays: number | null;
/**
* Cycle-length variability (days) — the median absolute deviation of the
* observed lengths, a robust spread measure. Null with < 2 cycles.
*/
cycleLengthVariabilityDays: number | null;
/** Median observed period length (days). Null when no period length known. */
averagePeriodLengthDays: number | null;
/** The phase the report date falls in, or null when it can't be resolved. */
currentPhase: CyclePhase | null;
}
/** Max observed cycles surfaced in the recent-cycles table. */
const RECENT_CYCLES_CAP = 12;
/**
* Build the cycle export summary for a user. `asOf` anchors the
* current-phase calculation (defaults to today). Returns null when the
* user has no observed cycles at all (the caller then omits the section).
*/
export async function buildCycleExportSummary(
userId: string,
asOf: string,
lutealLength: number = LUTEAL_DEFAULT,
): Promise<CycleExportSummary | null> {
const cycles = await prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null, isPredicted: false },
orderBy: { startDate: "asc" },
select: {
startDate: true,
endDate: true,
periodEndDate: true,
lengthDays: true,
ovulationDate: true,
},
});
if (cycles.length === 0) return null;
// Observed length: prefer the stored lengthDays, else derive from the
// next cycle's start (the canonical start→next-start definition).
const observed: CycleExportCycle[] = cycles.map((c, i) => {
const next = cycles[i + 1];
const lengthDays =
c.lengthDays ??
(next ? dayDiff(next.startDate, c.startDate) : null);
const periodLengthDays =
c.periodEndDate !== null
? dayDiff(c.periodEndDate, c.startDate) + 1
: null;
return { startDate: c.startDate, lengthDays, periodLengthDays };
});
const lengths = observed
.map((c) => c.lengthDays)
.filter((n): n is number => n !== null && n > 0);
const periodLengths = observed
.map((c) => c.periodLengthDays)
.filter((n): n is number => n !== null && n > 0);
const averageCycleLengthDays =
lengths.length >= 1 ? Math.round(median(lengths) * 10) / 10 : null;
const cycleLengthVariabilityDays =
lengths.length >= 2
? Math.round(mad(lengths, median(lengths)) * 10) / 10
: null;
const averagePeriodLengthDays =
periodLengths.length >= 1
? Math.round(median(periodLengths) * 10) / 10
: null;
const lastPeriodStart = cycles[cycles.length - 1]?.startDate ?? null;
// Current phase: resolve against the cycle whose window contains `asOf`.
let currentPhase: CyclePhase | null = null;
const last = cycles[cycles.length - 1];
if (last) {
// The open cycle's nextStart is unknown; estimate it from the median
// length so the phase math has a window.
const estLength = averageCycleLengthDays ?? 28;
const nextStart = last.endDate
? addDays(last.endDate, 1)
: addDays(last.startDate, Math.round(estLength));
const periodLen = observed[observed.length - 1]?.periodLengthDays ?? null;
const { phase } = phaseForDay(asOf, {
startDate: last.startDate,
nextStart,
ovulationDate: last.ovulationDate,
periodLength: periodLen,
lutealLength,
});
currentPhase = phase;
}
return {
lastPeriodStart,
recentCycles: observed
.slice(-RECENT_CYCLES_CAP)
.reverse(),
observedCycleCount: observed.length,
averageCycleLengthDays,
cycleLengthVariabilityDays,
averagePeriodLengthDays,
currentPhase,
};
}
+355
View File
@@ -0,0 +1,355 @@
/**
* HealthKit reproductive-category → CycleDayLog / CycleProfile mapping.
*
* The single source of truth for how an Apple Health reproductive sample
* folds into HealthLog's cycle model. Both the `export.xml` streaming
* importer (`src/lib/measurements/import-apple-health-export.ts`) and the
* iOS batch contract reference this table; the canonical key tables also
* live in `docs/api/cycle-healthkit-mapping.md` so a self-hoster reading
* the importer and a developer reading the docs see one mapping.
*
* Routing rules (per `.planning/v1.15-cycle/ios-contract.md` §5):
* - MenstrualFlow / IntermenstrualBleeding / CervicalMucusQuality /
* OvulationTestResult / SexualActivity / Pregnancy/Progesterone test
* results → fields on a `CycleDayLog` (the day-log write helper), NOT
* `Measurement`.
* - Pregnancy / Lactation / Contraceptive STATUS → `CycleProfile` flags
* (post-partum / contraception mode), NOT day-logs.
* - Symptom category types → seeded `CycleSymptom` keys via the
* `CycleSymptomLink` join.
* - BBT (`basalBodyTemperature`) stays a `Measurement(BODY_TEMPERATURE)`
* AND mirrors `CycleDayLog.basalBodyTempC`; it is NOT routed here (it
* is a quantity sample handled by the measurement mapping + the cycle
* day-log mirror).
* - BleedingDuringPregnancy / BleedingAfterPregnancy stay DEFERRED
* (pregnancy-mode is a later release).
*
* Apple's `export.xml` writes a category sample's `value` as the symbolic
* `HKCategoryValue…` name; the live HealthKit API uses the integer
* codepoint. Every lookup table below is keyed by BOTH so the importer and
* the batch ingest resolve a sample regardless of which form it carries.
*/
import type {
FlowLevel,
OvulationTest,
CervicalMucus,
HomeTestResult,
ContraceptiveKind,
} from "@/generated/prisma/client";
/* ── HK identifier constants ─────────────────────────────────────── */
export const HK_MENSTRUAL_FLOW = "HKCategoryTypeIdentifierMenstrualFlow";
export const HK_INTERMENSTRUAL_BLEEDING =
"HKCategoryTypeIdentifierIntermenstrualBleeding";
export const HK_CERVICAL_MUCUS = "HKCategoryTypeIdentifierCervicalMucusQuality";
export const HK_OVULATION_TEST = "HKCategoryTypeIdentifierOvulationTestResult";
export const HK_SEXUAL_ACTIVITY = "HKCategoryTypeIdentifierSexualActivity";
export const HK_PREGNANCY_TEST = "HKCategoryTypeIdentifierPregnancyTestResult";
export const HK_PROGESTERONE_TEST =
"HKCategoryTypeIdentifierProgesteroneTestResult";
export const HK_CONTRACEPTIVE = "HKCategoryTypeIdentifierContraceptive";
export const HK_PREGNANCY = "HKCategoryTypeIdentifierPregnancy";
export const HK_LACTATION = "HKCategoryTypeIdentifierLactation";
/**
* The HealthKit metadata key carrying the protection-used boolean on a
* SexualActivity sample. Apple writes it as a `<MetadataEntry>` child with
* a `"0"`/`"1"` (or `"true"`/`"false"`) value.
*/
export const HK_SEXUAL_ACTIVITY_PROTECTION_META =
"HKMetadataKeySexualActivityProtectionUsed";
/* ── category-value lookup tables (symbolic name + integer) ──────── */
/**
* HKCategoryValueMenstrualFlow: unspecified(1)/light(2)/medium(3)/heavy(4)/
* none(5). HK `unspecified` maps to LIGHT as the conservative boundary
* (a logged-but-unspecified bleeding day is at least light flow); HK
* `none` maps to our NONE. Documented with iOS in the spotting boundary
* note — HealthLog's SPOTTING has no HK counterpart, so it is only ever
* produced by a manual MANUAL-source entry.
*/
export const HK_MENSTRUAL_FLOW_VALUES: Record<string, FlowLevel> = {
"1": "LIGHT", // HKCategoryValueMenstrualFlowUnspecified
HKCategoryValueMenstrualFlowUnspecified: "LIGHT",
"2": "LIGHT", // HKCategoryValueMenstrualFlowLight
HKCategoryValueMenstrualFlowLight: "LIGHT",
"3": "MEDIUM", // HKCategoryValueMenstrualFlowMedium
HKCategoryValueMenstrualFlowMedium: "MEDIUM",
"4": "HEAVY", // HKCategoryValueMenstrualFlowHeavy
HKCategoryValueMenstrualFlowHeavy: "HEAVY",
"5": "NONE", // HKCategoryValueMenstrualFlowNone
HKCategoryValueMenstrualFlowNone: "NONE",
};
/**
* HKCategoryValueOvulationTestResult: negative(1)/luteinizingHormoneSurge(2)/
* indeterminate(3)/estrogenSurge(4).
*/
export const HK_OVULATION_TEST_VALUES: Record<string, OvulationTest> = {
"1": "NEGATIVE",
HKCategoryValueOvulationTestResultNegative: "NEGATIVE",
"2": "POSITIVE_LH_SURGE",
HKCategoryValueOvulationTestResultLuteinizingHormoneSurge: "POSITIVE_LH_SURGE",
"3": "INDETERMINATE",
HKCategoryValueOvulationTestResultIndeterminate: "INDETERMINATE",
"4": "ESTROGEN_SURGE",
HKCategoryValueOvulationTestResultEstrogenSurge: "ESTROGEN_SURGE",
};
/**
* HKCategoryValueCervicalMucusQuality: dry(1)/sticky(2)/creamy(3)/watery(4)/
* eggWhite(5).
*/
export const HK_CERVICAL_MUCUS_VALUES: Record<string, CervicalMucus> = {
"1": "DRY",
HKCategoryValueCervicalMucusQualityDry: "DRY",
"2": "STICKY",
HKCategoryValueCervicalMucusQualitySticky: "STICKY",
"3": "CREAMY",
HKCategoryValueCervicalMucusQualityCreamy: "CREAMY",
"4": "WATERY",
HKCategoryValueCervicalMucusQualityWatery: "WATERY",
"5": "EGG_WHITE",
HKCategoryValueCervicalMucusQualityEggWhite: "EGG_WHITE",
};
/**
* Shared home-test result enum for the pregnancy- and progesterone-test
* category types: negative(1)/positive(2)/indeterminate(3).
*/
export const HK_HOME_TEST_VALUES: Record<string, HomeTestResult> = {
"1": "NEGATIVE",
HKCategoryValuePregnancyTestResultNegative: "NEGATIVE",
HKCategoryValueProgesteroneTestResultNegative: "NEGATIVE",
"2": "POSITIVE",
HKCategoryValuePregnancyTestResultPositive: "POSITIVE",
HKCategoryValueProgesteroneTestResultPositive: "POSITIVE",
"3": "INDETERMINATE",
HKCategoryValuePregnancyTestResultIndeterminate: "INDETERMINATE",
HKCategoryValueProgesteroneTestResultIndeterminate: "INDETERMINATE",
};
/**
* HKCategoryValueContraceptive: unspecified(1)/implant(2)/injection(3)/
* intrauterineDevice(4)/intravaginalRing(5)/oral(6)/patch(7)/emergency(8).
* Routed to the CycleProfile contraception flag (status), but a same-day
* contraceptive sample also lands on the day-log `contraceptive` field so
* the timeline shows the method that was active.
*/
export const HK_CONTRACEPTIVE_VALUES: Record<string, ContraceptiveKind> = {
"1": "UNSPECIFIED",
HKCategoryValueContraceptiveUnspecified: "UNSPECIFIED",
"2": "IMPLANT",
HKCategoryValueContraceptiveImplant: "IMPLANT",
"3": "INJECTION",
HKCategoryValueContraceptiveInjection: "INJECTION",
"4": "IUD",
HKCategoryValueContraceptiveIntrauterineDevice: "IUD",
"5": "INTRAVAGINAL_RING",
HKCategoryValueContraceptiveIntravaginalRing: "INTRAVAGINAL_RING",
"6": "ORAL",
HKCategoryValueContraceptiveOral: "ORAL",
"7": "PATCH",
HKCategoryValueContraceptivePatch: "PATCH",
"8": "EMERGENCY",
HKCategoryValueContraceptiveEmergency: "EMERGENCY",
};
/**
* Symptom category types → seeded `CycleSymptom.key` (the catalogue keys
* from migration 0129). One HK identifier per canonical symptom key; the
* symptom severity (none/mild/moderate/severe) is carried on the link's
* severity in the batch contract but the seeded catalogue join is
* key-only, so severity is dropped on the XML import path (the symptom is
* still recorded). Identifiers without a seeded counterpart are omitted —
* they fall through to the importer's `deferred` tally rather than
* inventing a catalogue row.
*/
export const HK_SYMPTOM_KEY_BY_IDENTIFIER: Record<string, string> = {
HKCategoryTypeIdentifierAbdominalCramps: "cramps",
HKCategoryTypeIdentifierHeadache: "headache",
HKCategoryTypeIdentifierBloating: "bloating",
HKCategoryTypeIdentifierAcne: "acne",
HKCategoryTypeIdentifierBreastPain: "breast_tenderness",
HKCategoryTypeIdentifierFatigue: "fatigue",
HKCategoryTypeIdentifierLowerBackPain: "back_pain",
HKCategoryTypeIdentifierSleepChanges: "insomnia",
HKCategoryTypeIdentifierMoodChanges: "mood_swings",
HKCategoryTypeIdentifierAppetiteChanges: "food_cravings",
HKCategoryTypeIdentifierNausea: "nausea",
};
/**
* HK severity codepoints for a symptom category sample:
* notPresent(1)/present(2)/mild(3)/moderate(4)/severe(5). A `notPresent`
* sample is an explicit "I did not have this" record — it must NOT create
* a symptom link. Returns true when the sample asserts the symptom.
*/
export function hkSymptomIsPresent(rawValue: string | undefined): boolean {
if (rawValue === undefined) return true; // sample present, no severity
if (
rawValue === "1" ||
rawValue === "HKCategoryValueSeverityNotPresent" ||
rawValue === "HKCategoryValuePresenceNotPresent"
) {
return false;
}
return true;
}
/* ── routed-output shape ─────────────────────────────────────────── */
/** Partial CycleDayLog field set a single HK reproductive sample fills. */
export interface CycleDayLogFields {
flow?: FlowLevel;
intermenstrualBleeding?: boolean;
ovulationTest?: OvulationTest;
cervicalMucus?: CervicalMucus;
sexualActivity?: boolean;
protectedSex?: boolean | null;
pregnancyTest?: HomeTestResult;
progesteroneTest?: HomeTestResult;
contraceptive?: ContraceptiveKind;
/** Seeded symptom catalogue key to link on the day. */
symptomKey?: string;
}
/**
* CycleProfile status fields a HK STATUS sample updates. The v1.15.0
* `CycleProfile` schema carries no dedicated pregnant/lactating columns
* (pregnancy-mode is a deferred release), so the only profile signal a
* status sample can land today is a goal nudge: an active contraceptive
* method nudges the goal toward AVOID_PREGNANCY. The nudge is conservative
* — the importer only applies it when the profile is still on its
* GENERAL_HEALTH default, never clobbering an explicit user choice.
*/
export interface CycleProfileFields {
/**
* The contraceptive method the sample asserts, also written to the
* day-log `contraceptive` field for the timeline. Drives the goal nudge.
*/
contraceptive?: ContraceptiveKind;
}
export type HkCycleRoute =
/** Fold the fields into the day's `CycleDayLog` (the common case). */
| { kind: "day-log"; fields: CycleDayLogFields }
/**
* A contraceptive STATUS sample: record the method on the day-log AND
* nudge the CycleProfile goal. Carries both payloads so the importer
* applies them in one pass.
*/
| { kind: "day-log+profile"; fields: CycleDayLogFields; profile: CycleProfileFields }
/** No cycle destination (unrecognised value, or deferred pregnancy-mode). */
| { kind: "skip" };
/** Every HK identifier this module routes (status + day-log + symptoms). */
export const HK_CYCLE_IDENTIFIERS: ReadonlySet<string> = new Set<string>([
HK_MENSTRUAL_FLOW,
HK_INTERMENSTRUAL_BLEEDING,
HK_CERVICAL_MUCUS,
HK_OVULATION_TEST,
HK_SEXUAL_ACTIVITY,
HK_PREGNANCY_TEST,
HK_PROGESTERONE_TEST,
HK_CONTRACEPTIVE,
...Object.keys(HK_SYMPTOM_KEY_BY_IDENTIFIER),
]);
/** Does this module own the inbound identifier? */
export function isCycleHkIdentifier(hkIdentifier: string): boolean {
return HK_CYCLE_IDENTIFIERS.has(hkIdentifier);
}
/**
* Map one inbound HK reproductive sample to its cycle destination.
*
* @param hkIdentifier the `HKCategoryTypeIdentifier…` string.
* @param rawValue the sample's `value` attribute (symbolic name OR integer
* codepoint as a string), `undefined` for a presence-only sample.
* @param protectionUsed resolved `HKMetadataKeySexualActivityProtectionUsed`
* metadata for a SexualActivity sample (`undefined` when absent).
* @returns a routed destination, or `{ kind: "skip" }` when the value is
* unrecognised or the identifier carries no cycle meaning.
*/
export function mapHkCycleSample(
hkIdentifier: string,
rawValue: string | undefined,
protectionUsed?: boolean,
): HkCycleRoute {
// Symptom category types → catalogue key (skip an explicit not-present).
const symptomKey = HK_SYMPTOM_KEY_BY_IDENTIFIER[hkIdentifier];
if (symptomKey) {
if (!hkSymptomIsPresent(rawValue)) return { kind: "skip" };
return { kind: "day-log", fields: { symptomKey } };
}
switch (hkIdentifier) {
case HK_MENSTRUAL_FLOW: {
const flow = rawValue ? HK_MENSTRUAL_FLOW_VALUES[rawValue] : undefined;
if (!flow) return { kind: "skip" };
return { kind: "day-log", fields: { flow } };
}
case HK_INTERMENSTRUAL_BLEEDING:
// A present sample asserts spotting outside the period window.
return { kind: "day-log", fields: { intermenstrualBleeding: true } };
case HK_CERVICAL_MUCUS: {
const cervicalMucus = rawValue
? HK_CERVICAL_MUCUS_VALUES[rawValue]
: undefined;
if (!cervicalMucus) return { kind: "skip" };
return { kind: "day-log", fields: { cervicalMucus } };
}
case HK_OVULATION_TEST: {
const ovulationTest = rawValue
? HK_OVULATION_TEST_VALUES[rawValue]
: undefined;
if (!ovulationTest) return { kind: "skip" };
return { kind: "day-log", fields: { ovulationTest } };
}
case HK_SEXUAL_ACTIVITY:
return {
kind: "day-log",
fields: {
sexualActivity: true,
protectedSex: protectionUsed === undefined ? null : protectionUsed,
},
};
case HK_PREGNANCY_TEST: {
const pregnancyTest = rawValue
? HK_HOME_TEST_VALUES[rawValue]
: undefined;
if (!pregnancyTest) return { kind: "skip" };
return { kind: "day-log", fields: { pregnancyTest } };
}
case HK_PROGESTERONE_TEST: {
const progesteroneTest = rawValue
? HK_HOME_TEST_VALUES[rawValue]
: undefined;
if (!progesteroneTest) return { kind: "skip" };
return { kind: "day-log", fields: { progesteroneTest } };
}
case HK_CONTRACEPTIVE: {
const contraceptive = rawValue
? HK_CONTRACEPTIVE_VALUES[rawValue]
: undefined;
if (!contraceptive) return { kind: "skip" };
// Record the method on the day-log timeline AND nudge the profile
// goal toward AVOID_PREGNANCY (the importer applies the nudge only
// when the goal is still on its GENERAL_HEALTH default).
return {
kind: "day-log+profile",
fields: { contraceptive },
profile: { contraceptive },
};
}
// HK_PREGNANCY / HK_LACTATION carry no v1.15.0 schema destination
// (pregnancy-mode is deferred); they fall through to skip and the
// importer leaves them in its `deferred` tally rather than inventing a
// status column.
default:
return { kind: "skip" };
}
}
+243
View File
@@ -0,0 +1,243 @@
/**
* 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 { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
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:";
/** 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 },
}),
]);
// Inline the gate logic (no profile auto-create on the import path).
const toggle = profile?.cycleTrackingEnabled;
this.gated =
toggle === true
? true
: toggle === false
? false
: user?.gender === "FEMALE";
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;
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 = await findOwningCycleId(this.userId, dayKey);
const result = await upsertCycleDayLog(
this.userId,
entry,
this.userTimezone,
cycleId,
);
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;
}
return stats;
}
}
+25
View File
@@ -23,6 +23,7 @@ import {
DEFAULT_DOCTOR_REPORT_PREFS,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { buildCycleExportSummary } from "@/lib/cycle/export-data";
export interface DoctorReportStats {
avg: number;
@@ -192,6 +193,17 @@ export interface DoctorReportData {
/** ISO timestamp of the latest score. */
latestAt: string;
}> | null;
/**
* v1.15.0 — concise menstrual-cycle summary (LMP, recent cycles, avg
* length + variability, current phase). Populated ONLY when the `cycle`
* section toggle is explicitly ON (privacy default OFF — a user sharing a
* BP report with a cardiologist must not auto-leak reproductive data) AND
* the user has at least one observed cycle. Statistics only — no free-text
* notes ever reach this surface. Null/absent otherwise; both the PDF and
* FHIR builders then skip the section. Optional so pre-v1.15 fixtures
* still typecheck.
*/
cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null;
}
/** The three persisted score types surfaced in the wellness summary. */
@@ -801,6 +813,18 @@ export async function collectDoctorReportData(
const wellnessScores =
wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null;
// v1.15.0 — cycle summary. Privacy default OFF: only read + assemble when
// the `cycle` section toggle is explicitly ON. Statistics only — the
// helper never touches `notesEncrypted`, so no plaintext free-text can
// leak through this surface. `null` when disabled or no observed cycle.
let cycle: DoctorReportData["cycle"] = null;
if (sections.cycle) {
cycle = await buildCycleExportSummary(
userId,
end.toISOString().slice(0, 10),
);
}
return {
period: {
days,
@@ -848,6 +872,7 @@ export async function collectDoctorReportData(
mood,
glp1,
wellnessScores,
cycle,
};
}
+92
View File
@@ -1222,6 +1222,98 @@ export function buildDoctorReportPdfDocument(
.finalY + 8;
}
// v1.15.0 — cycle / reproductive-health section. Privacy default OFF: the
// aggregator only populates `data.cycle` when the cycle section toggle is
// explicitly ON AND the user has an observed cycle. Statistics only (no
// free-text notes). Skipped entirely otherwise.
if (data.cycle) {
const cyc = data.cycle;
y = ensureSpace(y, 6 + 6 + estimateTableHeight(1));
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.cycleTitle"), margin, y);
y += 6;
// Summary line: LMP, avg length ± variability, avg period, phase.
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
const summaryBits: string[] = [];
if (cyc.lastPeriodStart) {
summaryBits.push(
`${t("doctorReport.cycleLmp")}: ${fmtDate(`${cyc.lastPeriodStart}T12:00:00.000Z`)}`,
);
}
if (cyc.averageCycleLengthDays !== null) {
const varSuffix =
cyc.cycleLengthVariabilityDays !== null
? ` (± ${num(cyc.cycleLengthVariabilityDays, 1)})`
: "";
summaryBits.push(
`${t("doctorReport.cycleAvgLength")}: ${num(cyc.averageCycleLengthDays, 1)} ${t("doctorReport.cycleDays")}${varSuffix}`,
);
}
if (cyc.averagePeriodLengthDays !== null) {
summaryBits.push(
`${t("doctorReport.cycleAvgPeriod")}: ${num(cyc.averagePeriodLengthDays, 1)} ${t("doctorReport.cycleDays")}`,
);
}
if (cyc.currentPhase) {
summaryBits.push(
`${t("doctorReport.cyclePhase")}: ${t(`doctorReport.cyclePhases.${cyc.currentPhase}`)}`,
);
}
for (const line of doc.splitTextToSize(
summaryBits.join(" • "),
pageWidth - margin * 2,
)) {
doc.text(line, margin, y);
y += 4.5;
}
y += 2;
if (cyc.recentCycles.length > 0) {
const cycleRows = cyc.recentCycles.map((c) => [
fmtDate(`${c.startDate}T12:00:00.000Z`),
c.lengthDays !== null ? String(c.lengthDays) : "—",
c.periodLengthDays !== null ? String(c.periodLengthDays) : "—",
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.cycleColStart"),
t("doctorReport.cycleColLength"),
t("doctorReport.cycleColPeriod"),
],
],
body: cycleRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
}
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
// rendered ONLY when the user explicitly opted in. Clearly labelled and
// flagged as not clinically validated so a physician never mistakes it
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { cycleObservationsFromReportData } from "@/lib/fhir/resources";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import {
LMP_LOINC,
CYCLE_LENGTH_LOINC,
PERIOD_LENGTH_LOINC,
} from "@/lib/fhir/loinc-map";
function baseData(overrides?: Partial<DoctorReportData>): DoctorReportData {
return {
period: {
days: 90,
since: "2026-02-02T00:00:00.000Z",
start: "2026-02-02T00:00:00.000Z",
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
dateOfBirth: "1990-06-15T00:00:00.000Z",
gender: "FEMALE",
heightCm: 168,
},
practiceName: null,
measurements: {},
stats: {},
glucoseStats: {},
glucoseRanges: {},
glucoseUnit: "mg/dL",
bmi: null,
compliance: {},
medications: [],
mood: null,
glp1: null,
...overrides,
};
}
const CYCLE_SUMMARY: NonNullable<DoctorReportData["cycle"]> = {
lastPeriodStart: "2026-04-20",
recentCycles: [
{ startDate: "2026-04-20", lengthDays: null, periodLengthDays: 5 },
{ startDate: "2026-03-23", lengthDays: 28, periodLengthDays: 5 },
{ startDate: "2026-02-24", lengthDays: 27, periodLengthDays: 4 },
],
observedCycleCount: 3,
averageCycleLengthDays: 27.5,
cycleLengthVariabilityDays: 0.5,
averagePeriodLengthDays: 5,
currentPhase: "LUTEAL",
};
describe("fhir/cycle observations", () => {
it("emits nothing when no cycle summary is present", () => {
expect(cycleObservationsFromReportData(baseData())).toEqual([]);
});
it("emits LMP, cycle length, period length, and phase", () => {
const obs = cycleObservationsFromReportData(
baseData({ cycle: CYCLE_SUMMARY }),
);
const codes = obs.flatMap((o) =>
(o.code.coding ?? []).map((c) => c.code).filter(Boolean),
);
expect(codes).toContain(LMP_LOINC);
expect(codes).toContain(CYCLE_LENGTH_LOINC);
expect(codes).toContain(PERIOD_LENGTH_LOINC);
const lmp = obs.find((o) =>
(o.code.coding ?? []).some((c) => c.code === LMP_LOINC),
);
expect(lmp?.valueDateTime).toBe("2026-04-20");
const phase = obs.find((o) => o.code.text === "Current menstrual cycle phase");
expect(phase?.valueString).toBe("Luteal phase");
// Cycle observations carry their own id sequence, never `obs-N`.
expect(obs.every((o) => o.id.startsWith("obs-cycle-"))).toBe(true);
});
it("adds a 'Menstrual cycle' Composition section to the document bundle", () => {
const bundle = buildFhirDocumentBundle(
baseData({ cycle: CYCLE_SUMMARY }),
{ insuranceNumber: null },
);
const composition = bundle.entry?.find(
(e) => e.resource.resourceType === "Composition",
)?.resource as { section?: Array<{ title: string }> } | undefined;
const titles = composition?.section?.map((s) => s.title) ?? [];
expect(titles).toContain("Menstrual cycle");
});
it("omits the cycle section entirely when the summary is null", () => {
const bundle = buildFhirDocumentBundle(
baseData({ cycle: null }),
{ insuranceNumber: null },
);
const composition = bundle.entry?.find(
(e) => e.resource.resourceType === "Composition",
)?.resource as { section?: Array<{ title: string }> } | undefined;
const titles = composition?.section?.map((s) => s.title) ?? [];
expect(titles).not.toContain("Menstrual cycle");
});
});
+21
View File
@@ -32,6 +32,7 @@ import {
patientResource,
coverageResource,
observationsFromReportData,
cycleObservationsFromReportData,
medicationStatementsFromReportData,
medicationAdministrationsFromReportData,
} from "@/lib/fhir/resources";
@@ -80,6 +81,7 @@ export function buildFhirDocumentBundle(
};
const entries: FhirBundleEntry[] = [];
const observationRefs: FhirReference[] = [];
const cycleObservationRefs: FhirReference[] = [];
const medicationRefs: FhirReference[] = [];
const administrationRefs: FhirReference[] = [];
@@ -99,6 +101,15 @@ export function buildFhirDocumentBundle(
observationRefs.push({ reference: `Observation/${obs.id}` });
}
// --- Cycle / reproductive-health Observations (opt-in only) -----------
// v1.15.0 — grouped into their own "Menstrual cycle" Composition section
// so a reproductive-health finding is never mixed into the vital-signs
// panel. Absent unless the user toggled the cycle section AND has data.
for (const obs of cycleObservationsFromReportData(data)) {
entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs });
cycleObservationRefs.push({ reference: `Observation/${obs.id}` });
}
// --- MedicationStatement per active medication -------------------------
for (const stmt of medicationStatementsFromReportData(data, options)) {
entries.push({ fullUrl: `urn:uuid:${stmt.id}`, resource: stmt });
@@ -175,6 +186,16 @@ export function buildFhirDocumentBundle(
},
]
: []),
// v1.15.0 — opt-in reproductive-health section. Only present when the
// cycle toggle surfaced cycle Observations.
...(cycleObservationRefs.length > 0
? [
{
title: "Menstrual cycle",
entry: cycleObservationRefs,
},
]
: []),
],
};
+8
View File
@@ -308,3 +308,11 @@ export const GLUCOSE_LOINC: Record<string, { loinc: string; display: string }> =
export const MEDICATION_ADHERENCE_LOINC = "71799-1";
/** Mood Observation LOINC (opt-in only). */
export const MOOD_LOINC = "76542-6";
/* ── Cycle / reproductive-health LOINCs (v1.15.0, opt-in only) ──────── */
/** Last menstrual period (LMP) start date. */
export const LMP_LOINC = "8665-2";
/** Menstrual cycle length [time]. */
export const CYCLE_LENGTH_LOINC = "64700-8";
/** Length of menses (period / bleeding duration). */
export const PERIOD_LENGTH_LOINC = "64698-4";
+142
View File
@@ -31,6 +31,9 @@ import {
GLUCOSE_LOINC,
MEDICATION_ADHERENCE_LOINC,
MOOD_LOINC,
LMP_LOINC,
CYCLE_LENGTH_LOINC,
PERIOD_LENGTH_LOINC,
type LoincMapping,
} from "@/lib/fhir/loinc-map";
import type {
@@ -593,6 +596,145 @@ export function observationsFromReportData(
return observations;
}
/**
* Phase → SNOMED-free display string for the current-phase Observation.
* The four cycle phases have no single published LOINC answer-list term we
* commit to, so the phase rides a text-only concept under the `survey`
* category (a clinician's viewer reads it as a descriptive finding, not a
* coded diagnosis), mirroring the wellness-score stance.
*/
const CYCLE_PHASE_DISPLAY: Record<string, string> = {
MENSTRUAL: "Menstrual phase",
FOLLICULAR: "Follicular phase",
OVULATORY: "Ovulatory phase",
LUTEAL: "Luteal phase",
};
/**
* v1.15.0 — emit the cycle / reproductive-health Observations from the
* opt-in cycle summary: LMP (LOINC 8665-2, a date value), average cycle
* length (64700-8, days), average period length (64698-4, days), and the
* current phase (text-only survey finding). Absent when the aggregator
* carried no cycle summary (toggle off or no observed cycle). Ids run
* `obs-cycle-N` so they never collide with the main `obs-N` sequence.
*/
export function cycleObservationsFromReportData(
data: DoctorReportData,
): FhirObservation[] {
const cycle = data.cycle;
if (!cycle) return [];
const observations: FhirObservation[] = [];
let seq = 0;
const next = () => `obs-cycle-${(seq += 1)}`;
const effective = data.period.end;
if (cycle.lastPeriodStart) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: LMP_LOINC,
display: "Last menstrual period start date",
},
],
text: "Last menstrual period (LMP)",
},
subject: patientRef,
effectiveDateTime: effective,
// LMP is a date value; emit the YYYY-MM-DD as a FHIR `date`.
valueDateTime: cycle.lastPeriodStart,
});
}
if (cycle.averageCycleLengthDays !== null) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: CYCLE_LENGTH_LOINC,
display: "Menstrual cycle length",
},
],
text: "Average menstrual cycle length",
},
subject: patientRef,
effectiveDateTime: effective,
valueQuantity: {
value: cycle.averageCycleLengthDays,
unit: "d",
system: UCUM_SYSTEM,
code: "d",
},
...(cycle.cycleLengthVariabilityDays !== null
? {
note: [
{
text: `Cycle-length variability (median absolute deviation): ±${cycle.cycleLengthVariabilityDays} days over ${cycle.observedCycleCount} observed cycle(s).`,
},
],
}
: {}),
});
}
if (cycle.averagePeriodLengthDays !== null) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: PERIOD_LENGTH_LOINC,
display: "Length of menses",
},
],
text: "Average period length",
},
subject: patientRef,
effectiveDateTime: effective,
valueQuantity: {
value: cycle.averagePeriodLengthDays,
unit: "d",
system: UCUM_SYSTEM,
code: "d",
},
});
}
if (cycle.currentPhase) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: { text: "Current menstrual cycle phase" },
subject: patientRef,
effectiveDateTime: effective,
valueString: CYCLE_PHASE_DISPLAY[cycle.currentPhase] ?? cycle.currentPhase,
note: [
{
text: "Phase derived from logged cycle boundaries; descriptive, not a clinical assessment.",
},
],
});
}
return observations;
}
/**
* Emit one `MedicationStatement` per active medication. v1.9.0 — additive
* ATC (primary) / RxNorm (secondary) codings when the user stored them;
+3
View File
@@ -79,6 +79,9 @@ export interface FhirObservation {
valueQuantity?: FhirQuantity;
valueInteger?: number;
valueString?: string;
/** v1.15.0 — date-typed value (e.g. the LMP start date). */
valueDateTime?: string;
valueCodeableConcept?: FhirCodeableConcept;
component?: FhirObservationComponent[];
/** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */
note?: FhirAnnotation[];
@@ -111,6 +111,9 @@ describe("runOffhostBackup", () => {
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
};
const now = new Date("2026-05-08T03:00:00Z");
@@ -150,6 +153,9 @@ describe("runOffhostBackup", () => {
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
};
const report = await runOffhostBackup(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+32 -13
View File
@@ -214,19 +214,35 @@ export async function runOffhostBackup(
const evt = getEvent();
for (const user of users) {
try {
const [measurements, medications, intakeEvents, moodEntries] =
await Promise.all([
// includes soft-deleted rows because this is the DR snapshot,
// not a user-facing export — see
// `/api/export/full-backup/route.ts` for the symmetric exclusion.
prisma.measurement.findMany({ where: { userId: user.id } }),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }),
prisma.moodEntry.findMany({ where: { userId: user.id } }),
]);
const [
measurements,
medications,
intakeEvents,
moodEntries,
cycleProfile,
cycles,
cycleDayLogs,
] = await Promise.all([
// includes soft-deleted rows because this is the DR snapshot,
// not a user-facing export — see
// `/api/export/full-backup/route.ts` for the symmetric exclusion.
prisma.measurement.findMany({ where: { userId: user.id } }),
prisma.medication.findMany({
where: { userId: user.id },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }),
prisma.moodEntry.findMany({ where: { userId: user.id } }),
// v1.15.0 — cycle tables in the DR snapshot. Raw rows (this is a
// disaster-recovery dump, not the canonical user-facing backup);
// `notesEncrypted` stays ciphertext.
prisma.cycleProfile.findUnique({ where: { userId: user.id } }),
prisma.menstrualCycle.findMany({ where: { userId: user.id } }),
prisma.cycleDayLog.findMany({
where: { userId: user.id },
include: { symptomLinks: true },
}),
]);
const payload = JSON.stringify({
exportedAt: now.toISOString(),
@@ -235,6 +251,9 @@ export async function runOffhostBackup(
medications,
intakeEvents,
moodEntries,
cycleProfile,
cycles,
cycleDayLogs,
});
const ciphertext = encryptBackup(payload, cfg.encryptionKey);
+8 -1
View File
@@ -11,6 +11,7 @@ import type { Job } from "pg-boss";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
@@ -2169,7 +2170,7 @@ async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
let backed = 0;
for (const user of users) {
try {
const [measurements, medications, intakeEvents, moodEntries] =
const [measurements, medications, intakeEvents, moodEntries, cycle] =
await Promise.all([
prisma.measurement.findMany({
where: { userId: user.id },
@@ -2188,6 +2189,8 @@ async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
where: { userId: user.id },
orderBy: { moodLoggedAt: "desc" },
}),
// v1.15.0 — cycle slice (shared helper, notesEncrypted verbatim).
buildCycleBackupSection(prisma, user.id),
]);
const backupJson = JSON.stringify({
@@ -2231,6 +2234,10 @@ async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
source: e.source,
loggedAt: e.moodLoggedAt.toISOString(),
})),
// v1.15.0 — cycle slice (profile + observed spans + day-logs).
cycleProfile: cycle.cycleProfile,
cycles: cycle.cycles,
cycleDayLogs: cycle.cycleDayLogs,
});
// Encrypt the backup data (contains sensitive health information)
@@ -202,19 +202,39 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
// Behavioural / habit
"HKCategoryTypeIdentifierHandwashingEvent",
"HKCategoryTypeIdentifierToothbrushingEvent",
// Reproductive / fertility / pregnancy
"HKCategoryTypeIdentifierContraceptive",
// Reproductive / fertility — v1.15.0 promoted MenstrualFlow,
// IntermenstrualBleeding, CervicalMucusQuality, OvulationTestResult,
// Contraceptive, PregnancyTestResult, ProgesteroneTestResult,
// SexualActivity and SleepChanges OUT of the deferred list (they route
// into CYCLE day-logs via the cycle accumulator). Pregnancy +
// Lactation STATUS and the four awareness types stay deferred (no
// v1.15.0 schema destination / server-derived).
"HKCategoryTypeIdentifierLactation",
"HKCategoryTypeIdentifierPregnancy",
"HKCategoryTypeIdentifierPregnancyTestResult",
"HKCategoryTypeIdentifierProgesteroneTestResult",
"HKCategoryTypeIdentifierSexualActivity",
"HKCategoryTypeIdentifierSleepChanges",
"HKCategoryTypeIdentifierBleedingAfterPregnancy",
"HKCategoryTypeIdentifierBleedingDuringPregnancy",
"HKCategoryTypeIdentifierPersistentIntermenstrualBleeding",
"HKCategoryTypeIdentifierProlongedMenstrualPeriods",
"HKCategoryTypeIdentifierIrregularMenstrualCycles",
"HKCategoryTypeIdentifierInfrequentMenstrualCycles",
];
// v1.15.0 — these are NO LONGER deferred (promoted into cycle day-logs).
for (const id of [
"HKCategoryTypeIdentifierMenstrualFlow",
"HKCategoryTypeIdentifierIntermenstrualBleeding",
"HKCategoryTypeIdentifierCervicalMucusQuality",
"HKCategoryTypeIdentifierOvulationTestResult",
"HKCategoryTypeIdentifierContraceptive",
"HKCategoryTypeIdentifierPregnancyTestResult",
"HKCategoryTypeIdentifierProgesteroneTestResult",
"HKCategoryTypeIdentifierSexualActivity",
]) {
expect(
HK_QUANTITY_TYPE_DEFERRED.has(id),
`${id} should NOT be deferred (promoted to cycle in v1.15.0)`,
).toBe(false);
}
for (const id of expectedLongTail) {
expect(
HK_QUANTITY_TYPE_DEFERRED.has(id),
+17 -12
View File
@@ -890,13 +890,17 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// Mental-state / mindfulness — v1.5 (route to existing mood model)
"HKCategoryTypeIdentifierMindfulSession",
"HKDataTypeIdentifierStateOfMind",
// Pregnancy / cycle — explicit hold (privacy stance)
// Pregnancy bleeding — stays deferred (pregnancy-mode is a later
// release). v1.15.0 promoted the cycle-tracking reproductive types
// (MenstrualFlow / IntermenstrualBleeding / CervicalMucusQuality /
// OvulationTestResult and the symptom + test-result + contraceptive
// identifiers) OUT of this deferred list — they route into CYCLE
// day-logs via the importer's cycle accumulator (see
// `src/lib/cycle/healthkit-mapping.ts`), NOT into Measurement, so they
// must NOT appear here or the importer would skip them before the cycle
// branch runs.
"HKCategoryTypeIdentifierBleedingAfterPregnancy",
"HKCategoryTypeIdentifierBleedingDuringPregnancy",
"HKCategoryTypeIdentifierMenstrualFlow",
"HKCategoryTypeIdentifierIntermenstrualBleeding",
"HKCategoryTypeIdentifierCervicalMucusQuality",
"HKCategoryTypeIdentifierOvulationTestResult",
// Clinical (FHIR) — v1.6+
"HKClinicalTypeIdentifierAllergyRecord",
"HKClinicalTypeIdentifierConditionRecord",
@@ -950,15 +954,16 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// Behavioural / habit category-types — not in HealthLog scope yet.
"HKCategoryTypeIdentifierHandwashingEvent",
"HKCategoryTypeIdentifierToothbrushingEvent",
// Reproductive / fertility / pregnancy — explicit privacy hold
// (matches the menstrual-flow stance already encoded above).
"HKCategoryTypeIdentifierContraceptive",
// Reproductive / fertility — v1.15.0 promoted Contraceptive,
// PregnancyTestResult, ProgesteroneTestResult and SexualActivity OUT of
// this list (they route into CYCLE day-logs via the cycle accumulator).
// Pregnancy + Lactation STATUS stay deferred: the v1.15.0 CycleProfile
// has no pregnant/lactating column (pregnancy-mode is a later release),
// so there is no destination yet — promoting them would only grow the
// importer's skip tally. The four awareness types are server-DERIVED
// from the cycle engine, never ingested.
"HKCategoryTypeIdentifierLactation",
"HKCategoryTypeIdentifierPregnancy",
"HKCategoryTypeIdentifierPregnancyTestResult",
"HKCategoryTypeIdentifierProgesteroneTestResult",
"HKCategoryTypeIdentifierSexualActivity",
"HKCategoryTypeIdentifierSleepChanges",
"HKCategoryTypeIdentifierPersistentIntermenstrualBleeding",
"HKCategoryTypeIdentifierProlongedMenstrualPeriods",
"HKCategoryTypeIdentifierIrregularMenstrualCycles",
@@ -44,6 +44,11 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import {
CycleImportAccumulator,
type CycleImportStats,
} from "@/lib/cycle/import-accumulator";
import { HK_SEXUAL_ACTIVITY_PROTECTION_META } from "@/lib/cycle/healthkit-mapping";
/**
* Reverse-lookup for the symbolic `HKCategoryValueSleepAnalysis*`
@@ -103,6 +108,13 @@ export interface ImportJobResult {
durationMs: number;
};
clinical: { skipped: number };
/**
* v1.15.0 — reproductive HealthKit samples routed into CYCLE day-logs
* (NOT Measurement). Absent / zeroed when the account has no cycle
* tracking enabled (the fold is gated) or the export carried no
* reproductive records.
*/
cycle: CycleImportStats;
deferred: Record<string, number>;
unknown: Record<string, number>;
totals: {
@@ -281,6 +293,16 @@ export async function streamParseExportXml(
const deferred: Record<string, number> = {};
const unknown: Record<string, number> = {};
// v1.15.0 — reproductive HK samples fold into one CycleDayLog per day.
// The accumulator buckets in memory; the flush at end of parse upserts.
const cycleAccumulator = new CycleImportAccumulator(userId, userTimezone);
// Context for a reproductive `<Record>` whose protection metadata
// arrives as a following `<MetadataEntry>` child (SexualActivity). Held
// between the Record open-tag and its close-tag so the child can attach.
let currentCycleRecord:
| { hkType: string; dayKey: string; rawValue: string | undefined; protectionUsed?: boolean }
| null = null;
// Cumulative-type fold: type -> dayKey -> running sum.
const cumulativeBucket = new Map<MeasurementType, Map<string, number>>();
// Spot-row batch awaiting flush.
@@ -520,6 +542,22 @@ export async function streamParseExportXml(
if (!hkType) return;
recordsRead += 1;
// v1.15.0 — reproductive HK identifiers route into CYCLE day-logs,
// not Measurement. Defer the fold until the Record's close-tag so a
// child `<MetadataEntry>` (SexualActivity protection flag) can
// attach. The accumulator's per-day bucketing handles same-day
// merges + idempotent re-import.
if (CycleImportAccumulator.handles(hkType)) {
const dayKey = dayKeyForUserTz(
new Date(attrs.endDate ?? attrs.startDate ?? ""),
userTimezone,
);
currentCycleRecord = Number.isNaN(Date.parse(attrs.endDate ?? attrs.startDate ?? ""))
? null
: { hkType, dayKey, rawValue: attrs.value };
return;
}
if (HK_QUANTITY_TYPE_DEFERRED.has(hkType)) {
deferred[hkType] = (deferred[hkType] ?? 0) + 1;
return;
@@ -666,8 +704,19 @@ export async function streamParseExportXml(
return;
}
if (name === "MetadataEntry") {
// Attach the SexualActivity protection flag to the open cycle record.
// Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a
// `"0"`/`"1"` (or `"true"`/`"false"`) value.
if (currentCycleRecord && attrs.key === HK_SEXUAL_ACTIVITY_PROTECTION_META) {
const v = (attrs.value ?? "").toLowerCase();
currentCycleRecord.protectionUsed = v === "1" || v === "true" || v === "yes";
}
return;
}
if (name === "ExportDate" || name === "Me" || name === "Correlation"
|| name === "ActivitySummary" || name === "MetadataEntry"
|| name === "ActivitySummary"
|| name === "WorkoutEvent" || name === "WorkoutRoute"
|| name === "FileReference" || name === "HealthData") {
// Known elements we intentionally ignore at the open-tag stage.
@@ -686,6 +735,22 @@ export async function streamParseExportXml(
workoutBatch.push(currentWorkout);
currentWorkout = null;
}
if (tagName === "Record" && currentCycleRecord) {
const rec = currentCycleRecord;
currentCycleRecord = null;
const consumed = cycleAccumulator.consume(
rec.hkType,
rec.dayKey,
rec.rawValue,
rec.protectionUsed,
);
if (!consumed) {
// Recognised identifier but unrecognised value — count it under
// `unknown` with the reason tag so operators can spot it.
unknown[`${rec.hkType}::cycle_unmapped`] =
(unknown[`${rec.hkType}::cycle_unmapped`] ?? 0) + 1;
}
}
};
// ── Drive the parser from a node read stream ────────────────
@@ -747,12 +812,31 @@ export async function streamParseExportXml(
await flushWorkoutBatch();
await flushCumulativeBuckets();
// v1.15.0 — fold the accumulated reproductive samples into CYCLE
// day-logs. Gated on cycle-tracking being enabled for the account so a
// non-cycle Apple Health export never silently provisions cycle rows.
let cycle: CycleImportStats = {
samplesConsumed: 0,
daysUpserted: 0,
daysInserted: 0,
goalNudged: false,
};
// Only touch the cycle tables when the export actually carried
// reproductive samples AND the account has cycle tracking enabled. The
// empty-accumulator short-circuit also keeps the no-cycle import path
// (and its unit tests) free of any cycle DB round-trip.
if (cycleAccumulator.hasSamples() && (await cycleAccumulator.isEnabled())) {
cycle = await cycleAccumulator.flush();
rowsUpserted += cycle.daysUpserted;
}
await emitProgress("upserting");
return {
perType,
workouts,
clinical,
cycle,
deferred,
unknown,
totals: {
@@ -84,3 +84,67 @@ describe("moodEntrySchema.tags strict validation", () => {
expect(r.success).toBe(true);
});
});
describe("backupPayloadSchema — v1.15.0 cycle round-trip", () => {
it("defaults the cycle fields when a pre-v1.15 blob omits them", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.cycleProfile).toBeNull();
expect(r.data.cycles).toEqual([]);
expect(r.data.cycleDayLogs).toEqual([]);
}
});
it("round-trips a cycle profile, observed span, and day-log with notes ciphertext", () => {
const r = backupPayloadSchema.safeParse({
schemaVersion: "1",
exportedAt: "2026-05-08T07:00:00.000Z",
userId: "u1",
cycleProfile: {
goal: "TRYING_TO_CONCEIVE",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
lutealPhaseLength: 14,
sensitiveCategoryEncryption: true,
},
cycles: [
{
startDate: "2026-04-20",
endDate: "2026-05-17",
periodEndDate: "2026-04-24",
lengthDays: 28,
ovulationDate: "2026-05-04",
ovulationConfirmed: true,
tz: "Europe/Berlin",
},
],
cycleDayLogs: [
{
date: "2026-04-20",
flow: "MEDIUM",
intermenstrualBleeding: false,
sexualActivity: true,
protectedSex: false,
contraceptive: "NONE",
// The note must round-trip as the opaque ciphertext envelope.
notesEncrypted: "v1:deadbeef:cipher",
source: "APPLE_HEALTH",
externalId: "hkcycle:2026-04-20",
symptomKeys: ["cramps", "fatigue"],
},
],
});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.cycleProfile?.goal).toBe("TRYING_TO_CONCEIVE");
expect(r.data.cycles[0].lengthDays).toBe(28);
expect(r.data.cycleDayLogs[0].notesEncrypted).toBe("v1:deadbeef:cipher");
expect(r.data.cycleDayLogs[0].symptomKeys).toEqual(["cramps", "fatigue"]);
}
});
});
@@ -85,6 +85,7 @@ describe("resolveDoctorReportPrefs", () => {
mood: true,
compliance: false,
sleep: false,
cycle: false,
};
const out = resolveDoctorReportPrefs(current, { mood: false });
expect(out).toEqual({ ...current, mood: false });
+74
View File
@@ -108,6 +108,69 @@ const moodEntrySchema = z
})
.passthrough();
/* ── v1.15.0 cycle-tracking backup shapes ──────────────────────────── */
/**
* One menstrual-cycle span. `startDate` is the natural per-user key
* (matching the `(userId, startDate)` unique), so a restore upserts on it.
* Predicted (forecast) rows are excluded from the backup — only observed
* history round-trips.
*/
const cycleSpanSchema = z
.object({
startDate: z.string().min(1),
endDate: z.string().nullable().optional(),
periodEndDate: z.string().nullable().optional(),
lengthDays: z.number().int().nullable().optional(),
ovulationDate: z.string().nullable().optional(),
ovulationConfirmed: z.boolean().optional(),
tz: z.string().nullable().optional(),
})
.passthrough();
/**
* One cycle day-log. `notesEncrypted` is carried as the AES-256-GCM
* ciphertext envelope verbatim — the backup never decrypts it, so the
* owner's free-text note round-trips encrypted (and a wrong-surface leak is
* impossible). `symptomKeys` carries the seeded catalogue keys so the
* restore can re-link without exporting internal join ids.
*/
const cycleDayLogSchema = z
.object({
date: z.string().min(1),
flow: z.string().nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().nullable().optional(),
ovulationTest: z.string().nullable().optional(),
cervicalMucus: z.string().nullable().optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: z.string().nullable().optional(),
progesteroneTest: z.string().nullable().optional(),
contraceptive: z.string().nullable().optional(),
notesEncrypted: z.string().nullable().optional(),
source: z.string().min(1).optional(),
externalId: z.string().nullable().optional(),
tz: z.string().nullable().optional(),
symptomKeys: z.array(z.string()).default([]),
})
.passthrough();
/** Cycle-tracking preferences (one row per user). */
const cycleProfileSchema = z
.object({
goal: z.string().min(1).optional(),
cycleTrackingEnabled: z.boolean().nullable().optional(),
typicalCycleLength: z.number().int().nullable().optional(),
typicalPeriodLength: z.number().int().nullable().optional(),
lutealPhaseLength: z.number().int().nullable().optional(),
predictionEnabled: z.boolean().optional(),
rawChartMode: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
sensitiveCategoryEncryption: z.boolean().optional(),
})
.passthrough();
/**
* Wire shape — exactly what the pg-boss worker writes today, plus a
* `schemaVersion` field that newer writers stamp explicitly. Older blobs
@@ -122,6 +185,11 @@ export const backupPayloadSchema = z
medications: z.array(medicationSchema).default([]),
intakeEvents: z.array(intakeEventSchema).default([]),
moodEntries: z.array(moodEntrySchema).default([]),
// v1.15.0 — cycle-tracking tables. Default to empty arrays / null so a
// pre-v1.15 backup (no cycle keys) still round-trips unchanged.
cycleProfile: cycleProfileSchema.nullable().default(null),
cycles: z.array(cycleSpanSchema).default([]),
cycleDayLogs: z.array(cycleDayLogSchema).default([]),
})
.passthrough();
@@ -140,6 +208,10 @@ export interface BackupSummary {
medications: number;
intakeEvents: number;
moodEntries: number;
/** v1.15.0 — observed cycle spans in the backup. */
cycles: number;
/** v1.15.0 — cycle day-logs in the backup. */
cycleDayLogs: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
@@ -151,6 +223,8 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
medications: payload.medications.length,
intakeEvents: payload.intakeEvents.length,
moodEntries: payload.moodEntries.length,
cycles: payload.cycles.length,
cycleDayLogs: payload.cycleDayLogs.length,
};
}
@@ -31,6 +31,7 @@ export const doctorReportPrefsSchema = z
mood: z.boolean(),
compliance: z.boolean(),
sleep: z.boolean(),
cycle: z.boolean(),
})
.partial();
@@ -49,6 +50,7 @@ export interface DoctorReportPrefs {
mood: boolean;
compliance: boolean;
sleep: boolean;
cycle: boolean;
}
/**
@@ -65,6 +67,9 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
mood: false, // privacy default per Marc
compliance: true,
sleep: true,
// Cycle data is opt-in: a user sharing a BP report with a cardiologist
// should not auto-leak reproductive data. Same privacy stance as mood.
cycle: false,
};
/**
@@ -72,6 +72,8 @@ export const exportSectionsSchema = z
medications: medicationsGroupSchema,
mood: z.boolean(),
bmi: z.boolean(),
// Cycle / reproductive health — opt-in (privacy default OFF, like mood).
cycle: z.boolean(),
})
.partial();
@@ -131,5 +133,7 @@ export function toDoctorReportPrefs(
mood: s.mood === true,
compliance: m.compliance ?? m.list ?? fallback.compliance,
sleep: a.sleep ?? fallback.sleep,
// Cycle is opt-in: only true when explicitly requested (privacy).
cycle: s.cycle === true,
};
}
@@ -0,0 +1,230 @@
/**
* Integration suite for the v1.15.0 cycle import-promotion (wave 4) and the
* cycle export inclusion (wave 9) against a real Postgres testcontainer.
*
* - the Apple Health `export.xml` importer routes reproductive HK samples
* into CYCLE day-logs (NOT Measurement), gated on cycle-tracking enabled,
* merging same-day samples into one row with first-write-wins re-import
* - the full-backup cycle section round-trips through
* `buildCycleBackupSection` → `restoreCycleData` with `notesEncrypted`
* preserved verbatim and symptom links re-resolved by key
*/
import { writeFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { streamParseExportXml } from "@/lib/measurements/import-apple-health-export";
import { buildCycleBackupSection, restoreCycleData } from "@/lib/cycle/backup";
import { parseBackupPayload } from "@/lib/validations/backup";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const USER_ID = "user-cycle-import";
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: USER_ID,
username: "cycle-import",
email: "cycle-import@example.test",
gender: "FEMALE",
timezone: "Europe/Berlin",
},
});
// Ensure a catalogue symptom exists for the link re-resolution (the
// TRUNCATE CASCADE may wipe the migration-seeded catalogue).
await prisma.cycleSymptomCategory.upsert({
where: { key: "physical" },
update: {},
create: { key: "physical", labelKey: "cycle.symptomCategory.physical" },
});
const category = await prisma.cycleSymptomCategory.findUniqueOrThrow({
where: { key: "physical" },
});
await prisma.cycleSymptom.upsert({
where: { key: "cramps" },
update: {},
create: {
key: "cramps",
labelKey: "cycle.symptom.cramps",
categoryId: category.id,
},
});
});
function writeXml(records: string): string {
const dir = mkdtempSync(join(tmpdir(), "hl-cycle-xml-"));
const path = join(dir, "export.xml");
writeFileSync(
path,
`<?xml version="1.0" encoding="UTF-8"?>\n<HealthData>\n${records}\n</HealthData>`,
);
return path;
}
describe("cycle import — reproductive HK samples route to CycleDayLog", () => {
it("folds same-day flow + mucus + cramps into one day-log, not Measurement", async () => {
const prisma = getPrismaClient();
const xml = writeXml(
[
`<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowMedium" startDate="2026-03-02 08:00:00 +0000" endDate="2026-03-02 08:00:00 +0000"/>`,
`<Record type="HKCategoryTypeIdentifierCervicalMucusQuality" value="HKCategoryValueCervicalMucusQualityEggWhite" startDate="2026-03-02 09:00:00 +0000" endDate="2026-03-02 09:00:00 +0000"/>`,
`<Record type="HKCategoryTypeIdentifierAbdominalCramps" value="3" startDate="2026-03-02 10:00:00 +0000" endDate="2026-03-02 10:00:00 +0000"/>`,
].join("\n"),
);
const result = await streamParseExportXml({
xmlPath: xml,
userId: USER_ID,
userTimezone: "Europe/Berlin",
prisma,
});
expect(result.cycle.samplesConsumed).toBe(3);
expect(result.cycle.daysUpserted).toBe(1);
expect(result.cycle.daysInserted).toBe(1);
const dayLogs = await prisma.cycleDayLog.findMany({
where: { userId: USER_ID },
include: { symptomLinks: { include: { symptom: true } } },
});
expect(dayLogs).toHaveLength(1);
expect(dayLogs[0].date).toBe("2026-03-02");
expect(dayLogs[0].flow).toBe("MEDIUM");
expect(dayLogs[0].cervicalMucus).toBe("EGG_WHITE");
expect(dayLogs[0].source).toBe("APPLE_HEALTH");
expect(dayLogs[0].symptomLinks.map((l) => l.symptom.key)).toContain(
"cramps",
);
// No reproductive Measurement rows were created.
const measurements = await prisma.measurement.count({
where: { userId: USER_ID },
});
expect(measurements).toBe(0);
});
it("is idempotent on re-import (first-write-wins on the synthetic day key)", async () => {
const prisma = getPrismaClient();
const records = `<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowHeavy" startDate="2026-03-05 08:00:00 +0000" endDate="2026-03-05 08:00:00 +0000"/>`;
const xml = writeXml(records);
await streamParseExportXml({
xmlPath: xml,
userId: USER_ID,
userTimezone: "Europe/Berlin",
prisma,
});
const second = await streamParseExportXml({
xmlPath: writeXml(records),
userId: USER_ID,
userTimezone: "Europe/Berlin",
prisma,
});
expect(second.cycle.daysUpserted).toBe(1);
expect(second.cycle.daysInserted).toBe(0); // already existed
const count = await prisma.cycleDayLog.count({ where: { userId: USER_ID } });
expect(count).toBe(1);
});
it("skips the cycle fold entirely for a non-cycle account", async () => {
const prisma = getPrismaClient();
await prisma.user.update({
where: { id: USER_ID },
data: { gender: "MALE" },
});
const xml = writeXml(
`<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowLight" startDate="2026-03-02 08:00:00 +0000" endDate="2026-03-02 08:00:00 +0000"/>`,
);
const result = await streamParseExportXml({
xmlPath: xml,
userId: USER_ID,
userTimezone: "Europe/Berlin",
prisma,
});
// Sample was bucketed but the gate suppressed the flush.
expect(result.cycle.daysUpserted).toBe(0);
const count = await prisma.cycleDayLog.count({ where: { userId: USER_ID } });
expect(count).toBe(0);
});
});
describe("cycle backup — round-trips through build + restore", () => {
it("preserves notesEncrypted ciphertext and symptom links by key", async () => {
const prisma = getPrismaClient();
const cycle = await prisma.menstrualCycle.create({
data: {
userId: USER_ID,
startDate: "2026-03-01",
endDate: "2026-03-28",
periodEndDate: "2026-03-05",
lengthDays: 28,
},
});
const dayLog = await prisma.cycleDayLog.create({
data: {
userId: USER_ID,
date: "2026-03-02",
flow: "MEDIUM",
cycleId: cycle.id,
notesEncrypted: "v1:abc123:cipher-envelope",
source: "MANUAL",
},
});
const symptom = await prisma.cycleSymptom.findUniqueOrThrow({
where: { key: "cramps" },
});
await prisma.cycleSymptomLink.create({
data: { dayLogId: dayLog.id, symptomId: symptom.id },
});
await prisma.cycleProfile.create({
data: { userId: USER_ID, goal: "TRYING_TO_CONCEIVE", cycleTrackingEnabled: true },
});
// Build the backup section, then assert it round-trips through the schema.
const section = await buildCycleBackupSection(prisma, USER_ID);
expect(section.cycles).toHaveLength(1);
expect(section.cycleDayLogs).toHaveLength(1);
expect(section.cycleDayLogs[0].notesEncrypted).toBe(
"v1:abc123:cipher-envelope",
);
expect(section.cycleDayLogs[0].symptomKeys).toEqual(["cramps"]);
const payload = parseBackupPayload({
schemaVersion: "1",
exportedAt: new Date().toISOString(),
userId: USER_ID,
...section,
});
// Restore into a clean transaction (wipes then recreates).
await prisma.$transaction(async (tx) => {
await restoreCycleData(tx, USER_ID, payload);
});
const restored = await prisma.cycleDayLog.findMany({
where: { userId: USER_ID },
include: { symptomLinks: { include: { symptom: true } } },
});
expect(restored).toHaveLength(1);
expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope");
expect(restored[0].cycleId).not.toBeNull();
expect(restored[0].symptomLinks.map((l) => l.symptom.key)).toEqual([
"cramps",
]);
const profile = await prisma.cycleProfile.findUniqueOrThrow({
where: { userId: USER_ID },
});
expect(profile.goal).toBe("TRYING_TO_CONCEIVE");
});
});