mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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.
492 lines
16 KiB
TypeScript
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() });
|
|
},
|
|
});
|
|
}
|