mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Close the second-round audit UI cluster: - Cycle reminders: add the missing notification-event labels so the period-soon / period-confirm toggles read correctly on the notifications page, and point to them from cycle settings. - Per-cycle history: render a recent-cycles list (range, length, period length, confirmed-ovulation chip) — the cycles were fetched but only the aggregate stats showed. - Confirmed ovulation: carry it on the prediction DTO and surface it as a solid BBT-chart marker + a predictions row + a history chip. - Log-day sheet: deselecting an enum now clears it (edits route through the nullable PATCH); period start/end work on any selected date for retroactive correction; "end period" shows only while one is open. Extract the payload builders as pure, unit-tested functions. - Cycle-tracking enable toggle in account settings so any account can opt in before the gated page is reachable; wire the cycle data-purge button to the hard-delete route and document the route in the OpenAPI spec. - Calendar legend: add the symptom dot, render fertile as a ring to match the grid; tighten the luteal prior bound to the engine clamp; mute the BBT ovulation-label colour for contrast. - Share the cycle-start backward-walk between the wheel and the chart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
8.7 KiB
TypeScript
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";
|
|
}
|