mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(ux): honest deletes, stable hydration and tidy numbers after release review
Server and first client render now agree everywhere query state used to leak into the chrome, checkboxes no longer nest inside buttons, delete confirmations admit the undo they offer, raw step counts drop their decimals, the truncated unit label wraps instead, streaks know their singular, the medication header speaks the user's date format, a failed coach turn keeps the user's message and says what went wrong, settings status notes follow a language switch, the locale only flips once its bundle actually loaded, and the last raw api fetches in hooks move onto the shared client.
This commit is contained in:
1 parent
25f41f88fe
commit
a8149e0bdf
35 files changed
+347
-191
No files matched your search
+14
-10
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { TimeFormatPreference } from "@/lib/format-locale";
|
||||
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
|
||||
@@ -81,18 +82,21 @@ export interface AuthUser {
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!res.ok) throw new Error("Not authenticated");
|
||||
const json = await res.json();
|
||||
// v1.16.4 — routed through the typed wrapper; a non-OK /me (401)
|
||||
// throws `ApiError`, which `useAuth` treats as "not authenticated"
|
||||
// exactly like the old hand-rolled throw.
|
||||
//
|
||||
// v1.4.47 W3 — coerce `disableCoach` against `undefined` so a stale
|
||||
// /me payload from a partial-deploy rollback (older server image
|
||||
// without the field) keeps the Coach surface visible by default.
|
||||
const data = json.data as Partial<AuthUser> & {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
timezone: string;
|
||||
};
|
||||
const data = await apiGet<
|
||||
Partial<AuthUser> & {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
timezone: string;
|
||||
}
|
||||
>("/api/auth/me");
|
||||
// Hour-cycle preference: coerce against a stale /me payload, then mirror
|
||||
// into localStorage so `useFormatters()` and the legacy format helpers
|
||||
// (which cannot reach the query cache) render the same clock.
|
||||
@@ -143,7 +147,7 @@ export function useLogout() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
await apiFetchRaw("/api/auth/logout", { method: "POST" });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(queryKeys.authMe(), null);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.18 — TanStack Query hook that returns a single chart's overlay
|
||||
@@ -43,12 +44,8 @@ export function useChartOverlayPrefs(
|
||||
|
||||
const { data: layout } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async (): Promise<DashboardLayout> => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed to load dashboard layout");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
},
|
||||
queryFn: async (): Promise<DashboardLayout> =>
|
||||
apiGet<DashboardLayout>("/api/dashboard/widgets"),
|
||||
// Keep the cache warm — the dashboard page mounts a query against
|
||||
// the same key on first paint, so this hook just piggy-backs.
|
||||
staleTime: 60_000,
|
||||
@@ -68,14 +65,10 @@ export function useChartOverlayPrefs(
|
||||
// No-op when no chartKey — the setter is wired up but the hook
|
||||
// is in "ad-hoc render" mode where overlays don't persist.
|
||||
if (!chartKey) return;
|
||||
const res = await fetch("/api/dashboard/chart-overlay-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ chartKey, prefs: next }),
|
||||
await apiPut("/api/dashboard/chart-overlay-prefs", {
|
||||
chartKey,
|
||||
prefs: next,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to save chart overlay prefs");
|
||||
}
|
||||
},
|
||||
onMutate: async (next) => {
|
||||
if (!chartKey) return { previous: undefined };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
DEFAULT_COACH_PREFS,
|
||||
type CoachPrefs,
|
||||
@@ -27,7 +28,9 @@ export function useCoachPrefs(opts?: { enabled?: boolean }) {
|
||||
return useQuery<CoachPrefs>({
|
||||
queryKey: queryKeys.coachPrefs(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/coach-prefs");
|
||||
// `apiFetchRaw` (no .ok throw) — a non-OK read soft-fails to the
|
||||
// defaults instead of surfacing an error state.
|
||||
const res = await apiFetchRaw("/api/auth/me/coach-prefs");
|
||||
if (!res.ok) return DEFAULT_COACH_PREFS;
|
||||
const env = (await res.json()) as { data: CoachPrefs };
|
||||
return env.data;
|
||||
@@ -49,20 +52,10 @@ export function useSaveCoachPrefs(opts?: { onSuccess?: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.coachPrefs(),
|
||||
mutationFn: async (next: CoachPrefs) => {
|
||||
const res = await fetch("/api/auth/me/coach-prefs", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!res.ok) throw new Error("coach-prefs.save_failed");
|
||||
return (await res.json()) as { data: CoachPrefs };
|
||||
},
|
||||
onSuccess: (envelope) => {
|
||||
queryClient.setQueryData<CoachPrefs>(
|
||||
queryKeys.coachPrefs(),
|
||||
envelope.data,
|
||||
);
|
||||
mutationFn: async (next: CoachPrefs) =>
|
||||
apiPut<CoachPrefs>("/api/auth/me/coach-prefs", next),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<CoachPrefs>(queryKeys.coachPrefs(), data);
|
||||
opts?.onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,9 @@ export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({
|
||||
});
|
||||
|
||||
async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
|
||||
const res = await fetch("/api/feature-flags");
|
||||
// `apiFetchRaw` (no .ok throw) — this read soft-fails to the all-on
|
||||
// default below instead of surfacing an error state.
|
||||
const res = await apiFetchRaw("/api/feature-flags");
|
||||
if (!res.ok) {
|
||||
// Soft-fail: any HTTP error returns the all-on default so the
|
||||
// surface continues to render. The endpoint is a non-critical
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Shape returned by every `/api/insights/<metric>-status` endpoint.
|
||||
@@ -151,13 +152,10 @@ export function useInsightStatus(metric: InsightStatusMetric) {
|
||||
STATUS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const res = await fetch(
|
||||
return await apiGet<InsightStatusData>(
|
||||
`/api/insights/${metric}-status?locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = (await res.json()) as { data: InsightStatusData };
|
||||
return json.data;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
@@ -222,15 +220,12 @@ export function useInsightMetricStatus(
|
||||
STATUS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const res = await fetch(
|
||||
return await apiGet<InsightStatusData>(
|
||||
`/api/insights/metric-status?metric=${encodeURIComponent(
|
||||
metric,
|
||||
)}&locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = (await res.json()) as { data: InsightStatusData };
|
||||
return json.data;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
type DashboardLayout,
|
||||
@@ -28,12 +29,7 @@ import {
|
||||
export function useInsightsLayoutPrefs(enabled: boolean) {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed to load dashboard layout");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
},
|
||||
queryFn: async () => apiGet<DashboardLayout>("/api/dashboard/widgets"),
|
||||
enabled,
|
||||
});
|
||||
const compareBaseline =
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
DEFAULT_INSIGHTS_LAYOUT,
|
||||
resolveInsightsLayout,
|
||||
@@ -39,11 +40,8 @@ export function useInsightsLayoutQuery(enabled: boolean): {
|
||||
} {
|
||||
const { data, isLoading, isSuccess } = useQuery({
|
||||
queryKey: queryKeys.insightsLayout(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/layout");
|
||||
if (!res.ok) throw new Error("Failed to load insights layout");
|
||||
return resolveInsightsLayout((await res.json()).data);
|
||||
},
|
||||
queryFn: async () =>
|
||||
resolveInsightsLayout(await apiGet("/api/insights/layout")),
|
||||
enabled,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Stable no-op subscription — the "store" never changes; only the
|
||||
// server/client snapshot split below does the work.
|
||||
const emptySubscribe = () => () => {};
|
||||
|
||||
/**
|
||||
* v1.16.4 — SSR/CSR-consistent mount flag for query-dependent chrome.
|
||||
*
|
||||
* React hydrates streamed Suspense boundaries late, so a client
|
||||
* component can hydrate AFTER a TanStack query already resolved.
|
||||
* Any first render that branches on query state (`isLoading`,
|
||||
* `isAuthenticated`, cached data) then disagrees with the
|
||||
* server-rendered HTML and React logs hydration error #418.
|
||||
*
|
||||
* `useMounted()` returns `false` during SSR AND during the hydration
|
||||
* render (React always uses the server snapshot of a
|
||||
* `useSyncExternalStore` while hydrating, regardless of when the
|
||||
* boundary hydrates), then `true` from the first client re-render.
|
||||
* Gate query-derived branches on it:
|
||||
*
|
||||
* const mounted = useMounted();
|
||||
* if (!mounted || query.isLoading) return <Skeleton />;
|
||||
*
|
||||
* Unlike the classic `useEffect(() => setMounted(true), [])` pattern
|
||||
* this needs no state setter inside an effect (the repo's
|
||||
* `react-hooks/set-state-in-effect` rule rejects that) and stays a
|
||||
* single re-render.
|
||||
*/
|
||||
export function useMounted(): boolean {
|
||||
return useSyncExternalStore(
|
||||
emptySubscribe,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* v1.4.32 — TanStack Query wrapper for `GET /api/workouts`.
|
||||
@@ -73,12 +74,7 @@ async function fetchWorkouts(
|
||||
const query = params.toString();
|
||||
const url = query ? `/api/workouts?${query}` : "/api/workouts";
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load workouts (${res.status})`);
|
||||
}
|
||||
const json = (await res.json()) as { data: WorkoutListPayload };
|
||||
return json.data;
|
||||
return apiGet<WorkoutListPayload>(url);
|
||||
}
|
||||
|
||||
export interface UseWorkoutsResult {
|
||||
@@ -132,12 +128,9 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
|
||||
}
|
||||
|
||||
async function fetchWorkoutDetail(id: string): Promise<WorkoutDetailPayload> {
|
||||
const res = await fetch(`/api/workouts/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load workout (${res.status})`);
|
||||
}
|
||||
const json = (await res.json()) as { data: WorkoutDetailPayload };
|
||||
return json.data;
|
||||
return apiGet<WorkoutDetailPayload>(
|
||||
`/api/workouts/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseWorkoutDetailResult {
|
||||
|
||||
Reference in new issue
Block a user