mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge wave-med-staleness: reflect supply changes on the next read
The medication card and table render dose-derived stock from the cached medications list, but the inventory write routes evicted no server cache and the client invalidated only the per-item inventory key, so a new container's stock kept serving the pre-write value for the stale window. Hard-evict the user's medication caches on every supply write and invalidate the list key alongside the inventory key on the client.
This commit is contained in:
commit
876e94b5c4
6 files changed
+130
-10
No files matched your search
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Supply-staleness regression — the medications-list card / table read
|
||||
* the dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`)
|
||||
* straight from the `GET /api/medications` list payload. The add / adjust /
|
||||
* delete supply mutations used to invalidate only the per-medication
|
||||
* inventory query, leaving the list query (`queryKeys.medications()`)
|
||||
* serving the pre-write stock — so the card kept showing the old supply
|
||||
* until an unrelated refetch landed.
|
||||
*
|
||||
* `invalidateSupplyQueries` is the single helper all three mutations call;
|
||||
* this pins that it drops BOTH the list key (the card / table source) and
|
||||
* the per-medication inventory key. The repo's component-test convention
|
||||
* avoids `@testing-library/react` DOM interaction, so the mutation's
|
||||
* invalidation contract is asserted through the extracted helper against a
|
||||
* minimal QueryClient stand-in.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { invalidateSupplyQueries } from "@/components/medications/sections/inventory-section";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
describe("invalidateSupplyQueries — supply staleness regression", () => {
|
||||
it("invalidates the medications LIST key AND the per-medication inventory key", async () => {
|
||||
const invalidateQueries = vi.fn().mockResolvedValue(undefined);
|
||||
const queryClient = { invalidateQueries } as unknown as Parameters<
|
||||
typeof invalidateSupplyQueries
|
||||
>[0];
|
||||
|
||||
await invalidateSupplyQueries(queryClient, "med-1");
|
||||
|
||||
const invalidatedKeys = invalidateQueries.mock.calls.map((call) =>
|
||||
JSON.stringify((call[0] as { queryKey: unknown }).queryKey),
|
||||
);
|
||||
|
||||
// The card / table source — the regression was this key being absent.
|
||||
expect(invalidatedKeys).toContain(JSON.stringify(queryKeys.medications()));
|
||||
// The per-medication supply tab still refetches too.
|
||||
expect(invalidatedKeys).toContain(
|
||||
JSON.stringify(queryKeys.medicationInventory("med-1")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,11 @@
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
|
||||
@@ -74,6 +78,31 @@ import {
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { summariseSupply } from "@/lib/medications/inventory/summary";
|
||||
|
||||
/**
|
||||
* Invalidate every read key whose payload reflects a medication's supply
|
||||
* after a container write (register / adjust / delete).
|
||||
*
|
||||
* The per-medication inventory read (`medicationInventory`) is the supply
|
||||
* tab's own list. The medications LIST read (`medications`) carries the
|
||||
* dose-derived stock (`stockUnitsRemaining` / `stockDosesRemaining`) that
|
||||
* the card and table render — without invalidating it, the card kept
|
||||
* showing the pre-write stock until an unrelated refetch landed (the
|
||||
* supply-staleness bug). Both keys must drop together.
|
||||
*/
|
||||
export async function invalidateSupplyQueries(
|
||||
queryClient: QueryClient,
|
||||
medicationId: string,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medications(),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
type InventoryState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
|
||||
|
||||
const CONTAINER_TYPES = [
|
||||
@@ -138,9 +167,7 @@ export function InventorySection({
|
||||
async function deleteItem(item: InventoryItem) {
|
||||
try {
|
||||
await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
});
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.deleteSuccess"));
|
||||
} catch {
|
||||
toast.error(t("medications.detail.bestand.deleteFailed"));
|
||||
@@ -492,9 +519,7 @@ export function AddInventoryDialog({
|
||||
? new Date(`${expiry}T00:00:00`).toISOString()
|
||||
: null,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
});
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.addSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -694,9 +719,7 @@ function AdjustInventoryDialog({
|
||||
// The wire field carries UNITS (v1.16.10 symmetric naming).
|
||||
unitsRemaining: parsed,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.medicationInventory(medicationId),
|
||||
});
|
||||
await invalidateSupplyQueries(queryClient, medicationId);
|
||||
toast.success(t("medications.detail.bestand.adjustSuccess"));
|
||||
onClose();
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user