feat(cycle): single-day read endpoint, symptom severity, and a richer log-day sheet

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 12:39:28 +02:00
1 parent 824dcbb01a
commit 4b10bdc5da
23 files changed
+846 -143

No files matched your search

+86 -43
View File
@@ -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:
+26 -2
View File
@@ -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": {
+25 -1
View File
@@ -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": {
+26 -2
View File
@@ -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": {
+26 -2
View File
@@ -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": {
+26 -2
View File
@@ -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": {
+26 -2
View File
@@ -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": {
@@ -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;
+4
View File
@@ -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)
+1 -5
View File
@@ -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({
+38 -1
View File
@@ -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<Response> {
const { user } = await requireAuth();
@@ -46,6 +46,18 @@ describe("<CycleRing>", () => {
expect(html).toContain("3");
});
it("parks the day-1 marker at the 12-o'clock origin (no double-rotate)", () => {
// The <svg> 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(
<CycleRing dayOfCycle={1} cycleLength={28} phase="MENSTRUAL" />,
);
// The marker circle's cx is the last <circle> 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(
<CycleRing dayOfCycle={null} cycleLength={null} phase={null} />,
+7 -4
View File
@@ -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 <svg> 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;
+18 -6
View File
@@ -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() {
</Button>
</div>
{/* Wheel — the signature ring above the tabs. */}
<Card>
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel — the signature ring. */}
<Card className="lg:sticky lg:top-6">
<CardContent className="flex flex-col items-center gap-3 py-6">
{loading ? (
<div className="flex h-[220px] items-center justify-center">
@@ -108,6 +109,16 @@ export function CycleView() {
<p className="text-muted-foreground text-xs">
{t("cycle.ring.caption")}
</p>
{/* First-period CTA — only when no cycle is active yet. */}
{!loading && wheel.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
onClick={() => openSheet(today)}
>
{t("cycle.ring.firstPeriodCta")}
</Button>
) : null}
</CardContent>
</Card>
@@ -171,6 +182,7 @@ export function CycleView() {
)}
</TabsContent>
</Tabs>
</div>
<LogDaySheet
open={sheetOpen}
+258 -54
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Droplets, Loader2 } from "lucide-react";
import { Droplets, Loader2, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -12,11 +12,20 @@ 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 { useLogDay, useStartPeriod } from "./use-cycle";
import {
useCycleDayLog,
useDeleteDayLog,
useEndPeriod,
useLogDay,
useStartPeriod,
} from "./use-cycle";
import type {
CervicalMucus,
ContraceptiveKind,
CycleDayLogInput,
CycleSymptomSelection,
FlowLevel,
HomeTestResult,
OvulationTest,
} from "./types";
@@ -47,6 +56,22 @@ const MUCUS_VALUES: CervicalMucus[] = [
"WATERY",
"EGG_WHITE",
];
const TEST_RESULTS: HomeTestResult[] = [
"POSITIVE",
"NEGATIVE",
"INDETERMINATE",
];
const CONTRACEPTIVE_KINDS: ContraceptiveKind[] = [
"ORAL",
"IUD",
"IMPLANT",
"INJECTION",
"INTRAVAGINAL_RING",
"PATCH",
"EMERGENCY",
"NONE",
];
const SEVERITY_LEVELS = [1, 2, 3, 4] as const;
export interface LogDaySheetProps {
open: boolean;
@@ -92,64 +117,103 @@ export function LogDaySheet({
const { t } = useTranslations();
const logDay = useLogDay();
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<FlowLevel | null>(null);
const [symptoms, setSymptoms] = useState<Set<string>>(new Set());
const [intermenstrual, setIntermenstrual] = useState(false);
const [symptoms, setSymptoms] = useState<Map<string, number | null>>(
new Map(),
);
const [bbt, setBbt] = useState<string>("");
const [opk, setOpk] = useState<OvulationTest | null>(null);
const [mucus, setMucus] = useState<CervicalMucus | null>(null);
const [intercourse, setIntercourse] = useState(false);
const [protectedSex, setProtectedSex] = useState(false);
const [pregnancyTest, setPregnancyTest] = useState<HomeTestResult | null>(
null,
);
const [progesteroneTest, setProgesteroneTest] =
useState<HomeTestResult | null>(null);
const [contraceptive, setContraceptive] = useState<ContraceptiveKind | null>(
null,
);
const [note, setNote] = useState("");
const [rowId, setRowId] = useState<string | null>(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<string | null>(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 (
<ResponsiveSheet
@@ -170,6 +249,22 @@ export function LogDaySheet({
description={t("cycle.sheet.description")}
footer={
<>
{rowId ? (
<Button
variant="outline"
onClick={handleDelete}
disabled={busy}
className="text-destructive hover:text-destructive mr-auto"
aria-label={t("cycle.sheet.delete")}
>
{deleteDay.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
{t("cycle.sheet.delete")}
</Button>
) : null}
<Button
variant="outline"
onClick={() => onOpenChange(false)}
@@ -186,22 +281,35 @@ export function LogDaySheet({
</>
}
>
{/* One-tap period start — only meaningful for today. */}
{/* One-tap period boundaries. */}
{date === today ? (
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={handleStartPeriod}
disabled={busy}
style={{ borderColor: FLOW_HUE }}
>
{startPeriod.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Droplets className="h-4 w-4" style={{ color: FLOW_HUE }} />
)}
{t("cycle.startedPeriod")}
</Button>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="flex-1 justify-start gap-2"
onClick={handleStartPeriod}
disabled={busy}
style={{ borderColor: FLOW_HUE }}
>
{startPeriod.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Droplets className="h-4 w-4" style={{ color: FLOW_HUE }} />
)}
{t("cycle.startedPeriod")}
</Button>
<Button
variant="outline"
className="flex-1 justify-start gap-2"
onClick={handleEndPeriod}
disabled={busy}
>
{endPeriod.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.endedPeriod")}
</Button>
</div>
) : null}
{/* Flow */}
@@ -217,6 +325,19 @@ export function LogDaySheet({
</Chip>
))}
</div>
<div className="mt-2 flex items-center justify-between">
<Label
htmlFor="cycle-intermenstrual"
className="text-sm font-normal"
>
{t("cycle.sheet.intermenstrualBleeding")}
</Label>
<Switch
id="cycle-intermenstrual"
checked={intermenstrual}
onCheckedChange={setIntermenstrual}
/>
</div>
</Field>
{/* Symptoms */}
@@ -229,18 +350,50 @@ export function LogDaySheet({
{t(cat.labelKey)}
</div>
<div className="flex flex-wrap gap-2">
{cat.symptoms.map((s) => (
<Chip
key={s.key}
active={symptoms.has(s.key)}
onClick={() => toggleSymptom(s.key)}
>
<span className="flex items-center gap-1.5">
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(s.labelKey)}
</span>
</Chip>
))}
{cat.symptoms.map((s) => {
const active = symptoms.has(s.key);
const sev = symptoms.get(s.key) ?? null;
return (
<div key={s.key} className="flex items-center gap-1">
<Chip active={active} onClick={() => toggleSymptom(s.key)}>
<span className="flex items-center gap-1.5">
<s.icon
className="h-3.5 w-3.5"
aria-hidden="true"
/>
{t(s.labelKey)}
</span>
</Chip>
{active ? (
<div
className="flex gap-0.5"
role="group"
aria-label={t("cycle.sheet.severity")}
>
{SEVERITY_LEVELS.map((lvl) => (
<button
key={lvl}
type="button"
aria-pressed={sev === lvl}
aria-label={t("cycle.sheet.severityLevel", {
level: lvl,
})}
onClick={() => setSymptomSeverity(s.key, lvl)}
className={cn(
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2",
sev === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
{lvl}
</button>
))}
</div>
) : null}
</div>
);
})}
</div>
</div>
))}
@@ -324,6 +477,57 @@ export function LogDaySheet({
</div>
</Field>
{/* Pregnancy test */}
<Field label={t("cycle.sheet.pregnancyTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={pregnancyTest === v}
onClick={() =>
setPregnancyTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Progesterone test */}
<Field label={t("cycle.sheet.progesteroneTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={progesteroneTest === v}
onClick={() =>
setProgesteroneTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Contraceptive */}
<Field label={t("cycle.sheet.contraceptive")}>
<div className="flex flex-wrap gap-2">
{CONTRACEPTIVE_KINDS.map((v) => (
<Chip
key={v}
active={contraceptive === v}
onClick={() =>
setContraceptive((cur) => (cur === v ? null : v))
}
>
{t(`cycle.contraceptive.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Note */}
<Field label={t("cycle.sheet.note")}>
<Textarea
+47 -1
View File
@@ -108,16 +108,62 @@ export interface CycleProfileDTO {
}
/** The day-log capture payload (subset of the API input we send from web). */
export type HomeTestResult = "NEGATIVE" | "POSITIVE" | "INDETERMINATE";
export type ContraceptiveKind =
| "NONE"
| "UNSPECIFIED"
| "IMPLANT"
| "INJECTION"
| "IUD"
| "INTRAVAGINAL_RING"
| "ORAL"
| "PATCH"
| "EMERGENCY";
export interface CycleSymptomSelection {
key: string;
severity?: number | null;
}
export interface CycleDayLogInput {
date: string;
flow?: FlowLevel;
intermenstrualBleeding?: boolean;
basalBodyTempC?: number;
ovulationTest?: OvulationTest;
cervicalMucus?: CervicalMucus;
sexualActivity?: boolean;
protectedSex?: boolean | null;
symptoms?: { key: string }[];
pregnancyTest?: HomeTestResult;
progesteroneTest?: HomeTestResult;
contraceptive?: ContraceptiveKind;
symptoms?: CycleSymptomSelection[];
note?: string;
loggedAt: string;
source?: "MANUAL";
}
/** The full day-log row read back from `GET /api/cycle/day-logs?date=`. */
export interface CycleDayLogDTO {
id: string;
date: string;
cycleId: string | null;
flow: FlowLevel | null;
intermenstrualBleeding: boolean;
basalBodyTempC: number | null;
ovulationTest: OvulationTest | null;
cervicalMucus: CervicalMucus | null;
sexualActivity: boolean;
protectedSex: boolean | null;
pregnancyTest: HomeTestResult | null;
progesteroneTest: HomeTestResult | null;
contraceptive: ContraceptiveKind | null;
symptoms: { key: string; severity: number | null }[];
note: string | null;
source: string;
externalId: string | null;
syncVersion: number;
updatedAt: string;
deletedAt: string | null;
}
+67
View File
@@ -18,6 +18,7 @@ import {
} from "@/lib/query-keys";
import type {
CalendarResponse,
CycleDayLogDTO,
CycleDayLogInput,
CycleHistoryResponse,
CycleProfileDTO,
@@ -36,6 +37,36 @@ async function unwrap<T>(res: Response): Promise<T> {
return json.data as T;
}
/**
* The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys
* are tz-anchored on the server; the client must derive the key from the
* user's wall-clock day, never from `toISOString()` (which is UTC and rolls
* the date over near midnight).
*/
export function localYmd(d: Date = new Date()): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* Read the full day-log for one date so the log-day sheet pre-fills (no
* blank-sheet data loss) and Delete can resolve the row id. `null` when
* nothing is logged that day. Enabled only when a date is supplied.
*/
export function useCycleDayLog(date: string | null) {
return useQuery({
queryKey: queryKeys.cycleDayLog(date ?? ""),
enabled: date !== null,
queryFn: () =>
fetch(`/api/cycle/day-logs?date=${date}`).then((r) =>
unwrap<CycleDayLogDTO | null>(r),
),
staleTime: 30_000,
});
}
export function useCycleCalendar(from: string, to: string) {
return useQuery({
queryKey: queryKeys.cycleCalendar(from, to),
@@ -125,6 +156,42 @@ export function useStartPeriod() {
});
}
/** The one-tap "end period" boundary (`action:"end"`). */
export function useEndPeriod() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (date: string) => {
const res = await fetch("/api/cycle/period", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "end",
date,
loggedAt: new Date().toISOString(),
}),
});
return unwrap<unknown>(res);
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
});
}
/** Soft-delete a logged day by its row id (the GET supplies the id). */
export function useDeleteDayLog() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/cycle/day-logs/${id}`, {
method: "DELETE",
});
if (!res.ok && res.status !== 204) {
throw new Error(`Request failed: ${res.status}`);
}
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
});
}
export interface CyclePrefsPatch {
enabled?: boolean;
goal?: CycleProfileDTO["goal"];
+36 -14
View File
@@ -50,11 +50,21 @@ interface SensitiveFields {
contraceptive: string | null;
}
/** Resolve catalog symptom keys → ids, dropping unknown keys silently. */
/** A symptom selection carrying its catalog key + optional 1-4 severity. */
export interface SymptomSelection {
key: string;
severity?: number | null;
}
/**
* Resolve catalog symptom keys → `{ key, id }`, dropping unknown keys
* silently. Returns the pairs (not bare ids) so the caller can carry the
* per-link severity through to the join write.
*/
export async function resolveSymptomIds(
userId: string,
keys: readonly string[],
): Promise<string[]> {
): Promise<{ key: string; id: string }[]> {
if (keys.length === 0) return [];
const unique = Array.from(new Set(keys));
// Catalog rows (userId null) plus this user's own custom symptoms.
@@ -64,25 +74,41 @@ export async function resolveSymptomIds(
isActive: true,
OR: [{ userId: null }, { userId }],
},
select: { id: true },
select: { id: true, key: true },
});
return rows.map((r) => r.id);
return rows.map((r) => ({ key: r.key, id: r.id }));
}
/**
* Replace the symptom-link set for a day-log. Shared by the upsert helper
* and the PATCH route so both resolve + write the join identically.
* and the PATCH route so both resolve + write the join identically. Each
* selection's optional 1-4 severity is persisted on the link (NULL = a
* plain presence link).
*/
export async function replaceSymptomLinks(
userId: string,
dayLogId: string,
keys: readonly string[],
selections: readonly SymptomSelection[],
): Promise<void> {
const ids = await resolveSymptomIds(userId, keys);
const resolved = await resolveSymptomIds(
userId,
selections.map((s) => s.key),
);
// Map each resolved id back to its requested severity by key.
const severityByKey = new Map(
selections.map((s) => [
s.key,
typeof s.severity === "number" ? s.severity : null,
]),
);
await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId } });
if (ids.length > 0) {
if (resolved.length > 0) {
await prisma.cycleSymptomLink.createMany({
data: ids.map((symptomId) => ({ dayLogId, symptomId })),
data: resolved.map(({ key, id }) => ({
dayLogId,
symptomId: id,
severity: severityByKey.get(key) ?? null,
})),
skipDuplicates: true,
});
}
@@ -394,11 +420,7 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
// Replace the symptom-link set when symptoms were supplied. Absent
// `symptoms` leaves existing links untouched.
if (entry.symptoms !== undefined) {
await replaceSymptomLinks(
userId,
rowId,
entry.symptoms.map((s) => s.key),
);
await replaceSymptomLinks(userId, rowId, entry.symptoms);
}
return { id: rowId, existed, changed };
+3 -4
View File
@@ -5,9 +5,8 @@
* client mirrors. `notesEncrypted` is decrypted fail-soft on read (a
* key-rotation gap on one row reads `null`, never 500s the whole page).
* Symptom links are flattened to `[{ key, severity }]` using the catalog
* key (severity is not yet persisted per-link in v1.15.0 — the join is a
* presence link; severity rides the input but the catalog link carries
* presence only, so it reads back `null`).
* key; `severity` carries the persisted per-link 1-4 Likert intensity
* (NULL = a plain presence link).
*/
import { decrypt } from "@/lib/crypto";
import type { CyclePredictionResult } from "@/lib/cycle/types";
@@ -119,7 +118,7 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO {
contraceptive: hasEnvelope ? (enc.contraceptive ?? null) : row.contraceptive,
symptoms: (row.symptomLinks ?? []).map((l) => ({
key: l.symptom.key,
severity: null,
severity: l.severity ?? null,
})),
note: decryptNote(row.notesEncrypted),
source: row.source,
+23
View File
@@ -62,6 +62,7 @@ import {
cycleTrackingGoalEnum,
cycleDayLogInputSchema,
cycleDayLogPatchSchema,
cycleDayLogQuerySchema,
cycleBulkSchema,
cyclePeriodSchema,
cyclePrefsSchema,
@@ -2441,6 +2442,28 @@ const cycleDisabledResponse = {
export const openApiPaths: NonNullable<ZodOpenApiObject["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.",
requestParams: { query: cycleDayLogQuerySchema },
responses: {
"200": {
description: "The day-log for that date, or null.",
content: {
"application/json": {
schema: dataEnvelope(
cycleDayLogDto.nullable(),
"CycleDayLogReadEnvelope",
),
},
},
},
...cycleDisabledResponse,
...stdResponses,
},
},
post: {
tags: ["Cycle"],
summary: "Capture a single cycle day-log (v1.15.0)",
+1
View File
@@ -515,6 +515,7 @@ export const queryKeys = {
cycleHistory: (limit: number) => ["cycle", "history", limit] as const,
cycleProfile: () => ["cycle", "profile"] as const,
cycleInsights: () => ["cycle", "insights"] as const,
cycleDayLog: (date: string) => ["cycle", "day-log", date] as const,
};
/**
+3
View File
@@ -73,6 +73,9 @@ const dateString = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
/** The single-day read query: `GET /api/cycle/day-logs?date=YYYY-MM-DD`. */
export const cycleDayLogQuerySchema = z.object({ date: dateString });
/* ── day-log capture (single + bulk shared input) ────────────────── */
/**
+84
View File
@@ -167,6 +167,90 @@ describe("cycle day-logs — single capture", () => {
});
});
describe("cycle day-logs — single read (GET)", () => {
it("returns the full DTO for a logged day and null for an empty one", async () => {
await loginAs(FEMALE_USER_ID);
const route = await import("@/app/api/cycle/day-logs/route");
await route.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-04-01",
flow: "MEDIUM",
loggedAt: "2026-04-01T08:00:00.000Z",
}),
);
const hit = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-04-01", "GET"),
);
expect(hit.status).toBe(200);
const hitJson = await hit.json();
expect(hitJson.data).not.toBeNull();
expect(hitJson.data.flow).toBe("MEDIUM");
expect(typeof hitJson.data.id).toBe("string");
const miss = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-04-09", "GET"),
);
expect(miss.status).toBe(200);
expect((await miss.json()).data).toBeNull();
});
it("422s a malformed date query", async () => {
await loginAs(FEMALE_USER_ID);
const { GET } = await import("@/app/api/cycle/day-logs/route");
const res = await GET(jsonRequest("/api/cycle/day-logs?date=nope", "GET"));
expect(res.status).toBe(422);
});
});
describe("cycle day-logs — symptom severity", () => {
it("persists a 1-4 severity per link and reads it back in the DTO", async () => {
await loginAs(FEMALE_USER_ID);
// Ensure a catalog category + symptom exist so the link resolves
// (idempotent — the seed migration may already provide them).
const cat = await getPrismaClient().cycleSymptomCategory.upsert({
where: { key: "physical" },
create: { key: "physical", labelKey: "cycle.symptomCategory.physical" },
update: {},
});
await getPrismaClient().cycleSymptom.upsert({
where: { key: "cramps" },
create: {
key: "cramps",
categoryId: cat.id,
labelKey: "cycle.symptom.cramps",
isActive: true,
},
update: { isActive: true },
});
const route = await import("@/app/api/cycle/day-logs/route");
const insert = await route.POST(
jsonRequest("/api/cycle/day-logs", "POST", {
date: "2026-04-15",
symptoms: [{ key: "cramps", severity: 3 }],
loggedAt: "2026-04-15T08:00:00.000Z",
}),
);
expect(insert.status).toBe(201);
const insertJson = await insert.json();
expect(insertJson.data.symptoms).toEqual([{ key: "cramps", severity: 3 }]);
const link = await getPrismaClient().cycleSymptomLink.findFirstOrThrow({
where: { dayLog: { date: "2026-04-15" } },
});
expect(link.severity).toBe(3);
const read = await route.GET(
jsonRequest("/api/cycle/day-logs?date=2026-04-15", "GET"),
);
expect((await read.json()).data.symptoms).toEqual([
{ key: "cramps", severity: 3 },
]);
});
});
describe("cycle day-logs — bulk drain", () => {
it("returns per-entry inserted / updated / duplicate", async () => {
await loginAs(FEMALE_USER_ID);