mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(concurrency): echo the base token from the prefs write clients and document it
Wire the notification-prefs cards, the module toggles, the coach-preferences save, and the two mood-tag manage cards to echo the optimistic-concurrency token they last read and to reload on a 409 instead of resaving over a newer change. Document the token and the 409 on the four write endpoints in the OpenAPI registry via the shared base-token field and conflict-response fragments.
This commit is contained in:
1 parent
8849f2cecd
commit
3f33a30415
12 files changed
+352
-44
No files matched your search
@@ -59,6 +59,12 @@ import {
|
||||
apiPost,
|
||||
apiPut,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -72,6 +78,7 @@ import {
|
||||
snapshotManageCache,
|
||||
updateManageCache,
|
||||
type ManageCatalog,
|
||||
type MoodTagLayoutResolved,
|
||||
} from "./use-mood-tag-manage";
|
||||
|
||||
/**
|
||||
@@ -141,11 +148,31 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
|
||||
reorderGroups(current, nextOrder),
|
||||
);
|
||||
try {
|
||||
await apiPut("/api/mood/tags/layout", { groupOrder: nextOrder });
|
||||
// v1.32.22 (R5b) — echo the base token this order was based on. The two
|
||||
// cards on this page write DIFFERENT fields of the same blob (group order
|
||||
// vs tag placements); a stale merge would resurrect the other card's
|
||||
// overwritten field, which the token now 409s instead. Seed the token
|
||||
// from the response so the next write on this surface is guarded too.
|
||||
const resolved = await apiPut<MoodTagLayoutResolved>(
|
||||
"/api/mood/tags/layout",
|
||||
withBaseToken(
|
||||
{ groupOrder: nextOrder },
|
||||
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
|
||||
),
|
||||
);
|
||||
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
|
||||
toast.success(t("mood.manage.orderSaved"));
|
||||
return true;
|
||||
} catch (err) {
|
||||
rollback();
|
||||
if (isConflict(err)) {
|
||||
// Drop the stale token so a deliberate retry writes against the tree
|
||||
// the invalidation below reloads; nudge — the optimistic delta rolled
|
||||
// back and nothing was clobbered.
|
||||
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return false;
|
||||
}
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
@@ -47,6 +47,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -65,6 +71,7 @@ import {
|
||||
type ManageCatalog,
|
||||
type ManageCategory,
|
||||
type ManageTag,
|
||||
type MoodTagLayoutResolved,
|
||||
} from "./use-mood-tag-manage";
|
||||
|
||||
/**
|
||||
@@ -139,13 +146,26 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
|
||||
const rollback = await snapshotManageCache(queryClient);
|
||||
updateManageCache(queryClient, () => mergeBack(catalog, nextCatalog));
|
||||
try {
|
||||
await apiPut("/api/mood/tags/layout", {
|
||||
placements: buildVisiblePlacements(nextCatalog),
|
||||
});
|
||||
// v1.32.22 (R5b) — echo + seed the layout token (see `putGroupOrder` in
|
||||
// the groups card): the sibling group-order card writes the same blob, so
|
||||
// a stale placements merge is exactly the overwrite the token 409s.
|
||||
const resolved = await apiPut<MoodTagLayoutResolved>(
|
||||
"/api/mood/tags/layout",
|
||||
withBaseToken(
|
||||
{ placements: buildVisiblePlacements(nextCatalog) },
|
||||
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
|
||||
),
|
||||
);
|
||||
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
|
||||
toast.success(t("mood.manage.orderSaved"));
|
||||
return true;
|
||||
} catch (err) {
|
||||
rollback();
|
||||
if (isConflict(err)) {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return false;
|
||||
}
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
return false;
|
||||
} finally {
|
||||
@@ -237,12 +257,24 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
|
||||
}
|
||||
// Both kinds pin the resulting per-group order in the layout blob
|
||||
// so the resolved tree matches what the user just saw happen.
|
||||
await apiPut("/api/mood/tags/layout", {
|
||||
placements: buildVisiblePlacements(moved),
|
||||
});
|
||||
// v1.32.22 (R5b) — echo + seed the layout token so a concurrent write to
|
||||
// the same blob 409s instead of clobbering.
|
||||
const resolved = await apiPut<MoodTagLayoutResolved>(
|
||||
"/api/mood/tags/layout",
|
||||
withBaseToken(
|
||||
{ placements: buildVisiblePlacements(moved) },
|
||||
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
|
||||
),
|
||||
);
|
||||
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
|
||||
toast.success(t("common.saved"));
|
||||
} catch (err) {
|
||||
rollback();
|
||||
if (isConflict(err)) {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(err instanceof ApiError ? err.message : t("common.error"));
|
||||
} finally {
|
||||
void invalidateMoodTagCaches(queryClient);
|
||||
|
||||
@@ -62,6 +62,19 @@ export interface ManageCatalog {
|
||||
categories: ManageCategory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.32.22 (R5b) — the resolved `GET/PUT /api/mood/tags/layout` response.
|
||||
* `updatedAt` is the optimistic-concurrency token the two management cards
|
||||
* echo on their next write; no active query reads this key (it is only ever
|
||||
* invalidated), so the token is seeded straight from each write response into
|
||||
* `queryKeys.moodTagLayout()` and read back at mutate time.
|
||||
*/
|
||||
export interface MoodTagLayoutResolved {
|
||||
groupOrder: string[];
|
||||
placements: Record<string, string[]>;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export function useMoodTagManage(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.moodTagManage(),
|
||||
|
||||
@@ -26,6 +26,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
|
||||
|
||||
interface CoachPrefsShape {
|
||||
nudgesEnabled: boolean;
|
||||
@@ -39,6 +40,9 @@ interface CoachPrefsShape {
|
||||
|
||||
interface NotificationPrefsShape {
|
||||
coach: CoachPrefsShape;
|
||||
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
|
||||
// resolved prefs response, echoed on every PATCH from this card.
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const COACH_PREF_DEFAULTS: CoachPrefsShape = {
|
||||
@@ -140,10 +144,18 @@ export function CoachNudgeCard({
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
|
||||
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
|
||||
// write (the iOS client, another web card) 409s instead of reverting a
|
||||
// sibling category through the server-side deep-merge.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ coach: partial }),
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ coach: partial },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -156,6 +168,17 @@ export function CoachNudgeCard({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimistic({});
|
||||
} else if (res.status === 409) {
|
||||
// The prefs advanced since they were loaded. Revert the optimistic knob
|
||||
// and refetch (the active query reseeds a fresh token), then nudge — the
|
||||
// change did not persist, so ask the user to re-apply it.
|
||||
setOptimistic({});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimistic({});
|
||||
setMsg(t("notifications.coachNudge.saveError"));
|
||||
|
||||
@@ -93,6 +93,12 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
onSuccess: () => {
|
||||
toast.success(t("insights.coach.settingsSaved"));
|
||||
},
|
||||
// v1.32.22 (R5b) — a concurrent write advanced the row since this edit was
|
||||
// based. The hook already refetched (so the form re-syncs to server truth
|
||||
// and the token advances); nudge the user to re-apply and save again.
|
||||
onConflict: () => {
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
},
|
||||
});
|
||||
|
||||
function setExcluded(metric: CoachExcludeMetric, excluded: boolean) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
|
||||
|
||||
/**
|
||||
* v1.16.11 — medication low-stock alert threshold (Settings →
|
||||
@@ -26,6 +27,9 @@ import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface NotificationPrefsShape {
|
||||
medication: { lowStockRunwayDays: number | null; reorderLeadDays?: number };
|
||||
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
|
||||
// resolved prefs response, echoed on every PATCH from this card.
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 7;
|
||||
@@ -116,10 +120,18 @@ export function LowStockCard({
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
|
||||
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
|
||||
// write (the iOS client, another web card) 409s instead of reverting a
|
||||
// sibling category through the server-side deep-merge.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
body,
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -133,6 +145,15 @@ export function LowStockCard({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
}),
|
||||
]);
|
||||
} else if (res.status === 409) {
|
||||
// The prefs advanced since they were loaded. Refetch (the active query
|
||||
// reseeds a fresh token) and nudge — the caller reverts its optimistic
|
||||
// value, so the card snaps back to server truth for a re-apply.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
} else {
|
||||
setMsg(t("notifications.lowStock.saveError"));
|
||||
setMsgType("error");
|
||||
|
||||
@@ -65,6 +65,11 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
readUpdatedAtToken,
|
||||
withBaseToken,
|
||||
isConflict,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
MODULE_REGISTRY,
|
||||
@@ -73,6 +78,12 @@ import {
|
||||
type ModuleKey,
|
||||
} from "@/lib/modules/registry";
|
||||
|
||||
/** Shape of the `/api/auth/me/modules` PATCH response (resolved map + token). */
|
||||
type ModulesPatchResult = {
|
||||
modules: Partial<Record<ModuleKey, boolean>>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
/** Neutral Lucide glyph per toggleable module. */
|
||||
const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
cycle: CalendarHeart,
|
||||
@@ -130,7 +141,26 @@ export function ModulesSection() {
|
||||
return apiPatch("/api/auth/me/cycle-prefs", { enabled: vars.enabled });
|
||||
}
|
||||
// DISABLED allowlist: send only the single key the user flipped.
|
||||
return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled });
|
||||
//
|
||||
// v1.32.22 (R5b) — echo the optimistic-concurrency token so an
|
||||
// interleaved modules PATCH (another tab, the iOS client) 409s instead of
|
||||
// clobbering. The token rides on `User.updatedAt`; the module map itself
|
||||
// rides on `/auth/me`, and no dedicated GET populates the token, so it is
|
||||
// seeded from each write response below and read back here at mutate
|
||||
// time. The first write of a session is tokenless — the server's
|
||||
// backward-compatible unconditional arm.
|
||||
const result = await apiPatch<ModulesPatchResult>(
|
||||
"/api/auth/me/modules",
|
||||
withBaseToken(
|
||||
{ [vars.key]: vars.enabled },
|
||||
readUpdatedAtToken(queryClient, queryKeys.modulesPrefs()),
|
||||
),
|
||||
);
|
||||
queryClient.setQueryData<{ updatedAt?: string }>(
|
||||
queryKeys.modulesPrefs(),
|
||||
{ updatedAt: result.updatedAt },
|
||||
);
|
||||
return result;
|
||||
},
|
||||
onSuccess: (_data, vars) => {
|
||||
// Re-gate every surface that reads the module map off /auth/me: nav,
|
||||
@@ -146,7 +176,18 @@ export function ModulesSection() {
|
||||
}
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
},
|
||||
onError: () => {
|
||||
onError: (err) => {
|
||||
// v1.32.22 (R5b) — instant-write disposition: a 409 means the module map
|
||||
// advanced since this switch was based (another tab / the iOS client
|
||||
// wrote in between). Drop the now-stale seeded token and refetch /auth/me
|
||||
// so the switch snaps back to server truth, then nudge — nothing was
|
||||
// clobbered. A retry re-seeds a fresh token from its own response.
|
||||
if (isConflict(err)) {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.modulesPrefs() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
toast.message(t("common.conflictReloaded"));
|
||||
return;
|
||||
}
|
||||
toast.error(t("settings.sections.modules.error"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
|
||||
|
||||
interface ProfileShape {
|
||||
moodReminderEnabled: boolean;
|
||||
@@ -18,6 +19,9 @@ interface ProfileShape {
|
||||
|
||||
interface NotificationPrefsShape {
|
||||
mood: { reminderHour: number };
|
||||
// v1.32.22 (R5b) — optimistic-concurrency token, a sibling key on the
|
||||
// resolved prefs response, echoed on the reminder-hour PATCH.
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export function MoodReminderCard({
|
||||
@@ -132,10 +136,18 @@ export function MoodReminderCard({
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
|
||||
// v1.32.22 (R5b) — echo the base token so a concurrent notification-prefs
|
||||
// write (the iOS client's `clientManaged` flag, another web card) 409s
|
||||
// instead of silently reverting a sibling category through the deep-merge.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mood: { reminderHour: next } }),
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ mood: { reminderHour: next } },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -146,6 +158,17 @@ export function MoodReminderCard({
|
||||
});
|
||||
setOptimisticHour(null);
|
||||
scheduleClear();
|
||||
} else if (res.status === 409) {
|
||||
// The prefs advanced since they were loaded. Revert the optimistic hour
|
||||
// and refetch (the active query reseeds a fresh token), then nudge — the
|
||||
// change did not persist, so ask the user to re-apply it.
|
||||
setOptimisticHour(null);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimisticHour(null);
|
||||
setMsg(t("notifications.moodReminder.saveError"));
|
||||
|
||||
Reference in new issue
Block a user