From 4b10bdc5da1d3ab9874cff4f9d4f9216cbe0f7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 12:39:28 +0200 Subject: [PATCH] feat(cycle): single-day read endpoint, symptom severity, and a richer log-day sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `GET /api/cycle/day-logs?date=YYYY-MM-DD` returning the full day-log DTO (or null) for one tz-anchored day so the web log-day sheet pre-fills instead of opening blank — a blank re-log no longer nulls untouched or HealthKit-sourced fields. Delete resolves the row id from the same read. Persist an optional 1-4 Likert severity per symptom link (migration 0133_v1150_cycle_symptom_severity), carried through the shared symptom-link writer and surfaced in the day-log DTO. Sheet now captures the previously missing fields — spotting between periods, pregnancy/progesterone home-test results, contraceptive kind — plus per-symptom severity chips, a one-tap end-period boundary, and a Delete action. The wheel ring marker sits at the 12-o'clock origin again (drop the double rotation), the cycle view goes two-column on desktop, and the empty ring carries a first-period call to action. New copy is localized across all six locales; the prediction and fertility surfaces keep their disclaimers. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 129 +++++--- messages/de.json | 28 +- messages/en.json | 26 +- messages/es.json | 28 +- messages/fr.json | 28 +- messages/it.json | 28 +- messages/pl.json | 28 +- .../migration.sql | 3 + prisma/schema.prisma | 4 + src/app/api/cycle/day-logs/[id]/route.ts | 6 +- src/app/api/cycle/day-logs/route.ts | 39 ++- .../cycle/__tests__/cycle-ring.test.tsx | 12 + src/components/cycle/cycle-ring.tsx | 11 +- src/components/cycle/cycle-view.tsx | 24 +- src/components/cycle/log-day-sheet.tsx | 312 +++++++++++++++--- src/components/cycle/types.ts | 48 ++- src/components/cycle/use-cycle.ts | 67 ++++ src/lib/cycle/day-log-write.ts | 50 ++- src/lib/cycle/dto.ts | 7 +- src/lib/openapi/routes.ts | 23 ++ src/lib/query-keys.ts | 1 + src/lib/validations/cycle.ts | 3 + tests/integration/cycle-routes.test.ts | 84 +++++ 23 files changed, 846 insertions(+), 143 deletions(-) create mode 100644 prisma/migrations/0133_v1150_cycle_symptom_severity/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 575890eda..325097f05 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -42,6 +42,50 @@ tags: - 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 @@ -166,30 +210,10 @@ paths: application/json: schema: $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" - "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" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 /api/cycle/day-logs/{id}: patch: tags: @@ -4728,11 +4752,13 @@ 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. - CycleDayLogEnvelope: + CycleDayLogReadEnvelope: type: object properties: data: - $ref: "#/components/schemas/CycleDayLogDTO" + anyOf: + - $ref: "#/components/schemas/CycleDayLogDTO" + - type: "null" error: type: "null" meta: @@ -4882,23 +4908,6 @@ components: additionalProperties: false description: The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones. - 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 ErrorEnvelope: type: object properties: @@ -4919,6 +4928,40 @@ components: - 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: diff --git a/messages/de.json b/messages/de.json index cdbfcc74b..c870b625a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5008,7 +5008,8 @@ "dayUnknown": "—", "ariaPhase": "Zyklustag {day}, {phase}-Phase", "ariaUnknown": "Noch kein aktiver Zyklus", - "caption": "deines Zyklus" + "caption": "deines Zyklus", + "firstPeriodCta": "Erste Periode erfassen" }, "calendar": { "legendPeriod": "Erfasste Periode", @@ -5073,7 +5074,14 @@ "saved": "Tag gespeichert", "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", "periodStarted": "Periodenbeginn erfasst", - "clear": "Keine" + "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", @@ -5107,6 +5115,22 @@ "purgeCancel": "Abbrechen", "purgeSuccess": "Alle Zyklusdaten gelöscht.", "purgeError": "Zyklusdaten konnten nicht gelöscht werden. Bitte erneut versuchen." + }, + "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" } }, "cycleReminders": { diff --git a/messages/en.json b/messages/en.json index 61bb42df8..b2cf299bd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5008,7 +5008,8 @@ "dayUnknown": "—", "ariaPhase": "Cycle day {day}, {phase} phase", "ariaUnknown": "No active cycle yet", - "caption": "of your cycle" + "caption": "of your cycle", + "firstPeriodCta": "Log my first period" }, "calendar": { "legendPeriod": "Logged period", @@ -5065,6 +5066,13 @@ "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", @@ -5075,6 +5083,11 @@ "periodStarted": "Period start logged", "clear": "None" }, + "testResult": { + "POSITIVE": "Positive", + "NEGATIVE": "Negative", + "INDETERMINATE": "Unclear" + }, "settings": { "title": "Cycle settings", "enable": "Cycle tracking", @@ -5107,6 +5120,17 @@ "purgeCancel": "Cancel", "purgeSuccess": "All cycle data deleted.", "purgeError": "Could not delete cycle data. Try again." + }, + "contraceptive": { + "NONE": "None", + "UNSPECIFIED": "Other", + "IMPLANT": "Implant", + "INJECTION": "Injection", + "IUD": "IUD", + "INTRAVAGINAL_RING": "Ring", + "ORAL": "Pill", + "PATCH": "Patch", + "EMERGENCY": "Emergency" } }, "cycleReminders": { diff --git a/messages/es.json b/messages/es.json index 842109078..0e583048c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5008,7 +5008,8 @@ "dayUnknown": "—", "ariaPhase": "Día {day} del ciclo, fase {phase}", "ariaUnknown": "Aún no hay ciclo activo", - "caption": "de tu ciclo" + "caption": "de tu ciclo", + "firstPeriodCta": "Registrar mi primer periodo" }, "calendar": { "legendPeriod": "Periodo registrado", @@ -5073,7 +5074,14 @@ "saved": "Día guardado", "saveError": "No se pudo guardar. Inténtalo de nuevo.", "periodStarted": "Inicio del periodo registrado", - "clear": "Ninguno" + "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", @@ -5107,6 +5115,22 @@ "purgeCancel": "Cancelar", "purgeSuccess": "Todos los datos del ciclo eliminados.", "purgeError": "No se pudieron eliminar los datos del ciclo. Inténtalo de nuevo." + }, + "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" } }, "cycleReminders": { diff --git a/messages/fr.json b/messages/fr.json index 7d6d9e5f3..3d2feb18e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5008,7 +5008,8 @@ "dayUnknown": "—", "ariaPhase": "Jour {day} du cycle, phase {phase}", "ariaUnknown": "Pas encore de cycle actif", - "caption": "de ton cycle" + "caption": "de ton cycle", + "firstPeriodCta": "Enregistrer mes premières règles" }, "calendar": { "legendPeriod": "Règles enregistrées", @@ -5073,7 +5074,14 @@ "saved": "Journée enregistrée", "saveError": "Enregistrement impossible. Réessaie.", "periodStarted": "Début des règles enregistré", - "clear": "Aucun" + "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", @@ -5107,6 +5115,22 @@ "purgeCancel": "Annuler", "purgeSuccess": "Toutes les données de cycle ont été supprimées.", "purgeError": "Impossible de supprimer les données de cycle. Réessayez." + }, + "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" } }, "cycleReminders": { diff --git a/messages/it.json b/messages/it.json index 9bfa8463d..4bc527c12 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5008,7 +5008,8 @@ "dayUnknown": "—", "ariaPhase": "Giorno {day} del ciclo, fase {phase}", "ariaUnknown": "Nessun ciclo attivo ancora", - "caption": "del tuo ciclo" + "caption": "del tuo ciclo", + "firstPeriodCta": "Registra il mio primo ciclo" }, "calendar": { "legendPeriod": "Ciclo registrato", @@ -5073,7 +5074,14 @@ "saved": "Giornata salvata", "saveError": "Impossibile salvare. Riprova.", "periodStarted": "Inizio del ciclo registrato", - "clear": "Nessuno" + "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", @@ -5107,6 +5115,22 @@ "purgeCancel": "Annulla", "purgeSuccess": "Tutti i dati del ciclo eliminati.", "purgeError": "Impossibile eliminare i dati del ciclo. Riprova." + }, + "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" } }, "cycleReminders": { diff --git a/messages/pl.json b/messages/pl.json index bb141b4c8..621243ea6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5008,7 +5008,8 @@ "dayUnknown": "—", "ariaPhase": "Dzień {day} cyklu, faza {phase}", "ariaUnknown": "Brak aktywnego cyklu", - "caption": "twojego cyklu" + "caption": "twojego cyklu", + "firstPeriodCta": "Zapisz pierwszą miesiączkę" }, "calendar": { "legendPeriod": "Zapisana miesiączka", @@ -5073,7 +5074,14 @@ "saved": "Dzień zapisany", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", "periodStarted": "Zapisano początek miesiączki", - "clear": "Brak" + "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", @@ -5107,6 +5115,22 @@ "purgeCancel": "Anuluj", "purgeSuccess": "Usunięto wszystkie dane cyklu.", "purgeError": "Nie udało się usunąć danych cyklu. Spróbuj ponownie." + }, + "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" } }, "cycleReminders": { 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 ab1844a26..13989e06f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3539,6 +3539,10 @@ model CycleSymptom { 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) diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index a9d41ff18..0795d599b 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -139,11 +139,7 @@ export const PATCH = apiHandler( // Replace symptom links only when `symptoms` was supplied. if (body.symptoms !== undefined) { - await replaceSymptomLinks( - user.id, - id, - body.symptoms.map((s) => s.key), - ); + await replaceSymptomLinks(user.id, id, body.symptoms); } const row = await prisma.cycleDayLog.findUniqueOrThrow({ diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 9a0c62fa0..91a7c0f09 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -26,7 +26,10 @@ import { } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { requireCycleEnabled } from "@/lib/cycle/gate"; -import { cycleDayLogInputSchema } from "@/lib/validations/cycle"; +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"; @@ -34,6 +37,40 @@ 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(); diff --git a/src/components/cycle/__tests__/cycle-ring.test.tsx b/src/components/cycle/__tests__/cycle-ring.test.tsx index 4cb0073de..25c99a31a 100644 --- a/src/components/cycle/__tests__/cycle-ring.test.tsx +++ b/src/components/cycle/__tests__/cycle-ring.test.tsx @@ -46,6 +46,18 @@ describe("", () => { 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( , diff --git a/src/components/cycle/cycle-ring.tsx b/src/components/cycle/cycle-ring.tsx index 051ad38c4..2f8d24824 100644 --- a/src/components/cycle/cycle-ring.tsx +++ b/src/components/cycle/cycle-ring.tsx @@ -109,13 +109,16 @@ export function CycleRing({ [resolvedSpans, phase], ); - // The marker sits at the cycle-day proportion around the circle. + // 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 / cycleLength, 1) + ? Math.min((dayOfCycle - 1) / cycleLength, 1) : null; - const markerAngle = - markerProgress != null ? markerProgress * 2 * Math.PI - Math.PI / 2 : 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; diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index aee7a43d2..360e09b0a 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -21,6 +21,7 @@ import { import { CycleSettings } from "./cycle-settings"; import { deriveWheelState } from "./wheel-state"; import { + localYmd, useCycleCalendar, useCycleHistory, useCycleInsights, @@ -37,13 +38,11 @@ import { * RSC has already gated on `cycleTrackingEnabled`. */ -/** YYYY-MM-DD for `n` days from now in the local tz. */ +/** 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 `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( - d.getDate(), - ).padStart(2, "0")}`; + return localYmd(d); } export function CycleView() { @@ -90,8 +89,10 @@ export function CycleView() { - {/* Wheel — the signature ring above the tabs. */} - + {/* Desktop: ring (left) + tabs (right). Single column below lg. */} +
+ {/* Wheel — the signature ring. */} + {loading ? (
@@ -108,6 +109,16 @@ export function CycleView() {

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

+ {/* First-period CTA — only when no cycle is active yet. */} + {!loading && wheel.dayOfCycle == null ? ( + + ) : null} @@ -171,6 +182,7 @@ export function CycleView() { )} +
(null); - const [symptoms, setSymptoms] = useState>(new Set()); + 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); - // Reset the form to empty whenever the sheet (re)opens on a day. 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 pair, so a - // re-open on a different calendar day always starts blank. - const formKey = open ? date : 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) { + if (open && formKey !== lastFormKey && !dayLog.isLoading) { setLastFormKey(formKey); - setFlow(null); - setSymptoms(new Set()); - setBbt(""); - setOpk(null); - setMucus(null); - setIntercourse(false); - setProtectedSex(false); - setNote(""); + 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 Set(prev); + const next = new Map(prev); if (next.has(key)) next.delete(key); - else next.add(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 bbtNum = bbt.trim() === "" ? undefined : Number(bbt); + const symptomList: CycleSymptomSelection[] = Array.from( + symptoms.entries(), + ).map(([key, severity]) => ({ key, severity })); const input: CycleDayLogInput = { date, loggedAt: new Date().toISOString(), source: "MANUAL", ...(flow ? { flow } : {}), + intermenstrualBleeding: intermenstrual, ...(bbtNum != null && Number.isFinite(bbtNum) ? { basalBodyTempC: bbtNum } : {}), ...(opk ? { ovulationTest: opk } : {}), ...(mucus ? { cervicalMucus: mucus } : {}), - ...(intercourse - ? { sexualActivity: true, protectedSex } - : {}), - ...(symptoms.size > 0 - ? { symptoms: Array.from(symptoms).map((key) => ({ key })) } - : {}), - ...(note.trim() ? { note: note.trim() } : {}), + sexualActivity: intercourse, + protectedSex: intercourse ? protectedSex : null, + ...(pregnancyTest ? { pregnancyTest } : {}), + ...(progesteroneTest ? { progesteroneTest } : {}), + ...(contraceptive ? { contraceptive } : {}), + symptoms: symptomList, + ...(note.trim() ? { note: note.trim() } : { note: "" }), }; await logDay.mutateAsync(input); onOpenChange(false); @@ -160,7 +224,22 @@ export function LogDaySheet({ onOpenChange(false); } - const busy = logDay.isPending || startPeriod.isPending; + 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 || + startPeriod.isPending || + endPeriod.isPending || + deleteDay.isPending; return ( + {rowId ? ( + + ) : null} +
+ + +
) : null} {/* Flow */} @@ -217,6 +325,19 @@ export function LogDaySheet({ ))}
+
+ + +
{/* Symptoms */} @@ -229,18 +350,50 @@ export function LogDaySheet({ {t(cat.labelKey)}
- {cat.symptoms.map((s) => ( - toggleSymptom(s.key)} - > - - - - ))} + {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} +
+ ); + })}
))} @@ -324,6 +477,57 @@ export function LogDaySheet({ + {/* 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 */}