diff --git a/CHANGELOG.md b/CHANGELOG.md index 30d833228..41a5646e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.15.0] — 2026-06-06 — cycle tracking + +### Added + +- **Cycle tracking.** A new, private menstrual-cycle vertical. Log your period (flow and intermenstrual spotting), basal body temperature, cervical mucus, ovulation and pregnancy/progesterone tests, contraception, intercourse, and graded symptoms — all from one quick-entry sheet. A month calendar marks logged days, the predicted period window, the fertile window, and ovulation; the cycle wheel shows your current day and phase at a glance, and a logged day can be edited or deleted. +- **Honest predictions.** Next-period and fertile-window forecasts are always shown as a range with a confidence level — never a single certain date — and carry a clear, non-medical caveat (with a stronger one when you track to avoid pregnancy). A "read your body" mode suppresses every interpretation if you prefer to see only your raw data. The forecast is computed from your own data so the iOS client produces the identical result offline. +- **Basal-body-temperature chart** for the current cycle, with your fertility signs in the tooltip and the estimated — or confirmed — ovulation marked. +- **Phase insights.** An insights tab relates your cycle phase to your vitals — resting heart rate, HRV, sleep, steps, weight, blood glucose, mood — showing where each differs between phases with statistical significance, and which of your logged symptoms cluster in which phase. Associations only, never cause. A per-cycle history lists your recent cycle and period lengths. +- **Privacy first.** Cycle tracking is off by default except for accounts set to female, and anyone can turn it on or off from settings. The intent-revealing fields are encrypted at rest behind a default-on toggle; a discreet-notifications mode keeps any reminder generic on the lock screen; period reminders are opt-in per channel; and a one-tap purge hard-deletes every cycle row, the cycle audit trail, and reminder records, and clears your tracking intent. +- **Apple Health import.** Reproductive-health categories from an Apple Health export fold into your cycle log, and cycle data is included in the health-record export (PDF and FHIR). + ## [1.14.0] — 2026-06-06 — animated health scores, score explanations, mood-factor correlations, log past doses ### Added diff --git a/README.md b/README.md index 510606216..b52e88a11 100644 --- a/README.md +++ b/README.md @@ -595,7 +595,8 @@ The full documentation set lives under [`docs/`](docs/) (also published at [docs | Area | Where | What it covers | | ---- | ----- | -------------- | -| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling | +| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling, [reproductive-health data on your own server](docs/self-hosting/reproductive-health-data.md) | +| Threat model | [`docs/security/cycle-data-threat-model.md`](docs/security/cycle-data-threat-model.md) | What self-hosting cycle data does and does not protect against | | Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning | | API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset | | Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers | diff --git a/docs/api/cycle-healthkit-mapping.md b/docs/api/cycle-healthkit-mapping.md new file mode 100644 index 000000000..69bce5e91 --- /dev/null +++ b/docs/api/cycle-healthkit-mapping.md @@ -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:`) 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. | diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4b2c1c1db..f0aca9564 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.14.0 + version: 1.15.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -37,9 +37,728 @@ tags: - name: Devices - name: Export - name: Sync + - name: Cycle - name: Admin - name: Meta paths: + /api/cycle/day-logs: + get: + tags: + - Cycle + summary: Read a single day's cycle day-log (v1.15.0) + description: Returns the full `CycleDayLogDTO` for the tz-anchored `date`, or `null` when nothing is logged that day. + Lets a client pre-fill an edit sheet. Gated; owner-scoped; soft-deleted rows excluded. + parameters: + - in: query + name: date + schema: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + required: true + responses: + "200": + description: The day-log for that date, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogReadEnvelope" + "401": &a1 + description: Authentication required or invalid credentials. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": &a2 + description: Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": &a3 + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": &a4 + description: Rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + post: + tags: + - Cycle + summary: Capture a single cycle day-log (v1.15.0) + description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts + at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + flow: + type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + intermenstrualBleeding: + type: boolean + basalBodyTempC: + type: number + minimum: 30 + maximum: 45 + ovulationTest: + type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + cervicalMucus: + type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + progesteroneTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + contraceptive: + type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + type: string + maxLength: 500 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - date + - loggedAt + responses: + "200": + description: Existing day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogEnvelope" + "201": + description: New day-log created. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/day-logs/{id}: + patch: + tags: + - Cycle + summary: Edit a single cycle day-log (v1.15.0) + description: Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + intermenstrualBleeding: + type: boolean + basalBodyTempC: + anyOf: + - type: number + minimum: 30 + maximum: 45 + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + - type: "null" + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + responses: + "200": + description: Day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogPatchEnvelope" + "401": *a1 + "403": *a2 + "404": + description: Day-log not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Cycle + summary: Soft-delete a cycle day-log (v1.15.0) + description: Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. + Idempotent. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "403": *a2 + "404": + description: Day-log not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/cycle/day-logs/bulk: + post: + tags: + - Cycle + summary: Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0) + description: "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry + status: inserted | duplicate | updated | skipped. Always 200." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + entries: + minItems: 1 + maxItems: 500 + type: array + items: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + flow: + type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + intermenstrualBleeding: + type: boolean + basalBodyTempC: + type: number + minimum: 30 + maximum: 45 + ovulationTest: + type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + cervicalMucus: + type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + progesteroneTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + contraceptive: + type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + type: string + maxLength: 500 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - date + - loggedAt + required: + - entries + responses: + "200": + description: Batch processed (per-entry results). + content: + application/json: + schema: + $ref: "#/components/schemas/CycleBulkEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/period: + post: + tags: + - Cycle + summary: Period-boundary shortcut (v1.15.0) + description: One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. + Writes the boundary day-log. Gated. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: + - start + - end + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + externalId: + type: string + minLength: 1 + maxLength: 120 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - action + - date + - loggedAt + responses: + "200": + description: Cycle + boundary day-log. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePeriodEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/calendar: + get: + tags: + - Cycle + summary: Predicted cycle calendar (v1.15.0) + description: "Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are + server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated." + parameters: + - in: query + name: from + schema: + type: string + - in: query + name: to + schema: + type: string + responses: + "200": + description: Calendar grid + forecast. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleCalendarEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/cycles: + get: + tags: + - Cycle + summary: Cycle history + stats (v1.15.0) + description: Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, + regularity }`. `limit` default 24. Gated. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 60 + responses: + "200": + description: Cycle history. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleHistoryEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/insights: + get: + tags: + - Cycle + summary: Cycle-phase correlation insights (v1.15.0) + description: "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / + temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same + Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 + surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. + Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off." + responses: + "200": + description: Phase-correlation rows + headline. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleInsightsEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/cycles/{id}: + delete: + tags: + - Cycle + summary: Soft-delete a menstrual cycle (v1.15.0) + description: Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "403": *a2 + "404": + description: Cycle not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/cycle/all: + delete: + tags: + - Cycle + summary: Hard-purge all cycle data (v1.15.0) + description: "One-click privacy purge: HARD-deletes every cycle day-log (+ symptom links by cascade), menstrual cycle, + prediction, the cycle audit trail, and the cycle reminder rows in the push-attempts ledger — no dated + reproductive trace survives. The CycleProfile row is left in place. Gated + owner-scoped + audited." + responses: + "200": + description: Purge counts. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePurgeEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/cycle/profile: + get: + tags: + - Cycle + summary: Read the full cycle profile (v1.15.0) + description: Returns the resolved CycleProfileDTO. Gated. + responses: + "200": + description: Cycle profile. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleProfileEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /api/auth/me/cycle-prefs: + get: + tags: + - Cycle + summary: Read cycle preferences (v1.15.0) + description: Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate. + responses: + "200": + description: Cycle preferences. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePrefsGetEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Cycle + summary: Update cycle preferences (v1.15.0) + description: Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged + CycleProfileDTO. NOT gated. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + discreetNotifications: + type: boolean + sensitiveCategoryEncryption: + type: boolean + typicalCycleLength: + anyOf: + - type: integer + minimum: 15 + maximum: 60 + - type: "null" + typicalPeriodLength: + anyOf: + - type: integer + minimum: 1 + maximum: 15 + - type: "null" + lutealPhaseLength: + anyOf: + - type: integer + minimum: 10 + maximum: 16 + - type: "null" + responses: + "200": + description: Merged cycle preferences. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePrefsPatchEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/meta/capabilities: get: tags: @@ -57,24 +776,9 @@ paths: application/json: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" - "401": &a1 - description: Authentication required or invalid credentials. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "422": &a2 - description: Request validation failed. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": &a3 - description: Rate limit exceeded. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/export/health-record: post: tags: @@ -168,6 +872,8 @@ paths: type: boolean bmi: type: boolean + cycle: + type: boolean locale: type: string enum: @@ -207,8 +913,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/share-links: post: tags: @@ -294,6 +1000,8 @@ paths: type: boolean bmi: type: boolean + cycle: + type: boolean resourceTypes: maxItems: 8 type: array @@ -323,8 +1031,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 get: tags: - Export @@ -339,8 +1047,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/share-links/{id}: delete: tags: @@ -368,8 +1076,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/metadata: get: tags: @@ -387,8 +1095,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/Patient: get: tags: @@ -414,8 +1122,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/Observation: get: tags: @@ -441,8 +1149,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/MedicationStatement: get: tags: @@ -468,8 +1176,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/MedicationAdministration: get: tags: @@ -495,8 +1203,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/$everything: get: tags: @@ -523,8 +1231,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/login: post: tags: @@ -556,8 +1264,8 @@ paths: schema: $ref: "#/components/schemas/LoginResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/passkey/login-verify: post: tags: @@ -577,8 +1285,8 @@ paths: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/refresh: post: tags: @@ -608,8 +1316,8 @@ paths: schema: $ref: "#/components/schemas/RefreshResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sync/state: get: tags: @@ -626,8 +1334,8 @@ paths: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sync/changes: get: tags: @@ -663,8 +1371,8 @@ paths: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements: get: tags: @@ -819,8 +1527,8 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 post: tags: - Measurements @@ -940,8 +1648,8 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements/batch: post: tags: @@ -965,8 +1673,8 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements/by-external-ids: delete: tags: @@ -988,8 +1696,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sleep/night: get: tags: @@ -1017,8 +1725,8 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts: get: tags: @@ -1065,8 +1773,8 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts/{id}: get: tags: @@ -1095,8 +1803,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts/batch: post: tags: @@ -1310,8 +2018,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/devices: post: tags: @@ -1339,8 +2047,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications: get: tags: @@ -1358,8 +2066,8 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 post: tags: - Medications @@ -1381,8 +2089,8 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}: get: tags: @@ -1410,8 +2118,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Medications @@ -1446,8 +2154,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Medications @@ -1474,8 +2182,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}/intake: post: tags: @@ -1517,8 +2225,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/extract: post: tags: @@ -1543,8 +2251,8 @@ paths: schema: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 "502": description: Upstream provider returned an empty, unparseable, or off-schema reply. content: @@ -1594,8 +2302,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}/compliance: get: tags: @@ -1626,8 +2334,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/coach-prefs: get: tags: @@ -1643,8 +2351,8 @@ paths: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Auth @@ -1725,8 +2433,8 @@ paths: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/disable-coach: get: tags: @@ -1742,8 +2450,8 @@ paths: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 patch: tags: - Auth @@ -1764,8 +2472,8 @@ paths: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/source-priority: get: tags: @@ -1781,8 +2489,8 @@ paths: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Auth @@ -2278,8 +2986,8 @@ paths: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/avatar: post: tags: @@ -2322,8 +3030,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Auth @@ -2334,8 +3042,8 @@ paths: "204": description: Avatar cleared (or already empty). "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/avatar/{id}: get: tags: @@ -2381,8 +3089,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/profile: get: tags: @@ -2398,8 +3106,8 @@ paths: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 patch: tags: - Auth @@ -2421,8 +3129,8 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -2438,8 +3146,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Insights @@ -2460,8 +3168,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutSaved" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Insights @@ -2475,8 +3183,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/coach/facts: get: tags: @@ -2494,8 +3202,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Insights @@ -2510,8 +3218,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/coach/facts/{id}: delete: tags: @@ -2528,8 +3236,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/chat/messages/{id}/feedback: post: tags: @@ -2564,8 +3272,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/admin/notifications/diagnostic: get: tags: @@ -2588,8 +3296,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/dashboard/snapshot: get: tags: @@ -2606,8 +3314,8 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/comprehensive: get: tags: @@ -2623,8 +3331,8 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/pregenerate: post: tags: @@ -2650,8 +3358,8 @@ paths: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/analytics/range: get: tags: @@ -2756,8 +3464,8 @@ paths: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/blood-pressure-status: get: tags: @@ -2784,8 +3492,8 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/pulse-status: get: tags: @@ -2812,8 +3520,8 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/weight-status: get: tags: @@ -2840,8 +3548,8 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/bmi-status: get: tags: @@ -2868,8 +3576,8 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/mood-status: get: tags: @@ -2896,8 +3604,8 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/medication-compliance-status: get: tags: @@ -2924,8 +3632,8 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/metric-status: get: tags: @@ -3003,8 +3711,8 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/derived: get: tags: @@ -3075,8 +3783,8 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/derived/batch: get: tags: @@ -3109,8 +3817,8 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/correlations: get: tags: @@ -3129,8 +3837,8 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -4063,6 +4771,1030 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + CycleDayLogReadEnvelope: + type: object + properties: + data: + anyOf: + - $ref: "#/components/schemas/CycleDayLogDTO" + - type: "null" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleDayLogDTO: + type: object + properties: + id: + type: string + date: + type: string + cycleId: + anyOf: + - type: string + - type: "null" + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + intermenstrualBleeding: + type: boolean + basalBodyTempC: + anyOf: + - type: number + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + - type: "null" + symptoms: + type: array + items: + type: object + properties: + key: + type: string + severity: + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" + required: + - key + - severity + additionalProperties: false + note: + anyOf: + - type: string + - type: "null" + source: + type: string + externalId: + anyOf: + - type: string + - type: "null" + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + deletedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + required: + - id + - date + - cycleId + - flow + - intermenstrualBleeding + - basalBodyTempC + - ovulationTest + - cervicalMucus + - sexualActivity + - protectedSex + - pregnancyTest + - progesteroneTest + - contraceptive + - symptoms + - note + - source + - externalId + - syncVersion + - updatedAt + - deletedAt + additionalProperties: false + description: The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride + `/api/sync/changes` as tombstones. + ErrorEnvelope: + type: object + properties: + data: + type: "null" + error: + type: string + meta: + type: object + properties: + requestId: + type: string + errorCode: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + description: "Standard error response: data is null, error is human prose." + CycleDayLogEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleDayLogCreatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleDayLogPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleBulkEnvelope: + type: object + properties: + data: + type: object + properties: + processed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inserted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updated: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + duplicates: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + skipped: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + entries: + type: array + items: + type: object + properties: + index: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + status: + type: string + enum: + - inserted + - duplicate + - updated + - skipped + id: + type: string + externalId: + type: string + reason: + type: string + required: + - index + - status + additionalProperties: false + required: + - processed + - inserted + - updated + - duplicates + - skipped + - entries + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePeriodEnvelope: + type: object + properties: + data: + type: object + properties: + cycle: + anyOf: + - $ref: "#/components/schemas/MenstrualCycleDTO" + - type: "null" + dayLog: + anyOf: + - $ref: "#/components/schemas/CycleDayLogDTO" + - type: "null" + required: + - cycle + - dayLog + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MenstrualCycleDTO: + type: object + properties: + id: + type: string + startDate: + type: string + endDate: + anyOf: + - type: string + - type: "null" + periodEndDate: + anyOf: + - type: string + - type: "null" + lengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + ovulationDate: + anyOf: + - type: string + - type: "null" + ovulationConfirmed: + type: boolean + isPredicted: + type: boolean + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - startDate + - endDate + - periodEndDate + - lengthDays + - ovulationDate + - ovulationConfirmed + - isPredicted + - syncVersion + - updatedAt + additionalProperties: false + description: One menstrual cycle (observed or forward-predicted). + CycleCalendarEnvelope: + type: object + properties: + data: + type: object + properties: + profile: + type: object + properties: + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - goal + - rawChartMode + - predictionEnabled + - cyclesObserved + additionalProperties: false + prediction: + anyOf: + - $ref: "#/components/schemas/CyclePredictionDTO" + - type: "null" + days: + type: array + items: + type: object + properties: + date: + type: string + phase: + anyOf: + - $ref: "#/components/schemas/CyclePhase" + - type: "null" + isPredictedPeriod: + type: boolean + isFertileWindow: + type: boolean + isPredictedOvulation: + type: boolean + isPeriodLogged: + type: boolean + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + hasSymptoms: + type: boolean + confidence: + type: number + basalBodyTempC: + anyOf: + - type: number + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" + required: + - date + - phase + - isPredictedPeriod + - isFertileWindow + - isPredictedOvulation + - isPeriodLogged + - flow + - hasSymptoms + - confidence + - basalBodyTempC + - ovulationTest + - cervicalMucus + additionalProperties: false + meta: + type: object + properties: + generatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - generatedAt + additionalProperties: false + required: + - profile + - prediction + - days + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePredictionDTO: + type: object + properties: + method: + $ref: "#/components/schemas/CyclePredictionMethod" + nextPeriodStart: + type: string + nextPeriodStartLow: + type: string + nextPeriodStartHigh: + type: string + fertileWindowStart: + anyOf: + - type: string + - type: "null" + fertileWindowEnd: + anyOf: + - type: string + - type: "null" + predictedOvulation: + anyOf: + - type: string + - type: "null" + ovulationConfirmed: + type: boolean + confidence: + type: number + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + stillLearning: + type: boolean + disclaimer: + type: string + required: + - method + - nextPeriodStart + - nextPeriodStartLow + - nextPeriodStartHigh + - fertileWindowStart + - fertileWindowEnd + - predictedOvulation + - ovulationConfirmed + - confidence + - cyclesObserved + - stillLearning + - disclaimer + additionalProperties: false + description: The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are + server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY. + CyclePredictionMethod: + type: string + enum: + - CALENDAR + - SYMPTOTHERMAL + - TEMPERATURE_TREND + - BLENDED + CyclePhase: + type: string + enum: + - MENSTRUAL + - FOLLICULAR + - OVULATORY + - LUTEAL + CycleHistoryEnvelope: + type: object + properties: + data: + type: object + properties: + cycles: + type: array + items: + $ref: "#/components/schemas/MenstrualCycleDTO" + stats: + type: object + properties: + avgLengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lengthVariabilityDays: + anyOf: + - type: number + - type: "null" + avgPeriodLengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + regularity: + type: string + enum: + - REGULAR + - IRREGULAR + - LEARNING + required: + - avgLengthDays + - lengthVariabilityDays + - avgPeriodLengthDays + - regularity + additionalProperties: false + required: + - cycles + - stats + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleInsightsEnvelope: + type: object + properties: + data: + type: object + properties: + rows: + type: array + items: + $ref: "#/components/schemas/CyclePhaseCrosstabRow" + headline: + anyOf: + - $ref: "#/components/schemas/CyclePhaseCrosstabRow" + - type: "null" + lagged: + type: object + properties: + discovered: + type: array + items: + $ref: "#/components/schemas/CyclePhaseLaggedPair" + pairsTested: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + fdrQ: + type: number + minPairs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - discovered + - pairsTested + - fdrQ + - minPairs + additionalProperties: false + symptomPatterns: + type: array + items: + $ref: "#/components/schemas/CycleSymptomPhaseRow" + contrast: + type: object + properties: + high: + type: string + const: LUTEAL + low: + type: string + const: FOLLICULAR + required: + - high + - low + additionalProperties: false + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - rows + - headline + - lagged + - symptomPatterns + - contrast + - windowDays + - cyclesObserved + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePhaseCrosstabRow: + type: object + properties: + metricKey: + type: string + enum: + - restingHeartRate + - heartRateVariability + - sleepDuration + - steps + - weight + - basalBodyTemp + - wristTemperature + - skinTemperature + - bloodGlucose + - mood + display: + type: string + enum: + - hours + - steps + - bpm + - ms + - kg + - celsius + - glucose + - mood + lutealDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + follicularDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lutealAvg: + type: number + follicularAvg: + type: number + delta: + type: number + pValue: + type: number + qValue: + type: number + confidence: + type: string + enum: + - low + - medium + - high + required: + - metricKey + - display + - lutealDays + - follicularDays + - lutealAvg + - follicularAvg + - delta + - pValue + - qValue + - confidence + additionalProperties: false + description: One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. + Observational, never causal. + CyclePhaseLaggedPair: + type: object + properties: + behaviour: + type: string + outcome: + type: string + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + r: + type: number + pValue: + type: number + qValue: + type: number + interpretation: + type: string + lagDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - behaviour + - outcome + - n + - r + - pValue + - qValue + - interpretation + - lagDays + additionalProperties: false + description: One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism + B). Descriptive, never causal. + CycleSymptomPhaseRow: + type: object + properties: + symptomKey: + type: string + counts: + type: object + properties: + MENSTRUAL: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + FOLLICULAR: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + OVULATORY: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + LUTEAL: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - MENSTRUAL + - FOLLICULAR + - OVULATORY + - LUTEAL + additionalProperties: false + total: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + topPhase: + type: string + enum: + - MENSTRUAL + - FOLLICULAR + - OVULATORY + - LUTEAL + topShare: + type: number + required: + - symptomKey + - counts + - total + - topPhase + - topShare + additionalProperties: false + description: Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled + days. Observational, never causal. + CyclePurgeEnvelope: + type: object + properties: + data: + type: object + properties: + purged: + type: boolean + dayLogs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + predictions: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + cycles: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + auditRows: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + pushRows: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - purged + - dayLogs + - predictions + - cycles + - auditRows + - pushRows + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleProfileEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleProfileDTO: + type: object + properties: + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + cycleTrackingEnabled: + type: boolean + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + discreetNotifications: + type: boolean + sensitiveCategoryEncryption: + type: boolean + typicalCycleLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + typicalPeriodLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lutealPhaseLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - goal + - cycleTrackingEnabled + - rawChartMode + - predictionEnabled + - discreetNotifications + - sensitiveCategoryEncryption + - typicalCycleLength + - typicalPeriodLength + - lutealPhaseLength + - updatedAt + additionalProperties: false + description: The full per-user cycle settings row. + CyclePrefsGetEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePrefsPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false CapabilitiesEnvelope: type: object properties: @@ -4237,26 +5969,6 @@ components: additionalProperties: false description: Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit. - ErrorEnvelope: - type: object - properties: - data: - type: "null" - error: - type: string - meta: - type: object - properties: - requestId: - type: string - errorCode: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - description: "Standard error response: data is null, error is human prose." ShareLinkCreated: type: object properties: diff --git a/docs/security/cycle-data-threat-model.md b/docs/security/cycle-data-threat-model.md new file mode 100644 index 000000000..83c148430 --- /dev/null +++ b/docs/security/cycle-data-threat-model.md @@ -0,0 +1,52 @@ +# Cycle-data threat model + +A compact statement of what self-hosting HealthLog's cycle tracking +protects against and what it does not. Pair this with the operator +guidance in +[`self-hosting/reproductive-health-data.md`](../self-hosting/reproductive-health-data.md). + +The scope here is reproductive-health data specifically (menstrual, +fertility, pregnancy, contraception, sexual-activity fields), because +that is the category where the "who can reach my data" question carries +the highest stakes. + +## What self-hosting + encryption-at-rest protects against + +- **A cloud vendor holding your data.** There is no SaaS account and no + vendor key escrow. No third party can be served process for data it + does not have. +- **A stolen disk or a leaked backup, while the key is held separately.** + The intent-revealing fields are AES-256-GCM encrypted at rest; a disk + image without the `ENCRYPTION_KEYS` value does not yield them. +- **Casual lock-screen disclosure.** Discreet notifications collapse + every cycle reminder to a generic "HealthLog reminder" so the event is + never named on a lock screen. +- **Third-party analytics / ad-tech leakage.** HealthLog ships no + telemetry and no third-party trackers; cycle data is never sent to an + analytics or advertising endpoint. + +## What it does NOT protect against + +- **Seizure or compulsion of your own host.** If the running machine is + seized, imaged, or compelled, the data is on it. Encryption at rest + does not help against a live host or a key stored beside the data. +- **Your hosting provider.** A VPS provider can snapshot the disk and + can be served process. Self-hosting on rented infrastructure narrows + the set of parties that hold the data; it does not eliminate them. +- **Your own backups.** Any `pg_dump` or off-host export carries the + same data and must be protected as part of the same surface. +- **A compromised or unlocked client device.** A phone or laptop with a + live session or cached client holds data locally. Server-side + protection does nothing for an unlocked device. +- **Re-identification from "plaintext" fields.** Flow, basal body + temperature, ovulation tests, cervical mucus, and cycle dates stay in + plaintext so the engines can read them. They are health observations, + not the disclosure-sensitive intent fields — but they are still + health data on your host. + +## The one-line version + +Self-hosting means **no cloud vendor to subpoena**. It does **not** mean +the data is beyond reach: your own infrastructure, your provider, your +backups, and your devices are all still part of the surface. Size your +operator-side hardening to your own threat model. diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md new file mode 100644 index 000000000..bb30fd909 --- /dev/null +++ b/docs/self-hosting/reproductive-health-data.md @@ -0,0 +1,103 @@ +# Reproductive-health data on your own server + +HealthLog's cycle tracking stores menstrual, fertility, and related +reproductive-health data the same way it stores every other metric: in +the PostgreSQL database that runs on the host you control, encrypted at +rest for the intent-revealing fields. This page explains what that +arrangement does and does not change about who can reach that data, in +plain terms and without overstating the protection. + +## What self-hosting changes + +A consumer cycle-tracking app runs on the vendor's servers. Your +reproductive-health data lives in their database, under their keys, in +their jurisdiction. If that vendor receives a subpoena, a civil +discovery request, or a law-enforcement demand it judges valid, the +vendor — not you — decides how to respond, and it holds the data and the +keys needed to comply. Several vendors state in their own privacy +policies that they comply with valid legal process; that is a factual +description of where the data sits, not an accusation. + +Self-hosting removes that vendor from the picture. There is no cloud +account holding your cycle data, no vendor key escrow, and no third +party that can be served process to hand over data it does not have. The +honest framing is narrow and worth stating precisely: + +> Self-hosting means there is no cloud vendor to subpoena for your +> reproductive-health data. It does **not** make the data +> unreachable. + +The encryption-at-rest layer is real: the intent-revealing day-log +fields (free-text notes always; pregnancy, contraception, and +sexual-activity fields by default for this category) are encrypted with +AES-256-GCM under a key you supply via `ENCRYPTION_KEYS`, and the +application fails closed rather than ever writing those fields in +plaintext. Flow, basal body temperature, ovulation-test results, +cervical-mucus observations, and the cycle dates stay in plaintext +because the prediction and correlation engines read them; they are +health observations, not the disclosure-sensitive intent fields. + +## The residual threat (read this part) + +Self-hosting moves the data onto a box you own. That box can still be +reached: + +- **Physical or legal seizure of your own host.** If the machine + running PostgreSQL — your VPS, your home server, your NAS — is seized, + imaged, or compelled, the data is on it. Encryption at rest protects + against a stolen disk or a leaked backup only while the key is *not* + also available; if the host is running, or the key is stored next to + the data, that protection is gone. +- **Your hosting provider.** A VPS provider can snapshot your disk and + can be served process the same way a SaaS vendor can. Self-hosting on + rented infrastructure narrows the set of parties who hold your data; + it does not reduce it to zero. +- **Your own backups.** Off-host backups (the optional S3 export, or any + `pg_dump` you take) carry the same data. Encrypt them, control where + they land, and treat them as part of the same threat surface. +- **The device that talks to the server.** A phone or laptop with a + live session, a cached PWA, or the native client holds data locally + too. Protecting the server does nothing for an unlocked device. + +The promise is therefore specific: **no cloud vendor to subpoena**, not +**data nobody can ever reach**. If your threat model includes seizure of +your own infrastructure, encryption at rest plus a key you keep off the +host is the floor, and the threat-model note below covers the rest. + +## Public record + +The reason this distinction matters is documented in public legal +record, not speculation: + +- In 2021 the U.S. Federal Trade Commission settled with the maker of a + period- and fertility-tracking app over allegations that it shared + users' health data with third-party analytics and advertising + services despite privacy promises. The settlement is public record. +- In 2025 a federal jury found that the same app's maker violated a + California privacy statute over the sharing of menstrual-tracking + data; the verdict and the underlying class action are public record. + +These are cited as factual public record to explain *why* the +"who holds the data" question is the one that matters for this category. +HealthLog makes no claim about any other product's current practices. + +## Practical hardening + +If reproductive-health privacy is the reason you self-host, the +operator-side steps that matter most: + +- Keep `ENCRYPTION_KEYS` off the host that runs PostgreSQL where your + threat model allows it (an injected secret at boot, not a file next to + the database). See `docs/ops/encryption-key-rotation.md`. +- Turn off any integration you do not need. The fewer outbound paths, + the smaller the surface. +- Encrypt and control your backups. The off-host backup feature uses + AES-GCM; the destination bucket and its access are yours to lock down. +- Use the in-app **discreet notifications** toggle so cycle reminders + never name the event on a lock screen, and the **client-managed + reminders** path if you want the device, not the server, to own those + notifications. +- Lock the devices that hold a live session. + +See also the [threat-model note](../security/cycle-data-threat-model.md) +for a compact "protects against / does not protect against" summary. diff --git a/messages/de.json b/messages/de.json index 8497c0d13..c7a8058ec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", @@ -216,7 +232,8 @@ "medicationDescription": "Eine Dosis als genommen markieren", "mood": "Stimmung", "moodDescription": "Stimmung und Tags erfassen" - } + }, + "cycle": "Zyklus" }, "auth": { "login": "Anmelden", @@ -3860,6 +3877,14 @@ "statusRevoked": "Widerrufen", "statusExpired": "Abgelaufen", "inactiveTitle": "Widerrufen und abgelaufen ({count})" + }, + "genderOther": "Divers", + "cycleTracking": { + "title": "Zyklus-Tracking", + "description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights. Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar.", + "enable": "Zyklus-Tracking aktivieren", + "manage": "Zyklus-Tracking öffnen", + "error": "Zyklus-Tracking konnte nicht aktualisiert werden." } }, "admin": { @@ -4673,7 +4698,11 @@ "eventPersonalRecord": "Persönlicher Rekord", "eventPersonalRecordDesc": "Wird gesendet, wenn ein Messwert deine bisherige Bestmarke übertrifft.", "eventMoodReminder": "Stimmungs-Erinnerung", - "eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren." + "eventMoodReminderDesc": "Tägliche Erinnerung, deine aktuelle Stimmung zu protokollieren.", + "eventCyclePeriodSoon": "Periode steht bevor", + "eventCyclePeriodSoonDesc": "Ein paar Tage vor deiner vorhergesagten nächsten Periode.", + "eventCyclePeriodConfirm": "Hat deine Periode begonnen?", + "eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist." }, "i18n": { "maintainershipBanner": { @@ -4867,5 +4896,285 @@ "score": "Wellness-Wert" }, "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker." + }, + "cycle": { + "symptomCategory": { + "physical": "Körperlich", + "emotional": "Emotional", + "digestive": "Verdauung" + }, + "symptom": { + "cramps": "Krämpfe", + "headache": "Kopfschmerzen", + "bloating": "Blähbauch", + "acne": "Akne", + "breastTenderness": "Brustspannen", + "fatigue": "Müdigkeit", + "backPain": "Rückenschmerzen", + "insomnia": "Schlaflosigkeit", + "libidoHigh": "Hohe Libido", + "libidoLow": "Niedrige Libido", + "moodSwings": "Stimmungsschwankungen", + "foodCravings": "Heißhunger", + "nausea": "Übelkeit", + "diarrhea": "Durchfall", + "constipation": "Verstopfung" + }, + "prediction": { + "disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst." + }, + "insights": { + "headline": { + "title": "Dein Zyklus und dein Körper", + "restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "dirHigher": "höher", + "dirLower": "niedriger", + "empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter — das füllt sich, je länger deine Historie wird." + }, + "crosstab": { + "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht – auf deinen eigenen Daten.", + "pairLabel": "{metric} · Luteal vs. Follikel", + "detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).", + "footer": "Das sind Zusammenhänge in deinen eigenen Daten, keine Ursachen. Nur Muster, die einen Signifikanztest bestehen, werden gezeigt.", + "metricRestingHeartRate": "Ruhepuls", + "metricHrv": "Herzfrequenzvariabilität", + "metricSleepDuration": "Schlafdauer", + "metricSteps": "Schritte", + "metricWeight": "Gewicht", + "metricBasalBodyTemp": "Basaltemperatur", + "metricWristTemperature": "Handgelenkstemperatur nachts", + "metricSkinTemperature": "Hauttemperatur", + "unitHours": "Std.", + "unitSteps": "Schritte", + "unitBpm": "S/min", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Geringe Sicherheit", + "confidenceMedium": "Mittlere Sicherheit", + "confidenceHigh": "Hohe Sicherheit" + }, + "title": "Zyklus-Erkenntnisse", + "empty": "Phasen-Erkenntnisse erscheinen hier, sobald du genug Zyklen erfasst hast. Sie vergleichen deine Werte über die Zyklusphasen hinweg, mit offen gezeigter Statistik.", + "sampleSize": "n = {n}", + "effectSize": "Effektstärke {value}", + "qValue": "q = {value}", + "symptomPatternsTitle": "Symptommuster nach Phase", + "symptomPatternsEmpty": "Erfasse Symptome über einen Zyklus, um zu sehen, in welcher Phase sie sich häufen.", + "symptomMostlyIn": "Meist in {phase} ({count}/{total} Tage)" + }, + "disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.", + "title": "Dein Zyklus", + "subtitle": "Erfasse deine Periode und Symptome und sieh, was als Nächstes kommt.", + "tabs": { + "calendar": "Kalender", + "predictions": "Prognosen", + "insights": "Erkenntnisse", + "settings": "Einstellungen" + }, + "logToday": "Heute erfassen", + "startedPeriod": "Periode hat heute begonnen", + "endedPeriod": "Periode heute beendet", + "phase": { + "MENSTRUAL": "Menstruation", + "FOLLICULAR": "Follikelphase", + "OVULATORY": "Ovulation", + "LUTEAL": "Lutealphase", + "none": "Keine Phase", + "label": "Phase" + }, + "flow": { + "NONE": "Keine", + "SPOTTING": "Schmierblutung", + "LIGHT": "Leicht", + "MEDIUM": "Mittel", + "HEAVY": "Stark", + "label": "Stärke" + }, + "ovulationTest": { + "NEGATIVE": "Negativ", + "POSITIVE_LH_SURGE": "LH-Anstieg", + "ESTROGEN_SURGE": "Östrogen-Anstieg", + "INDETERMINATE": "Unklar", + "label": "Ovulationstest" + }, + "mucus": { + "DRY": "Trocken", + "STICKY": "Klebrig", + "CREAMY": "Cremig", + "WATERY": "Wässrig", + "EGG_WHITE": "Eiweißartig", + "label": "Zervixschleim" + }, + "goal": { + "GENERAL_HEALTH": "Allgemeine Gesundheit", + "AVOID_PREGNANCY": "Verhütung", + "TRYING_TO_CONCEIVE": "Kinderwunsch", + "PERIMENOPAUSE": "Perimenopause", + "OFF": "Tracking aus", + "label": "Wofür trackst du?", + "description": "Das bestimmt die Sprache und welche Prognosen du siehst. Hinweise zum fruchtbaren Fenster erscheinen nur bei Kinderwunsch." + }, + "ring": { + "dayOfCycle": "Tag {day}", + "dayUnknown": "—", + "ariaPhase": "Zyklustag {day}, {phase}-Phase", + "ariaUnknown": "Noch kein aktiver Zyklus", + "caption": "deines Zyklus", + "firstPeriodCta": "Erste Periode erfassen" + }, + "calendar": { + "legendPeriod": "Erfasste Periode", + "legendPredicted": "Prognostizierte Periode", + "legendFertile": "Fruchtbares Fenster", + "legendOvulation": "Ovulation", + "legendSymptoms": "Symptome erfasst", + "prevMonth": "Vorheriger Monat", + "nextMonth": "Nächster Monat", + "loading": "Kalender wird geladen…", + "empty": "Erfasse Daten, damit sich dein Kalender füllt.", + "today": "Heute", + "dayAria": "{date}", + "legendSymptom": "Symptome" + }, + "predictions": { + "title": "Was als Nächstes kommt", + "nextPeriod": "Nächste Periode", + "nextPeriodRange": "Etwa am {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Fruchtbares Fenster", + "ovulation": "Geschätzter Eisprung", + "confidence": "Sicherheit", + "confidenceHigh": "Hoch", + "confidenceMedium": "Mittel", + "confidenceLow": "Niedrig", + "stillLearning": "Noch am Lernen", + "stillLearningBody": "Erfasse mindestens drei vollständige Zyklen, dann werden deine Prognosen genauer. Bis dahin ist dies eine grobe Schätzung.", + "rawMode": "Prognose ist aus", + "rawModeBody": "Du hast Prognosen ausgeschaltet — es werden nur deine erfassten Daten gezeigt, ohne Vorhersage.", + "none": "Noch keine Prognose. Erfasse eine Periode, um zu starten.", + "ovulationConfirmed": "Eisprung (bestätigt)" + }, + "history": { + "title": "Deine Zyklen", + "avgLength": "Durchschnittliche Länge", + "variability": "Schwankung", + "avgPeriodLength": "Durchschnittliche Periode", + "regularity": "Regelmäßigkeit", + "regularityREGULAR": "Regelmäßig", + "regularityIRREGULAR": "Unregelmäßig", + "regularityLEARNING": "Lernend", + "days": "{count} Tage", + "daysPlusMinus": "± {count} Tage", + "none": "Noch keine Zyklen erfasst.", + "recentCycles": "Letzte Zyklen", + "ongoing": "laufend", + "periodDays": "Periode {count} T", + "ovulationChip": "Eisprung" + }, + "sheet": { + "title": "Tag erfassen", + "description": "Erfasse Stärke, Symptome und Notizen von heute.", + "date": "Datum", + "flow": "Stärke", + "symptoms": "Symptome", + "temperature": "Basaltemperatur", + "temperaturePlaceholder": "°C", + "ovulationTest": "Ovulationstest", + "mucus": "Zervixschleim", + "intercourse": "Geschlechtsverkehr", + "protected": "Geschützt", + "note": "Notiz", + "notePlaceholder": "Was du dir über heute merken möchtest…", + "save": "Speichern", + "saving": "Speichern…", + "cancel": "Abbrechen", + "saved": "Tag gespeichert", + "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", + "periodStarted": "Periodenbeginn erfasst", + "clear": "Keine", + "intermenstrualBleeding": "Schmierblutung zwischen Perioden", + "pregnancyTest": "Schwangerschaftstest", + "progesteroneTest": "Progesterontest", + "contraceptive": "Verhütung", + "severity": "Schweregrad", + "severityLevel": "Schweregrad {level} von 4", + "delete": "Löschen" + }, + "settings": { + "title": "Zyklus-Einstellungen", + "enable": "Zyklus-Tracking", + "enableDescription": "Schalte den Zyklus-Tab für dein Konto ein oder aus.", + "typicalCycleLength": "Typische Zykluslänge (Tage)", + "typicalPeriodLength": "Typische Periodenlänge (Tage)", + "lutealPhaseLength": "Länge der Lutealphase (Tage)", + "priorsDescription": "Diese Vorgaben helfen deinen Prognosen, bevor du viele Zyklen erfasst hast. Leer lassen, um die Standardwerte zu nutzen.", + "rawChartMode": "Roh-Diagramm-Modus", + "rawChartModeDescription": "Zeige nur deine erfassten Daten, ohne Prognosen oder Deutung.", + "prediction": "Prognosen", + "predictionDescription": "Prognostiziere deine nächste Periode und, wenn relevant, dein fruchtbares Fenster.", + "encryption": "Sensible Einträge verschlüsseln", + "encryptionDescription": "Verschlüssele absichtsverratende Felder (Schwangerschaftstests, Verhütung, Geschlechtsverkehr) ruhend, zusätzlich zu den stets verschlüsselten Notizen.", + "discreet": "Diskrete Benachrichtigungen", + "discreetDescription": "Verbirg Zyklusdetails im Benachrichtigungstext auf deinem Sperrbildschirm.", + "dataTitle": "Deine Daten", + "dataDescription": "Deine Zyklusdaten liegen nur auf diesem Server. Exportiere sie jederzeit oder lösche sie endgültig.", + "export": "Meine Zyklusdaten exportieren", + "deleteData": "Meine Zyklusdaten löschen", + "saved": "Einstellungen gespeichert", + "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", + "save": "Speichern", + "purgeTitle": "Alle Zyklusdaten löschen", + "purgeDescription": "Entfernt dauerhaft jeden erfassten Tag, Zyklus, jede Vorhersage und jeden Erinnerungseintrag. Kann nicht rückgängig gemacht werden.", + "purgeButton": "Alle Zyklusdaten löschen", + "purgeConfirmTitle": "Alle Zyklusdaten löschen?", + "purgeConfirmBody": "Dies löscht dauerhaft jede Periode, jeden Tageseintrag, jedes Symptom und jede Vorhersage. Dies kann nicht rückgängig gemacht werden.", + "purgeConfirm": "Alle Zyklusdaten dauerhaft löschen?", + "purgeCancel": "Abbrechen", + "purgeSuccess": "Alle Zyklusdaten gelöscht.", + "purgeError": "Zyklusdaten konnten nicht gelöscht werden.", + "reminders": "Erinnerungen", + "remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.", + "remindersLink": "Benachrichtigungseinstellungen", + "purgeData": "Zyklusdaten löschen", + "purgeConfirmAction": "Ja, alles löschen", + "purgeDone": "Zyklusdaten gelöscht." + }, + "testResult": { + "POSITIVE": "Positiv", + "NEGATIVE": "Negativ", + "INDETERMINATE": "Unklar" + }, + "contraceptive": { + "NONE": "Keine", + "UNSPECIFIED": "Andere", + "IMPLANT": "Implantat", + "INJECTION": "Spritze", + "IUD": "Spirale", + "INTRAVAGINAL_RING": "Ring", + "ORAL": "Pille", + "PATCH": "Pflaster", + "EMERGENCY": "Notfall" + }, + "loadError": "Zyklusdaten konnten nicht geladen werden.", + "bbt": { + "title": "Basaltemperatur", + "caption": "Deine Morgentemperatur über diesen Zyklus. Ein anhaltender Anstieg um etwa 0,2 °C folgt auf den Eisprung.", + "empty": "Noch keine Temperaturwerte", + "emptyHint": "Miss deine Basaltemperatur jeden Morgen, um den Temperaturanstieg deines Zyklus zu sehen.", + "ovulationEstimate": "Gesch. Eisprung", + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Bestätigter Eisprung" + } + }, + "cycleReminders": { + "periodSoonTitle": "Periode steht wahrscheinlich bevor", + "periodSoonBody": "Deine nächste Periode wird in etwa zwei Tagen erwartet.", + "periodConfirmTitle": "Hat deine Periode begonnen?", + "periodConfirmBody": "Deine Periode wurde für etwa jetzt vorhergesagt. Trage sie ein, damit die Vorhersagen genau bleiben.", + "discreetTitle": "HealthLog-Erinnerung", + "discreetBody": "Du hast eine Erinnerung in HealthLog." } } diff --git a/messages/en.json b/messages/en.json index 6fb5b62e9..7fd9b439b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", @@ -216,7 +232,8 @@ "medicationDescription": "Mark a dose as taken", "mood": "Mood", "moodDescription": "Log your mood and tags" - } + }, + "cycle": "Cycle" }, "auth": { "login": "Sign in", @@ -3860,6 +3877,14 @@ "statusRevoked": "Revoked", "statusExpired": "Expired", "inactiveTitle": "Revoked and expired ({count})" + }, + "genderOther": "Other", + "cycleTracking": { + "title": "Cycle tracking", + "description": "Track your menstrual cycle, predictions, symptoms, and phase insights. On by default for accounts set to female; available to everyone.", + "enable": "Enable cycle tracking", + "manage": "Open cycle tracking", + "error": "Couldn't update cycle tracking." } }, "admin": { @@ -4673,7 +4698,11 @@ "eventPersonalRecord": "Personal record", "eventPersonalRecordDesc": "Sent when a reading beats your previous best.", "eventMoodReminder": "Mood reminder", - "eventMoodReminderDesc": "Daily nudge to log your current mood." + "eventMoodReminderDesc": "Daily nudge to log your current mood.", + "eventCyclePeriodSoon": "Period coming up", + "eventCyclePeriodSoonDesc": "Sent a couple of days before your predicted next period.", + "eventCyclePeriodConfirm": "Did your period start?", + "eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged." }, "i18n": { "maintainershipBanner": { @@ -4867,5 +4896,285 @@ "score": "Wellness score" }, "footer": "Shared from HealthLog — a self-hosted personal health tracker." + }, + "cycle": { + "symptomCategory": { + "physical": "Physical", + "emotional": "Emotional", + "digestive": "Digestive" + }, + "symptom": { + "cramps": "Cramps", + "headache": "Headache", + "bloating": "Bloating", + "acne": "Acne", + "breastTenderness": "Breast tenderness", + "fatigue": "Fatigue", + "backPain": "Back pain", + "insomnia": "Insomnia", + "libidoHigh": "High libido", + "libidoLow": "Low libido", + "moodSwings": "Mood swings", + "foodCravings": "Food cravings", + "nausea": "Nausea", + "diarrhea": "Diarrhea", + "constipation": "Constipation" + }, + "prediction": { + "disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles." + }, + "insights": { + "headline": { + "title": "Your cycle and your body", + "restingHeartRate": "Your resting heart rate runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "heartRateVariability": "Your heart-rate variability runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "generic": "Your {metric} runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "dirHigher": "higher", + "dirLower": "lower", + "empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging — this fills in as your history grows." + }, + "crosstab": { + "description": "How each measurement compares between your luteal and follicular phases, on your own data.", + "pairLabel": "{metric} · luteal vs follicular", + "detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).", + "footer": "These are associations in your own data, not causes. Only patterns that clear a significance check are shown.", + "metricRestingHeartRate": "Resting heart rate", + "metricHrv": "Heart-rate variability", + "metricSleepDuration": "Sleep duration", + "metricSteps": "Steps", + "metricWeight": "Weight", + "metricBasalBodyTemp": "Basal body temperature", + "metricWristTemperature": "Overnight wrist temperature", + "metricSkinTemperature": "Skin temperature", + "unitHours": "h", + "unitSteps": "steps", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Low confidence", + "confidenceMedium": "Medium confidence", + "confidenceHigh": "High confidence" + }, + "title": "Cycle insights", + "empty": "Phase insights appear here once you have enough logged cycles. They compare your metrics across cycle phases, with the statistics shown openly.", + "sampleSize": "n = {n}", + "effectSize": "Effect size {value}", + "qValue": "q = {value}", + "symptomPatternsTitle": "Symptom patterns by phase", + "symptomPatternsEmpty": "Log symptoms across a cycle to see which phase they cluster in.", + "symptomMostlyIn": "Mostly in {phase} ({count}/{total} days)" + }, + "disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.", + "title": "Your cycle", + "subtitle": "Log your period and symptoms, see what comes next.", + "tabs": { + "calendar": "Calendar", + "predictions": "Predictions", + "insights": "Insights", + "settings": "Settings" + }, + "logToday": "Log today", + "startedPeriod": "Started my period today", + "endedPeriod": "Ended my period today", + "phase": { + "MENSTRUAL": "Menstrual", + "FOLLICULAR": "Follicular", + "OVULATORY": "Ovulatory", + "LUTEAL": "Luteal", + "none": "No phase", + "label": "Phase" + }, + "flow": { + "NONE": "None", + "SPOTTING": "Spotting", + "LIGHT": "Light", + "MEDIUM": "Medium", + "HEAVY": "Heavy", + "label": "Flow" + }, + "ovulationTest": { + "NEGATIVE": "Negative", + "POSITIVE_LH_SURGE": "LH surge", + "ESTROGEN_SURGE": "Estrogen surge", + "INDETERMINATE": "Indeterminate", + "label": "Ovulation test" + }, + "mucus": { + "DRY": "Dry", + "STICKY": "Sticky", + "CREAMY": "Creamy", + "WATERY": "Watery", + "EGG_WHITE": "Egg white", + "label": "Cervical mucus" + }, + "goal": { + "GENERAL_HEALTH": "General health", + "AVOID_PREGNANCY": "Avoiding pregnancy", + "TRYING_TO_CONCEIVE": "Trying to conceive", + "PERIMENOPAUSE": "Perimenopause", + "OFF": "Tracking off", + "label": "What are you tracking for?", + "description": "This shapes the language and which predictions you see. Fertile-window guidance shows only when you are trying to conceive." + }, + "ring": { + "dayOfCycle": "Day {day}", + "dayUnknown": "—", + "ariaPhase": "Cycle day {day}, {phase} phase", + "ariaUnknown": "No active cycle yet", + "caption": "of your cycle", + "firstPeriodCta": "Log my first period" + }, + "calendar": { + "legendPeriod": "Logged period", + "legendPredicted": "Predicted period", + "legendFertile": "Fertile window", + "legendOvulation": "Ovulation", + "legendSymptoms": "Symptoms logged", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "loading": "Loading your calendar…", + "empty": "Start logging to see your calendar fill in.", + "today": "Today", + "dayAria": "{date}", + "legendSymptom": "Symptoms" + }, + "predictions": { + "title": "What comes next", + "nextPeriod": "Next period", + "nextPeriodRange": "Around {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Fertile window", + "ovulation": "Estimated ovulation", + "confidence": "Confidence", + "confidenceHigh": "High", + "confidenceMedium": "Medium", + "confidenceLow": "Low", + "stillLearning": "Still learning", + "stillLearningBody": "Log at least three full cycles and your predictions sharpen. Until then this is a rough estimate.", + "rawMode": "Prediction is off", + "rawModeBody": "You turned predictions off — only your logged data is shown, with no forecast.", + "none": "No prediction yet. Log a period to get started.", + "ovulationConfirmed": "Ovulation (confirmed)" + }, + "history": { + "title": "Your cycles", + "avgLength": "Average length", + "variability": "Variability", + "avgPeriodLength": "Average period", + "regularity": "Regularity", + "regularityREGULAR": "Regular", + "regularityIRREGULAR": "Irregular", + "regularityLEARNING": "Learning", + "days": "{count} days", + "daysPlusMinus": "± {count} days", + "none": "No cycles recorded yet.", + "recentCycles": "Recent cycles", + "ongoing": "ongoing", + "periodDays": "period {count} d", + "ovulationChip": "Ovulation" + }, + "sheet": { + "title": "Log your day", + "description": "Capture today's flow, symptoms, and notes.", + "date": "Date", + "flow": "Flow", + "symptoms": "Symptoms", + "temperature": "Basal body temperature", + "temperaturePlaceholder": "°C", + "ovulationTest": "Ovulation test", + "mucus": "Cervical mucus", + "intercourse": "Intercourse", + "protected": "Protected", + "intermenstrualBleeding": "Spotting between periods", + "pregnancyTest": "Pregnancy test", + "progesteroneTest": "Progesterone test", + "contraceptive": "Contraceptive", + "severity": "Severity", + "severityLevel": "Severity {level} of 4", + "delete": "Delete", + "note": "Note", + "notePlaceholder": "Anything you want to remember about today…", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "saved": "Day saved", + "saveError": "Could not save. Try again.", + "periodStarted": "Period start logged", + "clear": "None" + }, + "testResult": { + "POSITIVE": "Positive", + "NEGATIVE": "Negative", + "INDETERMINATE": "Unclear" + }, + "settings": { + "title": "Cycle settings", + "enable": "Cycle tracking", + "enableDescription": "Turn the cycle tab on or off for your account.", + "typicalCycleLength": "Typical cycle length (days)", + "typicalPeriodLength": "Typical period length (days)", + "lutealPhaseLength": "Luteal phase length (days)", + "priorsDescription": "These priors help your predictions before you have logged many cycles. Leave blank to use the defaults.", + "rawChartMode": "Raw chart mode", + "rawChartModeDescription": "Show only your logged data with no predictions or interpretation.", + "prediction": "Predictions", + "predictionDescription": "Forecast your next period and, when relevant, your fertile window.", + "encryption": "Encrypt sensitive entries", + "encryptionDescription": "Encrypt intent-revealing fields (pregnancy tests, contraception, intercourse) at rest, on top of the always-encrypted notes.", + "discreet": "Discreet notifications", + "discreetDescription": "Hide cycle details from notification text on your lock screen.", + "dataTitle": "Your data", + "dataDescription": "Your cycle data lives only on this server. Export it any time, or delete it for good.", + "export": "Export my cycle data", + "deleteData": "Delete my cycle data", + "saved": "Settings saved", + "saveError": "Could not save. Try again.", + "save": "Save", + "purgeTitle": "Delete all cycle data", + "purgeDescription": "Permanently removes every logged day, cycle, prediction, and reminder record. This cannot be undone.", + "purgeButton": "Delete all cycle data", + "purgeConfirmTitle": "Delete all cycle data?", + "purgeConfirmBody": "This permanently deletes every period, day-log, symptom, and prediction. This cannot be undone.", + "purgeConfirm": "Delete all cycle data permanently?", + "purgeCancel": "Cancel", + "purgeSuccess": "All cycle data deleted.", + "purgeError": "Couldn't delete cycle data.", + "reminders": "Reminders", + "remindersDescription": "Turn period reminders on per channel in notification settings.", + "remindersLink": "Notification settings", + "purgeData": "Delete cycle data", + "purgeConfirmAction": "Yes, delete everything", + "purgeDone": "Cycle data deleted." + }, + "contraceptive": { + "NONE": "None", + "UNSPECIFIED": "Other", + "IMPLANT": "Implant", + "INJECTION": "Injection", + "IUD": "IUD", + "INTRAVAGINAL_RING": "Ring", + "ORAL": "Pill", + "PATCH": "Patch", + "EMERGENCY": "Emergency" + }, + "loadError": "Couldn't load your cycle data.", + "bbt": { + "title": "Basal body temperature", + "caption": "Your morning temperature across this cycle. A sustained rise of about 0.2 °C follows ovulation.", + "empty": "No temperature readings yet", + "emptyHint": "Log your basal body temperature each morning to see your cycle's temperature shift.", + "ovulationEstimate": "Est. ovulation", + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Confirmed ovulation" + } + }, + "cycleReminders": { + "periodSoonTitle": "Period likely soon", + "periodSoonBody": "Your next period is predicted in about two days.", + "periodConfirmTitle": "Did your period start?", + "periodConfirmBody": "Your period was predicted around now. Log it to keep predictions accurate.", + "discreetTitle": "HealthLog reminder", + "discreetBody": "You have a reminder in HealthLog." } } diff --git a/messages/es.json b/messages/es.json index 513ca1b2e..66ad23a33 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", @@ -216,7 +232,8 @@ "medicationDescription": "Marcar una dosis como tomada", "mood": "Estado de ánimo", "moodDescription": "Registrar estado de ánimo y etiquetas" - } + }, + "cycle": "Ciclo" }, "auth": { "login": "Iniciar sesión", @@ -3860,6 +3877,14 @@ "statusRevoked": "Revocado", "statusExpired": "Caducado", "inactiveTitle": "Revocados y caducados ({count})" + }, + "genderOther": "Otro", + "cycleTracking": { + "title": "Seguimiento del ciclo", + "description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases. Activado por defecto en cuentas configuradas como femeninas; disponible para todos.", + "enable": "Activar el seguimiento del ciclo", + "manage": "Abrir seguimiento del ciclo", + "error": "No se pudo actualizar el seguimiento del ciclo." } }, "admin": { @@ -4673,7 +4698,11 @@ "eventPersonalRecord": "Récord personal", "eventPersonalRecordDesc": "Se envía cuando una lectura supera tu mejor marca anterior.", "eventMoodReminder": "Recordatorio de ánimo", - "eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual." + "eventMoodReminderDesc": "Recordatorio diario para registrar tu ánimo actual.", + "eventCyclePeriodSoon": "Período próximo", + "eventCyclePeriodSoonDesc": "Se envía un par de días antes de tu próximo período previsto.", + "eventCyclePeriodConfirm": "¿Empezó tu período?", + "eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período." }, "i18n": { "maintainershipBanner": { @@ -4867,5 +4896,285 @@ "score": "Puntuación de bienestar" }, "footer": "Compartido desde HealthLog — un registro de salud autoalojado." + }, + "cycle": { + "symptomCategory": { + "physical": "Físico", + "emotional": "Emocional", + "digestive": "Digestivo" + }, + "symptom": { + "cramps": "Calambres", + "headache": "Dolor de cabeza", + "bloating": "Hinchazón", + "acne": "Acné", + "breastTenderness": "Sensibilidad en los senos", + "fatigue": "Fatiga", + "backPain": "Dolor de espalda", + "insomnia": "Insomnio", + "libidoHigh": "Libido alta", + "libidoLow": "Libido baja", + "moodSwings": "Cambios de humor", + "foodCravings": "Antojos", + "nausea": "Náuseas", + "diarrhea": "Diarrea", + "constipation": "Estreñimiento" + }, + "prediction": { + "disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos." + }, + "insights": { + "headline": { + "title": "Tu ciclo y tu cuerpo", + "restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "generic": "Tu {metric} es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "dirHigher": "más alta", + "dirLower": "más baja", + "empty": "Aún no hay suficientes ciclos ni registros para mostrar un patrón de fase. Sigue registrando: se completará a medida que crezca tu historial." + }, + "crosstab": { + "description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.", + "pairLabel": "{metric} · lútea vs. folicular", + "detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).", + "footer": "Son asociaciones en tus propios datos, no causas. Solo se muestran los patrones que superan una prueba de significación.", + "metricRestingHeartRate": "Frecuencia cardíaca en reposo", + "metricHrv": "Variabilidad de la frecuencia cardíaca", + "metricSleepDuration": "Duración del sueño", + "metricSteps": "Pasos", + "metricWeight": "Peso", + "metricBasalBodyTemp": "Temperatura basal", + "metricWristTemperature": "Temperatura de la muñeca nocturna", + "metricSkinTemperature": "Temperatura de la piel", + "unitHours": "h", + "unitSteps": "pasos", + "unitBpm": "lpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Confianza baja", + "confidenceMedium": "Confianza media", + "confidenceHigh": "Confianza alta" + }, + "title": "Análisis del ciclo", + "empty": "Los análisis por fase aparecen aquí cuando tienes suficientes ciclos registrados. Comparan tus métricas entre fases del ciclo, mostrando la estadística abiertamente.", + "sampleSize": "n = {n}", + "effectSize": "Tamaño del efecto {value}", + "qValue": "q = {value}", + "symptomPatternsTitle": "Patrones de síntomas por fase", + "symptomPatternsEmpty": "Registra síntomas a lo largo de un ciclo para ver en qué fase se concentran.", + "symptomMostlyIn": "Sobre todo en {phase} ({count}/{total} días)" + }, + "disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.", + "title": "Tu ciclo", + "subtitle": "Registra tu periodo y síntomas, y mira qué viene después.", + "tabs": { + "calendar": "Calendario", + "predictions": "Predicciones", + "insights": "Análisis", + "settings": "Ajustes" + }, + "logToday": "Registrar hoy", + "startedPeriod": "Mi periodo empezó hoy", + "endedPeriod": "Mi periodo terminó hoy", + "phase": { + "MENSTRUAL": "Menstrual", + "FOLLICULAR": "Folicular", + "OVULATORY": "Ovulatoria", + "LUTEAL": "Lútea", + "none": "Sin fase", + "label": "Fase" + }, + "flow": { + "NONE": "Ninguno", + "SPOTTING": "Manchado", + "LIGHT": "Ligero", + "MEDIUM": "Medio", + "HEAVY": "Abundante", + "label": "Flujo" + }, + "ovulationTest": { + "NEGATIVE": "Negativo", + "POSITIVE_LH_SURGE": "Pico de LH", + "ESTROGEN_SURGE": "Pico de estrógeno", + "INDETERMINATE": "Indeterminado", + "label": "Test de ovulación" + }, + "mucus": { + "DRY": "Seco", + "STICKY": "Pegajoso", + "CREAMY": "Cremoso", + "WATERY": "Acuoso", + "EGG_WHITE": "Clara de huevo", + "label": "Moco cervical" + }, + "goal": { + "GENERAL_HEALTH": "Salud general", + "AVOID_PREGNANCY": "Evitar el embarazo", + "TRYING_TO_CONCEIVE": "Buscando embarazo", + "PERIMENOPAUSE": "Perimenopausia", + "OFF": "Seguimiento desactivado", + "label": "¿Para qué haces seguimiento?", + "description": "Esto define el lenguaje y qué predicciones ves. La guía de ventana fértil solo aparece si buscas embarazo." + }, + "ring": { + "dayOfCycle": "Día {day}", + "dayUnknown": "—", + "ariaPhase": "Día {day} del ciclo, fase {phase}", + "ariaUnknown": "Aún no hay ciclo activo", + "caption": "de tu ciclo", + "firstPeriodCta": "Registrar mi primer periodo" + }, + "calendar": { + "legendPeriod": "Periodo registrado", + "legendPredicted": "Periodo previsto", + "legendFertile": "Ventana fértil", + "legendOvulation": "Ovulación", + "legendSymptoms": "Síntomas registrados", + "prevMonth": "Mes anterior", + "nextMonth": "Mes siguiente", + "loading": "Cargando tu calendario…", + "empty": "Empieza a registrar para ver tu calendario.", + "today": "Hoy", + "dayAria": "{date}", + "legendSymptom": "Síntomas" + }, + "predictions": { + "title": "Qué viene después", + "nextPeriod": "Próximo periodo", + "nextPeriodRange": "Alrededor del {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Ventana fértil", + "ovulation": "Ovulación estimada", + "confidence": "Confianza", + "confidenceHigh": "Alta", + "confidenceMedium": "Media", + "confidenceLow": "Baja", + "stillLearning": "Aún aprendiendo", + "stillLearningBody": "Registra al menos tres ciclos completos y tus predicciones mejorarán. Hasta entonces es una estimación aproximada.", + "rawMode": "Predicción desactivada", + "rawModeBody": "Has desactivado las predicciones — solo se muestran tus datos registrados, sin pronóstico.", + "none": "Aún no hay predicción. Registra un periodo para empezar.", + "ovulationConfirmed": "Ovulación (confirmada)" + }, + "history": { + "title": "Tus ciclos", + "avgLength": "Duración media", + "variability": "Variabilidad", + "avgPeriodLength": "Periodo medio", + "regularity": "Regularidad", + "regularityREGULAR": "Regular", + "regularityIRREGULAR": "Irregular", + "regularityLEARNING": "Aprendiendo", + "days": "{count} días", + "daysPlusMinus": "± {count} días", + "none": "Aún no hay ciclos registrados.", + "recentCycles": "Ciclos recientes", + "ongoing": "en curso", + "periodDays": "período {count} d", + "ovulationChip": "Ovulación" + }, + "sheet": { + "title": "Registrar tu día", + "description": "Captura el flujo, los síntomas y las notas de hoy.", + "date": "Fecha", + "flow": "Flujo", + "symptoms": "Síntomas", + "temperature": "Temperatura basal", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test de ovulación", + "mucus": "Moco cervical", + "intercourse": "Relaciones", + "protected": "Protegidas", + "note": "Nota", + "notePlaceholder": "Algo que quieras recordar de hoy…", + "save": "Guardar", + "saving": "Guardando…", + "cancel": "Cancelar", + "saved": "Día guardado", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "periodStarted": "Inicio del periodo registrado", + "clear": "Ninguno", + "intermenstrualBleeding": "Manchado entre periodos", + "pregnancyTest": "Prueba de embarazo", + "progesteroneTest": "Prueba de progesterona", + "contraceptive": "Anticonceptivo", + "severity": "Intensidad", + "severityLevel": "Intensidad {level} de 4", + "delete": "Eliminar" + }, + "settings": { + "title": "Ajustes del ciclo", + "enable": "Seguimiento del ciclo", + "enableDescription": "Activa o desactiva la pestaña del ciclo en tu cuenta.", + "typicalCycleLength": "Duración típica del ciclo (días)", + "typicalPeriodLength": "Duración típica del periodo (días)", + "lutealPhaseLength": "Duración de la fase lútea (días)", + "priorsDescription": "Estos valores ayudan a tus predicciones antes de registrar muchos ciclos. Déjalos en blanco para usar los predeterminados.", + "rawChartMode": "Modo de gráfico en bruto", + "rawChartModeDescription": "Muestra solo tus datos registrados, sin predicciones ni interpretación.", + "prediction": "Predicciones", + "predictionDescription": "Pronostica tu próximo periodo y, cuando corresponda, tu ventana fértil.", + "encryption": "Cifrar entradas sensibles", + "encryptionDescription": "Cifra en reposo los campos que revelan intención (test de embarazo, anticoncepción, relaciones), además de las notas siempre cifradas.", + "discreet": "Notificaciones discretas", + "discreetDescription": "Oculta los detalles del ciclo en el texto de las notificaciones de tu pantalla de bloqueo.", + "dataTitle": "Tus datos", + "dataDescription": "Tus datos del ciclo viven solo en este servidor. Expórtalos cuando quieras o elimínalos para siempre.", + "export": "Exportar mis datos del ciclo", + "deleteData": "Eliminar mis datos del ciclo", + "saved": "Ajustes guardados", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "save": "Guardar", + "purgeTitle": "Eliminar todos los datos del ciclo", + "purgeDescription": "Elimina de forma permanente cada día registrado, ciclo, predicción y registro de recordatorio. No se puede deshacer.", + "purgeButton": "Eliminar todos los datos del ciclo", + "purgeConfirmTitle": "¿Eliminar todos los datos del ciclo?", + "purgeConfirmBody": "Esto elimina de forma permanente todos los periodos, registros diarios, síntomas y predicciones. No se puede deshacer.", + "purgeConfirm": "¿Eliminar permanentemente todos los datos del ciclo?", + "purgeCancel": "Cancelar", + "purgeSuccess": "Todos los datos del ciclo eliminados.", + "purgeError": "No se pudieron eliminar los datos del ciclo.", + "reminders": "Recordatorios", + "remindersDescription": "Activa los recordatorios de período por canal en los ajustes de notificaciones.", + "remindersLink": "Ajustes de notificaciones", + "purgeData": "Eliminar datos del ciclo", + "purgeConfirmAction": "Sí, eliminar todo", + "purgeDone": "Datos del ciclo eliminados." + }, + "testResult": { + "POSITIVE": "Positivo", + "NEGATIVE": "Negativo", + "INDETERMINATE": "No claro" + }, + "contraceptive": { + "NONE": "Ninguno", + "UNSPECIFIED": "Otro", + "IMPLANT": "Implante", + "INJECTION": "Inyección", + "IUD": "DIU", + "INTRAVAGINAL_RING": "Anillo", + "ORAL": "Píldora", + "PATCH": "Parche", + "EMERGENCY": "Emergencia" + }, + "loadError": "No se pudieron cargar tus datos del ciclo.", + "bbt": { + "title": "Temperatura basal", + "caption": "Tu temperatura matutina a lo largo de este ciclo. Una subida sostenida de unos 0,2 °C sigue a la ovulación.", + "empty": "Aún no hay mediciones de temperatura", + "emptyHint": "Registra tu temperatura basal cada mañana para ver el cambio de temperatura de tu ciclo.", + "ovulationEstimate": "Ovulación est.", + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Ovulación confirmada" + } + }, + "cycleReminders": { + "periodSoonTitle": "El periodo llegará pronto", + "periodSoonBody": "Se prevé tu próximo periodo en unos dos días.", + "periodConfirmTitle": "¿Ha comenzado tu periodo?", + "periodConfirmBody": "Tu periodo estaba previsto para ahora. Regístralo para mantener las predicciones precisas.", + "discreetTitle": "Recordatorio de HealthLog", + "discreetBody": "Tienes un recordatorio en HealthLog." } } diff --git a/messages/fr.json b/messages/fr.json index b2543a04a..cb6b6825a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", @@ -216,7 +232,8 @@ "medicationDescription": "Marquer une dose comme prise", "mood": "Humeur", "moodDescription": "Enregistrer l'humeur et les tags" - } + }, + "cycle": "Cycle" }, "auth": { "login": "Se connecter", @@ -3860,6 +3877,14 @@ "statusRevoked": "Révoqué", "statusExpired": "Expiré", "inactiveTitle": "Révoqués et expirés ({count})" + }, + "genderOther": "Autre", + "cycleTracking": { + "title": "Suivi du cycle", + "description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases. Activé par défaut pour les comptes définis comme féminins ; disponible pour tous.", + "enable": "Activer le suivi du cycle", + "manage": "Ouvrir le suivi du cycle", + "error": "Impossible de mettre à jour le suivi du cycle." } }, "admin": { @@ -4673,7 +4698,11 @@ "eventPersonalRecord": "Record personnel", "eventPersonalRecordDesc": "Envoyé quand une valeur dépasse votre meilleur précédent.", "eventMoodReminder": "Rappel d'humeur", - "eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment." + "eventMoodReminderDesc": "Rappel quotidien pour noter votre humeur du moment.", + "eventCyclePeriodSoon": "Règles à venir", + "eventCyclePeriodSoonDesc": "Envoyé quelques jours avant vos prochaines règles prévues.", + "eventCyclePeriodConfirm": "Vos règles ont-elles commencé ?", + "eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée." }, "i18n": { "maintainershipBanner": { @@ -4867,5 +4896,285 @@ "score": "Score de bien-être" }, "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé." + }, + "cycle": { + "symptomCategory": { + "physical": "Physique", + "emotional": "Émotionnel", + "digestive": "Digestif" + }, + "symptom": { + "cramps": "Crampes", + "headache": "Mal de tête", + "bloating": "Ballonnements", + "acne": "Acné", + "breastTenderness": "Sensibilité des seins", + "fatigue": "Fatigue", + "backPain": "Mal de dos", + "insomnia": "Insomnie", + "libidoHigh": "Libido élevée", + "libidoLow": "Libido faible", + "moodSwings": "Sautes d'humeur", + "foodCravings": "Fringales", + "nausea": "Nausées", + "diarrhea": "Diarrhée", + "constipation": "Constipation" + }, + "prediction": { + "disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles." + }, + "insights": { + "headline": { + "title": "Ton cycle et ton corps", + "restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "generic": "Ton/ta {metric} est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "dirHigher": "plus élevée", + "dirLower": "plus basse", + "empty": "Pas encore assez de cycles et de relevés pour faire ressortir un schéma de phase. Continue à enregistrer : cela se complétera à mesure que ton historique grandit." + }, + "crosstab": { + "description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.", + "pairLabel": "{metric} · lutéale vs folliculaire", + "detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).", + "footer": "Ce sont des associations dans tes propres données, pas des causes. Seuls les schémas qui passent un test de significativité sont affichés.", + "metricRestingHeartRate": "Fréquence cardiaque au repos", + "metricHrv": "Variabilité de la fréquence cardiaque", + "metricSleepDuration": "Durée du sommeil", + "metricSteps": "Pas", + "metricWeight": "Poids", + "metricBasalBodyTemp": "Température basale", + "metricWristTemperature": "Température du poignet la nuit", + "metricSkinTemperature": "Température de la peau", + "unitHours": "h", + "unitSteps": "pas", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Confiance faible", + "confidenceMedium": "Confiance moyenne", + "confidenceHigh": "Confiance élevée" + }, + "title": "Analyses du cycle", + "empty": "Les analyses par phase apparaissent ici une fois assez de cycles enregistrés. Elles comparent tes mesures entre les phases du cycle, avec les statistiques affichées ouvertement.", + "sampleSize": "n = {n}", + "effectSize": "Taille d'effet {value}", + "qValue": "q = {value}", + "symptomPatternsTitle": "Tendances des symptômes par phase", + "symptomPatternsEmpty": "Notez vos symptômes sur un cycle pour voir dans quelle phase ils se regroupent.", + "symptomMostlyIn": "Surtout en {phase} ({count}/{total} jours)" + }, + "disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.", + "title": "Ton cycle", + "subtitle": "Enregistre tes règles et symptômes, et vois ce qui arrive ensuite.", + "tabs": { + "calendar": "Calendrier", + "predictions": "Prévisions", + "insights": "Analyses", + "settings": "Réglages" + }, + "logToday": "Enregistrer aujourd'hui", + "startedPeriod": "Mes règles ont commencé aujourd'hui", + "endedPeriod": "Mes règles se sont terminées aujourd'hui", + "phase": { + "MENSTRUAL": "Menstruelle", + "FOLLICULAR": "Folliculaire", + "OVULATORY": "Ovulatoire", + "LUTEAL": "Lutéale", + "none": "Aucune phase", + "label": "Phase" + }, + "flow": { + "NONE": "Aucun", + "SPOTTING": "Spotting", + "LIGHT": "Léger", + "MEDIUM": "Moyen", + "HEAVY": "Abondant", + "label": "Flux" + }, + "ovulationTest": { + "NEGATIVE": "Négatif", + "POSITIVE_LH_SURGE": "Pic de LH", + "ESTROGEN_SURGE": "Pic d'œstrogène", + "INDETERMINATE": "Indéterminé", + "label": "Test d'ovulation" + }, + "mucus": { + "DRY": "Sec", + "STICKY": "Collant", + "CREAMY": "Crémeux", + "WATERY": "Aqueux", + "EGG_WHITE": "Blanc d'œuf", + "label": "Glaire cervicale" + }, + "goal": { + "GENERAL_HEALTH": "Santé générale", + "AVOID_PREGNANCY": "Éviter une grossesse", + "TRYING_TO_CONCEIVE": "Désir d'enfant", + "PERIMENOPAUSE": "Périménopause", + "OFF": "Suivi désactivé", + "label": "Pourquoi fais-tu ce suivi ?", + "description": "Cela adapte le langage et les prévisions affichées. Les conseils sur la fenêtre fertile n'apparaissent qu'en cas de désir d'enfant." + }, + "ring": { + "dayOfCycle": "Jour {day}", + "dayUnknown": "—", + "ariaPhase": "Jour {day} du cycle, phase {phase}", + "ariaUnknown": "Pas encore de cycle actif", + "caption": "de ton cycle", + "firstPeriodCta": "Enregistrer mes premières règles" + }, + "calendar": { + "legendPeriod": "Règles enregistrées", + "legendPredicted": "Règles prévues", + "legendFertile": "Fenêtre fertile", + "legendOvulation": "Ovulation", + "legendSymptoms": "Symptômes enregistrés", + "prevMonth": "Mois précédent", + "nextMonth": "Mois suivant", + "loading": "Chargement de ton calendrier…", + "empty": "Commence à enregistrer pour remplir ton calendrier.", + "today": "Aujourd'hui", + "dayAria": "{date}", + "legendSymptom": "Symptômes" + }, + "predictions": { + "title": "Ce qui arrive ensuite", + "nextPeriod": "Prochaines règles", + "nextPeriodRange": "Vers le {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Fenêtre fertile", + "ovulation": "Ovulation estimée", + "confidence": "Confiance", + "confidenceHigh": "Élevée", + "confidenceMedium": "Moyenne", + "confidenceLow": "Faible", + "stillLearning": "Encore en apprentissage", + "stillLearningBody": "Enregistre au moins trois cycles complets et tes prévisions s'affineront. D'ici là, ceci reste une estimation approximative.", + "rawMode": "Prévision désactivée", + "rawModeBody": "Tu as désactivé les prévisions — seules tes données enregistrées sont affichées, sans prévision.", + "none": "Pas encore de prévision. Enregistre des règles pour commencer.", + "ovulationConfirmed": "Ovulation (confirmée)" + }, + "history": { + "title": "Tes cycles", + "avgLength": "Durée moyenne", + "variability": "Variabilité", + "avgPeriodLength": "Règles moyennes", + "regularity": "Régularité", + "regularityREGULAR": "Régulier", + "regularityIRREGULAR": "Irrégulier", + "regularityLEARNING": "En apprentissage", + "days": "{count} jours", + "daysPlusMinus": "± {count} jours", + "none": "Aucun cycle enregistré pour l'instant.", + "recentCycles": "Cycles récents", + "ongoing": "en cours", + "periodDays": "règles {count} j", + "ovulationChip": "Ovulation" + }, + "sheet": { + "title": "Enregistrer ta journée", + "description": "Saisis le flux, les symptômes et les notes du jour.", + "date": "Date", + "flow": "Flux", + "symptoms": "Symptômes", + "temperature": "Température basale", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test d'ovulation", + "mucus": "Glaire cervicale", + "intercourse": "Rapports", + "protected": "Protégés", + "note": "Note", + "notePlaceholder": "Ce que tu veux retenir d'aujourd'hui…", + "save": "Enregistrer", + "saving": "Enregistrement…", + "cancel": "Annuler", + "saved": "Journée enregistrée", + "saveError": "Enregistrement impossible. Réessaie.", + "periodStarted": "Début des règles enregistré", + "clear": "Aucun", + "intermenstrualBleeding": "Saignements entre les règles", + "pregnancyTest": "Test de grossesse", + "progesteroneTest": "Test de progestérone", + "contraceptive": "Contraception", + "severity": "Intensité", + "severityLevel": "Intensité {level} sur 4", + "delete": "Supprimer" + }, + "settings": { + "title": "Réglages du cycle", + "enable": "Suivi du cycle", + "enableDescription": "Active ou désactive l'onglet cycle pour ton compte.", + "typicalCycleLength": "Durée typique du cycle (jours)", + "typicalPeriodLength": "Durée typique des règles (jours)", + "lutealPhaseLength": "Durée de la phase lutéale (jours)", + "priorsDescription": "Ces valeurs aident tes prévisions avant d'avoir enregistré beaucoup de cycles. Laisse vide pour utiliser les valeurs par défaut.", + "rawChartMode": "Mode graphique brut", + "rawChartModeDescription": "Affiche uniquement tes données enregistrées, sans prévision ni interprétation.", + "prediction": "Prévisions", + "predictionDescription": "Prévois tes prochaines règles et, le cas échéant, ta fenêtre fertile.", + "encryption": "Chiffrer les entrées sensibles", + "encryptionDescription": "Chiffre au repos les champs révélant l'intention (tests de grossesse, contraception, rapports), en plus des notes toujours chiffrées.", + "discreet": "Notifications discrètes", + "discreetDescription": "Masque les détails du cycle dans le texte des notifications sur ton écran verrouillé.", + "dataTitle": "Tes données", + "dataDescription": "Tes données de cycle ne vivent que sur ce serveur. Exporte-les quand tu veux, ou supprime-les définitivement.", + "export": "Exporter mes données de cycle", + "deleteData": "Supprimer mes données de cycle", + "saved": "Réglages enregistrés", + "saveError": "Enregistrement impossible. Réessaie.", + "save": "Enregistrer", + "purgeTitle": "Supprimer toutes les données de cycle", + "purgeDescription": "Supprime définitivement chaque jour enregistré, cycle, prédiction et rappel. Action irréversible.", + "purgeButton": "Supprimer toutes les données de cycle", + "purgeConfirmTitle": "Supprimer toutes les données de cycle ?", + "purgeConfirmBody": "Cela supprime définitivement chaque règle, journal quotidien, symptôme et prédiction. Cette action est irréversible.", + "purgeConfirm": "Supprimer définitivement toutes les données de cycle ?", + "purgeCancel": "Annuler", + "purgeSuccess": "Toutes les données de cycle ont été supprimées.", + "purgeError": "Impossible de supprimer les données de cycle.", + "reminders": "Rappels", + "remindersDescription": "Activez les rappels de règles par canal dans les paramètres de notification.", + "remindersLink": "Paramètres de notification", + "purgeData": "Supprimer les données de cycle", + "purgeConfirmAction": "Oui, tout supprimer", + "purgeDone": "Données de cycle supprimées." + }, + "testResult": { + "POSITIVE": "Positif", + "NEGATIVE": "Négatif", + "INDETERMINATE": "Incertain" + }, + "contraceptive": { + "NONE": "Aucune", + "UNSPECIFIED": "Autre", + "IMPLANT": "Implant", + "INJECTION": "Injection", + "IUD": "DIU", + "INTRAVAGINAL_RING": "Anneau", + "ORAL": "Pilule", + "PATCH": "Patch", + "EMERGENCY": "Urgence" + }, + "loadError": "Impossible de charger vos données de cycle.", + "bbt": { + "title": "Température basale", + "caption": "Votre température matinale sur ce cycle. Une hausse durable d'environ 0,2 °C suit l'ovulation.", + "empty": "Pas encore de relevés de température", + "emptyHint": "Notez votre température basale chaque matin pour voir le décalage de température de votre cycle.", + "ovulationEstimate": "Ovulation est.", + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Ovulation confirmée" + } + }, + "cycleReminders": { + "periodSoonTitle": "Règles probablement bientôt", + "periodSoonBody": "Vos prochaines règles sont prévues dans environ deux jours.", + "periodConfirmTitle": "Vos règles ont-elles commencé ?", + "periodConfirmBody": "Vos règles étaient prévues pour maintenant. Enregistrez-les pour garder des prévisions précises.", + "discreetTitle": "Rappel HealthLog", + "discreetBody": "Vous avez un rappel dans HealthLog." } } diff --git a/messages/it.json b/messages/it.json index e94caef04..7b560241f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", @@ -216,7 +232,8 @@ "medicationDescription": "Segna una dose come assunta", "mood": "Umore", "moodDescription": "Registra umore e tag" - } + }, + "cycle": "Ciclo" }, "auth": { "login": "Accedi", @@ -3860,6 +3877,14 @@ "statusRevoked": "Revocato", "statusExpired": "Scaduto", "inactiveTitle": "Revocati e scaduti ({count})" + }, + "genderOther": "Altro", + "cycleTracking": { + "title": "Monitoraggio del ciclo", + "description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi. Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti.", + "enable": "Attiva il monitoraggio del ciclo", + "manage": "Apri il monitoraggio del ciclo", + "error": "Impossibile aggiornare il monitoraggio del ciclo." } }, "admin": { @@ -4673,7 +4698,11 @@ "eventPersonalRecord": "Record personale", "eventPersonalRecordDesc": "Inviato quando una misurazione supera il tuo precedente record.", "eventMoodReminder": "Promemoria umore", - "eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale." + "eventMoodReminderDesc": "Promemoria quotidiano per registrare il tuo umore attuale.", + "eventCyclePeriodSoon": "Ciclo in arrivo", + "eventCyclePeriodSoonDesc": "Inviato un paio di giorni prima del ciclo previsto.", + "eventCyclePeriodConfirm": "Il ciclo è iniziato?", + "eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo." }, "i18n": { "maintainershipBanner": { @@ -4867,5 +4896,285 @@ "score": "Punteggio di benessere" }, "footer": "Condiviso da HealthLog — un tracker della salute self-hosted." + }, + "cycle": { + "symptomCategory": { + "physical": "Fisico", + "emotional": "Emotivo", + "digestive": "Digestivo" + }, + "symptom": { + "cramps": "Crampi", + "headache": "Mal di testa", + "bloating": "Gonfiore", + "acne": "Acne", + "breastTenderness": "Tensione al seno", + "fatigue": "Affaticamento", + "backPain": "Mal di schiena", + "insomnia": "Insonnia", + "libidoHigh": "Libido alta", + "libidoLow": "Libido bassa", + "moodSwings": "Sbalzi d'umore", + "foodCravings": "Voglie alimentari", + "nausea": "Nausea", + "diarrhea": "Diarrea", + "constipation": "Stitichezza" + }, + "prediction": { + "disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli." + }, + "insights": { + "headline": { + "title": "Il tuo ciclo e il tuo corpo", + "restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "generic": "Il/la tuo/a {metric} è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "dirHigher": "più alta", + "dirLower": "più bassa", + "empty": "Non ci sono ancora abbastanza cicli e rilevazioni per mostrare uno schema di fase. Continua a registrare: si completerà man mano che cresce il tuo storico." + }, + "crosstab": { + "description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.", + "pairLabel": "{metric} · luteale vs follicolare", + "detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).", + "footer": "Sono associazioni nei tuoi dati, non cause. Vengono mostrati solo gli schemi che superano un test di significatività.", + "metricRestingHeartRate": "Frequenza cardiaca a riposo", + "metricHrv": "Variabilità della frequenza cardiaca", + "metricSleepDuration": "Durata del sonno", + "metricSteps": "Passi", + "metricWeight": "Peso", + "metricBasalBodyTemp": "Temperatura basale", + "metricWristTemperature": "Temperatura del polso notturna", + "metricSkinTemperature": "Temperatura della pelle", + "unitHours": "h", + "unitSteps": "passi", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Affidabilità bassa", + "confidenceMedium": "Affidabilità media", + "confidenceHigh": "Affidabilità alta" + }, + "title": "Analisi del ciclo", + "empty": "Le analisi per fase compaiono qui quando hai registrato abbastanza cicli. Confrontano le tue misure tra le fasi del ciclo, con la statistica mostrata apertamente.", + "sampleSize": "n = {n}", + "effectSize": "Dimensione dell'effetto {value}", + "qValue": "q = {value}", + "symptomPatternsTitle": "Pattern dei sintomi per fase", + "symptomPatternsEmpty": "Registra i sintomi durante un ciclo per vedere in quale fase si concentrano.", + "symptomMostlyIn": "Soprattutto in {phase} ({count}/{total} giorni)" + }, + "disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.", + "title": "Il tuo ciclo", + "subtitle": "Registra il ciclo e i sintomi e scopri cosa arriva dopo.", + "tabs": { + "calendar": "Calendario", + "predictions": "Previsioni", + "insights": "Analisi", + "settings": "Impostazioni" + }, + "logToday": "Registra oggi", + "startedPeriod": "Le mestruazioni sono iniziate oggi", + "endedPeriod": "Le mestruazioni sono finite oggi", + "phase": { + "MENSTRUAL": "Mestruale", + "FOLLICULAR": "Follicolare", + "OVULATORY": "Ovulatoria", + "LUTEAL": "Luteale", + "none": "Nessuna fase", + "label": "Fase" + }, + "flow": { + "NONE": "Nessuno", + "SPOTTING": "Spotting", + "LIGHT": "Leggero", + "MEDIUM": "Medio", + "HEAVY": "Abbondante", + "label": "Flusso" + }, + "ovulationTest": { + "NEGATIVE": "Negativo", + "POSITIVE_LH_SURGE": "Picco di LH", + "ESTROGEN_SURGE": "Picco di estrogeni", + "INDETERMINATE": "Indeterminato", + "label": "Test di ovulazione" + }, + "mucus": { + "DRY": "Secco", + "STICKY": "Appiccicoso", + "CREAMY": "Cremoso", + "WATERY": "Acquoso", + "EGG_WHITE": "Albume", + "label": "Muco cervicale" + }, + "goal": { + "GENERAL_HEALTH": "Salute generale", + "AVOID_PREGNANCY": "Evitare la gravidanza", + "TRYING_TO_CONCEIVE": "Cerco una gravidanza", + "PERIMENOPAUSE": "Perimenopausa", + "OFF": "Monitoraggio disattivato", + "label": "Per cosa stai monitorando?", + "description": "Questo definisce il linguaggio e quali previsioni vedi. Le indicazioni sulla finestra fertile compaiono solo se cerchi una gravidanza." + }, + "ring": { + "dayOfCycle": "Giorno {day}", + "dayUnknown": "—", + "ariaPhase": "Giorno {day} del ciclo, fase {phase}", + "ariaUnknown": "Nessun ciclo attivo ancora", + "caption": "del tuo ciclo", + "firstPeriodCta": "Registra il mio primo ciclo" + }, + "calendar": { + "legendPeriod": "Ciclo registrato", + "legendPredicted": "Ciclo previsto", + "legendFertile": "Finestra fertile", + "legendOvulation": "Ovulazione", + "legendSymptoms": "Sintomi registrati", + "prevMonth": "Mese precedente", + "nextMonth": "Mese successivo", + "loading": "Caricamento del calendario…", + "empty": "Inizia a registrare per riempire il calendario.", + "today": "Oggi", + "dayAria": "{date}", + "legendSymptom": "Sintomi" + }, + "predictions": { + "title": "Cosa arriva dopo", + "nextPeriod": "Prossimo ciclo", + "nextPeriodRange": "Intorno al {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Finestra fertile", + "ovulation": "Ovulazione stimata", + "confidence": "Affidabilità", + "confidenceHigh": "Alta", + "confidenceMedium": "Media", + "confidenceLow": "Bassa", + "stillLearning": "Sto ancora imparando", + "stillLearningBody": "Registra almeno tre cicli completi e le previsioni miglioreranno. Fino ad allora è una stima approssimativa.", + "rawMode": "Previsione disattivata", + "rawModeBody": "Hai disattivato le previsioni — vengono mostrati solo i tuoi dati registrati, senza previsione.", + "none": "Nessuna previsione ancora. Registra un ciclo per iniziare.", + "ovulationConfirmed": "Ovulazione (confermata)" + }, + "history": { + "title": "I tuoi cicli", + "avgLength": "Durata media", + "variability": "Variabilità", + "avgPeriodLength": "Mestruazioni medie", + "regularity": "Regolarità", + "regularityREGULAR": "Regolare", + "regularityIRREGULAR": "Irregolare", + "regularityLEARNING": "In apprendimento", + "days": "{count} giorni", + "daysPlusMinus": "± {count} giorni", + "none": "Nessun ciclo registrato ancora.", + "recentCycles": "Cicli recenti", + "ongoing": "in corso", + "periodDays": "ciclo {count} g", + "ovulationChip": "Ovulazione" + }, + "sheet": { + "title": "Registra la tua giornata", + "description": "Annota flusso, sintomi e note di oggi.", + "date": "Data", + "flow": "Flusso", + "symptoms": "Sintomi", + "temperature": "Temperatura basale", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test di ovulazione", + "mucus": "Muco cervicale", + "intercourse": "Rapporti", + "protected": "Protetti", + "note": "Nota", + "notePlaceholder": "Qualcosa che vuoi ricordare di oggi…", + "save": "Salva", + "saving": "Salvataggio…", + "cancel": "Annulla", + "saved": "Giornata salvata", + "saveError": "Impossibile salvare. Riprova.", + "periodStarted": "Inizio del ciclo registrato", + "clear": "Nessuno", + "intermenstrualBleeding": "Spotting tra i cicli", + "pregnancyTest": "Test di gravidanza", + "progesteroneTest": "Test del progesterone", + "contraceptive": "Contraccettivo", + "severity": "Intensità", + "severityLevel": "Intensità {level} su 4", + "delete": "Elimina" + }, + "settings": { + "title": "Impostazioni del ciclo", + "enable": "Monitoraggio del ciclo", + "enableDescription": "Attiva o disattiva la scheda ciclo per il tuo account.", + "typicalCycleLength": "Durata tipica del ciclo (giorni)", + "typicalPeriodLength": "Durata tipica delle mestruazioni (giorni)", + "lutealPhaseLength": "Durata della fase luteale (giorni)", + "priorsDescription": "Questi valori aiutano le previsioni prima di aver registrato molti cicli. Lascia vuoto per usare i valori predefiniti.", + "rawChartMode": "Modalità grafico grezzo", + "rawChartModeDescription": "Mostra solo i tuoi dati registrati, senza previsioni né interpretazioni.", + "prediction": "Previsioni", + "predictionDescription": "Prevedi il prossimo ciclo e, quando rilevante, la tua finestra fertile.", + "encryption": "Cifra le voci sensibili", + "encryptionDescription": "Cifra a riposo i campi che rivelano l'intenzione (test di gravidanza, contraccezione, rapporti), oltre alle note sempre cifrate.", + "discreet": "Notifiche discrete", + "discreetDescription": "Nascondi i dettagli del ciclo nel testo delle notifiche sulla schermata di blocco.", + "dataTitle": "I tuoi dati", + "dataDescription": "I tuoi dati del ciclo vivono solo su questo server. Esportali quando vuoi, o eliminali per sempre.", + "export": "Esporta i miei dati del ciclo", + "deleteData": "Elimina i miei dati del ciclo", + "saved": "Impostazioni salvate", + "saveError": "Impossibile salvare. Riprova.", + "save": "Salva", + "purgeTitle": "Elimina tutti i dati del ciclo", + "purgeDescription": "Rimuove definitivamente ogni giorno registrato, ciclo, previsione e promemoria. Operazione irreversibile.", + "purgeButton": "Elimina tutti i dati del ciclo", + "purgeConfirmTitle": "Eliminare tutti i dati del ciclo?", + "purgeConfirmBody": "Questo elimina definitivamente ogni mestruazione, registro giornaliero, sintomo e previsione. L'operazione è irreversibile.", + "purgeConfirm": "Eliminare definitivamente tutti i dati del ciclo?", + "purgeCancel": "Annulla", + "purgeSuccess": "Tutti i dati del ciclo eliminati.", + "purgeError": "Impossibile eliminare i dati del ciclo.", + "reminders": "Promemoria", + "remindersDescription": "Attiva i promemoria del ciclo per canale nelle impostazioni di notifica.", + "remindersLink": "Impostazioni di notifica", + "purgeData": "Elimina i dati del ciclo", + "purgeConfirmAction": "Sì, elimina tutto", + "purgeDone": "Dati del ciclo eliminati." + }, + "testResult": { + "POSITIVE": "Positivo", + "NEGATIVE": "Negativo", + "INDETERMINATE": "Non chiaro" + }, + "contraceptive": { + "NONE": "Nessuno", + "UNSPECIFIED": "Altro", + "IMPLANT": "Impianto", + "INJECTION": "Iniezione", + "IUD": "IUD", + "INTRAVAGINAL_RING": "Anello", + "ORAL": "Pillola", + "PATCH": "Cerotto", + "EMERGENCY": "Emergenza" + }, + "loadError": "Impossibile caricare i dati del ciclo.", + "bbt": { + "title": "Temperatura basale", + "caption": "La tua temperatura mattutina in questo ciclo. Un rialzo prolungato di circa 0,2 °C segue l'ovulazione.", + "empty": "Ancora nessuna misurazione della temperatura", + "emptyHint": "Registra la temperatura basale ogni mattina per vedere il rialzo termico del tuo ciclo.", + "ovulationEstimate": "Ovulazione stim.", + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Ovulazione confermata" + } + }, + "cycleReminders": { + "periodSoonTitle": "Ciclo probabilmente in arrivo", + "periodSoonBody": "Il tuo prossimo ciclo è previsto tra circa due giorni.", + "periodConfirmTitle": "Il ciclo è iniziato?", + "periodConfirmBody": "Il tuo ciclo era previsto per adesso. Registralo per mantenere accurate le previsioni.", + "discreetTitle": "Promemoria HealthLog", + "discreetBody": "Hai un promemoria in HealthLog." } } diff --git a/messages/pl.json b/messages/pl.json index 6e87401a3..27b5b0a66 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", @@ -216,7 +232,8 @@ "medicationDescription": "Oznacz dawkę jako przyjętą", "mood": "Nastrój", "moodDescription": "Zapisz nastrój i tagi" - } + }, + "cycle": "Cykl" }, "auth": { "login": "Zaloguj się", @@ -3860,6 +3877,14 @@ "statusRevoked": "Unieważniony", "statusExpired": "Wygasły", "inactiveTitle": "Unieważnione i wygasłe ({count})" + }, + "genderOther": "Inna", + "cycleTracking": { + "title": "Śledzenie cyklu", + "description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz. Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich.", + "enable": "Włącz śledzenie cyklu", + "manage": "Otwórz śledzenie cyklu", + "error": "Nie udało się zaktualizować śledzenia cyklu." } }, "admin": { @@ -4673,7 +4698,11 @@ "eventPersonalRecord": "Rekord osobisty", "eventPersonalRecordDesc": "Wysyłane, gdy odczyt pobije Twój dotychczasowy rekord.", "eventMoodReminder": "Przypomnienie o nastroju", - "eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój." + "eventMoodReminderDesc": "Codzienne przypomnienie, aby zapisać swój aktualny nastrój.", + "eventCyclePeriodSoon": "Zbliża się okres", + "eventCyclePeriodSoonDesc": "Wysyłane kilka dni przed przewidywanym okresem.", + "eventCyclePeriodConfirm": "Czy okres się zaczął?", + "eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu." }, "i18n": { "maintainershipBanner": { @@ -4867,5 +4896,285 @@ "score": "Wynik dobrostanu" }, "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia." + }, + "cycle": { + "symptomCategory": { + "physical": "Fizyczne", + "emotional": "Emocjonalne", + "digestive": "Trawienne" + }, + "symptom": { + "cramps": "Skurcze", + "headache": "Ból głowy", + "bloating": "Wzdęcia", + "acne": "Trądzik", + "breastTenderness": "Tkliwość piersi", + "fatigue": "Zmęczenie", + "backPain": "Ból pleców", + "insomnia": "Bezsenność", + "libidoHigh": "Wysokie libido", + "libidoLow": "Niskie libido", + "moodSwings": "Wahania nastroju", + "foodCravings": "Zachcianki", + "nausea": "Nudności", + "diarrhea": "Biegunka", + "constipation": "Zaparcia" + }, + "prediction": { + "disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli." + }, + "insights": { + "headline": { + "title": "Twój cykl i Twoje ciało", + "restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "generic": "Twoje {metric} jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "dirHigher": "wyższe", + "dirLower": "niższe", + "empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej — uzupełni się to wraz z historią." + }, + "crosstab": { + "description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.", + "pairLabel": "{metric} · lutealna vs folikularna", + "detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).", + "footer": "To powiązania w Twoich danych, nie przyczyny. Pokazywane są tylko wzorce, które przechodzą test istotności.", + "metricRestingHeartRate": "Tętno spoczynkowe", + "metricHrv": "Zmienność rytmu serca", + "metricSleepDuration": "Czas snu", + "metricSteps": "Kroki", + "metricWeight": "Waga", + "metricBasalBodyTemp": "Temperatura podstawowa", + "metricWristTemperature": "Nocna temperatura nadgarstka", + "metricSkinTemperature": "Temperatura skóry", + "unitHours": "godz.", + "unitSteps": "kroki", + "unitBpm": "ud./min", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Niska pewność", + "confidenceMedium": "Średnia pewność", + "confidenceHigh": "Wysoka pewność" + }, + "title": "Analizy cyklu", + "empty": "Analizy faz pojawią się tutaj, gdy zapiszesz wystarczająco wiele cykli. Porównują twoje wskaźniki między fazami cyklu, z otwarcie pokazaną statystyką.", + "sampleSize": "n = {n}", + "effectSize": "Wielkość efektu {value}", + "qValue": "q = {value}", + "symptomPatternsTitle": "Wzorce objawów wg fazy", + "symptomPatternsEmpty": "Zapisuj objawy przez cały cykl, aby zobaczyć, w której fazie się grupują.", + "symptomMostlyIn": "Głównie w fazie {phase} ({count}/{total} dni)" + }, + "disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.", + "title": "Twój cykl", + "subtitle": "Zapisuj miesiączkę i objawy oraz sprawdzaj, co dalej.", + "tabs": { + "calendar": "Kalendarz", + "predictions": "Prognozy", + "insights": "Analizy", + "settings": "Ustawienia" + }, + "logToday": "Zapisz dziś", + "startedPeriod": "Miesiączka zaczęła się dzisiaj", + "endedPeriod": "Miesiączka skończyła się dzisiaj", + "phase": { + "MENSTRUAL": "Miesiączkowa", + "FOLLICULAR": "Folikularna", + "OVULATORY": "Owulacyjna", + "LUTEAL": "Lutealna", + "none": "Brak fazy", + "label": "Faza" + }, + "flow": { + "NONE": "Brak", + "SPOTTING": "Plamienie", + "LIGHT": "Słaby", + "MEDIUM": "Średni", + "HEAVY": "Obfity", + "label": "Krwawienie" + }, + "ovulationTest": { + "NEGATIVE": "Negatywny", + "POSITIVE_LH_SURGE": "Skok LH", + "ESTROGEN_SURGE": "Skok estrogenu", + "INDETERMINATE": "Nieokreślony", + "label": "Test owulacyjny" + }, + "mucus": { + "DRY": "Suchy", + "STICKY": "Lepki", + "CREAMY": "Kremowy", + "WATERY": "Wodnisty", + "EGG_WHITE": "Jak białko jaja", + "label": "Śluz szyjkowy" + }, + "goal": { + "GENERAL_HEALTH": "Ogólne zdrowie", + "AVOID_PREGNANCY": "Unikanie ciąży", + "TRYING_TO_CONCEIVE": "Staranie o dziecko", + "PERIMENOPAUSE": "Perimenopauza", + "OFF": "Śledzenie wyłączone", + "label": "W jakim celu śledzisz?", + "description": "To dostosowuje język i widoczne prognozy. Wskazówki dotyczące okna płodnego pojawiają się tylko przy staraniu o dziecko." + }, + "ring": { + "dayOfCycle": "Dzień {day}", + "dayUnknown": "—", + "ariaPhase": "Dzień {day} cyklu, faza {phase}", + "ariaUnknown": "Brak aktywnego cyklu", + "caption": "twojego cyklu", + "firstPeriodCta": "Zapisz pierwszą miesiączkę" + }, + "calendar": { + "legendPeriod": "Zapisana miesiączka", + "legendPredicted": "Prognozowana miesiączka", + "legendFertile": "Okno płodne", + "legendOvulation": "Owulacja", + "legendSymptoms": "Zapisane objawy", + "prevMonth": "Poprzedni miesiąc", + "nextMonth": "Następny miesiąc", + "loading": "Ładowanie kalendarza…", + "empty": "Zacznij zapisywać, aby wypełnić kalendarz.", + "today": "Dziś", + "dayAria": "{date}", + "legendSymptom": "Objawy" + }, + "predictions": { + "title": "Co dalej", + "nextPeriod": "Następna miesiączka", + "nextPeriodRange": "Około {date}", + "nextPeriodWindow": "{low} – {high}", + "fertileWindow": "Okno płodne", + "ovulation": "Szacowana owulacja", + "confidence": "Pewność", + "confidenceHigh": "Wysoka", + "confidenceMedium": "Średnia", + "confidenceLow": "Niska", + "stillLearning": "Wciąż się uczę", + "stillLearningBody": "Zapisz co najmniej trzy pełne cykle, a prognozy się wyostrzą. Do tego czasu to zgrubne oszacowanie.", + "rawMode": "Prognoza wyłączona", + "rawModeBody": "Wyłączyłaś prognozy — pokazywane są tylko zapisane dane, bez przewidywań.", + "none": "Brak prognozy. Zapisz miesiączkę, aby zacząć.", + "ovulationConfirmed": "Owulacja (potwierdzona)" + }, + "history": { + "title": "Twoje cykle", + "avgLength": "Średnia długość", + "variability": "Zmienność", + "avgPeriodLength": "Średnia miesiączka", + "regularity": "Regularność", + "regularityREGULAR": "Regularny", + "regularityIRREGULAR": "Nieregularny", + "regularityLEARNING": "Uczenie", + "days": "{count} dni", + "daysPlusMinus": "± {count} dni", + "none": "Brak zapisanych cykli.", + "recentCycles": "Ostatnie cykle", + "ongoing": "w toku", + "periodDays": "okres {count} d", + "ovulationChip": "Owulacja" + }, + "sheet": { + "title": "Zapisz swój dzień", + "description": "Zapisz dzisiejsze krwawienie, objawy i notatki.", + "date": "Data", + "flow": "Krwawienie", + "symptoms": "Objawy", + "temperature": "Podstawowa temperatura ciała", + "temperaturePlaceholder": "°C", + "ovulationTest": "Test owulacyjny", + "mucus": "Śluz szyjkowy", + "intercourse": "Współżycie", + "protected": "Zabezpieczone", + "note": "Notatka", + "notePlaceholder": "Cokolwiek chcesz zapamiętać z dzisiaj…", + "save": "Zapisz", + "saving": "Zapisywanie…", + "cancel": "Anuluj", + "saved": "Dzień zapisany", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "periodStarted": "Zapisano początek miesiączki", + "clear": "Brak", + "intermenstrualBleeding": "Plamienie między okresami", + "pregnancyTest": "Test ciążowy", + "progesteroneTest": "Test progesteronu", + "contraceptive": "Antykoncepcja", + "severity": "Nasilenie", + "severityLevel": "Nasilenie {level} z 4", + "delete": "Usuń" + }, + "settings": { + "title": "Ustawienia cyklu", + "enable": "Śledzenie cyklu", + "enableDescription": "Włącz lub wyłącz zakładkę cyklu dla swojego konta.", + "typicalCycleLength": "Typowa długość cyklu (dni)", + "typicalPeriodLength": "Typowa długość miesiączki (dni)", + "lutealPhaseLength": "Długość fazy lutealnej (dni)", + "priorsDescription": "Te wartości pomagają prognozom, zanim zapiszesz wiele cykli. Zostaw puste, aby użyć wartości domyślnych.", + "rawChartMode": "Tryb surowego wykresu", + "rawChartModeDescription": "Pokazuj tylko zapisane dane, bez prognoz i interpretacji.", + "prediction": "Prognozy", + "predictionDescription": "Prognozuj następną miesiączkę oraz, gdy to istotne, okno płodne.", + "encryption": "Szyfruj wrażliwe wpisy", + "encryptionDescription": "Szyfruj w spoczynku pola ujawniające intencje (testy ciążowe, antykoncepcja, współżycie), obok zawsze szyfrowanych notatek.", + "discreet": "Dyskretne powiadomienia", + "discreetDescription": "Ukryj szczegóły cyklu w treści powiadomień na ekranie blokady.", + "dataTitle": "Twoje dane", + "dataDescription": "Twoje dane cyklu istnieją tylko na tym serwerze. Eksportuj je w każdej chwili lub usuń na zawsze.", + "export": "Eksportuj moje dane cyklu", + "deleteData": "Usuń moje dane cyklu", + "saved": "Ustawienia zapisane", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "save": "Zapisz", + "purgeTitle": "Usuń wszystkie dane cyklu", + "purgeDescription": "Trwale usuwa każdy zapisany dzień, cykl, prognozę i przypomnienie. Tej operacji nie można cofnąć.", + "purgeButton": "Usuń wszystkie dane cyklu", + "purgeConfirmTitle": "Usunąć wszystkie dane cyklu?", + "purgeConfirmBody": "Spowoduje to trwałe usunięcie każdej miesiączki, wpisu dziennego, objawu i prognozy. Tej operacji nie można cofnąć.", + "purgeConfirm": "Trwale usunąć wszystkie dane cyklu?", + "purgeCancel": "Anuluj", + "purgeSuccess": "Usunięto wszystkie dane cyklu.", + "purgeError": "Nie udało się usunąć danych cyklu.", + "reminders": "Przypomnienia", + "remindersDescription": "Włącz przypomnienia o okresie dla każdego kanału w ustawieniach powiadomień.", + "remindersLink": "Ustawienia powiadomień", + "purgeData": "Usuń dane cyklu", + "purgeConfirmAction": "Tak, usuń wszystko", + "purgeDone": "Dane cyklu usunięte." + }, + "testResult": { + "POSITIVE": "Pozytywny", + "NEGATIVE": "Negatywny", + "INDETERMINATE": "Niejasny" + }, + "contraceptive": { + "NONE": "Brak", + "UNSPECIFIED": "Inne", + "IMPLANT": "Implant", + "INJECTION": "Zastrzyk", + "IUD": "Wkładka", + "INTRAVAGINAL_RING": "Krążek", + "ORAL": "Tabletka", + "PATCH": "Plaster", + "EMERGENCY": "Awaryjna" + }, + "loadError": "Nie udało się wczytać danych cyklu.", + "bbt": { + "title": "Podstawowa temperatura ciała", + "caption": "Twoja poranna temperatura w tym cyklu. Trwały wzrost o około 0,2 °C następuje po owulacji.", + "empty": "Brak pomiarów temperatury", + "emptyHint": "Zapisuj podstawową temperaturę ciała każdego ranka, aby zobaczyć przesunięcie temperatury w cyklu.", + "ovulationEstimate": "Szac. owulacja", + "tooltipTemp": "{temp} °C", + "ovulationConfirmed": "Potwierdzona owulacja" + } + }, + "cycleReminders": { + "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", + "periodSoonBody": "Twoja następna miesiączka jest przewidywana za około dwa dni.", + "periodConfirmTitle": "Czy miesiączka się zaczęła?", + "periodConfirmBody": "Twoja miesiączka była przewidywana mniej więcej teraz. Zapisz ją, aby prognozy pozostały dokładne.", + "discreetTitle": "Przypomnienie HealthLog", + "discreetBody": "Masz przypomnienie w HealthLog." } } diff --git a/package.json b/package.json index a7bd35db9..e0a3cc0de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.14.0", + "version": "1.15.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0128_v1150_cycle_core/migration.sql b/prisma/migrations/0128_v1150_cycle_core/migration.sql new file mode 100644 index 000000000..10314d75c --- /dev/null +++ b/prisma/migrations/0128_v1150_cycle_core/migration.sql @@ -0,0 +1,150 @@ +-- v1.15.0 — cycle tracking core. +-- +-- Adds the eight cycle enums plus the three capture tables: the 1:1 +-- CycleProfile settings row, the soft-deleted MenstrualCycle + CycleDayLog +-- capture rows (same syncVersion + deletedAt shape as MoodEntry so they +-- ride the `/api/sync/changes` delta feed), and their indexes / uniques. +-- Purely additive — no existing table is touched. +-- +-- CycleDayLog carries the MoodEntry dual-unique: the canonical-day key +-- `(user_id, date)` plus the NULL-distinct external-dedup key +-- `(user_id, source, external_id)` for HealthKit re-ingest. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "cycle_day_logs"; +-- DROP TABLE IF EXISTS "menstrual_cycles"; +-- DROP TABLE IF EXISTS "cycle_profiles"; +-- DROP TYPE IF EXISTS "prediction_method"; +-- DROP TYPE IF EXISTS "cycle_phase"; +-- DROP TYPE IF EXISTS "contraceptive_kind"; +-- DROP TYPE IF EXISTS "home_test_result"; +-- DROP TYPE IF EXISTS "cervical_mucus"; +-- DROP TYPE IF EXISTS "ovulation_test"; +-- DROP TYPE IF EXISTS "flow_level"; +-- DROP TYPE IF EXISTS "cycle_tracking_goal"; + +-- CreateEnum +CREATE TYPE "cycle_tracking_goal" AS ENUM ('GENERAL_HEALTH', 'AVOID_PREGNANCY', 'TRYING_TO_CONCEIVE', 'PERIMENOPAUSE', 'OFF'); + +-- CreateEnum +CREATE TYPE "flow_level" AS ENUM ('NONE', 'SPOTTING', 'LIGHT', 'MEDIUM', 'HEAVY'); + +-- CreateEnum +CREATE TYPE "ovulation_test" AS ENUM ('NEGATIVE', 'POSITIVE_LH_SURGE', 'ESTROGEN_SURGE', 'INDETERMINATE'); + +-- CreateEnum +CREATE TYPE "cervical_mucus" AS ENUM ('DRY', 'STICKY', 'CREAMY', 'WATERY', 'EGG_WHITE'); + +-- CreateEnum +CREATE TYPE "home_test_result" AS ENUM ('NEGATIVE', 'POSITIVE', 'INDETERMINATE'); + +-- CreateEnum +CREATE TYPE "contraceptive_kind" AS ENUM ('NONE', 'UNSPECIFIED', 'IMPLANT', 'INJECTION', 'IUD', 'INTRAVAGINAL_RING', 'ORAL', 'PATCH', 'EMERGENCY'); + +-- CreateEnum +CREATE TYPE "cycle_phase" AS ENUM ('MENSTRUAL', 'FOLLICULAR', 'OVULATORY', 'LUTEAL'); + +-- CreateEnum +CREATE TYPE "prediction_method" AS ENUM ('CALENDAR', 'SYMPTOTHERMAL', 'TEMPERATURE_TREND', 'BLENDED'); + +-- CreateTable +CREATE TABLE "cycle_profiles" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "goal" "cycle_tracking_goal" NOT NULL DEFAULT 'GENERAL_HEALTH', + "typical_cycle_length" INTEGER, + "typical_period_length" INTEGER, + "luteal_phase_length" INTEGER, + "prediction_enabled" BOOLEAN NOT NULL DEFAULT true, + "raw_chart_mode" BOOLEAN NOT NULL DEFAULT false, + "discreet_notifications" BOOLEAN NOT NULL DEFAULT false, + "sensitive_category_encryption" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "cycle_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "menstrual_cycles" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "start_date" TEXT NOT NULL, + "end_date" TEXT, + "period_end_date" TEXT, + "length_days" INTEGER, + "ovulation_date" TEXT, + "ovulation_confirmed" BOOLEAN NOT NULL DEFAULT false, + "is_predicted" BOOLEAN NOT NULL DEFAULT false, + "tz" TEXT, + "sync_version" INTEGER NOT NULL DEFAULT 0, + "deleted_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "menstrual_cycles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cycle_day_logs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "date" TEXT NOT NULL, + "cycle_id" TEXT, + "flow" "flow_level", + "intermenstrual_bleeding" BOOLEAN NOT NULL DEFAULT false, + "basal_body_temp_c" DOUBLE PRECISION, + "ovulation_test" "ovulation_test", + "cervical_mucus" "cervical_mucus", + "sexual_activity" BOOLEAN NOT NULL DEFAULT false, + "protected_sex" BOOLEAN, + "pregnancy_test" "home_test_result", + "progesterone_test" "home_test_result", + "contraceptive" "contraceptive_kind", + "notes_encrypted" TEXT, + "source" "measurement_source" NOT NULL DEFAULT 'MANUAL', + "external_id" TEXT, + "tz" TEXT, + "sync_version" INTEGER NOT NULL DEFAULT 0, + "deleted_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "cycle_day_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "cycle_profiles_user_id_key" ON "cycle_profiles"("user_id"); + +-- CreateIndex +CREATE INDEX "menstrual_cycles_user_id_start_date_idx" ON "menstrual_cycles"("user_id", "start_date"); + +-- CreateIndex +CREATE INDEX "menstrual_cycles_user_id_updated_at_id_idx" ON "menstrual_cycles"("user_id", "updated_at", "id"); + +-- CreateIndex +CREATE UNIQUE INDEX "menstrual_cycles_user_id_start_date_key" ON "menstrual_cycles"("user_id", "start_date"); + +-- CreateIndex +CREATE INDEX "cycle_day_logs_user_id_date_idx" ON "cycle_day_logs"("user_id", "date"); + +-- CreateIndex +CREATE INDEX "cycle_day_logs_user_id_updated_at_id_idx" ON "cycle_day_logs"("user_id", "updated_at", "id"); + +-- CreateIndex +CREATE UNIQUE INDEX "cycle_day_logs_user_id_date_key" ON "cycle_day_logs"("user_id", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "cycle_day_logs_user_id_source_external_id_key" ON "cycle_day_logs"("user_id", "source", "external_id"); + +-- AddForeignKey +ALTER TABLE "cycle_profiles" ADD CONSTRAINT "cycle_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "menstrual_cycles" ADD CONSTRAINT "menstrual_cycles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "menstrual_cycles"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql b/prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql new file mode 100644 index 000000000..9ee9075c0 --- /dev/null +++ b/prisma/migrations/0129_v1150_cycle_symptom_taxonomy/migration.sql @@ -0,0 +1,112 @@ +-- v1.15.0 — cycle symptom taxonomy. +-- +-- Clones the mood-tag trio (0101): a global Category -> Symptom catalog +-- plus a per-day-log join. `CycleSymptom` additionally carries +-- `user_id` + `label_encrypted` so a future release can mint per-user +-- custom symptoms; v1.15.0 ships seeded-catalogue-only. +-- +-- `label_key` columns carry an i18n message key (resolved client-side +-- against all six locales) rather than a hard-coded label, so the seeded +-- catalog renders localised everywhere. The seed at the bottom uses +-- deterministic ids + ON CONFLICT DO NOTHING on `key` so re-running is a +-- no-op (idempotent). +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "cycle_symptom_links"; +-- DROP TABLE IF EXISTS "cycle_symptoms"; +-- DROP TABLE IF EXISTS "cycle_symptom_categories"; + +-- CreateTable +CREATE TABLE "cycle_symptom_categories" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label_key" TEXT NOT NULL, + "icon" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "cycle_symptom_categories_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cycle_symptoms" ( + "id" TEXT NOT NULL, + "category_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "label_key" TEXT NOT NULL, + "icon" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "user_id" TEXT, + "label_encrypted" TEXT, + + CONSTRAINT "cycle_symptoms_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cycle_symptom_links" ( + "day_log_id" TEXT NOT NULL, + "symptom_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "cycle_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "cycle_symptom_categories_key_key" ON "cycle_symptom_categories"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "cycle_symptoms_key_key" ON "cycle_symptoms"("key"); + +-- CreateIndex +CREATE INDEX "cycle_symptoms_category_id_sort_order_idx" ON "cycle_symptoms"("category_id", "sort_order"); + +-- CreateIndex +CREATE INDEX "cycle_symptoms_user_id_idx" ON "cycle_symptoms"("user_id"); + +-- CreateIndex +CREATE INDEX "cycle_symptom_links_symptom_id_idx" ON "cycle_symptom_links"("symptom_id"); + +-- AddForeignKey +ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "cycle_symptom_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "cycle_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "cycle_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ─── Seed the default symptom taxonomy ─────────────────────────────── +-- Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING (the +-- mood-tag seed precedent 0101 / 0118 / 0126). Three categories +-- (physical, emotional, digestive) with ~15 high-signal symptoms. Keep +-- in sync with `src/lib/cycle/symptom-taxonomy.ts` if either changes. + +-- Categories +INSERT INTO "cycle_symptom_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES + ('csc_physical', 'physical', 'cycle.symptomCategory.physical', 'Activity', 0, true), + ('csc_emotional', 'emotional', 'cycle.symptomCategory.emotional', 'Heart', 1, true), + ('csc_digestive', 'digestive', 'cycle.symptomCategory.digestive', 'Soup', 2, true) +ON CONFLICT ("key") DO NOTHING; + +-- Symptoms +INSERT INTO "cycle_symptoms" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active") VALUES + ('cs_cramps', 'csc_physical', 'cramps', 'cycle.symptom.cramps', 'Zap', 0, true), + ('cs_headache', 'csc_physical', 'headache', 'cycle.symptom.headache', 'Brain', 1, true), + ('cs_bloating', 'csc_physical', 'bloating', 'cycle.symptom.bloating', 'CircleDot', 2, true), + ('cs_acne', 'csc_physical', 'acne', 'cycle.symptom.acne', 'Droplet', 3, true), + ('cs_breast_tenderness', 'csc_physical', 'breast_tenderness', 'cycle.symptom.breastTenderness', 'HeartPulse', 4, true), + ('cs_fatigue', 'csc_physical', 'fatigue', 'cycle.symptom.fatigue', 'BatteryLow', 5, true), + ('cs_back_pain', 'csc_physical', 'back_pain', 'cycle.symptom.backPain', 'PersonStanding', 6, true), + ('cs_insomnia', 'csc_physical', 'insomnia', 'cycle.symptom.insomnia', 'MoonStar', 7, true), + ('cs_libido_high', 'csc_emotional', 'libido_high', 'cycle.symptom.libidoHigh', 'Flame', 0, true), + ('cs_libido_low', 'csc_emotional', 'libido_low', 'cycle.symptom.libidoLow', 'Snowflake', 1, true), + ('cs_mood_swings', 'csc_emotional', 'mood_swings', 'cycle.symptom.moodSwings', 'Drama', 2, true), + ('cs_food_cravings', 'csc_digestive', 'food_cravings', 'cycle.symptom.foodCravings', 'Cookie', 0, true), + ('cs_nausea', 'csc_digestive', 'nausea', 'cycle.symptom.nausea', 'Frown', 1, true), + ('cs_diarrhea', 'csc_digestive', 'diarrhea', 'cycle.symptom.diarrhea', 'Toilet', 2, true), + ('cs_constipation', 'csc_digestive', 'constipation', 'cycle.symptom.constipation', 'CircleSlash', 3, true) +ON CONFLICT ("key") DO NOTHING; diff --git a/prisma/migrations/0130_v1150_cycle_prediction/migration.sql b/prisma/migrations/0130_v1150_cycle_prediction/migration.sql new file mode 100644 index 000000000..3a58389df --- /dev/null +++ b/prisma/migrations/0130_v1150_cycle_prediction/migration.sql @@ -0,0 +1,35 @@ +-- v1.15.0 — cycle prediction cache. +-- +-- One cached forecast per user, regenerated debounced / +-- stale-while-revalidate (the InsightNarrative + v1.8.7 assessment cache +-- pattern), NOT per-read. Derived data — not soft-deleted. The ranged +-- Low / High band is rendered as a band, never a single date. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "cycle_predictions"; + +-- CreateTable +CREATE TABLE "cycle_predictions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "method" "prediction_method" NOT NULL, + "next_period_start" TEXT NOT NULL, + "next_period_start_low" TEXT NOT NULL, + "next_period_start_high" TEXT NOT NULL, + "fertile_window_start" TEXT, + "fertile_window_end" TEXT, + "predicted_ovulation" TEXT, + "confidence" DOUBLE PRECISION NOT NULL, + "cycles_observed" INTEGER NOT NULL, + "generated_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "cycle_predictions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "cycle_predictions_user_id_key" ON "cycle_predictions"("user_id"); + +-- AddForeignKey +ALTER TABLE "cycle_predictions" ADD CONSTRAINT "cycle_predictions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0131_v1150_cycle_sensitive_encryption/migration.sql b/prisma/migrations/0131_v1150_cycle_sensitive_encryption/migration.sql new file mode 100644 index 000000000..b7dbf6ea2 --- /dev/null +++ b/prisma/migrations/0131_v1150_cycle_sensitive_encryption/migration.sql @@ -0,0 +1,5 @@ +-- Additive: at-rest envelope column for the intent-revealing cycle day-log +-- fields. Populated INSTEAD of the plaintext columns when +-- CycleProfile.sensitive_category_encryption is ON; the plaintext columns +-- are then NULL. AES-256-GCM ciphertext, decrypted fail-soft on read. +ALTER TABLE "cycle_day_logs" ADD COLUMN "sensitive_encrypted" TEXT; diff --git a/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql b/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql new file mode 100644 index 000000000..2d8c53bc2 --- /dev/null +++ b/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql @@ -0,0 +1,17 @@ +-- v1.15.0 — cycle-tracking feature enable flag. +-- +-- Adds the per-user `cycle_tracking_enabled` toggle to `cycle_profiles`. +-- The column is NULLABLE on purpose: NULL means "derive from gender" +-- (`FEMALE` → enabled, everything else → disabled), so no row backfill +-- is required — existing profiles read NULL and resolve through the +-- `isCycleEnabled()` gate helper. An explicit `true` opts a non-FEMALE +-- account in (Settings toggle); an explicit `false` opts a FEMALE +-- account out. Every `/api/cycle/*` route returns +-- `403 { error, meta:{ errorCode:"cycle.disabled" } }` when the resolved +-- value is false. +-- +-- Reversibility (down): +-- ALTER TABLE "cycle_profiles" DROP COLUMN IF EXISTS "cycle_tracking_enabled"; + +-- AlterTable +ALTER TABLE "cycle_profiles" ADD COLUMN "cycle_tracking_enabled" BOOLEAN; diff --git a/prisma/migrations/0133_v1150_cycle_symptom_severity/migration.sql b/prisma/migrations/0133_v1150_cycle_symptom_severity/migration.sql new file mode 100644 index 000000000..f12ac252f --- /dev/null +++ b/prisma/migrations/0133_v1150_cycle_symptom_severity/migration.sql @@ -0,0 +1,3 @@ +-- Optional 1-4 Likert intensity per symptom link. NULL preserves the +-- pre-severity presence-link contract for every existing row. +ALTER TABLE "cycle_symptom_links" ADD COLUMN "severity" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52d1e7cbe..13989e06f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,7 +48,7 @@ model User { // Profile heightCm Float? @map("height_cm") dateOfBirth DateTime? @map("date_of_birth") - gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe + gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe timezone String @default("Europe/Berlin") // Codex OAuth (ChatGPT subscription) @@ -495,6 +495,18 @@ model User { // unset `insurerName` collapses its name. insurerIkNumber String? @map("insurer_ik_number") + // v1.15.0 — cycle tracking. The CycleProfile is a 1:1 settings row + // (lazily upserted on first cycle-surface access); MenstrualCycle + + // CycleDayLog are the soft-deleted, sync-feed-bearing capture rows; + // CyclePrediction is a 1:1 cached forecast (debounced regenerate). + cycleProfile CycleProfile? + menstrualCycles MenstrualCycle[] + cycleDayLogs CycleDayLog[] + cyclePrediction CyclePrediction? + // v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global + // seeded catalogue). + cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") + @@map("users") } @@ -3161,3 +3173,422 @@ model InsightNarrative { @@index([userId, updatedAt(sort: Desc)]) @@map("insight_narratives") } + +// ─── Cycle Tracking (v1.15.0) ───────────────────────────────── +// +// Self-hosted reproductive-health tracking: period + flow, symptom +// taxonomy, BBT / OPK / cervical-mucus capture, ranged-confidence +// prediction. The capture rows (MenstrualCycle, CycleDayLog) carry the +// same soft-delete + syncVersion shape as MoodEntry so they ride the +// `/api/sync/changes` delta feed. CycleProfile + CyclePrediction are +// settings / derived rows and do NOT tombstone. +// +// Sensitivity: free-text notes are always AES-256-GCM at rest +// (`notesEncrypted`, the `*Encrypted` convention). The default-ON +// `sensitiveCategoryEncryption` flag drives whether the intent-revealing +// fields (pregnancy / contraception / sexual activity) also encrypt at +// the application layer — those stay queryable-plaintext-physiology +// (flow, BBT, OPK, mucus, dates, phase) untouched so the rollup + +// correlation tier keeps working. + +/// Per-user goal that gates every cycle copy + the fertile-window +/// surfacing. `OFF` disables the category entirely; `GENERAL_HEALTH` / +/// `PERIMENOPAUSE` hide conception framing; only `TRYING_TO_CONCEIVE` +/// (and, with a stronger caveat, `AVOID_PREGNANCY`) surface fertile-window +/// language. +enum CycleTrackingGoal { + GENERAL_HEALTH + AVOID_PREGNANCY + TRYING_TO_CONCEIVE + PERIMENOPAUSE + OFF + + @@map("cycle_tracking_goal") +} + +/// Menstrual-flow intensity for a logged day. Mirrors the HealthKit +/// `HKCategoryValueMenstrualFlow` ladder (unspecified collapses to the +/// presence of a row; `NONE` is an explicit no-flow marker). +enum FlowLevel { + NONE + SPOTTING + LIGHT + MEDIUM + HEAVY + + @@map("flow_level") +} + +/// Ovulation predictor-kit (OPK) reading. `POSITIVE_LH_SURGE` is the +/// LH-surge positive; `ESTROGEN_SURGE` is the estrogen-rise pre-peak +/// some dual-hormone kits report. Mirrors HealthKit +/// `HKCategoryValueOvulationTestResult`. +enum OvulationTest { + NEGATIVE + POSITIVE_LH_SURGE + ESTROGEN_SURGE + INDETERMINATE + + @@map("ovulation_test") +} + +/// Cervical-mucus quality, fertility-awareness ladder (dry → egg-white +/// peak). Mirrors HealthKit `HKCategoryValueCervicalMucusQuality`. +enum CervicalMucus { + DRY + STICKY + CREAMY + WATERY + EGG_WHITE + + @@map("cervical_mucus") +} + +/// Generic home-test result for the pregnancy / progesterone test +/// fields. Mirrors HealthKit `HKCategoryValuePregnancyTestResult` / +/// `HKCategoryValueProgesteroneTestResult`. +enum HomeTestResult { + NEGATIVE + POSITIVE + INDETERMINATE + + @@map("home_test_result") +} + +/// Contraceptive method logged for a day. `UNSPECIFIED` mirrors the +/// HealthKit `unspecified` member distinct from `NONE` (an explicit +/// no-method marker). Mirrors HealthKit `HKCategoryValueContraceptive`. +enum ContraceptiveKind { + NONE + UNSPECIFIED + IMPLANT + INJECTION + IUD + INTRAVAGINAL_RING + ORAL + PATCH + EMERGENCY + + @@map("contraceptive_kind") +} + +/// Derived cycle phase used by the prediction engine + the CYCLE_PHASE +/// correlation channel. Computed from day-of-cycle + the back-calculated +/// luteal length, never stored on the capture rows. +enum CyclePhase { + MENSTRUAL + FOLLICULAR + OVULATORY + LUTEAL + + @@map("cycle_phase") +} + +/// Which method produced a cached CyclePrediction. `CALENDAR` is the +/// median+MAD variability predictor; `SYMPTOTHERMAL` folds the +/// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the +/// retrospective wrist/skin-temperature ovulation; `BLENDED` combines +/// them. +enum PredictionMethod { + CALENDAR + SYMPTOTHERMAL + TEMPERATURE_TREND + BLENDED + + @@map("prediction_method") +} + +/// One settings row per user, lazily upserted on first cycle-surface +/// access (goal defaults to GENERAL_HEALTH). Not soft-deleted — it is a +/// profile row, like the other per-user settings columns on User. +model CycleProfile { + id String @id @default(cuid()) + userId String @map("user_id") + + /// Drives all cycle copy + fertile-window gating (see CycleTrackingGoal). + goal CycleTrackingGoal @default(GENERAL_HEALTH) + + /// Per-user feature enable. NULL = derive from gender (`FEMALE` → on, + /// everything else → off) so the column never needs a backfill; an + /// explicit `true` opts a non-FEMALE account in, an explicit `false` + /// opts a FEMALE account out. Resolved through `isCycleEnabled()` — + /// every `/api/cycle/*` route 403s with `errorCode:"cycle.disabled"` + /// when the resolved value is false. + cycleTrackingEnabled Boolean? @map("cycle_tracking_enabled") + + /// Optional user-supplied priors. The engine applies a luteal default + /// of 14 days in code (NOT a DB default) when `lutealPhaseLength` is + /// NULL, so a future constant change never strands historical rows. + typicalCycleLength Int? @map("typical_cycle_length") + typicalPeriodLength Int? @map("typical_period_length") + lutealPhaseLength Int? @map("luteal_phase_length") + + /// When false, the app computes / stores NO predictions — pure logging + /// (the Read-Your-Body posture for users who distrust algorithmic + /// interpretation). + predictionEnabled Boolean @default(true) @map("prediction_enabled") + /// Raw-chart mode — surfaces logged values without the interpreted + /// prediction overlays. + rawChartMode Boolean @default(false) @map("raw_chart_mode") + + /// When true, every cycle push body is generic ("HealthLog reminder") + /// so no cycle-event naming reaches the lock screen. + discreetNotifications Boolean @default(false) @map("discreet_notifications") + /// Default-ON for this category (post-Dobbs threat model): drives + /// whether the intent-revealing day-log fields encrypt at the + /// application layer in addition to the always-encrypted notes. + sensitiveCategoryEncryption Boolean @default(true) @map("sensitive_category_encryption") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId]) + @@map("cycle_profiles") +} + +/// One row per menstrual cycle (a period-start anchor + derived span). +/// `isPredicted` rows are forward-minted forecasts; observed rows are +/// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the +/// row rides the `/api/sync/changes` feed as a tombstone. +model MenstrualCycle { + id String @id @default(cuid()) + userId String @map("user_id") + + /// YYYY-MM-DD — period start, anchored to `tz` when non-null, else the + /// legacy Europe/Berlin assumption (the MoodEntry.date convention). + startDate String @map("start_date") + /// YYYY-MM-DD — cycle end (= day before the next cycle's start). + endDate String? @map("end_date") + /// YYYY-MM-DD — last bleeding day of this cycle's period. + periodEndDate String? @map("period_end_date") + + /// Observed cycle length in days (start→next start). NULL until the + /// next cycle anchors it. + lengthDays Int? @map("length_days") + /// YYYY-MM-DD — confirmed or estimated ovulation day. + ovulationDate String? @map("ovulation_date") + /// True once ovulation is confirmed (symptothermal / temperature-trend), + /// as opposed to a calendar estimate. + ovulationConfirmed Boolean @default(false) @map("ovulation_confirmed") + /// Forward-minted forecast row vs an observed cycle. + isPredicted Boolean @default(false) @map("is_predicted") + + /// Per-row IANA timezone the date strings are anchored to (NULL = + /// legacy Europe/Berlin), mirroring MoodEntry.tz. + tz String? + syncVersion Int @default(0) @map("sync_version") + deletedAt DateTime? @map("deleted_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + dayLogs CycleDayLog[] + + @@unique([userId, startDate]) + @@index([userId, startDate]) + // Keyset support for the `/api/sync/changes` cycle feed + // (`where userId` ordered by `(updatedAt asc, id asc)`). + @@index([userId, updatedAt, id]) + @@map("menstrual_cycles") +} + +/// One canonical row per (user, day): the HealthKit sync unit + the +/// manual log-day surface. Dual-unique exactly like MoodEntry — the +/// canonical-day key plus the NULL-distinct external-dedup key for +/// HealthKit re-ingest. Soft-deleted + syncVersion-bumped. +model CycleDayLog { + id String @id @default(cuid()) + userId String @map("user_id") + /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy + /// Europe/Berlin (the MoodEntry.date convention). + date String + /// Optional link to the owning MenstrualCycle (set once the day is + /// attributed to a cycle span). + cycleId String? @map("cycle_id") + + /// Menstrual-flow intensity (NULL = not logged this day). + flow FlowLevel? + /// Spotting / bleeding outside the period window. + intermenstrualBleeding Boolean @default(false) @map("intermenstrual_bleeding") + + /// Basal body temperature in Celsius. Queryable-plaintext — feeds the + /// symptothermal confirmation. BBT also lands in Measurement + /// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy. + basalBodyTempC Float? @map("basal_body_temp_c") + /// Ovulation predictor-kit (OPK) reading. + ovulationTest OvulationTest? @map("ovulation_test") + /// Cervical-mucus quality. + cervicalMucus CervicalMucus? @map("cervical_mucus") + + /// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption + /// is ON these are encrypted at the application layer (the dispatch + /// envelope), default-ON for this category. + sexualActivity Boolean @default(false) @map("sexual_activity") + protectedSex Boolean? @map("protected_sex") + pregnancyTest HomeTestResult? @map("pregnancy_test") + progesteroneTest HomeTestResult? @map("progesterone_test") + contraceptive ContraceptiveKind? @map("contraceptive") + + /// AES-256-GCM ciphertext envelope of the intent-revealing fields + /// (sexualActivity / protectedSex / pregnancyTest / progesteroneTest / + /// contraceptive). Populated INSTEAD of the plaintext columns above when + /// CycleProfile.sensitiveCategoryEncryption is ON: the plaintext columns + /// are NULLed and this carries the JSON envelope, so the most sensitive + /// reproductive data is encrypted at rest. Those five fields then drop + /// out of the rollup / correlation tier (queryable plaintext is the + /// trade for at-rest secrecy). Decrypted fail-soft on read. + sensitiveEncrypted String? @map("sensitive_encrypted") + + /// AES-256-GCM ciphertext envelope of the day's free-text note (always + /// encrypted — the `*Encrypted` String? convention, matching + /// MoodTag.labelEncrypted + InsightNarrative). + notesEncrypted String? @map("notes_encrypted") + + /// Provenance. MANUAL (web / single POST) or APPLE_HEALTH (the batch + /// ingest); mirrors MoodEntry.source. The wire spelling is APPLE_HEALTH. + source MeasurementSource @default(MANUAL) + /// Stable per-source dedup id. NULL for native / MANUAL entries (those + /// dedup on the canonical `(userId, date)` key); a HealthKit re-ingest + /// upserts on `(userId, source, externalId)`. Postgres treats NULLs as + /// distinct so MANUAL rows never collide on it. + externalId String? @map("external_id") + + tz String? + syncVersion Int @default(0) @map("sync_version") + deletedAt DateTime? @map("deleted_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + cycle MenstrualCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) + symptomLinks CycleSymptomLink[] + + @@unique([userId, date]) + // NULL-distinct external dedup key for HealthKit re-ingest (the + // MoodEntry dual-unique precedent). + @@unique([userId, source, externalId]) + @@index([userId, date]) + // Keyset support for the `/api/sync/changes` cycle-day feed. + @@index([userId, updatedAt, id]) + @@map("cycle_day_logs") +} + +// ─── Cycle Symptom Taxonomy (v1.15.0) ───────────────────────── +// +// Clones the MoodTagCategory → MoodTag → MoodEntryTagLink trio: a global, +// icon-bearing, labelKey-i18n, isActive/sortOrder catalog plus a per-day +// join. `CycleSymptom` additionally carries `userId?` + `labelEncrypted?` +// so a future release can mint per-user custom symptoms (the MoodTag +// custom-tag precedent); v1.15.0 ships seeded-catalogue-only. + +model CycleSymptomCategory { + id String @id @default(cuid()) + /// Stable machine key (e.g. `physical`). Unique so the seed migration + /// can upsert idempotently. + key String @unique + /// i18n message key for the category label (`cycle.symptomCategory.`). + labelKey String @map("label_key") + /// Lucide icon name for the category header. + icon String? + sortOrder Int @default(0) @map("sort_order") + /// Soft-hide a category without deleting its historical links. + isActive Boolean @default(true) @map("is_active") + + symptoms CycleSymptom[] + + @@map("cycle_symptom_categories") +} + +model CycleSymptom { + id String @id @default(cuid()) + categoryId String @map("category_id") + /// Stable machine key (e.g. `cramps`). Unique across the catalog. + key String @unique + /// i18n message key for the symptom label (`cycle.symptom.`). + labelKey String @map("label_key") + /// Lucide icon name for the symptom chip. + icon String? + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + + /// Owner of a per-user custom symptom. NULL = the global seeded + /// catalogue (resolves `labelKey` against the locale). Set = a user's + /// own custom symptom carrying a `custom:` key and a free-text + /// label held in `labelEncrypted` (the MoodTag custom-tag precedent). + userId String? @map("user_id") + /// AES-256-GCM ciphertext of a custom symptom's free-text label. NULL + /// for catalogue rows. Decrypted server-side for the effective read. + labelEncrypted String? @map("label_encrypted") + + category CycleSymptomCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + user User? @relation("UserCycleSymptoms", fields: [userId], references: [id], onDelete: Cascade) + links CycleSymptomLink[] + + @@index([categoryId, sortOrder]) + @@index([userId]) + @@map("cycle_symptoms") +} + +/// Join: which symptoms a cycle day-log carries. Composite PK keeps the +/// pairing unique; cascade on both sides so deleting a day-log (or a +/// catalog symptom) cleans up its links. +model CycleSymptomLink { + dayLogId String @map("day_log_id") + symptomId String @map("symptom_id") + /// Optional 1-4 Likert intensity for this symptom on this day. NULL = a + /// plain presence link (the pre-severity contract). Surfaced in + /// CycleDayLogDTO so the log-day sheet can pre-fill the severity chips. + severity Int? @map("severity") + createdAt DateTime @default(now()) @map("created_at") + + dayLog CycleDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade) + symptom CycleSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade) + + @@id([dayLogId, symptomId]) + @@index([symptomId]) + @@map("cycle_symptom_links") +} + +/// One cached forecast per user, regenerated debounced / +/// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment +/// cache pattern), NOT per-read. Derived — not soft-deleted. +model CyclePrediction { + id String @id @default(cuid()) + userId String @map("user_id") + + /// Which method produced this forecast. + method PredictionMethod + + /// YYYY-MM-DD — predicted next period start (point estimate) plus the + /// ranged Low / High band (rendered as a band, never a single date). + nextPeriodStart String @map("next_period_start") + nextPeriodStartLow String @map("next_period_start_low") + nextPeriodStartHigh String @map("next_period_start_high") + + /// YYYY-MM-DD — fertile-window bounds (goal-gated at the read surface). + fertileWindowStart String? @map("fertile_window_start") + fertileWindowEnd String? @map("fertile_window_end") + /// YYYY-MM-DD — predicted ovulation day. + predictedOvulation String? @map("predicted_ovulation") + + /// 0..1 confidence, shrinking with cycle-length variance + sparse + /// logging + < MIN_CYCLES observed. + confidence Float + /// How many observed cycles the forecast was computed from. + cyclesObserved Int @map("cycles_observed") + /// When the forecast was generated (drives the stale-while-revalidate + /// debounce). + generatedAt DateTime @map("generated_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId]) + @@map("cycle_predictions") +} diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 417ac5907..9c47ad1be 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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, }, }, }); diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index ce0387f74..9457cd99d 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -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, }, }, }); diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts new file mode 100644 index 000000000..3de329d1c --- /dev/null +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -0,0 +1,131 @@ +/** + * v1.15.0 — per-user cycle-tracking preferences. + * + * GET /api/auth/me/cycle-prefs — current resolved CycleProfileDTO. + * PATCH /api/auth/me/cycle-prefs — body `{ enabled?, goal?, rawChartMode?, + * predictionEnabled?, discreetNotifications?, + * sensitiveCategoryEncryption?, + * typicalCycleLength?, typicalPeriodLength?, + * lutealPhaseLength? }`. Deep-merges the + * supplied fields over the persisted row + * (the notification-prefs precedent) and + * returns the merged CycleProfileDTO. + * + * This route does NOT enforce the cycle gate — it is the surface that + * FLIPS the gate (`enabled` → `cycleTrackingEnabled`). `enabled` is + * idempotent last-writer-wins; the route is otherwise idempotent. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { toCycleProfileDTO } from "@/lib/cycle/dto"; +import { cyclePrefsSchema } from "@/lib/validations/cycle"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.cycle-prefs.get" } }); + + const profile = await getOrCreateCycleProfile(user.id); + const resolved = isCycleEnabled(user.gender, profile); + return apiSuccess(toCycleProfileDTO(profile, resolved)); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `cycle-prefs:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return apiError("Invalid JSON body", 422, { + errorCode: "cycle-prefs.body.invalid_json", + }); + } + + const parsed = cyclePrefsSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.cycle-prefs.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle-prefs.invalid", + }); + } + + // Ensure the row exists, then deep-merge: only the supplied fields are + // touched; siblings are left untouched (field-by-field, no mass + // assignment). `enabled` maps onto the `cycleTrackingEnabled` column. + await getOrCreateCycleProfile(user.id); + const p = parsed.data; + + const updated = await prisma.cycleProfile.update({ + where: { userId: user.id }, + data: { + ...(p.enabled !== undefined && { cycleTrackingEnabled: p.enabled }), + ...(p.goal !== undefined && { goal: p.goal }), + ...(p.rawChartMode !== undefined && { rawChartMode: p.rawChartMode }), + ...(p.predictionEnabled !== undefined && { + predictionEnabled: p.predictionEnabled, + }), + ...(p.discreetNotifications !== undefined && { + discreetNotifications: p.discreetNotifications, + }), + ...(p.sensitiveCategoryEncryption !== undefined && { + sensitiveCategoryEncryption: p.sensitiveCategoryEncryption, + }), + ...(p.typicalCycleLength !== undefined && { + typicalCycleLength: p.typicalCycleLength, + }), + ...(p.typicalPeriodLength !== undefined && { + typicalPeriodLength: p.typicalPeriodLength, + }), + ...(p.lutealPhaseLength !== undefined && { + lutealPhaseLength: p.lutealPhaseLength, + }), + }, + }); + + const resolved = isCycleEnabled(user.gender, updated); + + await auditLog("user.cycle-prefs.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { changed: Object.keys(p) }, + }); + + annotate({ + action: { name: "user.cycle_prefs.update" }, + meta: { changed: Object.keys(p), enabled: resolved }, + }); + + return apiSuccess(toCycleProfileDTO(updated, resolved)); +}); diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index 10b0aea2d..85722cba5 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -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)(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, diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 8de3e2566..b6ba3810f 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -90,6 +90,7 @@ describe("GET /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); }); @@ -109,6 +110,7 @@ describe("GET /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); }); @@ -130,6 +132,7 @@ describe("GET /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); }); }); @@ -162,6 +165,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -170,6 +174,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, }, }); @@ -182,10 +187,12 @@ describe("PATCH /api/auth/me/notification-prefs", () => { previous: { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, next: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, changed: ["medication"], }), @@ -246,6 +253,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, }, }); @@ -270,6 +278,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { expect(env.data).toEqual({ medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -278,6 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, + cycle: { clientManaged: false }, }, }, }); @@ -305,6 +315,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { notificationPrefs: { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 9 }, + cycle: { clientManaged: false }, }, }, }); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 49f74a96a..22b43748f 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -4,6 +4,8 @@ import { annotate } from "@/lib/logging/context"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { isCycleEnabled } from "@/lib/cycle/gate"; export const dynamic = "force-dynamic"; @@ -19,6 +21,15 @@ export const GET = apiHandler(async () => { annotate({ action: { name: "auth.me" } }); + // v1.15.0 — resolved cycle-tracking gate. Read the profile without + // forcing a row (a NULL toggle derives from gender); the resolver + // collapses both gates into the single boolean iOS hides the tab on. + const cycleProfile = await prisma.cycleProfile.findUnique({ + where: { userId: user.id }, + select: { cycleTrackingEnabled: true }, + }); + const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); + // v1.7.0 — patient-identity fields for the health-record export. The // KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on // one row never 500s the whole profile fetch (the field just reads @@ -68,5 +79,8 @@ export const GET = apiHandler(async () => { insurerName: user.insurerName ?? null, insurerIkNumber: user.insurerIkNumber ?? null, insuranceNumber, + // v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS + // hides the whole cycle tab when this is false. + cycleTrackingEnabled, }); }); diff --git a/src/app/api/cycle/all/route.ts b/src/app/api/cycle/all/route.ts new file mode 100644 index 000000000..170586667 --- /dev/null +++ b/src/app/api/cycle/all/route.ts @@ -0,0 +1,101 @@ +/** + * `DELETE /api/cycle/all` — one-click cycle hard-delete purge. + * + * Distinct from the per-row soft-delete (`DELETE /api/cycle/day-logs/{id}`, + * which leaves a tombstone for the sync feed): this HARD-deletes every + * cycle row the user owns — day-logs (and their symptom links, by cascade), + * menstrual cycles, predictions, the cycle audit trail, and the cycle + * reminder-delivery rows in the push-attempts ledger — so no dated + * reproductive trace survives in Postgres (the post-Dobbs threat model). The + * `CycleProfile` row is left in place (gate + settings keep working) but its + * intent-revealing fields — goal + cycle/period/luteal priors — are reset to + * neutral defaults, so no reproductive intent survives either. + * + * Gated (`cycle.disabled` 403) and owner-scoped. The purge action itself IS + * audited (written after the transaction, so it survives) — its `details` + * carry only counts, no reproductive content. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + // Hard delete in a transaction. Symptom links cascade off the day-log + // delete (the schema's onDelete: Cascade). Order child-before-parent for + // the rows that don't cascade. + const cleared = await prisma.$transaction(async (tx) => { + const dayLogs = await tx.cycleDayLog.deleteMany({ + where: { userId: user.id }, + }); + const predictions = await tx.cyclePrediction.deleteMany({ + where: { userId: user.id }, + }); + const cycles = await tx.menstrualCycle.deleteMany({ + where: { userId: user.id }, + }); + // The cycle audit trail carries dated reproductive-adjacent details + // (period boundaries, the AVOID_PREGNANCY goal-nudge, goal changes). A + // purge that promises "nothing reproductive persists" must clear it too. + // The purge's own audit row is written AFTER this transaction, so it + // survives (counts only, no reproductive content). + const auditRows = await tx.auditLog.deleteMany({ + where: { + userId: user.id, + OR: [ + { action: { startsWith: "cycle." } }, + { action: "user.cycle-prefs.update" }, + ], + }, + }); + // The shared push-attempts ledger holds dated cycle reminder-delivery + // rows (eventType + createdAt) for up to 90 days — drop the cycle ones. + const pushRows = await tx.pushAttempt.deleteMany({ + where: { + userId: user.id, + eventType: { in: ["CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM"] }, + }, + }); + // Reset the reproductive INTENT carried on the profile (goal + cycle/period/ + // luteal priors) to neutral defaults so a purge leaves no intent-revealing + // state, while keeping the row so the gate + settings keep working. + await tx.cycleProfile.updateMany({ + where: { userId: user.id }, + data: { + goal: "GENERAL_HEALTH", + typicalCycleLength: null, + typicalPeriodLength: null, + lutealPhaseLength: null, + }, + }); + return { + dayLogs: dayLogs.count, + predictions: predictions.count, + cycles: cycles.count, + auditRows: auditRows.count, + pushRows: pushRows.count, + }; + }); + + await auditLog("cycle.purge", { + userId: user.id, + ipAddress: getClientIp(request), + details: cleared, + }); + + annotate({ + action: { name: "cycle.purge" }, + meta: cleared, + }); + + return apiSuccess({ purged: true, ...cleared }); +}); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts new file mode 100644 index 000000000..e4bfa86c2 --- /dev/null +++ b/src/app/api/cycle/calendar/route.ts @@ -0,0 +1,190 @@ +/** + * `GET /api/cycle/calendar?from&to` — predicted calendar read + * (ios-contract §2.D). + * + * Calls the pure engine (`predictCycle` + per-day phase) to build + * `{ profile, prediction, days }`. The synchronous engine call is cheap + * (pure stats); the heavy part is the cache persist, which is debounced + * stale-while-revalidate and fire-and-forget so it never blocks the GET. + * Fertile-window fields are goal-gated (surfaced for TRYING_TO_CONCEIVE and + * AVOID_PREGNANCY — the latter with the stronger "not a contraceptive method" + * disclaimer; suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF), + * resolved server-side. + * + * Default range: current cycle − 90d … +180d forward. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cycleCalendarQuerySchema } from "@/lib/validations/cycle"; +import { + buildCalendar, + type CalendarDayLogRow, +} from "@/lib/cycle/engine-adapter"; +import { + toCyclePredictionDTO, + goalAllowsFertileWindow, + cycleDisclaimerKey, +} from "@/lib/cycle/dto"; +import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; +import { addDays, dayDiff } from "@/lib/cycle/day-math"; +import { BBT_WINDOW } from "@/lib/cycle/types"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; + +const DEFAULT_PAST_DAYS = 90; +const DEFAULT_FORWARD_DAYS = 180; +/** Hard cap on the rendered span (days) to bound the day-grid build. */ +const MAX_SPAN_DAYS = 400; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + const profile = gate.profile; + + const url = new URL(request.url); + const parsed = cycleCalendarQuerySchema.safeParse({ + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + }); + if (!parsed.success) { + return apiError("Invalid calendar query", 422, { + errorCode: "cycle.calendar.invalid", + }); + } + + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const today = moodDateKey(new Date(), tz); + const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS); + const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS); + + // Reject an inverted or absurdly wide range up front. + const fromMs = Date.parse(`${from}T12:00:00Z`); + const toMs = Date.parse(`${to}T12:00:00Z`); + if (toMs < fromMs || (toMs - fromMs) / 86_400_000 > MAX_SPAN_DAYS) { + return apiError("Calendar range too wide or inverted", 422, { + errorCode: "cycle.calendar.range", + }); + } + + const [cycles, dayLogRows, nightlyTemps] = await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + // Bound the day-log read to the rendered span plus the symptothermal + // lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length + // stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf + // — unbounded full-history read on a hot, per-navigation route). + prisma.cycleDayLog.findMany({ + where: { + userId: user.id, + deletedAt: null, + date: { + gte: + dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0 + ? from + : addDays(today, -BBT_WINDOW), + }, + }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + _count: { select: { symptomLinks: true } }, + }, + }), + // Apple Watch wrist/skin temperature feeds the temperature-trend + // ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly + // values; the engine derives the trailing-mean deviation itself. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + ]); + + const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({ + date: l.date, + flow: l.flow, + basalBodyTempC: l.basalBodyTempC, + ovulationTest: l.ovulationTest, + cervicalMucus: l.cervicalMucus, + hasSymptoms: l._count.symptomLinks > 0, + })); + + const nights = nightlyTemps.map((m) => ({ + date: moodDateKey(m.measuredAt, tz), + valueC: m.value, + })); + + const goalAllowsFertile = goalAllowsFertileWindow(profile.goal); + + const { prediction, days } = buildCalendar( + profile, + cycles, + dayLogs, + nights, + from, + to, + today, + goalAllowsFertile, + ); + + // Persist the cache fire-and-forget (debounced) — never block the read. + let generatedAt = new Date().toISOString(); + if (prediction) { + const now = new Date(); + generatedAt = now.toISOString(); + void persistPredictionCache(user.id, prediction, now); + } + + const locale = await resolveServerLocale({ + request, + userLocale: user.locale ?? undefined, + }); + const t = getServerTranslator(locale); + // AVOID_PREGNANCY surfaces the fertile window, so it must carry the stronger + // "not a contraceptive method" caveat (QA H-1). + const disclaimer = t.t(cycleDisclaimerKey(profile.goal)); + + annotate({ + action: { name: "cycle.calendar.read" }, + meta: { + days: days.length, + cycles_observed: prediction?.cyclesObserved ?? 0, + has_prediction: prediction !== null, + }, + }); + + return apiSuccess({ + profile: { + goal: profile.goal, + rawChartMode: profile.rawChartMode, + predictionEnabled: profile.predictionEnabled, + cyclesObserved: prediction?.cyclesObserved ?? cycles.length, + }, + prediction: prediction + ? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer) + : null, + days, + meta: { generatedAt }, + }); +}); diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts new file mode 100644 index 000000000..2810bc90e --- /dev/null +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -0,0 +1,55 @@ +/** + * `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle + * (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a + * tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const existing = await prisma.menstrualCycle.findUnique({ + where: { id }, + select: { id: true, userId: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Cycle not found", 404); + } + + await prisma.menstrualCycle.update({ + where: { id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + + await auditLog("cycle.cycle.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { cycleId: id }, + }); + + annotate({ + action: { + name: "cycle.cycle.delete", + entity_type: "menstrual_cycle", + entity_id: id, + }, + }); + + return new Response(null, { status: 204 }); + }, +); diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts new file mode 100644 index 000000000..ae66aded0 --- /dev/null +++ b/src/app/api/cycle/cycles/route.ts @@ -0,0 +1,101 @@ +/** + * `GET /api/cycle/cycles?limit=24` — cycle history + summary stats + * (ios-contract §2.E). + * + * Returns the most recent observed cycles (newest first) plus + * `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, + * regularity }`. The median / MAD reuse the engine helpers so the stats + * match the prediction's own variability math. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cycleHistoryQuerySchema } from "@/lib/validations/cycle"; +import { toMenstrualCycleDTO } from "@/lib/cycle/dto"; +import { median, mad } from "@/lib/cycle"; +import { dayDiff } from "@/lib/cycle/day-math"; +import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types"; + +const DEFAULT_LIMIT = 24; + +/** + * Regularity gate. < MIN_CYCLES → LEARNING; a length MAD at/under 2 days + * (the clinical "regular cycle" rule of thumb) → REGULAR, else IRREGULAR. + */ +const REGULAR_MAD_THRESHOLD = 2; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const url = new URL(request.url); + const parsed = cycleHistoryQuerySchema.safeParse({ + limit: url.searchParams.get("limit") ?? undefined, + }); + if (!parsed.success) { + return apiError("Invalid history query", 422, { + errorCode: "cycle.cycles.invalid", + }); + } + const limit = parsed.data.limit ?? DEFAULT_LIMIT; + + const rows = await prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "desc" }, + take: limit, + }); + + // Completed lengths (non-null lengthDays) drive the length stats. + const lengths = rows + .map((r) => r.lengthDays) + .filter((l): l is number => l != null && l > 0); + + // Period lengths from periodEndDate − startDate + 1. + const periodLengths = rows + .filter((r) => r.periodEndDate != null) + .map((r) => dayDiff(r.periodEndDate as string, r.startDate) + 1) + .filter((l) => l > 0); + + let avgLengthDays: number | null = null; + let lengthVariabilityDays: number | null = null; + let avgPeriodLengthDays: number | null = null; + let regularity: "REGULAR" | "IRREGULAR" | "LEARNING" = "LEARNING"; + + if (lengths.length > 0) { + const med = median(lengths); + avgLengthDays = Math.round(med); + lengthVariabilityDays = Math.round(mad(lengths, med) * 10) / 10; + if (lengths.length < MIN_CYCLES_TO_PREDICT) { + regularity = "LEARNING"; + } else { + regularity = + lengthVariabilityDays <= REGULAR_MAD_THRESHOLD + ? "REGULAR" + : "IRREGULAR"; + } + } + if (periodLengths.length > 0) { + avgPeriodLengthDays = Math.round(median(periodLengths)); + } + + annotate({ + action: { name: "cycle.cycles.read" }, + meta: { returned: rows.length, regularity }, + }); + + return apiSuccess({ + cycles: rows.map(toMenstrualCycleDTO), + stats: { + avgLengthDays, + lengthVariabilityDays, + avgPeriodLengthDays, + regularity, + }, + }); +}); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts new file mode 100644 index 000000000..87d4e32f7 --- /dev/null +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -0,0 +1,261 @@ +/** + * `PATCH /api/cycle/day-logs/{id}` — edit a single day-log + * (ios-contract §2.A). + * `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F): + * set `deletedAt` + bump `syncVersion`, emit a tombstone on the next + * sync page. 204. Idempotent. + * + * Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by + * another user 404s). + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { encrypt, decrypt } from "@/lib/crypto"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import { replaceSymptomLinks } from "@/lib/cycle/day-log-write"; +import { cycleDayLogPatchSchema } from "@/lib/validations/cycle"; +import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const existing = await prisma.cycleDayLog.findFirst({ + where: { id, deletedAt: null }, + select: { + id: true, + userId: true, + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Day-log not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = cycleDayLogPatchSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.day-log.patch.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.day-log.invalid", + }); + } + + const body = parsed.data; + + // Resolve the stored plaintext sensitive fields (from the envelope when + // the row was encrypted, else the columns) so a partial patch merges + // against the true current value. + const encryptSensitive = (await getOrCreateCycleProfile(user.id)) + .sensitiveCategoryEncryption; + const stored = readStoredSensitive(existing); + const merged = { + sexualActivity: + body.sexualActivity !== undefined + ? body.sexualActivity + : stored.sexualActivity, + protectedSex: + body.protectedSex !== undefined + ? (body.protectedSex ?? null) + : stored.protectedSex, + pregnancyTest: + body.pregnancyTest !== undefined + ? (body.pregnancyTest ?? null) + : stored.pregnancyTest, + progesteroneTest: + body.progesteroneTest !== undefined + ? (body.progesteroneTest ?? null) + : stored.progesteroneTest, + contraceptive: + body.contraceptive !== undefined + ? (body.contraceptive ?? null) + : stored.contraceptive, + }; + + // Field-by-field update (no mass assignment). Non-sensitive fields are + // written only when present; the sensitive set is re-resolved and split + // between plaintext columns and the envelope per the flag. + await prisma.cycleDayLog.update({ + where: { id }, + data: { + ...(body.flow !== undefined && { flow: body.flow }), + ...(body.intermenstrualBleeding !== undefined && { + intermenstrualBleeding: body.intermenstrualBleeding, + }), + ...(body.basalBodyTempC !== undefined && { + basalBodyTempC: body.basalBodyTempC, + }), + ...(body.ovulationTest !== undefined && { + ovulationTest: body.ovulationTest, + }), + ...(body.cervicalMucus !== undefined && { + cervicalMucus: body.cervicalMucus, + }), + sexualActivity: encryptSensitive ? false : merged.sexualActivity, + protectedSex: encryptSensitive ? null : merged.protectedSex, + pregnancyTest: (encryptSensitive + ? null + : merged.pregnancyTest) as never, + progesteroneTest: (encryptSensitive + ? null + : merged.progesteroneTest) as never, + contraceptive: (encryptSensitive + ? null + : merged.contraceptive) as never, + sensitiveEncrypted: encryptSensitive + ? encrypt(JSON.stringify(merged)) + : null, + ...(body.note !== undefined && { + notesEncrypted: body.note ? encrypt(body.note) : null, + }), + syncVersion: { increment: 1 }, + }, + }); + + // Replace symptom links only when `symptoms` was supplied. + if (body.symptoms !== undefined) { + await replaceSymptomLinks(user.id, id, body.symptoms); + } + + const row = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id }, + include: dayLogSymptomInclude, + }); + + await auditLog("cycle.day-log.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { dayLogId: id }, + }); + + annotate({ + action: { + name: "cycle.day-log.update", + entity_type: "cycle_day_log", + entity_id: id, + }, + }); + + return apiSuccess(toCycleDayLogDTO(row)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const existing = await prisma.cycleDayLog.findUnique({ + where: { id }, + select: { id: true, userId: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Day-log not found", 404); + } + + // Soft-delete: leave the row in place so the `/api/sync/changes` + // delta feed surfaces it as a tombstone. A re-delete re-bumps + // `syncVersion` harmlessly (idempotent). + await prisma.cycleDayLog.update({ + where: { id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + + await auditLog("cycle.day-log.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { dayLogId: id }, + }); + + annotate({ + action: { + name: "cycle.day-log.delete", + entity_type: "cycle_day_log", + entity_id: id, + }, + }); + + return new Response(null, { status: 204 }); + }, +); + +/** The stored plaintext sensitive fields (envelope when encrypted, else columns). */ +function readStoredSensitive(row: { + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; + sensitiveEncrypted: string | null; +}): { + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; +} { + if (row.sensitiveEncrypted) { + try { + const dec = JSON.parse(decrypt(row.sensitiveEncrypted)) as Record< + string, + unknown + >; + return { + sexualActivity: (dec.sexualActivity as boolean) ?? false, + protectedSex: (dec.protectedSex as boolean | null) ?? null, + pregnancyTest: (dec.pregnancyTest as string | null) ?? null, + progesteroneTest: (dec.progesteroneTest as string | null) ?? null, + contraceptive: (dec.contraceptive as string | null) ?? null, + }; + } catch { + // Fail-soft: an undecryptable envelope reads as cleared. + return { + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + }; + } + } + return { + sexualActivity: row.sexualActivity, + protectedSex: row.protectedSex, + pregnancyTest: row.pregnancyTest, + progesteroneTest: row.progesteroneTest, + contraceptive: row.contraceptive, + }; +} diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts new file mode 100644 index 000000000..d498ccade --- /dev/null +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -0,0 +1,218 @@ +/** + * `POST /api/cycle/day-logs/bulk` — Outbox / HealthKit drain + * (ios-contract §2.B / §3). + * + * The route iOS's Outbox flushes to. Per-entry UPSERT keyed on the + * NULL-distinct `(userId, source, externalId)` (or `(userId, date)` + * when no externalId). Capped at 500, wrapped in `withIdempotency`, + * rate-limited `cycle:day-logs:bulk:{userId}` 60/min. Always 200; each + * entry carries `{ index, status, id?, externalId?, reason? }` where + * status is `inserted` | `duplicate` | `updated` | `skipped`. `updated` + * = an externalId re-post that changed a field; `duplicate` = an + * unchanged re-post. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { + cycleBulkSchema, + MAX_CYCLE_BULK_ENTRIES, +} from "@/lib/validations/cycle"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +const BATCH_RATE_LIMIT_MAX = 60; +const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +type EntryStatus = "inserted" | "duplicate" | "updated" | "skipped"; +interface EntryResult { + index: number; + status: EntryStatus; + id?: string; + externalId?: string; + reason?: string; +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); + +async function postBulk(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `cycle:day-logs:bulk:${user.id}`, + BATCH_RATE_LIMIT_MAX, + BATCH_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk submissions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + if ( + typeof rawBody === "object" && + rawBody !== null && + "entries" in rawBody && + Array.isArray((rawBody as { entries: unknown }).entries) && + (rawBody as { entries: unknown[] }).entries.length > MAX_CYCLE_BULK_ENTRIES + ) { + return apiError( + `Batch exceeds the ${MAX_CYCLE_BULK_ENTRIES}-entry limit`, + 422, + { errorCode: "cycle.bulk.too_large" }, + ); + } + + const parsed = cycleBulkSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.bulk.validation-failed" }, + meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.bulk.invalid", + }); + } + + const { entries } = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + // Hoist cycle attribution + the invariant encryption flag out of the loop: + // load the user's cycle start dates once and resolve the owning span in + // memory (latest start <= date), instead of a findOwningCycleId query per + // entry (up to 500) + a profile read per entry (QA M-3 / round-1 N+1). + const cycleRows = await prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "asc" }, + select: { id: true, startDate: true }, + }); + const owningCycleId = (date: string): string | null => { + let id: string | null = null; + for (const c of cycleRows) { + if (c.startDate <= date) id = c.id; + else break; + } + return id; + }; + const encryptSensitive = gate.profile.sensitiveCategoryEncryption; + + const results: EntryResult[] = []; + let inserted = 0; + let updated = 0; + let duplicates = 0; + let skipped = 0; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + try { + const cycleId = owningCycleId(entry.date); + const r = await upsertCycleDayLog( + user.id, + entry, + tz, + cycleId, + encryptSensitive, + ); + + let status: EntryStatus; + if (!r.existed) { + status = "inserted"; + inserted += 1; + } else if (r.changed) { + status = "updated"; + updated += 1; + } else { + status = "duplicate"; + duplicates += 1; + } + + results.push({ + index: i, + status, + id: r.id, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); + } catch (err: unknown) { + // Map to a stable closed set of reason codes — never echo the raw + // driver message (it can carry column / constraint / value fragments). + // `annotate` meta is NOT run through the central redactor (only + // setError/setHttp are), so the raw message must not be attached here: + // record the stable code + the Prisma error code only (QA M-sec1). + const code = + typeof err === "object" && err !== null && "code" in err + ? (err as { code?: unknown }).code + : undefined; + const reason = + code === "P2002" + ? "constraint" + : code === "P2003" + ? "constraint" + : "upsert_failed"; + annotate({ + action: { name: "cycle.bulk.entry-failed" }, + meta: { + index: i, + reason, + error_code: typeof code === "string" ? code : "unknown", + }, + }); + skipped += 1; + results.push({ + index: i, + status: "skipped", + reason, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); + } + } + + await auditLog("cycle.bulk.ingest", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + processed: entries.length, + inserted, + updated, + duplicates, + skipped, + }, + }); + + annotate({ + action: { name: "cycle.bulk.ingest" }, + meta: { + processed: entries.length, + inserted, + updated, + duplicates, + skipped, + }, + }); + + return apiSuccess({ + processed: entries.length, + inserted, + updated, + duplicates, + skipped, + entries: results, + }); +} diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts new file mode 100644 index 000000000..91a7c0f09 --- /dev/null +++ b/src/app/api/cycle/day-logs/route.ts @@ -0,0 +1,143 @@ +/** + * `POST /api/cycle/day-logs` — single cycle day-log capture + * (ios-contract §2.A). + * + * Upserts on `(userId, source, externalId)` when an `externalId` is + * present (the cross-device dedup key), else the canonical `(userId, + * date)` key. `note` is encrypted at rest (`notesEncrypted`); the intent + * fields stay queryable plaintext (they feed the rollup / correlation + * tier). Returns the full `CycleDayLogDTO`: 201 on insert, 200 on update. + * + * Gated: a disabled / non-FEMALE-without-opt-in account 403s with + * `errorCode:"cycle.disabled"` even with a valid Bearer token. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { + cycleDayLogInputSchema, + cycleDayLogQuerySchema, +} from "@/lib/validations/cycle"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; +import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog)); + +/** + * `GET /api/cycle/day-logs?date=YYYY-MM-DD` — the full `CycleDayLogDTO` + * for one tz-anchored day, or `null` when nothing is logged. Lets the + * log-day sheet pre-fill (no blank-sheet data loss) and Delete resolve the + * row id. Gated + owner-scoped + `deletedAt:null`. + */ +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const parsed = cycleDayLogQuerySchema.safeParse({ + date: new URL(request.url).searchParams.get("date"), + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.day-log.invalid", + }); + } + + const row = await prisma.cycleDayLog.findFirst({ + where: { userId: user.id, date: parsed.data.date, deletedAt: null }, + include: dayLogSymptomInclude, + }); + + annotate({ + action: { name: "cycle.day-log.read", entity_type: "cycle_day_log" }, + meta: { found: row !== null }, + }); + + return apiSuccess(row ? toCycleDayLogDTO(row) : null); +}); + +async function postDayLog(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = cycleDayLogInputSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.day-log.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.day-log.invalid", + }); + } + + const entry = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + const cycleId = await findOwningCycleId(user.id, entry.date); + let result; + try { + result = await upsertCycleDayLog(user.id, entry, tz, cycleId); + } catch (err: unknown) { + // A residual unique-constraint collision (the helper adopts the + // canonical row on the common case) surfaces as a clean 409, never a + // 500 (the MoodEntry conflict precedent). + if ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "P2002" + ) { + annotate({ + action: { name: "cycle.day-log.conflict" }, + meta: { date: entry.date }, + }); + return apiError("Day-log already exists for this date", 409, { + errorCode: "cycle.day-log.conflict", + }); + } + throw err; + } + + const row = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: result.id }, + include: dayLogSymptomInclude, + }); + + await auditLog("cycle.day-log.upsert", { + userId: user.id, + ipAddress: getClientIp(request), + details: { dayLogId: result.id, existed: result.existed }, + }); + + annotate({ + action: { + name: "cycle.day-log.upsert", + entity_type: "cycle_day_log", + entity_id: result.id, + }, + meta: { existed: result.existed, source: entry.source }, + }); + + return apiSuccess(toCycleDayLogDTO(row), result.existed ? 200 : 201); +} diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts new file mode 100644 index 000000000..48275d773 --- /dev/null +++ b/src/app/api/cycle/insights/route.ts @@ -0,0 +1,238 @@ +/** + * `GET /api/cycle/insights` — cycle-phase × vital correlation insights + * (integration-audit §1 + §3 "INSIGHTS"). + * + * The gender-gated home for the CYCLE_PHASE correlation. The general + * `/api/insights/correlations` route is NOT gender-gated, so phase — and the + * temperature channels a phase relation rides on — must never surface there; + * this route is the deliberate, gated place they do. + * + * Surfaces the FDR-guarded LUTEAL-vs-FOLLICULAR phase contrast per outcome + * metric (the same Welch + Benjamini-Hochberg machinery the mood-factor + * crosstab runs) plus the ONE headline insight (resting-heart-rate-by-phase, + * falling back to HRV) for the v1.15.0 card. Honest: n / effect size / q are + * shown, every row is FDR-gated, nothing is fabricated on thin data, nothing + * is causal. + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; +import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; +import { + buildPhaseDayMap, + toCycleInputs, + toDayLogInputs, + toProfileInput, +} from "@/lib/cycle/engine-adapter"; +import { + computePhaseMetricCrosstab, + discoverPhaseCorrelations, + selectHeadlinePhaseRow, + PHASE_CROSSTAB_METRIC_TYPES, + MOOD_CHANNEL_KEY, +} from "@/lib/cycle/phase-crosstab"; +import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates"; +import { + computeSymptomPhasePatterns, + type SymptomDay, +} from "@/lib/cycle/symptom-phase"; +import { addDays } from "@/lib/cycle/day-math"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; + +/** Trailing window the phase-contrast walks (days). */ +const WINDOW_DAYS = 365; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + const profile = gate.profile; + + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const today = moodDateKey(new Date(), tz); + const from = addDays(today, -WINDOW_DAYS); + + const [ + cycles, + dayLogRows, + nightlyTempRows, + measurementRows, + prefsRow, + moodRows, + ] = await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + // Bound the day-log read to the rendered window (`from` = today − 365d), + // which is already earlier than the symptothermal lookback. Cycle-length + // stats run off MenstrualCycle rows so the day-logs can be windowed (QA: + // perf — unbounded full-history read). + prisma.cycleDayLog.findMany({ + where: { userId: user.id, deletedAt: null, date: { gte: from } }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + symptomLinks: { select: { symptom: { select: { key: true } } } }, + }, + }), + // Passive wrist temperature feeds the temperature-trend ovulation layer + // (used only to sharpen the prediction's next-start the phase-day map + // extends to). Not a crosstab input. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + // The outcome metrics the phase contrast compares — soft-delete-scoped, + // canonical-source deduped per day inside `metricDayMap`. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { + gte: new Date(Date.parse(`${from}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }), + // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a + // Measurement row, so read it here and inject it into the crosstab as a + // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). + prisma.moodEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }), + ]); + + // The latest open cycle runs to the predicted next-period start so the + // trailing window is phase-labelled even before the next period is logged. + const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({ + date: moodDateKey(m.measuredAt, tz), + valueC: m.value, + })); + const prediction = + profile.predictionEnabled && !profile.rawChartMode + ? predictCycle( + toCycleInputs(cycles), + toDayLogInputs(dayLogRows), + toProfileInput(profile), + today, + nights, + ) + : null; + + const phaseByDay = buildPhaseDayMap( + cycles, + prediction?.nextPeriodStart ?? null, + profile.lutealPhaseLength ?? LUTEAL_DEFAULT, + from, + today, + ); + + const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ + type: m.type, + value: m.value, + measuredAt: m.measuredAt, + source: m.source, + deviceType: m.deviceType, + })); + // Inject mood as a synthetic MOOD_CHANNEL_KEY series (no source ladder — the + // metricDayMap pass-through keeps every row, averaging multiple same-day + // entries like every other channel). + for (const m of moodRows) { + measurements.push({ + type: MOOD_CHANNEL_KEY, + value: m.score, + measuredAt: m.moodLoggedAt, + source: null, + deviceType: null, + }); + } + + const userPriorityJson = prefsRow?.sourcePriorityJson ?? null; + + // Mechanism A — the categorical luteal-vs-follicular contrast (the headline + // surface). Mechanism B — the continuous CYCLE_PHASE ordinal folded into the + // lagged-Pearson FDR matrix. Both are FDR-gated; both live only here. + const rows = computePhaseMetricCrosstab({ + phaseByDay, + measurements, + userPriorityJson, + }); + const headline = selectHeadlinePhaseRow(rows); + const lagged = discoverPhaseCorrelations({ + phaseByDay, + measurements, + userPriorityJson, + }); + + // Cycle-NATIVE insight: where each logged symptom clusters across the phases. + const symptomDays: SymptomDay[] = dayLogRows.map((d) => ({ + date: d.date, + keys: d.symptomLinks.map((l) => l.symptom.key), + })); + const symptomPatterns = computeSymptomPhasePatterns(symptomDays, phaseByDay); + + annotate({ + action: { name: "cycle.insights.read" }, + meta: { + labelled_days: phaseByDay.size, + cycles_observed: cycles.length, + rows: rows.length, + has_headline: headline !== null, + lagged_discovered: lagged.discovered.length, + lagged_pairs_tested: lagged.pairsTested, + symptom_patterns: symptomPatterns.length, + }, + }); + + return apiSuccess({ + rows, + headline, + lagged: { + discovered: lagged.discovered, + pairsTested: lagged.pairsTested, + fdrQ: lagged.fdrQ, + minPairs: lagged.minPairs, + }, + symptomPatterns, + contrast: { high: "LUTEAL", low: "FOLLICULAR" }, + windowDays: WINDOW_DAYS, + cyclesObserved: cycles.length, + }); +}); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts new file mode 100644 index 000000000..908ea889c --- /dev/null +++ b/src/app/api/cycle/period/route.ts @@ -0,0 +1,224 @@ +/** + * `POST /api/cycle/period` — one-tap period-boundary shortcut + * (ios-contract §2.C). + * + * `action:"start"` opens a new `MenstrualCycle` anchored at `date`, + * closes the prior open cycle (endDate = day before, lengthDays set), + * and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"` + * stamps the current cycle's `periodEndDate` and writes the boundary + * day-log. Convenience over §2.A; returns + * `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { withIdempotency } from "@/lib/idempotency"; +import { cyclePeriodSchema } from "@/lib/validations/cycle"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import type { FlowLevel } from "@/lib/cycle/types"; +import { + toCycleDayLogDTO, + toMenstrualCycleDTO, + dayLogSymptomInclude, +} from "@/lib/cycle/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { addDays, dayDiff } from "@/lib/cycle/day-math"; + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod)); + +async function postPeriod(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = cyclePeriodSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.period.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.period.invalid", + }); + } + + const { action, date, externalId, loggedAt } = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + // The close-prior + open-new (start) or stamp-current (end) mutation set + // is run inside a single transaction so two concurrent taps can never + // double-close a prior cycle or compute the new cycle's length against a + // stale read. `withIdempotency` on the route additionally collapses an + // exact-key replay; the transaction guards the interleaving case. + const txResult = await prisma.$transaction(async (db) => { + if (action === "start") { + // Close the prior open cycle (its end is the day before this start) + // and record its observed length. + const prior = await db.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { lt: date } }, + orderBy: { startDate: "desc" }, + select: { id: true, startDate: true }, + }); + if (prior) { + await db.menstrualCycle.update({ + where: { id: prior.id }, + data: { + endDate: addDays(date, -1), + lengthDays: dayDiff(date, prior.startDate), + syncVersion: { increment: 1 }, + }, + }); + } + + // Upsert the new cycle on the `(userId, startDate)` unique so a + // re-tap on the same day is idempotent. + const cycle = await db.menstrualCycle.upsert({ + where: { userId_startDate: { userId: user.id, startDate: date } }, + create: { userId: user.id, startDate: date, tz, isPredicted: false }, + update: { + deletedAt: null, + isPredicted: false, + syncVersion: { increment: 1 }, + }, + }); + + // Re-anchor the FOLLOWING neighbour: when this start is back-filled + // between two existing cycles, the new cycle's end + length are already + // known from its successor's start. Without this the inserted cycle keeps + // endDate/lengthDays = null even though the next start exists, and the + // iOS mirror + history surfaces read a stale open cycle (QA M-1). + const next = await db.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { gt: date } }, + orderBy: { startDate: "asc" }, + select: { startDate: true }, + }); + if (next) { + await db.menstrualCycle.update({ + where: { id: cycle.id }, + data: { + endDate: addDays(next.startDate, -1), + lengthDays: dayDiff(next.startDate, date), + syncVersion: { increment: 1 }, + }, + }); + } + return { cycleId: cycle.id as string | null }; + } + + // `end`: stamp the current cycle's periodEndDate. + const current = await db.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { lte: date } }, + orderBy: { startDate: "desc" }, + select: { id: true }, + }); + if (!current) { + return { cycleId: null }; + } + await db.menstrualCycle.update({ + where: { id: current.id }, + data: { periodEndDate: date, syncVersion: { increment: 1 } }, + }); + return { cycleId: current.id as string | null }; + }); + + if (txResult.cycleId === null) { + return apiSuccessNoCycle(); + } + const cycleId: string = txResult.cycleId; + + // Boundary flow. Start → MEDIUM opens the bleed; end → SPOTTING tail. + // Never downgrade a richer flow already logged for the same day (e.g. a + // manual HEAVY entry must survive a later one-tap "start") — only set the + // boundary flow when it ranks at or above what's stored. + const boundaryFlow: FlowLevel = action === "start" ? "MEDIUM" : "SPOTTING"; + const existingDay = await prisma.cycleDayLog.findFirst({ + where: { userId: user.id, date, deletedAt: null }, + select: { flow: true }, + }); + const flow = + flowRank(boundaryFlow) >= flowRank(existingDay?.flow ?? null) + ? boundaryFlow + : undefined; + + await upsertCycleDayLog( + user.id, + { + date, + ...(flow !== undefined ? { flow } : {}), + loggedAt, + source: "MANUAL", + ...(externalId ? { externalId } : {}), + }, + tz, + cycleId, + ); + + const [cycleRow, dayLogRow] = await Promise.all([ + prisma.menstrualCycle.findUniqueOrThrow({ where: { id: cycleId } }), + prisma.cycleDayLog.findFirstOrThrow({ + where: { userId: user.id, date, deletedAt: null }, + include: dayLogSymptomInclude, + }), + ]); + + await auditLog("cycle.period.boundary", { + userId: user.id, + ipAddress: getClientIp(request), + details: { action, date, cycleId }, + }); + + annotate({ + action: { + name: "cycle.period.boundary", + entity_type: "menstrual_cycle", + entity_id: cycleId, + }, + meta: { boundary: action }, + }); + + return apiSuccess({ + cycle: toMenstrualCycleDTO(cycleRow), + dayLog: toCycleDayLogDTO(dayLogRow), + }); +} + +/** `end` with no preceding cycle is a no-op the client should not hit. */ +function apiSuccessNoCycle(): Response { + return apiSuccess({ cycle: null, dayLog: null }); +} + +/** + * Ordinal rank of a flow level (NONE = 0 … HEAVY = 5; a missing flow ranks + * below NONE). Used to keep the one-tap boundary flow from downgrading a + * richer same-day entry. + */ +function flowRank(flow: FlowLevel | string | null): number { + switch (flow) { + case "NONE": + return 1; + case "SPOTTING": + return 2; + case "LIGHT": + return 3; + case "MEDIUM": + return 4; + case "HEAVY": + return 5; + default: + return 0; + } +} diff --git a/src/app/api/cycle/profile/route.ts b/src/app/api/cycle/profile/route.ts new file mode 100644 index 000000000..051d916d0 --- /dev/null +++ b/src/app/api/cycle/profile/route.ts @@ -0,0 +1,27 @@ +/** + * `GET /api/cycle/profile` — full CycleProfileDTO (ios-contract §2.G). + * + * Gated like every cycle route; returns the lazily-upserted profile with + * the resolved `cycleTrackingEnabled`. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { toCycleProfileDTO } from "@/lib/cycle/dto"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const resolved = isCycleEnabled(user.gender, gate.profile); + + annotate({ action: { name: "cycle.profile.read" } }); + + return apiSuccess(toCycleProfileDTO(gate.profile, resolved)); +}); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 141559a27..3af459473 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -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")); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 516048198..286e84282 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -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, }, }); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 952b8ab56..6a6d62e1b 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -8,7 +8,9 @@ * domain now soft-delete (set `deletedAt` + bump `syncVersion`) rather * than hard-delete. * - * Domains served: `measurements`, `mood`, `intakes`. The iOS consumer + * Domains served: `measurements`, `mood`, `intakes`, `cycleDays`, + * `cycles` (the last two added v1.15.0; `cycleDays` tombstones key on + * `externalId` when present else `id`, `cycles` on `id`). The iOS consumer * is measurements-only this cycle (iOS-coord * `v1.7.0-ios-offline-sync-answers.md` §7.1); mood + intakes are * forward-prep so the contract is complete + tested before the @@ -57,6 +59,13 @@ import { type SyncCursor, type SyncDomain, } from "@/lib/sync/cursor"; +import { + toCycleDayLogDTO, + toMenstrualCycleDTO, + dayLogSymptomInclude, + type CycleDayLogDTO, + type MenstrualCycleDTO, +} from "@/lib/cycle/dto"; const DEFAULT_LIMIT = 200; const MAX_LIMIT = 500; @@ -126,6 +135,25 @@ interface IntakeTombstone { updatedAt: string; } +// v1.15.0 — cycle domains. cycleDays key tombstones on `externalId` when +// present (HealthKit-origin rows are externalId-keyed cross-device, like +// measurements) else the server `id`; cycles key on the server `id` +// (computed rows carry no externalId). +interface CycleDayTombstone { + id: string; + externalId: string | null; + syncVersion: number; + deletedAt: string; + updatedAt: string; +} + +interface CycleTombstone { + id: string; + syncVersion: number; + deletedAt: string; + updatedAt: string; +} + /** * Build the `(updatedAt, id)` keyset predicate for a domain's watermark. * Empty object (full scan) when the client has no watermark for it yet. @@ -196,6 +224,8 @@ export const GET = apiHandler(async (request: NextRequest) => { measurements: { upserts: [], tombstones: [] }, mood: { upserts: [], tombstones: [] }, intakes: { upserts: [], tombstones: [] }, + cycleDays: { upserts: [], tombstones: [] }, + cycles: { upserts: [], tombstones: [] }, }, }); } @@ -204,7 +234,8 @@ export const GET = apiHandler(async (request: NextRequest) => { // same scan — a soft-delete bumps `updatedAt`, so a tombstone is just a // row whose `deletedAt` is non-null. Fetch limit+1 to detect `hasMore` // without a count query. - const [measurementRows, moodRows, intakeRows] = await Promise.all([ + const [measurementRows, moodRows, intakeRows, cycleDayRows, cycleRows] = + await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id, ...keysetFilter(cursor.measurements) }, orderBy: [{ updatedAt: "asc" }, { id: "asc" }], @@ -257,6 +288,17 @@ export const GET = apiHandler(async (request: NextRequest) => { updatedAt: true, }, }), + prisma.cycleDayLog.findMany({ + where: { userId: user.id, ...keysetFilter(cursor.cycleDays) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + include: dayLogSymptomInclude, + }), + prisma.menstrualCycle.findMany({ + where: { userId: user.id, ...keysetFilter(cursor.cycles) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + }), ]); const nextCursor: SyncCursor = { ...cursor }; @@ -354,6 +396,49 @@ export const GET = apiHandler(async (request: NextRequest) => { advanceCursor(nextCursor, "intakes", intakePage); hasMore = hasMore || intakeHasMore; + // ── cycleDays ───────────────────────────────────────────── + const cycleDayHasMore = cycleDayRows.length > limit; + const cycleDayPage = cycleDayHasMore + ? cycleDayRows.slice(0, limit) + : cycleDayRows; + const cycleDayUpserts: CycleDayLogDTO[] = []; + const cycleDayTombstones: CycleDayTombstone[] = []; + for (const row of cycleDayPage) { + if (row.deletedAt) { + cycleDayTombstones.push({ + id: row.id, + externalId: row.externalId, + syncVersion: row.syncVersion, + deletedAt: row.deletedAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + } else { + cycleDayUpserts.push(toCycleDayLogDTO(row)); + } + } + advanceCursor(nextCursor, "cycleDays", cycleDayPage); + hasMore = hasMore || cycleDayHasMore; + + // ── cycles ──────────────────────────────────────────────── + const cycleHasMore = cycleRows.length > limit; + const cyclePage = cycleHasMore ? cycleRows.slice(0, limit) : cycleRows; + const cycleUpserts: MenstrualCycleDTO[] = []; + const cycleTombstones: CycleTombstone[] = []; + for (const row of cyclePage) { + if (row.deletedAt) { + cycleTombstones.push({ + id: row.id, + syncVersion: row.syncVersion, + deletedAt: row.deletedAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + } else { + cycleUpserts.push(toMenstrualCycleDTO(row)); + } + } + advanceCursor(nextCursor, "cycles", cyclePage); + hasMore = hasMore || cycleHasMore; + annotate({ action: { name: "sync.changes.pull" }, meta: { @@ -363,6 +448,10 @@ export const GET = apiHandler(async (request: NextRequest) => { mood_tombstones: moodTombstones.length, intake_upserts: intakeUpserts.length, intake_tombstones: intakeTombstones.length, + cycle_day_upserts: cycleDayUpserts.length, + cycle_day_tombstones: cycleDayTombstones.length, + cycle_upserts: cycleUpserts.length, + cycle_tombstones: cycleTombstones.length, has_more: hasMore, cursor_present: Boolean(parsed.data.cursor), }, @@ -380,6 +469,11 @@ export const GET = apiHandler(async (request: NextRequest) => { }, mood: { upserts: moodUpserts, tombstones: moodTombstones }, intakes: { upserts: intakeUpserts, tombstones: intakeTombstones }, + cycleDays: { + upserts: cycleDayUpserts, + tombstones: cycleDayTombstones, + }, + cycles: { upserts: cycleUpserts, tombstones: cycleTombstones }, }, }); }); diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx new file mode 100644 index 000000000..54034d4a5 --- /dev/null +++ b/src/app/cycle/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { CycleView } from "@/components/cycle/cycle-view"; + +/** + * v1.15.0 — the cycle vertical entry. + * + * Gated on `cycleTrackingEnabled` from `GET /api/auth/me` (resolved + * server-side from gender + the per-user opt-in). An unauthenticated visitor + * is bounced to login; an authenticated account without the feature enabled + * is bounced home (the nav entry is already hidden for them, so this only + * catches a direct URL hit). Every `/api/cycle/*` route also enforces the + * gate server-side, so this is a UX redirect, not the security boundary. + */ +export default function CyclePage() { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.cycleTrackingEnabled === true; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + if (isLoading || !isAuthenticated || !enabled) { + return ( +
+ +
+ ); + } + + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index 4806a0bed..07d57cbf4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -238,6 +238,17 @@ --ring-red-from: #c93a2a; --ring-red-to: #ff7a6b; + /* + * v1.15.0 — cycle-phase ring hues (Dracula dark). Four calm, distinct + * tones for the cycle wheel: menstrual rose · follicular green · ovulatory + * amber-gold · luteal violet. Tuned to read as a soft instrument, never a + * gaudy rainbow — the same restraint as the wellness rings above. + */ + --cycle-phase-menstrual: #ff6e9c; + --cycle-phase-follicular: #5cf08f; + --cycle-phase-ovulatory: #ffce6b; + --cycle-phase-luteal: #bd93f9; + color-scheme: dark; } @@ -312,6 +323,17 @@ --ring-red-from: #a3271a; --ring-red-to: #cb3a2a; + /* + * v1.15.0 — cycle-phase ring hues (Alucard light). Deeper + more + * saturated than the dark set so each phase holds AA-grade presence on the + * white card. Same four distinct hues: menstrual rose · follicular green · + * ovulatory amber · luteal violet. + */ + --cycle-phase-menstrual: #c8295e; + --cycle-phase-follicular: #0f7a3a; + --cycle-phase-ovulatory: #a34d14; + --cycle-phase-luteal: #644ac9; + /* * Status-pill text in light mode (Alucard). The bright Dracula * green / orange / red read 1.2–2.7:1 on a near-white tint; these @@ -466,7 +488,8 @@ color-mix(in srgb, var(--tile-hue) 7%, var(--card)) 100% ); border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent); - box-shadow: 0 10px 30px -26px color-mix(in srgb, var(--tile-hue) 40%, transparent); + box-shadow: 0 10px 30px -26px + color-mix(in srgb, var(--tile-hue) 40%, transparent); transition: transform 200ms cubic-bezier(0.22, 0.61, 0.36, 1), box-shadow 200ms cubic-bezier(0.22, 0.61, 0.36, 1), @@ -494,7 +517,8 @@ .wellness-tile:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent); - box-shadow: 0 16px 40px -24px color-mix(in srgb, var(--tile-hue) 52%, transparent); + box-shadow: 0 16px 40px -24px + color-mix(in srgb, var(--tile-hue) 52%, transparent); } .wellness-tile:hover [data-slot="score-ring"] { transform: scale(1.015); @@ -530,7 +554,18 @@ — the Oura/WHOOP fill feel. Falls back to the browser default on very old engines that ignore linear() (instant fill, not broken). */ --ring-spring: linear( - 0, 0.06, 0.23, 0.46, 0.7, 0.89, 1.02, 1.06, 1.03, 1, 0.99, 1 + 0, + 0.06, + 0.23, + 0.46, + 0.7, + 0.89, + 1.02, + 1.06, + 1.03, + 1, + 0.99, + 1 ); --ring-ease: cubic-bezier(0.22, 0.61, 0.36, 1); } @@ -610,7 +645,9 @@ on the strongest (green-band) ring so the strip celebrates a good score rather than jittering all five. `--ring-glow` is set inline per ring. */ .wellness-ring-arc { - filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)); + filter: drop-shadow( + 0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent) + ); } .wellness-ring-arc--pulse { /* Breathe a few times to celebrate a strong score, then settle — an @@ -621,10 +658,14 @@ @keyframes ringGlow { 0%, 100% { - filter: drop-shadow(0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent)); + filter: drop-shadow( + 0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent) + ); } 50% { - filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent)); + filter: drop-shadow( + 0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent) + ); } } @@ -681,3 +722,79 @@ animation: none; } } + +/* + * v1.15.0 — the cycle-ring "signature reveal". + * + * The phase dial earns the same premium signature as the wellness rings + * (sweep + glow + sheen + count-up) without losing its own identity as a + * four-phase dial: + * 1. a clockwise CLOCK-WIPE draws the phase arcs on from the 12-o'clock + * day-0 origin (the already carries `-rotate-90`, so the conic + * origin lines up with both the arcs and the current-day marker); + * 2. a phase-tinted bloom on the active arc (shared `.wellness-ring-arc`, + * `--ring-glow` set inline to the active phase hue); + * 3. the shared `.wellness-ring-sheen` specular pass + `.wellness-tile` + * surface the strip already uses; + * 4. a soft halo pulse on the current-day marker so "today" reads as alive. + * All gated on the host's `data-revealed` (set once per session) so a + * background calendar refetch never re-triggers the moment, and all zeroed + * under prefers-reduced-motion. + */ +@property --cycle-wipe { + syntax: ""; + inherits: false; + initial-value: 360deg; +} + +.cycle-ring-reveal { + -webkit-mask: conic-gradient(#000 var(--cycle-wipe), transparent 0); + mask: conic-gradient(#000 var(--cycle-wipe), transparent 0); +} +[data-revealed="true"] .cycle-ring-reveal { + animation: cycleRingWipe 1100ms var(--ring-ease) forwards; +} +@keyframes cycleRingWipe { + from { + --cycle-wipe: 0deg; + } + to { + --cycle-wipe: 360deg; + } +} + +/* The current-day marker's halo — a few gentle pulses on reveal, then calm + (a finite count, never an infinite repaint of the non-compositable ring). */ +.cycle-ring-marker-halo { + transform-box: fill-box; + transform-origin: center; + opacity: 0; +} +[data-revealed="true"] .cycle-ring-marker-halo { + animation: cycleMarkerHalo 2.4s ease-in-out 950ms 3; +} +@keyframes cycleMarkerHalo { + 0%, + 100% { + opacity: 0; + transform: scale(1); + } + 50% { + opacity: 0.45; + transform: scale(1.9); + } +} + +@media (prefers-reduced-motion: reduce) { + .cycle-ring-reveal, + [data-revealed="true"] .cycle-ring-reveal { + -webkit-mask: none; + mask: none; + animation: none; + } + .cycle-ring-marker-halo, + [data-revealed="true"] .cycle-ring-marker-halo { + animation: none; + opacity: 0; + } +} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index b9a7d9b6d..9c82204e9 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -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) }), ); diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx new file mode 100644 index 000000000..75be6f152 --- /dev/null +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { BbtChart } from "../bbt-chart"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { CalendarDay } from "../types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function day( + date: string, + overrides: Partial = {}, +): CalendarDay { + return { + date, + phase: "FOLLICULAR", + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: false, + flow: null, + hasSymptoms: false, + confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + ...overrides, + }; +} + +describe("", () => { + const today = "2026-06-10"; + + it("shows the empty hint when fewer than two readings exist", () => { + const days = [ + day("2026-06-01", { phase: "MENSTRUAL" }), + day("2026-06-09", { basalBodyTempC: 36.5 }), + ]; + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-bbt-empty"'); + expect(html).not.toContain('data-slot="cycle-bbt-area"'); + }); + + it("draws the curve once two or more readings are present", () => { + const days = [ + day("2026-06-01", { phase: "MENSTRUAL", basalBodyTempC: 36.4 }), + day("2026-06-05", { phase: "FOLLICULAR", basalBodyTempC: 36.5 }), + day("2026-06-08", { phase: "OVULATORY", basalBodyTempC: 36.7 }), + day("2026-06-10", { phase: "LUTEAL", basalBodyTempC: 36.9 }), + ]; + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-bbt-chart"'); + expect(html).toContain('data-slot="cycle-bbt-caption"'); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx new file mode 100644 index 000000000..dfc93a3d1 --- /dev/null +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CycleCalendar } from "../cycle-calendar"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { CalendarDay } from "../types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function dayBase(date: string): CalendarDay { + return { + date, + phase: null, + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: false, + flow: null, + hasSymptoms: false, + confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + }; +} + +describe("", () => { + const today = "2026-06-15"; + + it("renders the month grid for today's month", () => { + const html = render( + {}} />, + ); + expect(html).toContain('data-slot="cycle-calendar"'); + expect(html).toContain('role="grid"'); + // The current day cell is marked. + expect(html).toContain('aria-current="date"'); + }); + + it("labels a logged-period day in its aria-label", () => { + const days = [{ ...dayBase("2026-06-10"), isPeriodLogged: true }]; + const html = render( + {}} />, + ); + expect(html).toContain("Logged period"); + }); + + it("renders the predicted period as a band, not a logged pip", () => { + const days = [{ ...dayBase("2026-06-20"), isPredictedPeriod: true }]; + const html = render( + {}} />, + ); + // The predicted band uses a dashed gradient underline + the aria marker. + expect(html).toContain("Predicted period"); + expect(html).toContain("repeating-linear-gradient"); + }); + + it("labels a fertile-window day (only present when goal-gated server allows it)", () => { + const days = [{ ...dayBase("2026-06-12"), isFertileWindow: true }]; + const html = render( + {}} />, + ); + expect(html).toContain("Fertile window"); + }); + + it("renders a localized Monday-first weekday header row", () => { + const html = render( + {}} />, + ); + expect(html).toContain('role="columnheader"'); + }); +}); diff --git a/src/components/cycle/__tests__/cycle-ring.test.tsx b/src/components/cycle/__tests__/cycle-ring.test.tsx new file mode 100644 index 000000000..25c99a31a --- /dev/null +++ b/src/components/cycle/__tests__/cycle-ring.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CycleRing } from "../cycle-ring"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the root data-slot tagged with the active phase", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-ring"'); + expect(html).toContain('data-phase="FOLLICULAR"'); + }); + + it("shows the day-of-cycle number as centred text", () => { + const html = render( + , + ); + expect(html).toContain(">12<"); + }); + + it("carries a phase-aware aria-label (never colour-only)", () => { + const html = render( + , + ); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + // The label restates the day + phase. + expect(html).toContain("3"); + }); + + it("parks the day-1 marker at the 12-o'clock origin (no double-rotate)", () => { + // The carries `-rotate-90`, so day 1 (progress 0, angle 0) must + // land at the un-rotated 3-o'clock SVG point (cx ≈ CX + R, cy ≈ CX) which + // renders at the top. The prior `-π/2` bug parked it at cx ≈ CX (9 o'clock + // after rotation). R = (100 - 8) / 2 = 46, CX = 50. + const html = render( + , + ); + // The marker circle's cx is the last with a stroke-width of 2. + expect(html).toMatch(/cx="9[0-9](\.\d+)?"/); + }); + + it("renders the no-cycle state for a null day with a fallback aria-label", () => { + const html = render( + , + ); + expect(html).toContain('data-phase="none"'); + expect(html).toContain('role="img"'); + }); +}); diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts new file mode 100644 index 000000000..a7a130f29 --- /dev/null +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; + +import { + buildDayLogPatch, + buildDayLogInput, + type DayLogFormState, +} from "../log-day-sheet"; + +function blank(): DayLogFormState { + return { + flow: null, + intermenstrual: false, + bbt: "", + opk: null, + mucus: null, + intercourse: false, + protectedSex: false, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + note: "", + symptoms: new Map(), + }; +} + +describe("buildDayLogPatch (edit → clear semantics, QA W-2)", () => { + it("sends explicit null for every deselected enum so an edit clears it", () => { + const patch = buildDayLogPatch(blank()); + // A blank form on an EXISTING row must null every field, not omit it — + // omission would leave the stored value behind (the bug this fixes). + expect(patch.flow).toBeNull(); + expect(patch.ovulationTest).toBeNull(); + expect(patch.cervicalMucus).toBeNull(); + expect(patch.pregnancyTest).toBeNull(); + expect(patch.progesteroneTest).toBeNull(); + expect(patch.contraceptive).toBeNull(); + expect(patch.basalBodyTempC).toBeNull(); + expect(patch.note).toBeNull(); + // Keys are PRESENT (not omitted) so the server actually applies the clear. + expect("flow" in patch).toBe(true); + expect("cervicalMucus" in patch).toBe(true); + }); + + it("carries the set values through and gates protection on intercourse", () => { + const patch = buildDayLogPatch({ + ...blank(), + flow: "MEDIUM", + opk: "POSITIVE_LH_SURGE", + bbt: "36.62", + intercourse: true, + protectedSex: true, + note: " hello ", + symptoms: new Map([["cramps", 3]]), + }); + expect(patch.flow).toBe("MEDIUM"); + expect(patch.ovulationTest).toBe("POSITIVE_LH_SURGE"); + expect(patch.basalBodyTempC).toBe(36.62); + expect(patch.protectedSex).toBe(true); + expect(patch.note).toBe("hello"); + expect(patch.symptoms).toEqual([{ key: "cramps", severity: 3 }]); + }); + + it("nulls protection when no intercourse is logged", () => { + const patch = buildDayLogPatch({ + ...blank(), + intercourse: false, + protectedSex: true, + }); + expect(patch.sexualActivity).toBe(false); + expect(patch.protectedSex).toBeNull(); + }); +}); + +describe("buildDayLogInput (new row → omit empties)", () => { + it("omits unset enums but posts an explicit empty note", () => { + const input = buildDayLogInput(blank(), "2026-06-06"); + expect("flow" in input).toBe(false); + expect("ovulationTest" in input).toBe(false); + expect("basalBodyTempC" in input).toBe(false); + expect(input.note).toBe(""); + expect(input.date).toBe("2026-06-06"); + expect(input.source).toBe("MANUAL"); + }); + + it("includes set values for a new row", () => { + const input = buildDayLogInput( + { ...blank(), flow: "LIGHT", bbt: "36.5" }, + "2026-06-06", + ); + expect(input.flow).toBe("LIGHT"); + expect(input.basalBodyTempC).toBe(36.5); + }); +}); diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx new file mode 100644 index 000000000..af2b832d4 --- /dev/null +++ b/src/components/cycle/__tests__/predictions-panel.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { PredictionsPanel } from "../predictions-panel"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { CyclePrediction, CycleHistoryResponse } from "../types"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const basePrediction: CyclePrediction = { + method: "CALENDAR", + nextPeriodStart: "2026-07-01", + nextPeriodStartLow: "2026-06-29", + nextPeriodStartHigh: "2026-07-03", + fertileWindowStart: null, + fertileWindowEnd: null, + predictedOvulation: null, + ovulationConfirmed: false, + confidence: 0.8, + cyclesObserved: 6, + stillLearning: false, + disclaimer: "Estimates only, not medical advice.", +}; + +const emptyHistory: CycleHistoryResponse = { + cycles: [], + stats: { + avgLengthDays: null, + lengthVariabilityDays: null, + avgPeriodLengthDays: null, + regularity: "LEARNING", + }, +}; + +describe("", () => { + it("renders the next-period range + the fixed disclaimer", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-disclaimer"'); + expect(html).toContain("Estimates only, not medical advice."); + // The range (not a single date) renders. + expect(html).toContain("–"); + }); + + it("renders the fertile window only when the prediction carries one", () => { + const withFertile: CyclePrediction = { + ...basePrediction, + fertileWindowStart: "2026-06-14", + fertileWindowEnd: "2026-06-19", + predictedOvulation: "2026-06-17", + }; + const html = render( + , + ); + expect(html).toContain("Fertile window"); + expect(html).toContain("Estimated ovulation"); + }); + + it("suppresses fertile-window language when the prediction nulls it (goal-gated server-side)", () => { + const html = render( + , + ); + expect(html).not.toContain("Fertile window"); + }); + + it("shows the still-learning state for an under-3-cycle prediction", () => { + const html = render( + , + ); + expect(html).toContain("Still learning"); + }); + + it("shows the raw-chart-mode state when predictions are off", () => { + const html = render( + , + ); + expect(html).toContain("Prediction is off"); + }); +}); diff --git a/src/components/cycle/__tests__/wheel-state.test.ts b/src/components/cycle/__tests__/wheel-state.test.ts new file mode 100644 index 000000000..e700f8f43 --- /dev/null +++ b/src/components/cycle/__tests__/wheel-state.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; + +import { deriveWheelState, currentCycleStartDate } from "../wheel-state"; +import type { CalendarDay } from "../types"; + +function day(date: string, phase: CalendarDay["phase"]): CalendarDay { + return { + date, + phase, + isPredictedPeriod: false, + isFertileWindow: false, + isPredictedOvulation: false, + isPeriodLogged: phase === "MENSTRUAL", + flow: null, + hasSymptoms: false, + confidence: 1, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + }; +} + +describe("deriveWheelState", () => { + it("returns a null state when today carries no phase", () => { + const days = [day("2026-06-01", null), day("2026-06-02", null)]; + const w = deriveWheelState(days, "2026-06-02"); + expect(w.dayOfCycle).toBeNull(); + expect(w.phase).toBeNull(); + expect(w.spans).toEqual([]); + }); + + it("counts the day-of-cycle from the most recent menstrual start", () => { + // A run starting 2026-06-01 (menstrual) → today 2026-06-05 is cycle day 5. + const days = [ + day("2026-05-30", "LUTEAL"), + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "FOLLICULAR"), + day("2026-06-05", "FOLLICULAR"), + ]; + const w = deriveWheelState(days, "2026-06-05"); + expect(w.dayOfCycle).toBe(5); + expect(w.phase).toBe("FOLLICULAR"); + }); + + it("builds proportional phase spans summing to ~1", () => { + const days = [ + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "OVULATORY"), + day("2026-06-05", "LUTEAL"), + ]; + const w = deriveWheelState(days, "2026-06-03"); + const total = w.spans.reduce((s, x) => s + x.fraction, 0); + expect(total).toBeCloseTo(1, 5); + // Only phases present in the run are emitted. + expect(w.spans.map((s) => s.phase)).toContain("MENSTRUAL"); + }); +}); + +describe("currentCycleStartDate", () => { + it("returns the most recent menstrual run-start at/before today", () => { + const days = [ + day("2026-05-30", "LUTEAL"), + day("2026-06-01", "MENSTRUAL"), + day("2026-06-02", "MENSTRUAL"), + day("2026-06-03", "FOLLICULAR"), + day("2026-06-04", "FOLLICULAR"), + ]; + expect(currentCycleStartDate(days, "2026-06-04")).toBe("2026-06-01"); + }); + + it("returns null when today carries no phase", () => { + const days = [day("2026-06-01", null), day("2026-06-02", null)]; + expect(currentCycleStartDate(days, "2026-06-02")).toBeNull(); + }); + + it("returns null when today is absent from the calendar", () => { + const days = [day("2026-06-01", "MENSTRUAL")]; + expect(currentCycleStartDate(days, "2026-06-09")).toBeNull(); + }); +}); diff --git a/src/components/cycle/bbt-chart.tsx b/src/components/cycle/bbt-chart.tsx new file mode 100644 index 000000000..ed0555b4d --- /dev/null +++ b/src/components/cycle/bbt-chart.tsx @@ -0,0 +1,307 @@ +"use client"; + +/** + * v1.15.0 — the basal-body-temperature (BBT) chart. + * + * Plots the user's logged BBT across the CURRENT cycle (the same window the + * wheel shows, via `currentCycleStartDate`) as a line with phase-tinted dots, + * the symptothermal signal a fertility-aware reader expects. Fertility signs + * already logged for a day — egg-white/watery mucus, a positive LH test — + * surface in the tooltip so the temperature shift reads in context. When the + * goal surfaces a fertile window, the estimated ovulation day is marked with a + * reference line. + * + * Honest data contract: BBT is a LOGGED fact (no estimate), so the curve needs + * no disclaimer of its own; the estimated-ovulation marker is the prediction + * panel's, which already carries the fixed non-medical disclaimer beside it. + * In raw-chart ("Read Your Body") mode the phase tint + ovulation marker are + * suppressed — only the neutral temperatures are drawn, no interpretation. + * + * Recharts (the project's charting lib), Dracula/Alucard tokens, reduced-motion + * gated via `isAnimationActive`. Needs ≥2 logged readings to draw; below that + * it surfaces a "log your morning temperature" hint rather than a flat dot. + */ + +import { useMemo } from "react"; +import { Thermometer } from "lucide-react"; +import { + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { cn } from "@/lib/utils"; +import type { CalendarDay, CervicalMucus, OvulationTest } from "./types"; +import { PHASE_HUE, OVULATION_HUE } from "./phase-tokens"; +import { currentCycleStartDate } from "./wheel-state"; + +/** Trailing fallback window (days) when no MENSTRUAL-anchored cycle is active. */ +const FALLBACK_WINDOW_DAYS = 35; + +const LINE_COLOR = "var(--cycle-phase-luteal)"; + +interface BbtPoint { + t: number; + temp: number; + phaseHue: string; + mucus: CervicalMucus | null; + ovulationTest: OvulationTest | null; +} + +export interface BbtChartProps { + days: CalendarDay[]; + today: string; + /** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */ + predictedOvulation: string | null; + /** Whether the ovulation day was confirmed by a signal layer (solid marker). */ + ovulationConfirmed?: boolean; + /** Read-Your-Body mode: suppress phase tint + ovulation marker. */ + rawChartMode: boolean; +} + +function ymdToMs(d: string): number { + // Noon UTC so a YYYY-MM-DD never rolls a day across the viewer's tz. + return Date.parse(`${d}T12:00:00Z`); +} + +export function BbtChart({ + days, + today, + predictedOvulation, + ovulationConfirmed = false, + rawChartMode, +}: BbtChartProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const animationsEnabled = !prefersReducedMotion(); + + // Scope to the current cycle (wheel window); fall back to a trailing window + // when no cycle is active so a user still sees their recent temperatures. + const fromDate = useMemo(() => { + const start = currentCycleStartDate(days, today); + if (start) return start; + const fallback = new Date(ymdToMs(today)); + fallback.setUTCDate(fallback.getUTCDate() - FALLBACK_WINDOW_DAYS); + return fallback.toISOString().slice(0, 10); + }, [days, today]); + + const points = useMemo(() => { + const fromMs = ymdToMs(fromDate); + const todayMs = ymdToMs(today); + return days + .filter((d) => { + if (d.basalBodyTempC == null || !Number.isFinite(d.basalBodyTempC)) { + return false; + } + const ms = ymdToMs(d.date); + return ms >= fromMs && ms <= todayMs; + }) + .map((d) => ({ + t: ymdToMs(d.date), + temp: d.basalBodyTempC as number, + phaseHue: + rawChartMode || d.phase == null ? LINE_COLOR : PHASE_HUE[d.phase], + mucus: d.cervicalMucus, + ovulationTest: d.ovulationTest, + })) + .sort((a, b) => a.t - b.t); + }, [days, fromDate, today, rawChartMode]); + + const ovulationMs = useMemo(() => { + if (rawChartMode || !predictedOvulation) return null; + const ms = ymdToMs(predictedOvulation); + if (points.length < 2) return null; + const first = points[0].t; + const last = points[points.length - 1].t; + return ms >= first && ms <= last ? ms : null; + }, [predictedOvulation, points, rawChartMode]); + + const yDomain = useMemo<[number, number]>(() => { + if (points.length === 0) return [36, 37.5]; + const temps = points.map((p) => p.temp); + const min = Math.min(...temps); + const max = Math.max(...temps); + // Pad ±0.15 °C so the shift is legible and the line never hugs an edge. + return [ + Math.floor((min - 0.15) * 10) / 10, + Math.ceil((max + 0.15) * 10) / 10, + ]; + }, [points]); + + const ticks = useMemo(() => { + if (points.length < 2) return []; + const first = points[0].t; + const last = points[points.length - 1].t; + const span = last - first; + if (span <= 0) return [first]; + return [0, 0.33, 0.66, 1].map((f) => Math.round(first + span * f)); + }, [points]); + + const hasCurve = points.length >= 2; + + return ( + + + + + {t("cycle.bbt.title")} + + + + {!hasCurve ? ( +
+

