Files
HealthLog/src/components/cycle/use-cycle.ts
T
Marc-André Bombeck dc5120b59a feat(lists): read and show the list cap on every capped list
Twelve routes answer a capped list with meta.truncated / meta.limit on
the envelope, and the web client read only data, so no surface said a
list had stopped short. apiGetList / apiPostList are now the one client
reader of the cap and ListCapNotice the one line that shows it; every
capped list on the web reads its cap and shows the notice under the list,
the paged readers (side effects, illness episodes) stop on the route's
answer instead of a full-page guess, and the ECG list moves its five
readers onto one hook. The record activity feed sent its cap twice, in
data and on the envelope; it rides the envelope alone now. A guard
derives the capped routes from the tree and binds each to a client that
reads its cap.
2026-10-20 10:04:11 +02:00

492 lines
16 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 { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
ApiError,
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { randomId } from "@/lib/random-id";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useCalendarDate } from "@/hooks/use-calendar-date";
import { readFailureKind } from "@/lib/api/read-failure";
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,
});
}
/**
* The calendar read for one window. `enabled` exists for the month grid's own
* read: the page's anchored window covers today, and a month the grid
* navigates outside it needs a second, bounded read rather than a widened
* anchor the route's span cap would refuse.
*/
export function useCycleCalendar(from: string, to: string, enabled = true) {
return useQuery({
queryKey: queryKeys.cycleCalendar(from, to),
enabled,
queryFn: () =>
apiGet<CalendarResponse>(`/api/cycle/calendar?from=${from}&to=${to}`),
staleTime: 60_000,
});
}
/** YYYY-MM-DD for `days` from today in the local zone. */
function localYmdFromToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
/**
* The calendar window the cycle ring reads: the cycle page's own window, so
* the ring, the page and the summary card share one cache entry.
*/
export function useCycleRingCalendar(enabled = true) {
const from = useMemo(() => localYmdFromToday(-90), []);
const to = useMemo(() => localYmdFromToday(180), []);
return useCycleCalendar(from, to, enabled);
}
/**
* Whether the cycle ring would draw a dial today, and whether that is still
* unknown. The wellness strip asks this before it gives the ring a cell (and
* takes Strain's place for it): the ring renders nothing without an active
* cycle, and a cell reserved for it would sit empty in the row. While the
* calendar read is `pending` the strip holds its skeleton, so it never paints
* Strain and then swaps it for the ring. A failed read draws no dial but is
* `failed`: the strip still gives the ring its cell, where the tile says the
* read failed and offers a retry. Settling it to "no dial" passed a failure
* off as a day without an active cycle.
*/
export function useCycleRingDial(enabled: boolean): {
dial: boolean;
pending: boolean;
failed: boolean;
} {
const calendar = useCycleRingCalendar(enabled);
const verdict = calendar.data?.verdict;
return {
dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null,
pending: enabled && calendar.isPending,
// A refused read is not a failure to report: the tile would draw nothing
// and leave its cell empty.
failed:
enabled &&
calendar.isError &&
!calendar.data &&
readFailureKind(calendar.error) !== "refused",
};
}
/** The latest `limit` cycles, with the route's cap beside them. */
export function useCycleHistory(limit = 24) {
return useQuery({
queryKey: queryKeys.cycleHistory(limit),
queryFn: () =>
apiGetList<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: (failure) =>
showErrorToast(t("cycle.deleteError"), { cause: failure }),
});
}
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() {
// The lagged rows come back with a narrated `interpretation` in the reader's
// language, so the cache cell belongs to one language — otherwise a switch
// would keep serving the sentence written for the previous one.
const { locale } = useTranslations();
return useQuery({
queryKey: queryKeys.cycleInsights(locale),
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 randomId();
}
/**
* Capture one day.
*
* The success toast is not decoration. A day-log write is silent by nature —
* the sheet closes and the grid repaints — and for a back-dated entry the grid
* had nothing to repaint, so a save that worked and a save that never happened
* looked identical. A write says so now, either way: this names the success and
* the sheet's inline strip names the failure.
*/
export function useLogDay() {
const qc = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (input: CycleDayLogInput) => {
return apiPost<unknown>("/api/cycle/day-logs", input, {
headers: { "Idempotency-Key": idempotencyKey() },
});
},
onSuccess: () => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.saveSuccess"));
},
});
}
/**
* 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;
}
/**
* A period start the server folded in or gave back (v1.43). The server names
* it; the toast only says it, so the person learns which date moved without
* comparing the calendar before and after.
*/
export interface CycleStartRef {
cycleId: string;
startDate: string;
}
/** The toast after a write that may have given starts back. */
function useRestoredStartsMessage() {
const { t } = useTranslations();
const formatDay = useCalendarDate();
return (fallback: string, restored: readonly CycleStartRef[] | undefined) =>
restored && restored.length > 0
? t("cycle.startRestored", {
dates: restored.map((r) => formatDay(r.startDate)).join(", "),
})
: fallback;
}
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
export function usePatchDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async ({
id,
patch,
}: {
id: string;
patch: CycleDayLogPatch;
}) => {
return apiPatch<CycleDayLogDTO & { restored?: CycleStartRef[] }>(
`/api/cycle/day-logs/${id}`,
patch,
);
},
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.saveSuccess"), data?.restored),
);
},
});
}
export function useStartPeriod() {
const qc = useQueryClient();
const { t } = useTranslations();
const formatDay = useCalendarDate();
return useMutation({
mutationFn: async (date: string) => {
return apiPost<{ absorbed?: CycleStartRef | null }>("/api/cycle/period", {
action: "start",
date,
loggedAt: new Date().toISOString(),
});
},
onSuccess: (data, date) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome(
"success",
data?.absorbed
? t("cycle.startAbsorbed", {
absorbed: formatDay(data.absorbed.startDate),
date: formatDay(date),
})
: t("cycle.periodStartSaved"),
);
},
// v1.16.4 — period boundaries had no failure signal at all (the sheet
// simply stayed open); a toast names the rejection.
onError: (failure) =>
showErrorToast(t("cycle.saveError"), { cause: failure }),
});
}
/** 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: () => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome("success", t("cycle.periodEndSaved"));
},
onError: (failure) =>
showErrorToast(t("cycle.saveError"), { cause: failure }),
});
}
/** Soft-delete a logged day by its row id (the GET supplies the id). */
export function useDeleteDayLog() {
const qc = useQueryClient();
const { t } = useTranslations();
const restoredMessage = useRestoredStartsMessage();
return useMutation({
mutationFn: async (id: string) => {
// Ask for what the delete changed: removing a start day can give back a
// start it had folded in, and the toast names it. A server that does
// not know the preference answers 204, read as nothing given back.
return apiDelete<{ restored?: CycleStartRef[] } | null>(
`/api/cycle/day-logs/${id}`,
undefined,
{ headers: { Prefer: "return=representation" } },
);
},
onSuccess: (data) => {
void invalidateKeys(qc, cycleDependentKeys);
toastWrittenOutcome(
"success",
restoredMessage(t("cycle.deleteSuccess"), data?.restored),
);
},
onError: (failure) =>
showErrorToast(t("cycle.deleteError"), { cause: failure }),
});
}
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() });
},
});
}