Files
HealthLog/src/components/cycle/use-cycle.ts
T
Marc-André Bombeck e832222402 feat(cycle): surface the cervix secondary-symptom choice in the UI
- Advanced cycle settings gains a "secondary fertility sign" selector
  (cervical mucus default | cervix observation). It sits below the priors
  in the advanced settings so it never forces itself on a casual user.
- The log-day sheet shows the cervix entry — position / firmness / opening
  chips — only when the user picked cervix observation; otherwise the
  cervical-mucus picker stays as before. Progressive disclosure: exactly
  one secondary sign is offered at a time so the sheet never overwhelms.
- Web client types, the cyclePrefs patch, and the day-log payload builders
  carry the new fields; copy added across all six locales.
2026-06-14 15:33:11 +02:00

349 lines
10 KiB
TypeScript

"use client";
/**
* v1.15.0 — cycle read + write hooks.
*
* Reads unwrap `(await res.json()).data` per the project envelope rule;
* every key is factory-routed through `queryKeys.cycle*`. Writes invalidate
* the whole `["cycle"]` prefix via `cycleDependentKeys` so the calendar,
* wheel, predictions panel, and history repaint in lockstep after a quick
* log.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
cycleDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import type {
CalendarResponse,
CervicalMucus,
CervixFirmness,
CervixOpening,
CervixPosition,
ContraceptiveKind,
CycleDayLogDTO,
CycleDayLogInput,
CycleHistoryResponse,
CycleProfileDTO,
CycleSymptomSelection,
FlowLevel,
HomeTestResult,
OvulationTest,
} from "./types";
import type { CyclePhaseCrosstabRow } from "./cycle-phase-crosstab";
/** One symptom's per-phase clustering (mirrors `SymptomPhasePatternRow`). */
export interface SymptomPhaseRow {
symptomKey: string;
counts: {
MENSTRUAL: number;
FOLLICULAR: number;
OVULATORY: number;
LUTEAL: number;
};
total: number;
topPhase: "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
topShare: number;
}
/** The `/api/cycle/insights` read: the phase-contrast rows + the headline. */
export interface CycleInsightsResponse {
rows: CyclePhaseCrosstabRow[];
headline: CyclePhaseCrosstabRow | null;
symptomPatterns: SymptomPhaseRow[];
}
/**
* The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys
* are tz-anchored on the server; the client must derive the key from the
* user's wall-clock day, never from `toISOString()` (which is UTC and rolls
* the date over near midnight).
*/
export function localYmd(d: Date = new Date()): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/**
* Read the full day-log for one date so the log-day sheet pre-fills (no
* blank-sheet data loss) and Delete can resolve the row id. `null` when
* nothing is logged that day. Enabled only when a date is supplied.
*/
export function useCycleDayLog(date: string | null) {
return useQuery({
queryKey: queryKeys.cycleDayLog(date ?? ""),
enabled: date !== null,
queryFn: () =>
apiGet<CycleDayLogDTO | null>(`/api/cycle/day-logs?date=${date}`),
staleTime: 30_000,
});
}
export function useCycleCalendar(from: string, to: string) {
return useQuery({
queryKey: queryKeys.cycleCalendar(from, to),
queryFn: () =>
apiGet<CalendarResponse>(`/api/cycle/calendar?from=${from}&to=${to}`),
staleTime: 60_000,
});
}
export function useCycleHistory(limit = 24) {
return useQuery({
queryKey: queryKeys.cycleHistory(limit),
queryFn: () =>
apiGet<CycleHistoryResponse>(`/api/cycle/cycles?limit=${limit}`),
staleTime: 60_000,
});
}
/** One per-user custom symptom: a `custom:<uuid>` key + decrypted label. */
export interface CustomSymptomDTO {
key: string;
label: string | null;
icon: string | null;
custom: true;
}
/**
* The caller's own custom symptoms, labels decrypted server-side. The log-day
* sheet merges these into the seeded chip grid under the `custom` category.
*/
export function useCustomSymptoms() {
return useQuery({
queryKey: queryKeys.cycleCustomSymptoms(),
queryFn: () =>
apiGet<{ symptoms: CustomSymptomDTO[] }>("/api/cycle/symptoms/custom"),
staleTime: 5 * 60_000,
});
}
/** The `errorCode` the create POST returns when the per-user cap is hit. */
export const CUSTOM_SYMPTOM_LIMIT_ERROR_CODE = "cycle.symptom.custom.limit";
/** An error that preserves the envelope `errorCode` so callers can branch. */
export class CustomSymptomError extends Error {
constructor(
public readonly errorCode: string | null,
public readonly status: number,
) {
super(errorCode ?? `Request failed: ${status}`);
this.name = "CustomSymptomError";
}
}
/** Create a custom symptom (mint `custom:<uuid>`, encrypt the label). */
export function useCreateCustomSymptom() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: { label: string; icon?: string | null }) => {
try {
return await apiPost<CustomSymptomDTO>(
"/api/cycle/symptoms/custom",
input,
);
} catch (e) {
// Preserve the envelope errorCode so the UI can tell the cap hit
// (cycle.symptom.custom.limit) apart from a transient/validation error.
if (e instanceof ApiError) {
throw new CustomSymptomError(
(e.meta?.errorCode as string | undefined) ?? null,
e.status,
);
}
throw e;
}
},
onSuccess: () =>
qc.invalidateQueries({ queryKey: queryKeys.cycleCustomSymptoms() }),
});
}
/** Soft-hide a custom symptom by its key (history-preserving). */
export function useDeleteCustomSymptom() {
const qc = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (key: string) => {
return apiDelete<{ key: string; purged: boolean }>(
`/api/cycle/symptoms/custom/${encodeURIComponent(key)}`,
);
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
// v1.16.4 — a failed delete used to fail silently (the sheet clears the
// chip optimistically), so surface the rejection.
onError: () => toast.error(t("cycle.deleteError")),
});
}
export function useCycleProfile() {
return useQuery({
queryKey: queryKeys.cycleProfile(),
queryFn: () =>
apiGet<CycleProfileDTO>("/api/cycle/profile"),
staleTime: 5 * 60_000,
});
}
/**
* The FDR-guarded phase-correlation surface: the luteal-vs-follicular contrast
* rows + the one headline finding. Gated server-side; reads only on the cycle
* insights tab.
*/
export function useCycleInsights() {
return useQuery({
queryKey: queryKeys.cycleInsights(),
queryFn: () =>
apiGet<CycleInsightsResponse>("/api/cycle/insights"),
staleTime: 5 * 60_000,
});
}
/** A fresh idempotency key per write attempt (matches the iOS Outbox shape). */
function idempotencyKey(): string {
return crypto.randomUUID();
}
export function useLogDay() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: CycleDayLogInput) => {
return apiPost<unknown>("/api/cycle/day-logs", input, {
headers: { "Idempotency-Key": idempotencyKey() },
});
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
});
}
/**
* 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;
temperatureExcluded?: boolean;
ovulationTest?: OvulationTest | null;
cervicalMucus?: CervicalMucus | null;
cervixPosition?: CervixPosition | null;
cervixFirmness?: CervixFirmness | null;
cervixOpening?: CervixOpening | 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;
}) => {
return apiPatch<CycleDayLogDTO>(`/api/cycle/day-logs/${id}`, patch);
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
});
}
export function useStartPeriod() {
const qc = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (date: string) => {
return apiPost<unknown>("/api/cycle/period", {
action: "start",
date,
loggedAt: new Date().toISOString(),
});
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
// v1.16.4 — period boundaries had no failure signal at all (the sheet
// simply stayed open); a toast names the rejection.
onError: () => toast.error(t("cycle.saveError")),
});
}
/** The one-tap "end period" boundary (`action:"end"`). */
export function useEndPeriod() {
const qc = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (date: string) => {
return apiPost<unknown>("/api/cycle/period", {
action: "end",
date,
loggedAt: new Date().toISOString(),
});
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
onError: () => toast.error(t("cycle.saveError")),
});
}
/** Soft-delete a logged day by its row id (the GET supplies the id). */
export function useDeleteDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/cycle/day-logs/${id}`);
},
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
onError: () => toast.error(t("cycle.deleteError")),
});
}
export interface CyclePrefsPatch {
enabled?: boolean;
goal?: CycleProfileDTO["goal"];
secondarySymptom?: CycleProfileDTO["secondarySymptom"];
rawChartMode?: boolean;
predictionEnabled?: boolean;
discreetNotifications?: boolean;
sensitiveCategoryEncryption?: boolean;
typicalCycleLength?: number | null;
typicalPeriodLength?: number | null;
lutealPhaseLength?: number | null;
}
export function useUpdateCyclePrefs() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: CyclePrefsPatch) => {
return apiPatch<CycleProfileDTO>("/api/auth/me/cycle-prefs", patch);
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
// The enable flag + goal live on /api/auth/me too — repaint the nav gate.
void qc.invalidateQueries({ queryKey: queryKeys.authMe() });
},
});
}