mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user