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

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

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

273 lines
8.7 KiB
TypeScript

/**
* Cycle row → wire-DTO mappers (ios-contract §2 + §3).
*
* The canonical `CycleDayLogDTO` and `MenstrualCycleDTO` shapes the iOS
* client mirrors. `notesEncrypted` is decrypted fail-soft on read (a
* key-rotation gap on one row reads `null`, never 500s the whole page).
* Symptom links are flattened to `[{ key, severity }]` using the catalog
* key; `severity` carries the persisted per-link 1-4 Likert intensity
* (NULL = a plain presence link).
*/
import { decrypt } from "@/lib/crypto";
import type { CyclePredictionResult } from "@/lib/cycle/types";
import type {
CycleDayLog,
CycleSymptomLink,
CycleSymptom,
MenstrualCycle,
} from "@/generated/prisma/client";
export interface CycleSymptomDTO {
key: string;
severity: number | null;
}
export interface CycleDayLogDTO {
id: string;
date: string;
cycleId: string | null;
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;
symptoms: CycleSymptomDTO[];
note: string | null;
source: string;
externalId: string | null;
syncVersion: number;
updatedAt: string;
deletedAt: string | null;
}
export interface MenstrualCycleDTO {
id: string;
startDate: string;
endDate: string | null;
periodEndDate: string | null;
lengthDays: number | null;
ovulationDate: string | null;
ovulationConfirmed: boolean;
isPredicted: boolean;
syncVersion: number;
updatedAt: string;
}
type DayLogWithLinks = CycleDayLog & {
symptomLinks?: (CycleSymptomLink & { symptom: Pick<CycleSymptom, "key"> })[];
};
/** Decrypt a day-log note fail-soft (null on missing / undecryptable). */
function decryptNote(notesEncrypted: string | null): string | null {
if (!notesEncrypted) return null;
try {
return decrypt(notesEncrypted);
} catch {
return null;
}
}
interface SensitiveEnvelope {
sexualActivity?: boolean;
protectedSex?: boolean | null;
pregnancyTest?: string | null;
progesteroneTest?: string | null;
contraceptive?: string | null;
}
/**
* Decrypt the sensitive-category envelope fail-soft. When
* `sensitiveCategoryEncryption` was ON at write the five intent fields live
* here instead of the plaintext columns; an undecryptable envelope reads as
* an empty object so the page never 500s.
*/
function decryptSensitive(envelope: string | null): SensitiveEnvelope {
if (!envelope) return {};
try {
return JSON.parse(decrypt(envelope)) as SensitiveEnvelope;
} catch {
return {};
}
}
export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
// Prefer the encryption envelope when present; else read the plaintext
// columns (the flag-OFF path).
const enc = decryptSensitive(row.sensitiveEncrypted);
const hasEnvelope = row.sensitiveEncrypted != null;
return {
id: row.id,
date: row.date,
cycleId: row.cycleId,
flow: row.flow,
intermenstrualBleeding: row.intermenstrualBleeding,
basalBodyTempC: row.basalBodyTempC,
ovulationTest: row.ovulationTest,
cervicalMucus: row.cervicalMucus,
sexualActivity: hasEnvelope
? (enc.sexualActivity ?? false)
: row.sexualActivity,
protectedSex: hasEnvelope ? (enc.protectedSex ?? null) : row.protectedSex,
pregnancyTest: hasEnvelope
? (enc.pregnancyTest ?? null)
: row.pregnancyTest,
progesteroneTest: hasEnvelope
? (enc.progesteroneTest ?? null)
: row.progesteroneTest,
contraceptive: hasEnvelope
? (enc.contraceptive ?? null)
: row.contraceptive,
symptoms: (row.symptomLinks ?? []).map((l) => ({
key: l.symptom.key,
severity: l.severity ?? null,
})),
note: decryptNote(row.notesEncrypted),
source: row.source,
externalId: row.externalId,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
};
}
export function toMenstrualCycleDTO(row: MenstrualCycle): MenstrualCycleDTO {
return {
id: row.id,
startDate: row.startDate,
endDate: row.endDate,
periodEndDate: row.periodEndDate,
lengthDays: row.lengthDays,
ovulationDate: row.ovulationDate,
ovulationConfirmed: row.ovulationConfirmed,
isPredicted: row.isPredicted,
syncVersion: row.syncVersion,
updatedAt: row.updatedAt.toISOString(),
};
}
/** The Prisma `include` that hydrates symptom links → DTO keys. */
export const dayLogSymptomInclude = {
symptomLinks: { include: { symptom: { select: { key: true } } } },
} as const;
export interface CyclePredictionDTO {
method: string;
nextPeriodStart: string;
nextPeriodStartLow: string;
nextPeriodStartHigh: string;
fertileWindowStart: string | null;
fertileWindowEnd: string | null;
predictedOvulation: string | null;
/** Whether `predictedOvulation` was confirmed by a signal layer (BBT / symptothermal). */
ovulationConfirmed: boolean;
confidence: number;
cyclesObserved: number;
stillLearning: boolean;
disclaimer: string;
}
/**
* Map the engine result to the wire `CyclePredictionDTO`. Fertile-window
* fields (and the predicted ovulation) are suppressed unless the goal
* permits them (ios-contract §2.D — GENERAL_HEALTH / PERIMENOPAUSE / OFF
* never surface a fertile window; the suppression is server-side, never
* iOS).
*/
export function toCyclePredictionDTO(
result: CyclePredictionResult,
goalAllowsFertile: boolean,
disclaimer: string,
): CyclePredictionDTO {
return {
method: result.method,
nextPeriodStart: result.nextPeriodStart,
nextPeriodStartLow: result.nextPeriodStartLow,
nextPeriodStartHigh: result.nextPeriodStartHigh,
fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null,
fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null,
predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null,
ovulationConfirmed: goalAllowsFertile ? result.ovulationConfirmed : false,
confidence: result.confidence,
cyclesObserved: result.cyclesObserved,
stillLearning: result.stillLearning,
disclaimer,
};
}
/** The full profile read DTO (ios-contract §2.G). */
export interface CycleProfileDTO {
goal: string;
cycleTrackingEnabled: boolean;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
updatedAt: string;
}
export function toCycleProfileDTO(
row: MenstrualCycleProfileRow,
cycleTrackingEnabled: boolean,
): CycleProfileDTO {
return {
goal: row.goal,
cycleTrackingEnabled,
rawChartMode: row.rawChartMode,
predictionEnabled: row.predictionEnabled,
discreetNotifications: row.discreetNotifications,
sensitiveCategoryEncryption: row.sensitiveCategoryEncryption,
typicalCycleLength: row.typicalCycleLength,
typicalPeriodLength: row.typicalPeriodLength,
lutealPhaseLength: row.lutealPhaseLength,
updatedAt: row.updatedAt.toISOString(),
};
}
type MenstrualCycleProfileRow = {
goal: string;
rawChartMode: boolean;
predictionEnabled: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
typicalCycleLength: number | null;
typicalPeriodLength: number | null;
lutealPhaseLength: number | null;
updatedAt: Date;
};
/**
* Whether a goal surfaces the fertile window. Per algorithm.md §4 the window is
* shown for TRYING_TO_CONCEIVE and AVOID_PREGNANCY (AVOID_PREGNANCY carries a
* stronger "estimate, not a contraceptive method" caveat in the copy), and
* suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF (the suppression is always
* server-side, never iOS).
*/
export function goalAllowsFertileWindow(goal: string): boolean {
// algorithm.md §4: shown for TRYING_TO_CONCEIVE AND AVOID_PREGNANCY (the
// latter carries a stronger "not a contraceptive method" caveat in copy),
// hidden for GENERAL_HEALTH / PERIMENOPAUSE / OFF.
return goal === "TRYING_TO_CONCEIVE" || goal === "AVOID_PREGNANCY";
}
/**
* The i18n key for the disclaimer a goal must show. AVOID_PREGNANCY surfaces the
* fertile window, so it MUST carry the stronger "not a contraceptive method —
* never safe to assume unprotected sex" copy (`cycle.disclaimer`); every other
* goal gets the standard "estimates, not medical advice" line
* (`cycle.prediction.disclaimer`). Shared by the server route and the client so
* web/iOS/engine show the same caveat. (algorithm.md §4 — safety-relevant.)
*/
export function cycleDisclaimerKey(goal: string): string {
return goal === "AVOID_PREGNANCY"
? "cycle.disclaimer"
: "cycle.prediction.disclaimer";
}