mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(forms): send one Idempotency-Key per opened create form
A mood, symptom, lab value, custom metric value, unscheduled dose, allergy, vaccination, visit or measurement saved on a weak signal could be stored twice: the POST committed, the answer missed the 15 s timeout, the form said it could not save, and the second tap was a new call with a new key, so the server ran it again. useIdempotentPost keeps one key until a save lands and renews it for the next entry, so the retry replays the first answer. The create forms and the three create hooks post through it; the comment on the per-call key in api-fetch now says what that key does and does not cover. LabForm and MoodForm each gain the one line that mounts the key, so their function-size baselines go up by one. The intake toast guard now recognises a dose posted through the hook.
This commit is contained in:
1 parent
121b23f4a2
commit
f761500247
15 files changed
+172
-35
No files matched your search
@@ -11,8 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -56,6 +56,7 @@ export function CustomMetricEntryForm({
|
||||
const { t } = useTranslations();
|
||||
const formId = useId();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
const [measuredAt, setMeasuredAt] = useState(() => nowLocal());
|
||||
@@ -86,7 +87,7 @@ export function CustomMetricEntryForm({
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiPost<CustomMetricEntryDto>(
|
||||
await post<CustomMetricEntryDto>(
|
||||
`/api/custom-metrics/${customMetricId}/entries`,
|
||||
body,
|
||||
);
|
||||
|
||||
@@ -399,7 +399,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not
|
||||
// AFTER the intake POST landed, and after the sheet was told it is done:
|
||||
// the save does not wait for the refetches before it closes.
|
||||
expect(componentSrc).toMatch(
|
||||
/async function handleSubmit\([\s\S]*?apiPost[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
|
||||
/async function handleSubmit\([\s\S]*?await post<[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/,
|
||||
);
|
||||
// The bundle carries every inline compliance chart, so no second sweep.
|
||||
expect(medicationDependentKeys).toContainEqual(
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
} from "@/lib/medications/default-medication";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
intakeToastOptions,
|
||||
@@ -119,6 +120,7 @@ export function MedicationIntakeQuickAdd({
|
||||
}: MedicationIntakeQuickAddProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
// v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same
|
||||
// source the cards use); Berlin stays the last-resort fallback so
|
||||
// logged-out mounts behave unchanged.
|
||||
@@ -226,7 +228,7 @@ export function MedicationIntakeQuickAdd({
|
||||
// v1.30.1 — the route returns the created event
|
||||
// (`apiSuccess(event, 201)`), same shape the card's take/skip path
|
||||
// already reads; the id drives the new Undo action below.
|
||||
const created = await apiPost<{ id?: string } | undefined>(
|
||||
const created = await post<{ id?: string } | undefined>(
|
||||
`/api/medications/${medicationId}/intake`,
|
||||
{
|
||||
takenAt: timestamp,
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
isUnreadableRange,
|
||||
@@ -144,6 +145,7 @@ export function LabForm({
|
||||
const labNumber = useLabNumber();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
const formId = useId();
|
||||
|
||||
const { data: catalog, isLoading: catalogLoading } = useQuery({
|
||||
@@ -265,7 +267,7 @@ export function LabForm({
|
||||
const printed = isQualitative
|
||||
? null
|
||||
: parseReferenceRange(sourceRange, selected?.unit);
|
||||
const created = await apiPost<LabResultDto>("/api/labs", {
|
||||
const created = await post<LabResultDto>("/api/labs", {
|
||||
biomarkerId,
|
||||
...(isQualitative
|
||||
? { valueText: qualitativeValue }
|
||||
|
||||
@@ -33,8 +33,8 @@ import {
|
||||
measurementDependentKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
|
||||
import {
|
||||
getLastUsedMeasurementType,
|
||||
@@ -327,6 +327,7 @@ export function MeasurementForm({
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
const { user } = useAuth();
|
||||
// Glucose is its own display axis, separate from metric/imperial: it is
|
||||
@@ -448,7 +449,7 @@ export function MeasurementForm({
|
||||
});
|
||||
}
|
||||
|
||||
await apiPost("/api/measurements", batch);
|
||||
await post("/api/measurements", batch);
|
||||
} else if (isKetoneMode) {
|
||||
// One fingerstick, posted as one transaction (`ketone-fingerstick.ts`).
|
||||
const batch = ketoneFingerstickBatch({
|
||||
@@ -463,7 +464,7 @@ export function MeasurementForm({
|
||||
setError(t("measurements.saveError"));
|
||||
return;
|
||||
}
|
||||
await apiPost("/api/measurements", batch);
|
||||
await post("/api/measurements", batch);
|
||||
} else {
|
||||
// Single measurement. Canonical storage stays SI: an imperial user
|
||||
// types in lb / in / °F and we invert to the canonical unit at the
|
||||
@@ -508,7 +509,7 @@ export function MeasurementForm({
|
||||
: inverted;
|
||||
}
|
||||
canonicalValue = entryValueToCanonical(type, canonicalValue);
|
||||
await apiPost("/api/measurements", {
|
||||
await post("/api/measurements", {
|
||||
type,
|
||||
value: canonicalValue,
|
||||
measuredAt: timestamp,
|
||||
|
||||
@@ -42,8 +42,8 @@ import {
|
||||
moodDependentKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
import {
|
||||
@@ -125,6 +125,7 @@ export function MoodForm({
|
||||
}: MoodFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const post = useIdempotentPost();
|
||||
|
||||
const [mood, setMood] = useState("");
|
||||
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
|
||||
@@ -236,7 +237,7 @@ export function MoodForm({
|
||||
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
const saved = await apiPost<DroppedLinkKeysWire>("/api/mood-entries", {
|
||||
const saved = await post<DroppedLinkKeysWire>("/api/mood-entries", {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { apiPut } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
allergyCategoryEnum,
|
||||
@@ -67,6 +68,7 @@ export function AllergyForm({
|
||||
footerSlot,
|
||||
}: AllergyFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const post = useIdempotentPost();
|
||||
const formId = useId();
|
||||
|
||||
const [substance, setSubstance] = useState(existing?.substance ?? "");
|
||||
@@ -106,7 +108,7 @@ export function AllergyForm({
|
||||
try {
|
||||
const saved = existing
|
||||
? await apiPut<AllergyDTO>(`/api/allergies/${existing.id}`, body)
|
||||
: await apiPost<AllergyDTO>("/api/allergies", body);
|
||||
: await post<AllergyDTO>("/api/allergies", body);
|
||||
toast.success(
|
||||
existing
|
||||
? t("records.allergies.form.updatedToast")
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
SymptomDefinitionDTO,
|
||||
@@ -184,11 +185,13 @@ export function useDeleteSymptomDefinition() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Under one key for as long as the entry form that mounts it is open. */
|
||||
export function useLogSymptomEvent() {
|
||||
const invalidate = useInvalidateSymptoms();
|
||||
const post = useIdempotentPost();
|
||||
return useMutation({
|
||||
mutationFn: (input: SymptomEventCreateInput) =>
|
||||
apiPost<SymptomEventDTO>("/api/symptoms/events", input),
|
||||
post<SymptomEventDTO>("/api/symptoms/events", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
invalidateKeys,
|
||||
@@ -133,10 +134,11 @@ export function useVaccinationMutations() {
|
||||
invalidateReminderReads(qc),
|
||||
]);
|
||||
|
||||
// One key for as long as the sheet that mounts this is open.
|
||||
const post = useIdempotentPost();
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.vaccinationCreate(),
|
||||
mutationFn: (body: VaccinationWriteBody) =>
|
||||
apiPost<Vaccination>(BASE, body),
|
||||
mutationFn: (body: VaccinationWriteBody) => post<Vaccination>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user