Files
HealthLog/src/lib/queries/use-medication-list-layout.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

149 lines
5.1 KiB
TypeScript

"use client";
import {
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import {
DEFAULT_MEDICATION_LIST_LAYOUT,
type MedicationListLayoutWithToken,
type MedicationListView,
} from "@/lib/medication-list-layout";
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* v1.16.10 — the /medications presentation preference (cards/table view
* + manual order), persisted per user through
* `GET`/`PUT /api/medications/layout`.
*
* The PUT is field-scoped: the server preserves whichever of `view` /
* `order` the body omits, so the two writers below each send only the
* field they own and can never wipe the other one.
*
* The orchestration lives in dependency-injected `run*` functions (the
* repo convention from `use-medication-intake.ts`) so the optimistic
* write + rollback contract is unit-testable without a React render.
*/
/**
* Flip the view optimistically (the toggle must feel instant), then
* persist. On failure the cache rolls back to the previous layout and
* a calm toast surfaces — the user is back on the view they came from.
*/
export async function runSetMedicationListView(deps: {
view: MedicationListView;
queryClient: QueryClient;
t: Translator;
}): Promise<void> {
const { view, queryClient, t } = deps;
const key = queryKeys.medicationListLayout();
const previous = queryClient.getQueryData<MedicationListLayoutWithToken>(key);
queryClient.setQueryData<MedicationListLayoutWithToken>(key, {
...(previous ?? DEFAULT_MEDICATION_LIST_LAYOUT),
view,
});
try {
// v1.32.21 — echo the base token so a concurrent order Save can't be
// resurrected by this view toggle (and vice versa).
const saved = await apiPut<MedicationListLayoutWithToken>(
"/api/medications/layout",
withBaseToken({ version: 1, view }, readUpdatedAtToken(queryClient, key)),
);
if (saved) queryClient.setQueryData(key, saved);
} catch (err) {
// Instant-write disposition: roll back the optimistic view. On a 409 also
// invalidate so the token advances, and nudge instead of a hard error.
queryClient.setQueryData(key, previous);
if (isConflict(err)) {
void queryClient.invalidateQueries({ queryKey: key });
toast.message(t("common.conflictReloaded"));
return;
}
showErrorToast(t("medications.viewSaveFailed"), { cause: err });
}
}
/**
* Persist a manual medication order (the reorder dialog's Save). Not
* optimistic — the dialog stays open until the PUT resolves, mirroring
* the dashboard layout section's explicit Save. Returns whether the
* save landed so the dialog knows to close.
*/
export async function runSaveMedicationListOrder(deps: {
order: string[];
queryClient: QueryClient;
t: Translator;
}): Promise<boolean> {
const { order, queryClient, t } = deps;
const key = queryKeys.medicationListLayout();
try {
// v1.32.21 — echo the base token so this order Save 409s rather than
// clobbering a view toggle that committed since the dialog opened.
const saved = await apiPut<MedicationListLayoutWithToken>(
"/api/medications/layout",
withBaseToken(
{ version: 1, order },
readUpdatedAtToken(queryClient, key),
),
);
if (saved) {
queryClient.setQueryData(key, saved);
}
toast.success(t("medications.reorderSaved"));
return true;
} catch (err) {
// Explicit-Save disposition: on a 409 refetch so the token advances, keep
// the dialog open (return false), nudge gently.
if (isConflict(err)) {
void queryClient.invalidateQueries({ queryKey: key });
toast.message(t("common.conflictReloaded"));
return false;
}
showErrorToast(t("medications.reorderSaveFailed"), { cause: err });
return false;
}
}
export function useMedicationListLayout(enabled: boolean = true): {
/** Resolved layout (plus the optimistic-concurrency token) — defaults until the GET lands. */
layout: MedicationListLayoutWithToken;
/** True while the preference GET is in flight (first load only). */
isLayoutLoading: boolean;
setView: (view: MedicationListView) => Promise<void>;
saveOrder: (order: string[]) => Promise<boolean>;
} {
const queryClient = useQueryClient();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.medicationListLayout(),
queryFn: () =>
apiGet<MedicationListLayoutWithToken>("/api/medications/layout"),
staleTime: 5 * 60 * 1000,
enabled,
});
return {
layout: data ?? DEFAULT_MEDICATION_LIST_LAYOUT,
isLayoutLoading: enabled && isLoading,
setView: (view) => runSetMedicationListView({ view, queryClient, t }),
saveOrder: (order) => runSaveMedicationListOrder({ order, queryClient, t }),
};
}