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

+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>
);
}