{t("cycle.bbt.empty")}

+

{t("cycle.bbt.emptyHint")}

+
+ ) : ( + <> +

+ {t("cycle.bbt.caption")} +

+
+ + + + fmt.dateShort(new Date(v as number))} + tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} + tickLine={false} + axisLine={false} + /> + `${fmt.number(v as number)}°`} + /> + } + /> + {ovulationMs != null ? ( + + ) : null} + } + activeDot={{ r: 5 }} + isAnimationActive={animationsEnabled} + animationDuration={animationsEnabled ? 600 : 0} + animationEasing="ease-out" + /> + + +
+ + )} + + + ); +} + +/** A line dot tinted to its day's cycle phase (neutral in raw-chart mode). */ +function PhaseDot(props: { cx?: number; cy?: number; payload?: BbtPoint }) { + const { cx, cy, payload } = props; + if (cx == null || cy == null || !payload) return null; + return ( + + ); +} + +/** Tooltip restating the temperature + any fertility signs logged that day. */ +function BbtTooltip(props: { + active?: boolean; + payload?: { payload: BbtPoint }[]; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!props.active || !props.payload || props.payload.length === 0) + return null; + const p = props.payload[0].payload; + return ( +
+

{fmt.dateShort(new Date(p.t))}

+

+ {t("cycle.bbt.tooltipTemp", { temp: fmt.number(p.temp) })} +

+ {p.mucus ? ( +

+ {t("cycle.mucus.label")}: {t(`cycle.mucus.${p.mucus}`)} +

+ ) : null} + {p.ovulationTest && p.ovulationTest !== "NEGATIVE" ? ( +

+ {t("cycle.ovulationTest.label")}:{" "} + {t(`cycle.ovulationTest.${p.ovulationTest}`)} +

+ ) : null} +
+ ); +} diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx new file mode 100644 index 000000000..ff5dcb18c --- /dev/null +++ b/src/components/cycle/cycle-calendar.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { CalendarDay } from "./types"; +import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; + +/** + * v1.15.0 — the month calendar. + * + * Renders one month at a time over the calendar read's day grid. Logged + * period days carry a filled rose pip; the predicted next period renders as + * a ranged BAND (a soft rose underline spanning the predicted days, never a + * single dated dot); the fertile window (already goal-gated server-side — the + * API nulls it unless TRYING_TO_CONCEIVE) renders as a green ring; the + * predicted-ovulation day gets an amber marker; per-day symptom presence + * shows a small dot. Selecting a day calls `onSelectDay` (the log-day sheet). + * + * a11y: each day is a real ` + {monthLabel} + + + +
+ {weekdays.map((wk, i) => ( +
+ {wk} +
+ ))} + + {grid.map((cell, i) => { + if (!cell) return + + +
+ ); +} + +function CalendarLegend() { + const { t } = useTranslations(); + // Each swatch mirrors its grid affordance: period = filled, predicted = + // dashed, fertile = a ring (not a filled dot), ovulation = filled, symptom = + // the small grey marker dot the grid draws (QA M4). + const items: { + hue: string; + labelKey: string; + variant: "fill" | "dashed" | "ring" | "dot"; + }[] = [ + { hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" }, + { + hue: FLOW_HUE, + labelKey: "cycle.calendar.legendPredicted", + variant: "dashed", + }, + { + hue: FERTILE_HUE, + labelKey: "cycle.calendar.legendFertile", + variant: "ring", + }, + { + hue: OVULATION_HUE, + labelKey: "cycle.calendar.legendOvulation", + variant: "fill", + }, + { + hue: "var(--muted-foreground)", + labelKey: "cycle.calendar.legendSymptom", + variant: "dot", + }, + ]; + return ( +
    + {items.map((it) => ( +
  • +
  • + ))} +
+ ); +} diff --git a/src/components/cycle/cycle-disclaimer.tsx b/src/components/cycle/cycle-disclaimer.tsx new file mode 100644 index 000000000..371fcd9eb --- /dev/null +++ b/src/components/cycle/cycle-disclaimer.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +/** + * v1.15.0 — the fixed non-medical disclaimer for every prediction / fertility + * surface. Mirrors the GLP-1 drug-level-estimate disclaimer pattern + * (`DrugLevelChart`): a muted, border-left note that stays attached wherever + * an estimate renders. The copy is descriptive-not-a-contraceptive, + * not-medical-advice — never a green/safe claim. The text comes from the + * server (`cycle.prediction.disclaimer`, surfaced on the prediction DTO) so + * the web + iOS + the engine speak the same words. + */ +export function CycleDisclaimer({ + text, + className, +}: { + text: string; + className?: string; +}) { + return ( +

+ {text} +

+ ); +} diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx new file mode 100644 index 000000000..74b5377ac --- /dev/null +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.15 — CYCLE_PHASE × vital crosstab card. + * + * Mirrors the mood-factor × vital crosstab (`mood-factor-metric-crosstab.tsx`): + * for each outcome metric, the vital's mean on LUTEAL-phase days vs + * FOLLICULAR-phase days, the signed delta, n per group, q, and a confidence + * chip. The math is pre-computed in `src/lib/cycle/phase-crosstab.ts` + * (`computePhaseMetricCrosstab`) — the same Welch t-test + day floors + + * Benjamini-Hochberg FDR the rest of the relations surface uses; only + * FDR-surviving rows reach here. Observational only — the page-level + * "associations, not causes" footer carries the caveat once. This surface is + * gender-gated by its `/api/cycle/insights` route; phase never renders on the + * general correlations surface. + */ + +export type CyclePhaseCrosstabDisplay = + | "hours" + | "steps" + | "bpm" + | "ms" + | "kg" + | "celsius"; + +export type CyclePhaseCrosstabConfidence = "low" | "medium" | "high"; + +export interface CyclePhaseCrosstabRow { + metricKey: string; + display: CyclePhaseCrosstabDisplay; + lutealDays: number; + follicularDays: number; + lutealAvg: number; + follicularAvg: number; + delta: number; + pValue: number; + qValue: number; + confidence: CyclePhaseCrosstabConfidence; +} + +const METRIC_LABEL_KEY: Record = { + restingHeartRate: "cycle.insights.crosstab.metricRestingHeartRate", + heartRateVariability: "cycle.insights.crosstab.metricHrv", + sleepDuration: "cycle.insights.crosstab.metricSleepDuration", + steps: "cycle.insights.crosstab.metricSteps", + weight: "cycle.insights.crosstab.metricWeight", + basalBodyTemp: "cycle.insights.crosstab.metricBasalBodyTemp", + wristTemperature: "cycle.insights.crosstab.metricWristTemperature", + skinTemperature: "cycle.insights.crosstab.metricSkinTemperature", +}; + +const UNIT_KEY: Record = { + hours: "cycle.insights.crosstab.unitHours", + steps: "cycle.insights.crosstab.unitSteps", + bpm: "cycle.insights.crosstab.unitBpm", + ms: "cycle.insights.crosstab.unitMs", + kg: "cycle.insights.crosstab.unitKg", + celsius: "cycle.insights.crosstab.unitCelsius", +}; + +const CONFIDENCE_KEY: Record = { + low: "cycle.insights.crosstab.confidenceLow", + medium: "cycle.insights.crosstab.confidenceMedium", + high: "cycle.insights.crosstab.confidenceHigh", +}; + +const CONFIDENCE_CLASS: Record = { + low: "bg-secondary text-muted-foreground", + medium: "bg-info/15 text-info", + high: "bg-success/15 text-success", +}; + +/** Whole numbers for steps; one decimal otherwise. */ +function fmt(value: number, display: CyclePhaseCrosstabDisplay): string { + return display === "steps" ? Math.round(value).toString() : value.toFixed(1); +} + +const HEADLINE_KEY: Record = { + restingHeartRate: "cycle.insights.headline.restingHeartRate", + heartRateVariability: "cycle.insights.headline.heartRateVariability", +}; + +/** + * The single headline finding rendered prominently above the grid. Resting + * heart rate / HRV get bespoke phrasing; every other metric uses the generic + * line. Returns null when nothing cleared the FDR gate — an honest empty state. + */ +export function CyclePhaseHeadline({ + headline, +}: { + headline: CyclePhaseCrosstabRow | null; +}) { + const { t } = useTranslations(); + if (!headline) { + return ( +

