mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
824dcbb01a
commit
4b10bdc5da
23 files changed
+846
-143
No files matched your search
+86
-43
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) ────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user