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

@@ -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"];