+ {t("cycle.insights.headline.empty")} +

+ ); + } + const metricLabel = t( + METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey, + ); + const unit = t(UNIT_KEY[headline.display]); + const up = headline.delta >= 0; + const dir = t( + up + ? "cycle.insights.headline.dirHigher" + : "cycle.insights.headline.dirLower", + ); + const params = { + metric: metricLabel, + delta: fmt(Math.abs(headline.delta), headline.display), + unit, + dir, + }; + const key = + HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic"; + return ( +

+ {t(key, params)} +

+ ); +} + +export function CyclePhaseCrosstab({ + rows, +}: { + rows: CyclePhaseCrosstabRow[]; +}) { + const { t } = useTranslations(); + if (rows.length === 0) return null; + + return ( +
+

+ {t("cycle.insights.crosstab.description")} +

+
    + {rows.map((row) => { + const metricLabel = t( + METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, + ); + const unit = t(UNIT_KEY[row.display]); + // `delta` = lutealAvg − follicularAvg: positive = the vital runs + // higher in the luteal phase. The number stays NEUTRAL — higher is + // good for steps but bad for resting HR, and this board is + // observational, so colouring it would imply a verdict the data + // doesn't support. The sign prefix + `data-direction` carry it. + const up = row.delta >= 0; + const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + return ( +
  • +
    + + {t("cycle.insights.crosstab.pairLabel", { + metric: metricLabel, + })} + + + {deltaText} + + + {t(CONFIDENCE_KEY[row.confidence])} + +
    +

    + {t("cycle.insights.crosstab.detail", { + metric: metricLabel, + lutealAvg: fmt(row.lutealAvg, row.display), + follicularAvg: fmt(row.follicularAvg, row.display), + unit, + lutealDays: row.lutealDays, + follicularDays: row.follicularDays, + })} +

    +
  • + ); + })} +
+

+ {t("cycle.insights.crosstab.footer")} +

+
+ ); +} diff --git a/src/components/cycle/cycle-ring.tsx b/src/components/cycle/cycle-ring.tsx new file mode 100644 index 000000000..21e94632d --- /dev/null +++ b/src/components/cycle/cycle-ring.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { useCountUp } from "@/hooks/use-count-up"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import type { CyclePhase } from "./types"; +import { PHASE_HUE } from "./phase-tokens"; + +/** + * v1.15.0 — the cycle wheel: a hand-rolled SVG phase ring (0 KB runtime, no + * chart lib) that carries the same premium "signature reveal" as the wellness + * rings while keeping its own identity as a FULL-circle four-phase dial. The + * four cycle phases render as proportional arc segments around the ring; a + * marker dot sits at the current day-of-cycle; the day number is real centred + * DOM text. + * + * Motion (all CSS-first, all gated on `prefers-reduced-motion` AND on the + * host's `data-revealed`, which the view sets ONCE per session so a background + * calendar refetch never re-triggers the moment): + * • a clockwise CLOCK-WIPE (`.cycle-ring-reveal`) draws the arcs on from the + * 12-o'clock day-0 origin — the already carries `-rotate-90`, so the + * conic mask origin lines up with both the arcs and the marker; + * • the active phase arc carries the shared `.wellness-ring-arc` bloom, + * `--ring-glow` set inline to the active phase hue; + * • the shared `.wellness-ring-sheen` sweeps a single specular pass; + * • the day number eases up with `useCountUp`; + * • the marker gets a soft `.cycle-ring-marker-halo` pulse. + * + * a11y: `role="img"` + a phase-aware aria-label so the ring is never + * colour-only. The arcs/marker are real SVG JSX (`` would not + * be `dangerouslySetInnerHTML`); the centred number is real DOM text. + */ + +const VIEW = 100; +const STROKE = 8; +const R = (VIEW - STROKE) / 2; +const C = 2 * Math.PI * R; +const CX = VIEW / 2; +const GAP = 0.5; // unit gap between segments so phases read as distinct arcs. + +const PHASE_ORDER: CyclePhase[] = [ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]; + +export interface PhaseSpan { + phase: CyclePhase; + /** Proportion of the cycle this phase covers (0..1). Spans sum to ~1. */ + fraction: number; +} + +export interface CycleRingProps { + /** 1-based day of the current cycle, or null when no cycle is active. */ + dayOfCycle: number | null; + /** Total length the ring represents (estimated cycle length, days). */ + cycleLength: number | null; + /** The current phase (drives the centre label + aria). */ + phase: CyclePhase | null; + /** Proportional phase spans around the ring. Omit for an even quarter split. */ + spans?: PhaseSpan[]; + /** Whether the once-per-session reveal plays (false = paint final). */ + animate?: boolean; + size?: number; + className?: string; +} + +function evenSpans(): PhaseSpan[] { + return PHASE_ORDER.map((phase) => ({ phase, fraction: 0.25 })); +} + +export function CycleRing({ + dayOfCycle, + cycleLength, + phase, + spans, + animate = false, + size = 220, + className, +}: CycleRingProps) { + const { t } = useTranslations(); + const reduced = prefersReducedMotion(); + const shouldAnimate = animate && !reduced; + + const resolvedSpans = useMemo(() => { + const raw = spans && spans.length > 0 ? spans : evenSpans(); + const total = raw.reduce((s, x) => s + x.fraction, 0) || 1; + return raw.map((x) => ({ ...x, fraction: x.fraction / total })); + }, [spans]); + + // Build each phase arc as a dash segment around the ring. The running + // start-offset is carried through `reduce` rather than mutating a closure + // variable inside `map` (the latter trips the render-immutability rule). + const segments = useMemo( + () => + resolvedSpans.reduce<{ + offset: number; + segs: { + phase: CyclePhase; + dashArray: string; + dashOffset: number; + active: boolean; + }[]; + }>( + (acc, span) => { + const len = Math.max(span.fraction * C - GAP, 0); + acc.segs.push({ + phase: span.phase, + dashArray: `${len} ${C - len}`, + dashOffset: -acc.offset, + active: span.phase === phase, + }); + return { + offset: acc.offset + span.fraction * C, + segs: acc.segs, + }; + }, + { offset: 0, segs: [] }, + ).segs, + [resolvedSpans, phase], + ); + + // The marker sits at the cycle-day proportion around the circle. The + // surrounding already carries `-rotate-90`, so 0 rad here maps to + // 12 o'clock — day 0 must NOT subtract another π/2 (the prior double-rotate + // bug parked the marker at 9 o'clock). Day 1 is treated as the 12-o'clock + // origin (1-based dayOfCycle → 0-based progress). + const markerProgress = + dayOfCycle != null && cycleLength != null && cycleLength > 0 + ? Math.min((dayOfCycle - 1) / cycleLength, 1) + : null; + const markerAngle = + markerProgress != null ? markerProgress * 2 * Math.PI : null; + const markerX = markerAngle != null ? CX + R * Math.cos(markerAngle) : 0; + const markerY = markerAngle != null ? CX + R * Math.sin(markerAngle) : 0; + + // The day number eases up on the reveal, mirroring the score-ring count-up. + const displayedDay = useCountUp(dayOfCycle ?? 0, { + enabled: shouldAnimate && dayOfCycle != null, + durationMs: 900, + startDelayMs: 220, + }); + + const phaseLabel = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none"); + + const ariaLabel = + dayOfCycle != null + ? t("cycle.ring.ariaPhase", { day: dayOfCycle, phase: phaseLabel }) + : t("cycle.ring.ariaUnknown"); + + const markerHue = phase ? PHASE_HUE[phase] : "var(--foreground)"; + + return ( +
+ + {/* Conic specular sheen — sweeps the dial once on the reveal. */} + + ); +} diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx new file mode 100644 index 000000000..e93df3d6f --- /dev/null +++ b/src/components/cycle/cycle-settings.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Loader2 } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Separator } from "@/components/ui/separator"; +import { useTranslations } from "@/lib/i18n/context"; +import type { CycleGoal, CycleProfileDTO } from "./types"; +import { useUpdateCyclePrefs, useDeleteAllCycleData } from "./use-cycle"; + +/** + * v1.15.0 — the single-page cycle settings form. + * + * One dynamic form, no top/bottom split (the project's settings-UX rule): + * the inclusive goal selector drives all copy — fertile-window language only + * appears for TRYING_TO_CONCEIVE; GENERAL_HEALTH / PERIMENOPAUSE / OFF hide + * conception framing. Priors, raw-chart mode, sensitive-category encryption, + * discreet notifications, and the data export/delete affordances all live on + * the one page. PATCHes `/api/auth/me/cycle-prefs`. + * + * The form seeds its local state from `profile` once on mount; the parent + * passes `key={profile.updatedAt}` so a profile refetch remounts the form + * with the fresh values rather than syncing through an effect. + */ + +const GOALS: CycleGoal[] = [ + "GENERAL_HEALTH", + "AVOID_PREGNANCY", + "TRYING_TO_CONCEIVE", + "PERIMENOPAUSE", + "OFF", +]; + +function numOrEmpty(v: number | null): string { + return v == null ? "" : String(v); +} + +export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { + const { t } = useTranslations(); + const update = useUpdateCyclePrefs(); + const purge = useDeleteAllCycleData(); + const [confirmingPurge, setConfirmingPurge] = useState(false); + + const [goal, setGoal] = useState(profile.goal); + const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode); + const [predictionEnabled, setPredictionEnabled] = useState( + profile.predictionEnabled, + ); + const [encryption, setEncryption] = useState( + profile.sensitiveCategoryEncryption, + ); + const [discreet, setDiscreet] = useState(profile.discreetNotifications); + const [cycleLen, setCycleLen] = useState( + numOrEmpty(profile.typicalCycleLength), + ); + const [periodLen, setPeriodLen] = useState( + numOrEmpty(profile.typicalPeriodLength), + ); + const [lutealLen, setLutealLen] = useState( + numOrEmpty(profile.lutealPhaseLength), + ); + + function parsePrior(v: string): number | null { + const n = Number(v); + return v.trim() === "" || !Number.isFinite(n) ? null : Math.round(n); + } + + async function handleSave() { + await update.mutateAsync({ + goal, + rawChartMode, + predictionEnabled, + sensitiveCategoryEncryption: encryption, + discreetNotifications: discreet, + typicalCycleLength: parsePrior(cycleLen), + typicalPeriodLength: parsePrior(periodLen), + lutealPhaseLength: parsePrior(lutealLen), + }); + } + + return ( + + + {t("cycle.settings.title")} + {t("cycle.goal.description")} + + + {/* Inclusive goal selector — drives the rest of the copy. */} +
+ + setGoal(e.target.value as CycleGoal)} + > + {GOALS.map((g) => ( + + ))} + +
+ + + + {/* Predictions — when the goal hides conception framing, the + description never names a fertile window. */} + + + + + + {/* Priors */} +
+

