feat(cycle): second-audit feature, QoL, and wiring close

Close the second-round audit UI cluster:

- Cycle reminders: add the missing notification-event labels so the
  period-soon / period-confirm toggles read correctly on the
  notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
  length, confirmed-ovulation chip) — the cycles were fetched but only
  the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
  solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
  the nullable PATCH); period start/end work on any selected date for
  retroactive correction; "end period" shows only while one is open.
  Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
  in before the gated page is reachable; wire the cycle data-purge button
  to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
  the grid; tighten the luteal prior bound to the engine clamp; mute the
  BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.

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 13:32:25 +02:00
1 parent 3375bbabac
commit c21131e26e
40 files changed
+1399 -446

No files matched your search

@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import {
buildDayLogPatch,
buildDayLogInput,
type DayLogFormState,
} from "../log-day-sheet";
function blank(): DayLogFormState {
return {
flow: null,
intermenstrual: false,
bbt: "",
opk: null,
mucus: null,
intercourse: false,
protectedSex: false,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
note: "",
symptoms: new Map(),
};
}
describe("buildDayLogPatch (edit → clear semantics, QA W-2)", () => {
it("sends explicit null for every deselected enum so an edit clears it", () => {
const patch = buildDayLogPatch(blank());
// A blank form on an EXISTING row must null every field, not omit it —
// omission would leave the stored value behind (the bug this fixes).
expect(patch.flow).toBeNull();
expect(patch.ovulationTest).toBeNull();
expect(patch.cervicalMucus).toBeNull();
expect(patch.pregnancyTest).toBeNull();
expect(patch.progesteroneTest).toBeNull();
expect(patch.contraceptive).toBeNull();
expect(patch.basalBodyTempC).toBeNull();
expect(patch.note).toBeNull();
// Keys are PRESENT (not omitted) so the server actually applies the clear.
expect("flow" in patch).toBe(true);
expect("cervicalMucus" in patch).toBe(true);
});
it("carries the set values through and gates protection on intercourse", () => {
const patch = buildDayLogPatch({
...blank(),
flow: "MEDIUM",
opk: "POSITIVE_LH_SURGE",
bbt: "36.62",
intercourse: true,
protectedSex: true,
note: " hello ",
symptoms: new Map([["cramps", 3]]),
});
expect(patch.flow).toBe("MEDIUM");
expect(patch.ovulationTest).toBe("POSITIVE_LH_SURGE");
expect(patch.basalBodyTempC).toBe(36.62);
expect(patch.protectedSex).toBe(true);
expect(patch.note).toBe("hello");
expect(patch.symptoms).toEqual([{ key: "cramps", severity: 3 }]);
});
it("nulls protection when no intercourse is logged", () => {
const patch = buildDayLogPatch({
...blank(),
intercourse: false,
protectedSex: true,
});
expect(patch.sexualActivity).toBe(false);
expect(patch.protectedSex).toBeNull();
});
});
describe("buildDayLogInput (new row → omit empties)", () => {
it("omits unset enums but posts an explicit empty note", () => {
const input = buildDayLogInput(blank(), "2026-06-06");
expect("flow" in input).toBe(false);
expect("ovulationTest" in input).toBe(false);
expect("basalBodyTempC" in input).toBe(false);
expect(input.note).toBe("");
expect(input.date).toBe("2026-06-06");
expect(input.source).toBe("MANUAL");
});
it("includes set values for a new row", () => {
const input = buildDayLogInput(
{ ...blank(), flow: "LIGHT", bbt: "36.5" },
"2026-06-06",
);
expect(input.flow).toBe("LIGHT");
expect(input.basalBodyTempC).toBe(36.5);
});
});
@@ -19,6 +19,7 @@ const basePrediction: CyclePrediction = {
fertileWindowStart: null,
fertileWindowEnd: null,
predictedOvulation: null,
ovulationConfirmed: false,
confidence: 0.8,
cyclesObserved: 6,
stillLearning: false,
+12 -3
View File
@@ -61,6 +61,8 @@ export interface BbtChartProps {
today: string;
/** Estimated ovulation day (`YYYY-MM-DD`), or null when goal-gated off. */
predictedOvulation: string | null;
/** Whether the ovulation day was confirmed by a signal layer (solid marker). */
ovulationConfirmed?: boolean;
/** Read-Your-Body mode: suppress phase tint + ovulation marker. */
rawChartMode: boolean;
}
@@ -74,6 +76,7 @@ export function BbtChart({
days,
today,
predictedOvulation,
ovulationConfirmed = false,
rawChartMode,
}: BbtChartProps) {
const { t } = useTranslations();
@@ -215,13 +218,19 @@ export function BbtChart({
<ReferenceLine
x={ovulationMs}
stroke={OVULATION_HUE}
strokeDasharray="4 3"
// Solid line for a confirmed ovulation (signal-detected),
// dashed for the calendar estimate (QA M1).
strokeDasharray={ovulationConfirmed ? undefined : "4 3"}
strokeWidth={1.5}
label={{
value: t("cycle.bbt.ovulationEstimate"),
value: ovulationConfirmed
? t("cycle.bbt.ovulationConfirmed")
: t("cycle.bbt.ovulationEstimate"),
position: "insideTopRight",
fontSize: 10,
fill: OVULATION_HUE,
// Muted text (not the amber hue) so the tiny label keeps
// AA contrast on the card; the line carries the colour (L1).
fill: "var(--muted-foreground)",
}}
/>
) : null}
+45 -22
View File
@@ -61,10 +61,7 @@ export function CycleCalendar({
}: CycleCalendarProps) {
const { t } = useTranslations();
const weekdays = useMemo(() => shortWeekdays(), []);
const byDate = useMemo(
() => new Map(days.map((d) => [d.date, d])),
[days],
);
const byDate = useMemo(() => new Map(days.map((d) => [d.date, d])), [days]);
// Anchor the visible month on today's month.
const [monthAnchor, setMonthAnchor] = useState(() => {
@@ -101,9 +98,7 @@ export function CycleCalendar({
});
function shiftMonth(delta: number) {
setMonthAnchor(
(a) => new Date(a.getFullYear(), a.getMonth() + delta, 1),
);
setMonthAnchor((a) => new Date(a.getFullYear(), a.getMonth() + delta, 1));
}
return (
@@ -149,7 +144,8 @@ export function CycleCalendar({
const info = byDate.get(date);
const isToday = date === today;
const markers: string[] = [];
if (info?.isPeriodLogged) markers.push(t("cycle.calendar.legendPeriod"));
if (info?.isPeriodLogged)
markers.push(t("cycle.calendar.legendPeriod"));
if (info?.isPredictedPeriod)
markers.push(t("cycle.calendar.legendPredicted"));
if (info?.isFertileWindow)
@@ -171,7 +167,7 @@ export function CycleCalendar({
onClick={() => onSelectDay(date)}
className={cn(
"relative flex aspect-square min-h-10 flex-col items-center justify-center rounded-lg text-sm transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:outline-none focus-visible:ring-2",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
info?.isFertileWindow && "ring-1 ring-inset",
isToday && "font-semibold",
)}
@@ -189,12 +185,7 @@ export function CycleCalendar({
style={{ backgroundColor: FLOW_HUE }}
/>
) : null}
<span
className={cn(
"relative z-10",
isToday && "text-primary",
)}
>
<span className={cn("relative z-10", isToday && "text-primary")}>
{cell.getDate()}
</span>
@@ -237,11 +228,35 @@ export function CycleCalendar({
function CalendarLegend() {
const { t } = useTranslations();
const items: { hue: string; labelKey: string; dashed?: boolean }[] = [
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod" },
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPredicted", dashed: true },
{ hue: FERTILE_HUE, labelKey: "cycle.calendar.legendFertile" },
{ hue: OVULATION_HUE, labelKey: "cycle.calendar.legendOvulation" },
// Each swatch mirrors its grid affordance: period = filled, predicted =
// dashed, fertile = a ring (not a filled dot), ovulation = filled, symptom =
// the small grey marker dot the grid draws (QA M4).
const items: {
hue: string;
labelKey: string;
variant: "fill" | "dashed" | "ring" | "dot";
}[] = [
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" },
{
hue: FLOW_HUE,
labelKey: "cycle.calendar.legendPredicted",
variant: "dashed",
},
{
hue: FERTILE_HUE,
labelKey: "cycle.calendar.legendFertile",
variant: "ring",
},
{
hue: OVULATION_HUE,
labelKey: "cycle.calendar.legendOvulation",
variant: "fill",
},
{
hue: "var(--muted-foreground)",
labelKey: "cycle.calendar.legendSymptom",
variant: "dot",
},
];
return (
<ul className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
@@ -249,8 +264,16 @@ function CalendarLegend() {
<li key={it.labelKey} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={cn("h-2.5 w-2.5 rounded-full", it.dashed && "opacity-60")}
style={{ backgroundColor: it.hue }}
className={cn(
"rounded-full",
it.variant === "dot" ? "h-1 w-1" : "h-2.5 w-2.5",
it.variant === "dashed" && "opacity-60",
)}
style={
it.variant === "ring"
? { border: `1.5px solid ${it.hue}` }
: { backgroundColor: it.hue }
}
/>
{t(it.labelKey)}
</li>
+14 -5
View File
@@ -104,11 +104,15 @@ export function CyclePhaseHeadline({
</p>
);
}
const metricLabel = t(METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey);
const metricLabel = t(
METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey,
);
const unit = t(UNIT_KEY[headline.display]);
const up = headline.delta >= 0;
const dir = t(
up ? "cycle.insights.headline.dirHigher" : "cycle.insights.headline.dirLower",
up
? "cycle.insights.headline.dirHigher"
: "cycle.insights.headline.dirLower",
);
const params = {
metric: metricLabel,
@@ -116,7 +120,8 @@ export function CyclePhaseHeadline({
unit,
dir,
};
const key = HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic";
const key =
HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic";
return (
<p
data-slot="cycle-phase-headline"
@@ -143,7 +148,9 @@ export function CyclePhaseCrosstab({
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const unit = t(UNIT_KEY[row.display]);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
@@ -166,7 +173,9 @@ export function CyclePhaseCrosstab({
className="text-foreground min-w-0 flex-1 truncate"
title={metricLabel}
>
{t("cycle.insights.crosstab.pairLabel", { metric: metricLabel })}
{t("cycle.insights.crosstab.pairLabel", {
metric: metricLabel,
})}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
+86 -4
View File
@@ -18,7 +18,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "@/lib/i18n/context";
import type { CycleGoal, CycleProfileDTO } from "./types";
import { useUpdateCyclePrefs } from "./use-cycle";
import { useUpdateCyclePrefs, useDeleteAllCycleData } from "./use-cycle";
/**
* v1.15.0 — the single-page cycle settings form.
@@ -50,6 +50,8 @@ function numOrEmpty(v: number | null): string {
export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
const { t } = useTranslations();
const update = useUpdateCyclePrefs();
const purge = useDeleteAllCycleData();
const [confirmingPurge, setConfirmingPurge] = useState(false);
const [goal, setGoal] = useState<CycleGoal>(profile.goal);
const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode);
@@ -159,8 +161,8 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
label={t("cycle.settings.lutealPhaseLength")}
value={lutealLen}
onChange={setLutealLen}
min={8}
max={20}
min={10}
max={16}
/>
</div>
</div>
@@ -185,6 +187,26 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
<Separator />
{/* Reminders — the per-channel toggles live on the notifications page;
point users there rather than duplicating the toggle state here. */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.reminders")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.remindersDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<Separator />
{/* Data export / delete */}
<div className="space-y-2">
<p className="text-sm font-medium">{t("cycle.settings.dataTitle")}</p>
@@ -203,6 +225,66 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
</Link>
</Button>
</div>
{/* Cycle-only hard purge (post-Dobbs threat model): removes every
cycle row + the cycle audit trail + reminder ledger, distinct
from the account-level delete. Two-step confirm — no accidental
destructive tap. */}
<div className="border-destructive/30 mt-3 space-y-2 rounded-md border p-3">
<p className="text-muted-foreground text-xs">
{t("cycle.settings.purgeDescription")}
</p>
{confirmingPurge ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium" role="alert">
{t("cycle.settings.purgeConfirm")}
</span>
<Button
variant="destructive"
size="sm"
disabled={purge.isPending}
onClick={async () => {
try {
await purge.mutateAsync();
} finally {
setConfirmingPurge(false);
}
}}
>
{purge.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.settings.purgeConfirmAction")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingPurge(false)}
>
{t("cycle.settings.purgeCancel")}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/40 hover:bg-destructive/10"
onClick={() => setConfirmingPurge(true)}
>
{t("cycle.settings.purgeData")}
</Button>
)}
{purge.isSuccess ? (
<span className="text-muted-foreground text-sm" role="status">
{t("cycle.settings.purgeDone")}
</span>
) : null}
{purge.isError ? (
<span className="text-destructive text-sm" role="alert">
{t("cycle.settings.purgeError")}
</span>
) : null}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
@@ -287,7 +369,7 @@ function PriorField({
max={max}
value={value}
onChange={(e) => onChange(e.target.value)}
className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
/>
</div>
);
+4
View File
@@ -241,6 +241,9 @@ export function CycleView() {
predictedOvulation={
calendar.data?.prediction?.predictedOvulation ?? null
}
ovulationConfirmed={
calendar.data?.prediction?.ovulationConfirmed ?? false
}
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
/>
</>
@@ -290,6 +293,7 @@ export function CycleView() {
onOpenChange={setSheetOpen}
date={selectedDate}
today={today}
activePeriod={wheel.phase === "MENSTRUAL"}
/>
</div>
);
+156 -70
View File
@@ -17,7 +17,9 @@ import {
useDeleteDayLog,
useEndPeriod,
useLogDay,
usePatchDayLog,
useStartPeriod,
type CycleDayLogPatch,
} from "./use-cycle";
import type {
CervicalMucus,
@@ -73,13 +75,99 @@ const CONTRACEPTIVE_KINDS: ContraceptiveKind[] = [
];
const SEVERITY_LEVELS = [1, 2, 3, 4] as const;
/** The sheet's editable form state, lifted out so the two save-payload builders
* are pure + unit-testable (the clear-on-edit semantics are the QA W-2 fix). */
export interface DayLogFormState {
flow: FlowLevel | null;
intermenstrual: boolean;
/** Raw text from the BBT input; "" / non-finite resolves to null. */
bbt: string;
opk: OvulationTest | null;
mucus: CervicalMucus | null;
intercourse: boolean;
protectedSex: boolean;
pregnancyTest: HomeTestResult | null;
progesteroneTest: HomeTestResult | null;
contraceptive: ContraceptiveKind | null;
note: string;
symptoms: Map<string, number | null>;
}
function resolveBbt(bbt: string): number | null {
const n = bbt.trim() === "" ? null : Number(bbt);
return n != null && Number.isFinite(n) ? n : null;
}
function symptomList(
symptoms: Map<string, number | null>,
): CycleSymptomSelection[] {
return Array.from(symptoms.entries()).map(([key, severity]) => ({
key,
severity,
}));
}
/**
* The PATCH payload for an EDIT: every enum carries an explicit `null` when
* deselected so the server clears it (the POST merge can only add/keep — QA
* W-2). Pure + exported for the unit test.
*/
export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch {
return {
flow: s.flow ?? null,
intermenstrualBleeding: s.intermenstrual,
basalBodyTempC: resolveBbt(s.bbt),
ovulationTest: s.opk ?? null,
cervicalMucus: s.mucus ?? null,
sexualActivity: s.intercourse,
protectedSex: s.intercourse ? s.protectedSex : null,
pregnancyTest: s.pregnancyTest ?? null,
progesteroneTest: s.progesteroneTest ?? null,
contraceptive: s.contraceptive ?? null,
symptoms: symptomList(s.symptoms),
note: s.note.trim() ? s.note.trim() : null,
};
}
/** The POST payload for a NEW row: omit empty enums (a fresh row has nothing to
* clear); `note` posts an explicit "" so an emptied note never persists. */
export function buildDayLogInput(
s: DayLogFormState,
date: string,
): CycleDayLogInput {
const bbtVal = resolveBbt(s.bbt);
return {
date,
loggedAt: new Date().toISOString(),
source: "MANUAL",
...(s.flow ? { flow: s.flow } : {}),
intermenstrualBleeding: s.intermenstrual,
...(bbtVal != null ? { basalBodyTempC: bbtVal } : {}),
...(s.opk ? { ovulationTest: s.opk } : {}),
...(s.mucus ? { cervicalMucus: s.mucus } : {}),
sexualActivity: s.intercourse,
protectedSex: s.intercourse ? s.protectedSex : null,
...(s.pregnancyTest ? { pregnancyTest: s.pregnancyTest } : {}),
...(s.progesteroneTest ? { progesteroneTest: s.progesteroneTest } : {}),
...(s.contraceptive ? { contraceptive: s.contraceptive } : {}),
symptoms: symptomList(s.symptoms),
...(s.note.trim() ? { note: s.note.trim() } : { note: "" }),
};
}
export interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The date being logged (YYYY-MM-DD). */
date: string;
/** Today (YYYY-MM-DD) — gates the one-tap period-start affordance. */
/** Today (YYYY-MM-DD). */
today: string;
/**
* Whether a period is currently open (today is in the MENSTRUAL phase) — gates
* the one-tap "end period" affordance so it never shows when no period is in
* progress (QA M2).
*/
activePeriod?: boolean;
}
function Chip({
@@ -97,7 +185,7 @@ function Chip({
aria-pressed={active}
onClick={onClick}
className={cn(
"focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none",
active
? "border-primary bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
@@ -112,10 +200,11 @@ export function LogDaySheet({
open,
onOpenChange,
date,
today,
activePeriod = false,
}: LogDaySheetProps) {
const { t } = useTranslations();
const logDay = useLogDay();
const patchDay = usePatchDayLog();
const startPeriod = useStartPeriod();
const endPeriod = useEndPeriod();
const deleteDay = useDeleteDayLog();
@@ -150,16 +239,16 @@ export function LogDaySheet({
// 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 formKey = open
? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
: null;
const [lastFormKey, setLastFormKey] = useState<string | null>(null);
if (open && formKey !== lastFormKey && !dayLog.isLoading) {
setLastFormKey(formKey);
setRowId(dto?.id ?? null);
setFlow((dto?.flow ?? null) as FlowLevel | null);
setIntermenstrual(dto?.intermenstrualBleeding ?? false);
setSymptoms(
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity])),
);
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);
@@ -192,35 +281,35 @@ export function LogDaySheet({
}
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 } : {}),
sexualActivity: intercourse,
protectedSex: intercourse ? protectedSex : null,
...(pregnancyTest ? { pregnancyTest } : {}),
...(progesteroneTest ? { progesteroneTest } : {}),
...(contraceptive ? { contraceptive } : {}),
symptoms: symptomList,
...(note.trim() ? { note: note.trim() } : { note: "" }),
const state: DayLogFormState = {
flow,
intermenstrual,
bbt,
opk,
mucus,
intercourse,
protectedSex,
pregnancyTest,
progesteroneTest,
contraceptive,
note,
symptoms,
};
await logDay.mutateAsync(input);
if (rowId) {
// Editing an existing row → PATCH with explicit nulls so a deselected
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
await patchDay.mutateAsync({ id: rowId, patch: buildDayLogPatch(state) });
onOpenChange(false);
return;
}
await logDay.mutateAsync(buildDayLogInput(state, date));
onOpenChange(false);
}
// Period boundaries operate on the SELECTED date, so a forgotten day-1 can be
// corrected from the calendar retroactively (QA M3) — not today-only.
async function handleStartPeriod() {
await startPeriod.mutateAsync(today);
await startPeriod.mutateAsync(date);
onOpenChange(false);
}
@@ -237,9 +326,11 @@ export function LogDaySheet({
const busy =
logDay.isPending ||
patchDay.isPending ||
startPeriod.isPending ||
endPeriod.isPending ||
deleteDay.isPending;
const saving = logDay.isPending || patchDay.isPending;
return (
<ResponsiveSheet
@@ -273,31 +364,33 @@ export function LogDaySheet({
{t("cycle.sheet.cancel")}
</Button>
<Button onClick={handleSave} disabled={busy}>
{logDay.isPending ? (
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{logDay.isPending ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
{saving ? t("cycle.sheet.saving") : t("cycle.sheet.save")}
</Button>
</>
}
>
{/* One-tap period boundaries. */}
{date === today ? (
<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>
{/* One-tap period boundaries — available on any selected date so a
forgotten day-1 can be corrected retroactively (M3). "End period"
shows only while a period is actually open (M2). */}
<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>
{activePeriod ? (
<Button
variant="outline"
className="flex-1 justify-start gap-2"
@@ -309,8 +402,8 @@ export function LogDaySheet({
) : null}
{t("cycle.endedPeriod")}
</Button>
</div>
) : null}
) : null}
</div>
{/* Flow */}
<Field label={t("cycle.sheet.flow")}>
@@ -326,10 +419,7 @@ export function LogDaySheet({
))}
</div>
<div className="mt-2 flex items-center justify-between">
<Label
htmlFor="cycle-intermenstrual"
className="text-sm font-normal"
>
<Label htmlFor="cycle-intermenstrual" className="text-sm font-normal">
{t("cycle.sheet.intermenstrualBleeding")}
</Label>
<Switch
@@ -355,12 +445,12 @@ export function LogDaySheet({
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)}>
<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"
/>
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
{t(s.labelKey)}
</span>
</Chip>
@@ -380,7 +470,7 @@ export function LogDaySheet({
})}
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",
"focus-visible:ring-ring/50 size-6 rounded-full border text-xs tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none",
sev === lvl
? "border-primary bg-primary/15 text-primary font-semibold"
: "border-border text-muted-foreground hover:bg-accent",
@@ -411,7 +501,7 @@ export function LogDaySheet({
value={bbt}
onChange={(e) => setBbt(e.target.value)}
placeholder={t("cycle.sheet.temperaturePlaceholder")}
className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2"
className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
aria-label={t("cycle.sheet.temperature")}
/>
</Field>
@@ -484,9 +574,7 @@ export function LogDaySheet({
<Chip
key={v}
active={pregnancyTest === v}
onClick={() =>
setPregnancyTest((cur) => (cur === v ? null : v))
}
onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
>
{t(`cycle.testResult.${v}`)}
</Chip>
@@ -518,9 +606,7 @@ export function LogDaySheet({
<Chip
key={v}
active={contraceptive === v}
onClick={() =>
setContraceptive((cur) => (cur === v ? null : v))
}
onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
>
{t(`cycle.contraceptive.${v}`)}
</Chip>
@@ -540,7 +626,7 @@ export function LogDaySheet({
/>
</Field>
{logDay.isError ? (
{logDay.isError || patchDay.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("cycle.sheet.saveError")}
</p>
+68 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { Sparkles } from "lucide-react";
import { Check, Sparkles } from "lucide-react";
import {
Card,
@@ -107,7 +107,11 @@ export function PredictionsPanel({
{prediction.predictedOvulation ? (
<Row
hue={OVULATION_HUE}
label={t("cycle.predictions.ovulation")}
label={
prediction.ovulationConfirmed
? t("cycle.predictions.ovulationConfirmed")
: t("cycle.predictions.ovulation")
}
value={formatDate(prediction.predictedOvulation)}
/>
) : null}
@@ -178,6 +182,13 @@ function Learning({ title, body }: { title: string; body: string }) {
);
}
/** Inclusive day span between two `YYYY-MM-DD` dates (noon-UTC anchored). */
function daySpan(from: string, to: string): number {
const a = Date.parse(`${from}T12:00:00Z`);
const b = Date.parse(`${to}T12:00:00Z`);
return Math.round((b - a) / 86_400_000) + 1;
}
function HistoryCard({
history,
}: {
@@ -185,6 +196,11 @@ function HistoryCard({
}) {
const { t } = useTranslations();
const stats = history?.stats;
// Most-recent observed cycles (the spread the regularity number summarises —
// seeing it builds trust; the data was already on the wire). QA H2.
const observed = (history?.cycles ?? [])
.filter((c) => !c.isPredicted)
.slice(0, 6);
return (
<Card>
@@ -196,7 +212,7 @@ function HistoryCard({
</CardDescription>
) : null}
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{!stats || stats.avgLengthDays == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
@@ -225,6 +241,55 @@ function HistoryCard({
) : null}
</dl>
)}
{observed.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.history.recentCycles")}
</p>
<ul
className="divide-border divide-y"
data-slot="cycle-history-list"
>
{observed.map((c) => {
const periodLen =
c.periodEndDate != null
? daySpan(c.startDate, c.periodEndDate)
: null;
return (
<li
key={c.id}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<span className="text-foreground tabular-nums">
{formatDate(c.startDate)}
{" – "}
{c.endDate
? formatDate(c.endDate)
: t("cycle.history.ongoing")}
</span>
<span className="text-muted-foreground flex items-center gap-2 tabular-nums">
{c.lengthDays != null
? t("cycle.history.days", { count: c.lengthDays })
: "—"}
{periodLen != null ? (
<span className="text-xs">
{t("cycle.history.periodDays", { count: periodLen })}
</span>
) : null}
{c.ovulationConfirmed ? (
<Badge variant="secondary" className="gap-1 text-xs">
<Check className="h-3 w-3" aria-hidden="true" />
{t("cycle.history.ovulationChip")}
</Badge>
) : null}
</span>
</li>
);
})}
</ul>
</div>
) : null}
</CardContent>
</Card>
);
+2 -5
View File
@@ -5,11 +5,7 @@
* type the unwrapped `(await res.json()).data`.
*/
export type CyclePhase =
| "MENSTRUAL"
| "FOLLICULAR"
| "OVULATORY"
| "LUTEAL";
export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
export type FlowLevel = "NONE" | "SPOTTING" | "LIGHT" | "MEDIUM" | "HEAVY";
@@ -57,6 +53,7 @@ export interface CyclePrediction {
fertileWindowStart: string | null;
fertileWindowEnd: string | null;
predictedOvulation: string | null;
ovulationConfirmed: boolean;
confidence: number;
cyclesObserved: number;
stillLearning: boolean;
+70
View File
@@ -18,10 +18,16 @@ import {
} from "@/lib/query-keys";
import type {
CalendarResponse,
CervicalMucus,
ContraceptiveKind,
CycleDayLogDTO,
CycleDayLogInput,
CycleHistoryResponse,
CycleProfileDTO,
CycleSymptomSelection,
FlowLevel,
HomeTestResult,
OvulationTest,
} from "./types";
import type { CyclePhaseCrosstabRow } from "./cycle-phase-crosstab";
@@ -137,6 +143,49 @@ export function useLogDay() {
});
}
/**
* The PATCH body for editing an existing day-log. Every field nullable so the
* web sheet can CLEAR a previously-set value (the POST merge can only add or
* keep — it never nulls an omitted field, so an edit that deselects a chip must
* route through PATCH with an explicit null).
*/
export interface CycleDayLogPatch {
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?: CycleSymptomSelection[];
note?: string | null;
}
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
export function usePatchDayLog() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
id,
patch,
}: {
id: string;
patch: CycleDayLogPatch;
}) => {
const res = await fetch(`/api/cycle/day-logs/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return unwrap<CycleDayLogDTO>(res);
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
});
}
export function useStartPeriod() {
const qc = useQueryClient();
return useMutation({
@@ -192,6 +241,27 @@ export function useDeleteDayLog() {
});
}
/**
* Hard-delete EVERY cycle row the user owns (day-logs, cycles, predictions,
* the cycle audit trail, and the cycle reminder-delivery ledger rows). The
* privacy "purge" the post-Dobbs threat model promises — distinct from the
* per-row soft-delete. Invalidates the whole cycle prefix + the nav gate.
*/
export function useDeleteAllCycleData() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
const res = await fetch("/api/cycle/all", { method: "DELETE" });
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return unwrap<{ purged: boolean }>(res);
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
void qc.invalidateQueries({ queryKey: queryKeys.authMe() });
},
});
}
export interface CyclePrefsPatch {
enabled?: boolean;
goal?: CycleProfileDTO["goal"];
+24 -29
View File
@@ -25,20 +25,14 @@ export interface WheelState {
}
/**
* The first day (`YYYY-MM-DD`) of the current cycle: the start of the most
* recent MENSTRUAL-anchored run at/before `today` with no phase gap. Shares
* the exact backward-walk `deriveWheelState` uses so the BBT chart scopes to
* the same cycle the wheel shows. Returns null when today has no phase (no
* active cycle).
* Walk back from `todayIdx` to the start of the current MENSTRUAL-anchored run:
* the first day of the most recent MENSTRUAL block at/before today with no phase
* gap. The ONE source of truth both `currentCycleStartDate` and
* `deriveWheelState` use, so the BBT chart, the day-of-cycle count, and the ring
* spans can never disagree on where the current cycle begins. Assumes
* `sorted[todayIdx].phase != null` (the callers check first).
*/
export function currentCycleStartDate(
days: CalendarDay[],
today: string,
): string | null {
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
const todayIdx = sorted.findIndex((d) => d.date === today);
if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
function findCycleStartIndex(sorted: CalendarDay[], todayIdx: number): number {
let startIdx = todayIdx;
for (let i = todayIdx; i >= 0; i--) {
const d = sorted[i];
@@ -51,7 +45,22 @@ export function currentCycleStartDate(
break;
}
}
return sorted[startIdx].date;
return startIdx;
}
/**
* The first day (`YYYY-MM-DD`) of the current cycle, or null when today has no
* phase (no active cycle). Scopes the BBT chart to the same cycle the wheel
* shows.
*/
export function currentCycleStartDate(
days: CalendarDay[],
today: string,
): string | null {
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
const todayIdx = sorted.findIndex((d) => d.date === today);
if (todayIdx < 0 || sorted[todayIdx].phase == null) return null;
return sorted[findCycleStartIndex(sorted, todayIdx)].date;
}
export function deriveWheelState(
@@ -71,21 +80,7 @@ export function deriveWheelState(
return { dayOfCycle: null, cycleLength: null, phase: null, spans: [] };
}
// Walk back to the start of the current MENSTRUAL-anchored run: the first
// day of the most recent MENSTRUAL block at/before today with no gap.
let startIdx = todayIdx;
for (let i = todayIdx; i >= 0; i--) {
const d = sorted[i];
if (d.phase == null) break;
startIdx = i;
if (
d.phase === "MENSTRUAL" &&
(i === 0 || sorted[i - 1].phase !== "MENSTRUAL")
) {
break;
}
}
const startIdx = findCycleStartIndex(sorted, todayIdx);
const run = sorted.slice(startIdx, todayIdx + 1);
const dayOfCycle = run.length;
+17 -14
View File
@@ -26,11 +26,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -56,6 +52,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface PasskeyInfo {
@@ -526,7 +523,9 @@ export function AccountSection() {
{t("settings.identity.description")}
</p>
<div className="space-y-2">
<Label htmlFor="full-name">{t("settings.identity.fullName")}</Label>
<Label htmlFor="full-name">
{t("settings.identity.fullName")}
</Label>
<Input
id="full-name"
value={fullName}
@@ -538,7 +537,9 @@ export function AccountSection() {
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="insurer">{t("settings.identity.insurer")}</Label>
<Label htmlFor="insurer">
{t("settings.identity.insurer")}
</Label>
<Input
id="insurer"
value={insurerName}
@@ -573,9 +574,7 @@ export function AccountSection() {
<p
role="alert"
className={`text-sm ${
saveMsgType === "success"
? "text-success"
: "text-destructive"
saveMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{saveMsg}
@@ -601,6 +600,11 @@ export function AccountSection() {
timezone dropdown. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
{/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
lets any account opt in (or opt out) before the gated /cycle page is
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Passkeys card */}
<div className="bg-card border-border rounded-xl border p-6">
<SettingsCardHeader
@@ -627,9 +631,7 @@ export function AccountSection() {
<p
role="alert"
className={`mt-2 text-right text-sm ${
passkeyMsgType === "success"
? "text-success"
: "text-destructive"
passkeyMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{passkeyMsg}
@@ -1074,7 +1076,8 @@ function AvatarSection() {
const res = await fetch("/api/user/avatar", { method: "POST", body });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 415) throw new Error(t("settings.avatar.invalidType"));
if (res.status === 415)
throw new Error(t("settings.avatar.invalidType"));
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
throw new Error(json.error || t("settings.avatar.error"));
}
@@ -0,0 +1,101 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { CalendarHeart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.15.0 — the cycle-tracking enable on-ramp.
*
* The cycle vertical auto-enables for accounts set to female (gender-derived
* default), but everyone else needs a way IN: the gated `/cycle` page bounces
* them home and the in-page settings (where the goal/prefs live) sit behind
* that same gate. This card reads the UNGATED `/api/auth/me/cycle-prefs` so any
* account can flip `cycleTrackingEnabled` on (or a female account can opt out)
* before the gated page is reachable. PATCHes the same prefs route the iOS app
* uses; invalidates the nav gate so the sidebar/bottom-nav entry appears.
*/
interface CyclePrefsShape {
cycleTrackingEnabled: boolean;
}
export function CycleTrackingCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: queryKeys.cyclePrefs(),
enabled: isAuthenticated,
queryFn: async () => {
const res = await fetch("/api/auth/me/cycle-prefs");
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return (await res.json()).data as CyclePrefsShape;
},
});
const toggle = useMutation({
mutationFn: async (enabled: boolean) => {
const res = await fetch("/api/auth/me/cycle-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return (await res.json()).data as CyclePrefsShape;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs() });
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
// The nav gate reads the resolved enable state off /auth/me.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
});
const enabled = data?.cycleTrackingEnabled ?? false;
return (
<div className="bg-card rounded-lg border p-4 sm:p-6">
<SettingsCardHeader
icon={CalendarHeart}
title={t("settings.cycleTracking.title")}
description={t("settings.cycleTracking.description")}
/>
<div className="mt-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="cycle-tracking-enable" className="text-sm">
{t("settings.cycleTracking.enable")}
</Label>
<Switch
id="cycle-tracking-enable"
checked={enabled}
disabled={toggle.isPending || data == null}
onCheckedChange={(v) => toggle.mutate(v)}
className="mt-0.5 shrink-0"
/>
</div>
{enabled ? (
<Button variant="outline" size="sm" asChild>
<Link href="/cycle">{t("settings.cycleTracking.manage")}</Link>
</Button>
) : null}
{toggle.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.cycleTracking.error")}
</p>
) : null}
</div>
</div>
);
}