+ {t("cycle.settings.priorsDescription")} +

+
+ + + +
+
+ + + + {/* Privacy toggles */} + + + + + + {/* Reminders — the per-channel toggles live on the notifications page; + point users there rather than duplicating the toggle state here. */} +
+
+

+ {t("cycle.settings.reminders")} +

+

+ {t("cycle.settings.remindersDescription")} +

+
+ +
+ + + + {/* Data export / delete */} +
+

{t("cycle.settings.dataTitle")}

+

+ {t("cycle.settings.dataDescription")} +

+
+ + +
+ + {/* Cycle-only hard purge (post-Dobbs threat model): removes every + cycle row + the cycle audit trail + reminder ledger, distinct + from the account-level delete. Two-step confirm — no accidental + destructive tap. */} +
+

+ {t("cycle.settings.purgeDescription")} +

+ {confirmingPurge ? ( +
+ + {t("cycle.settings.purgeConfirm")} + + + +
+ ) : ( + + )} + {purge.isSuccess ? ( + + {t("cycle.settings.purgeDone")} + + ) : null} + {purge.isError ? ( + + {t("cycle.settings.purgeError")} + + ) : null} +
+
+ +
+ {update.isSuccess ? ( + + {t("cycle.settings.saved")} + + ) : null} + {update.isError ? ( + + {t("cycle.settings.saveError")} + + ) : null} + +
+
+
+ ); +} + +function ToggleRow({ + id, + label, + description, + checked, + onChange, +}: { + id: string; + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+ +

{description}

+
+ +
+ ); +} + +function PriorField({ + id, + label, + value, + onChange, + min, + max, +}: { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + min: number; + max: number; +}) { + return ( +
+ + onChange(e.target.value)} + className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none" + /> +
+ ); +} diff --git a/src/components/cycle/cycle-symptom-patterns.tsx b/src/components/cycle/cycle-symptom-patterns.tsx new file mode 100644 index 000000000..84a4e2a2a --- /dev/null +++ b/src/components/cycle/cycle-symptom-patterns.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useMemo } from "react"; +import { Activity } from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; +import { PHASE_HUE } from "./phase-tokens"; +import type { CyclePhase } from "./types"; +import type { SymptomPhaseRow } from "./use-cycle"; + +/** + * v1.15.0 — symptom patterns by phase (the cycle-NATIVE insight). + * + * The phase×vitals crosstab correlates the cycle against your measurements; + * this card answers the complementary, cycle-own question: which of your logged + * symptoms cluster in which phase. Each row shows the symptom's per-phase + * distribution as a proportional 4-segment bar (phase-hued, never colour-only — + * the dominant phase is named in words) plus "mostly in your luteal phase + * (7 of 9 days)". Observational, never causal; only symptoms logged on ≥3 + * phase-labelled days surface (the server floor). + */ + +const PHASE_ORDER: CyclePhase[] = [ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]; + +export interface CycleSymptomPatternsProps { + rows: SymptomPhaseRow[]; +} + +export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) { + const { t } = useTranslations(); + + // Flatten the catalog once: symptom key → { labelKey, icon }. + const byKey = useMemo(() => { + const map = new Map< + string, + { + labelKey: string; + icon: (typeof CYCLE_SYMPTOM_CATALOG)[number]["symptoms"][number]["icon"]; + } + >(); + for (const cat of CYCLE_SYMPTOM_CATALOG) { + for (const s of cat.symptoms) { + map.set(s.key, { labelKey: s.labelKey, icon: s.icon }); + } + } + return map; + }, []); + + return ( + + + + + + + {rows.length === 0 ? ( +

+ {t("cycle.insights.symptomPatternsEmpty")} +

+ ) : ( +
    + {rows.map((row) => { + const meta = byKey.get(row.symptomKey); + const Icon = meta?.icon; + const name = meta ? t(meta.labelKey) : row.symptomKey; + const phaseName = t(`cycle.phase.${row.topPhase}`); + return ( +
  • +
    + + {Icon ? ( + + + {t("cycle.insights.symptomMostlyIn", { + phase: phaseName, + count: row.counts[row.topPhase], + total: row.total, + })} + +
    + {/* Proportional phase-distribution bar — phase-hued, with the + dominant phase named above so it is never colour-only. */} +
    + {PHASE_ORDER.map((phase) => { + const share = + row.total > 0 ? row.counts[phase] / row.total : 0; + if (share <= 0) return null; + return ( + + ); + })} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx new file mode 100644 index 000000000..1b29562da --- /dev/null +++ b/src/components/cycle/cycle-view.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader2, Plus, RefreshCw } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { CycleRing } from "./cycle-ring"; +import { BbtChart } from "./bbt-chart"; +import { CycleCalendar } from "./cycle-calendar"; +import { CycleDisclaimer } from "./cycle-disclaimer"; +import { LogDaySheet } from "./log-day-sheet"; +import { PredictionsPanel } from "./predictions-panel"; +import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab"; +import { CycleSymptomPatterns } from "./cycle-symptom-patterns"; +import { CycleSettings } from "./cycle-settings"; +import { deriveWheelState } from "./wheel-state"; +import { PHASE_HUE } from "./phase-tokens"; +import { + localYmd, + useCycleCalendar, + useCycleHistory, + useCycleInsights, + useCycleProfile, +} from "./use-cycle"; + +/** + * v1.15.0 — the cycle vertical's client orchestrator. + * + * Holds the four tabs (Calendar · Predictions · Insights · Settings), the + * wheel above the tab strip, and the log-day sheet. The calendar read drives + * the ring (day-of-cycle + phase), the grid, and the BBT chart; the + * predictions read powers the panel; history powers the stats. The page-level + * RSC has already gated on `cycleTrackingEnabled`. + * + * The wheel earns the once-per-session "signature reveal" (sweep + glow + + * sheen + count-up): a `data-revealed` flag set on first visit drives the CSS + * keyframes, gated by sessionStorage so a background calendar refetch never + * re-triggers the moment. + */ + +/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */ +function shiftToday(days: number): string { + const d = new Date(); + d.setDate(d.getDate() + days); + return localYmd(d); +} + +export function CycleView() { + const { t } = useTranslations(); + + const today = useMemo(() => shiftToday(0), []); + const from = useMemo(() => shiftToday(-90), []); + const to = useMemo(() => shiftToday(180), []); + + const calendar = useCycleCalendar(from, to); + const history = useCycleHistory(); + const profileQuery = useCycleProfile(); + const insights = useCycleInsights(); + + const [sheetOpen, setSheetOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(today); + + // The signature reveal plays ONCE per browser session (mirrors the wellness + // strip): a background calendar refetch after a quick-log would otherwise + // replay the sweep/sheen and read as janky. + const [play] = useState(() => { + if (typeof window === "undefined") return false; + try { + if (sessionStorage.getItem("cycle-wheel-revealed")) return false; + sessionStorage.setItem("cycle-wheel-revealed", "1"); + return true; + } catch { + return true; + } + }); + + const wheel = useMemo( + () => deriveWheelState(calendar.data?.days ?? [], today), + [calendar.data, today], + ); + + function openSheet(date: string) { + setSelectedDate(date); + setSheetOpen(true); + } + + const loading = calendar.isLoading && !calendar.data; + const calendarError = calendar.isError && !calendar.data; + const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined; + // AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger + // "not a contraceptive method" caveat. Prefer the server-resolved + // prediction.disclaimer (already goal-correct); fall back to the goal-derived + // key for the no-prediction calendar tab (QA H-1). + const goal = calendar.data?.profile.goal; + const disclaimerText = + calendar.data?.prediction?.disclaimer ?? + t( + goal === "AVOID_PREGNANCY" + ? "cycle.disclaimer" + : "cycle.prediction.disclaimer", + ); + + return ( +
+
+
+

+ {t("cycle.title")} +

+

+ {t("cycle.subtitle")} +

+
+ +
+ + {/* Desktop: ring (left) + tabs (right). Single column below lg. */} +
+ {/* Wheel — the signature ring on the premium wellness-tile surface. */} +
+ {loading ? ( +
+ +
+ ) : calendarError ? ( +
+

+ {t("cycle.loadError")} +

+ +
+ ) : ( + + )} + {!calendarError ? ( +

+ {t("cycle.ring.caption")} +

+ ) : null} + {/* First-period CTA — only when no cycle is active yet. */} + {!loading && !calendarError && wheel.dayOfCycle == null ? ( + + ) : null} +
+ + + + + {t("cycle.tabs.calendar")} + + + {t("cycle.tabs.predictions")} + + + {t("cycle.tabs.insights")} + + + {t("cycle.tabs.settings")} + + + + + + + {loading ? ( +

+ {t("cycle.calendar.loading")} +

+ ) : calendarError ? ( + void calendar.refetch()} /> + ) : ( + + )} +
+
+ {!loading && !calendarError ? ( + + ) : null} +
+ + + {calendarError ? ( + + + void calendar.refetch()} /> + + + ) : ( + <> + + + + )} + + + + {insights.isError ? ( + + + void insights.refetch()} /> + + + ) : ( + <> + + + + + )} + + + + {profileQuery.isError ? ( + + + void profileQuery.refetch()} /> + + + ) : profileQuery.isLoading ? ( +
+ +
+ ) : profileQuery.data ? ( + + ) : null} +
+
+
+ + +
+ ); +} + +/** A compact in-place error + Retry for a failed tab read. */ +function TabError({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslations(); + return ( +
+ {t("cycle.loadError")} + +
+ ); +} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx new file mode 100644 index 000000000..2ef9e9eb8 --- /dev/null +++ b/src/components/cycle/log-day-sheet.tsx @@ -0,0 +1,651 @@ +"use client"; + +import { useState } from "react"; +import { Droplets, Loader2, Trash2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; +import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; +import { FLOW_HUE } from "./phase-tokens"; +import { + useCycleDayLog, + useDeleteDayLog, + useEndPeriod, + useLogDay, + usePatchDayLog, + useStartPeriod, + type CycleDayLogPatch, +} from "./use-cycle"; +import type { + CervicalMucus, + ContraceptiveKind, + CycleDayLogInput, + CycleSymptomSelection, + FlowLevel, + HomeTestResult, + OvulationTest, +} from "./types"; + +/** + * v1.15.0 — the fast-entry log-day sheet. + * + * One-tap "started my period today" posts the period-boundary shortcut; the + * detailed form posts a single day-log (flow, symptoms, BBT, OPK, mucus, + * intercourse + protection, free-text note). The write is optimistic-ish + * (the mutation invalidates the cycle keys on success so the calendar + + * wheel repaint); the symptom chips reuse the mood-tag chip styling. + * + * a11y: every chip is a real ` + ); +} + +export function LogDaySheet({ + open, + onOpenChange, + date, + activePeriod = false, +}: LogDaySheetProps) { + const { t } = useTranslations(); + const logDay = useLogDay(); + const patchDay = usePatchDayLog(); + const startPeriod = useStartPeriod(); + const endPeriod = useEndPeriod(); + const deleteDay = useDeleteDayLog(); + // Pre-fill from the persisted day-log so a re-log never nulls untouched + // (or HealthKit-sourced) fields — the v1.15 web data-loss fix. + const dayLog = useCycleDayLog(open ? date : null); + + const [flow, setFlow] = useState(null); + const [intermenstrual, setIntermenstrual] = useState(false); + const [symptoms, setSymptoms] = useState>( + new Map(), + ); + const [bbt, setBbt] = useState(""); + const [opk, setOpk] = useState(null); + const [mucus, setMucus] = useState(null); + const [intercourse, setIntercourse] = useState(false); + const [protectedSex, setProtectedSex] = useState(false); + const [pregnancyTest, setPregnancyTest] = useState( + null, + ); + const [progesteroneTest, setProgesteroneTest] = + useState(null); + const [contraceptive, setContraceptive] = useState( + null, + ); + const [note, setNote] = useState(""); + const [rowId, setRowId] = useState(null); + + // Hydrate the form from the fetched DTO once per (open, date, fetched-row) + // tuple. Tracked as an "adjust state when a prop changes" during render + // (React's recommended alternative to a setState-in-effect) keyed on the + // open+date+row-identity, so a re-open or a late-arriving fetch repaints the + // controls exactly once. `dto === null` (nothing logged) resets to blank. + const dto = open ? (dayLog.data ?? null) : undefined; + const formKey = open + ? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}` + : null; + const [lastFormKey, setLastFormKey] = useState(null); + if (open && formKey !== lastFormKey && !dayLog.isLoading) { + setLastFormKey(formKey); + setRowId(dto?.id ?? null); + setFlow((dto?.flow ?? null) as FlowLevel | null); + setIntermenstrual(dto?.intermenstrualBleeding ?? false); + setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity]))); + setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : ""); + setOpk((dto?.ovulationTest ?? null) as OvulationTest | null); + setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null); + setIntercourse(dto?.sexualActivity ?? false); + setProtectedSex(dto?.protectedSex ?? false); + setPregnancyTest(dto?.pregnancyTest ?? null); + setProgesteroneTest(dto?.progesteroneTest ?? null); + setContraceptive(dto?.contraceptive ?? null); + setNote(dto?.note ?? ""); + } else if (!open && lastFormKey !== null) { + setLastFormKey(null); + } + + function toggleSymptom(key: string) { + setSymptoms((prev) => { + const next = new Map(prev); + if (next.has(key)) next.delete(key); + else next.set(key, null); + return next; + }); + } + + function setSymptomSeverity(key: string, severity: number) { + setSymptoms((prev) => { + const next = new Map(prev); + // Tapping the active level clears it back to a plain presence link. + next.set(key, next.get(key) === severity ? null : severity); + return next; + }); + } + + async function handleSave() { + const state: DayLogFormState = { + flow, + intermenstrual, + bbt, + opk, + mucus, + intercourse, + protectedSex, + pregnancyTest, + progesteroneTest, + contraceptive, + note, + symptoms, + }; + if (rowId) { + // Editing an existing row → PATCH with explicit nulls so a deselected + // chip actually CLEARS (the POST merge can only add/keep — QA W-2). + await patchDay.mutateAsync({ id: rowId, patch: buildDayLogPatch(state) }); + onOpenChange(false); + return; + } + await logDay.mutateAsync(buildDayLogInput(state, date)); + onOpenChange(false); + } + + // Period boundaries operate on the SELECTED date, so a forgotten day-1 can be + // corrected from the calendar retroactively (QA M3) — not today-only. + async function handleStartPeriod() { + await startPeriod.mutateAsync(date); + onOpenChange(false); + } + + async function handleEndPeriod() { + await endPeriod.mutateAsync(date); + onOpenChange(false); + } + + async function handleDelete() { + if (!rowId) return; + await deleteDay.mutateAsync(rowId); + onOpenChange(false); + } + + const busy = + logDay.isPending || + patchDay.isPending || + startPeriod.isPending || + endPeriod.isPending || + deleteDay.isPending; + const saving = logDay.isPending || patchDay.isPending; + + return ( + + {rowId ? ( + + ) : null} + + + + } + > + {/* One-tap period boundaries — available on any selected date so a + forgotten day-1 can be corrected retroactively (M3). "End period" + shows only while a period is actually open (M2). */} +
+ + {activePeriod ? ( + + ) : null} +
+ + {/* Flow */} + +
+ {FLOW_LEVELS.map((f) => ( + setFlow((cur) => (cur === f ? null : f))} + > + {t(`cycle.flow.${f}`)} + + ))} +
+
+ + +
+
+ + {/* Symptoms */} + +
+ {CYCLE_SYMPTOM_CATALOG.map((cat) => ( +
+
+
+
+ {cat.symptoms.map((s) => { + const active = symptoms.has(s.key); + const sev = symptoms.get(s.key) ?? null; + return ( +
+ toggleSymptom(s.key)} + > + + + + {active ? ( +
+ {SEVERITY_LEVELS.map((lvl) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+
+ ))} +
+
+ + {/* BBT */} + + setBbt(e.target.value)} + placeholder={t("cycle.sheet.temperaturePlaceholder")} + className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none" + aria-label={t("cycle.sheet.temperature")} + /> + + + {/* Ovulation test */} + +
+ {OPK_VALUES.map((v) => ( + setOpk((cur) => (cur === v ? null : v))} + > + {t(`cycle.ovulationTest.${v}`)} + + ))} +
+
+ + {/* Cervical mucus */} + +
+ {MUCUS_VALUES.map((v) => ( + setMucus((cur) => (cur === v ? null : v))} + > + {t(`cycle.mucus.${v}`)} + + ))} +
+
+ + {/* Intercourse + protection */} + +
+
+ + +
+ {intercourse ? ( +
+ + +
+ ) : null} +
+
+ + {/* Pregnancy test */} + +
+ {TEST_RESULTS.map((v) => ( + setPregnancyTest((cur) => (cur === v ? null : v))} + > + {t(`cycle.testResult.${v}`)} + + ))} +
+
+ + {/* Progesterone test */} + +
+ {TEST_RESULTS.map((v) => ( + + setProgesteroneTest((cur) => (cur === v ? null : v)) + } + > + {t(`cycle.testResult.${v}`)} + + ))} +
+
+ + {/* Contraceptive */} + +
+ {CONTRACEPTIVE_KINDS.map((v) => ( + setContraceptive((cur) => (cur === v ? null : v))} + > + {t(`cycle.contraceptive.${v}`)} + + ))} +
+
+ + {/* Note */} + +