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:
Marc-André Bombeck committed 2026-06-11 03:30:18 +02:00
1 parent 25f41f88fe
commit a8149e0bdf
35 files changed
+347 -191

No files matched your search

@@ -459,15 +459,21 @@ function ChatBubble({
a classic typing indicator (three pulsing dots) instead
of the old whole-bubble pulse + "Thinking…" prose. The
prose stays as the screen-reader announcement. */}
{/* v1.16.4 — a failed turn with no streamed prose renders the
error copy INSIDE the bubble instead of an empty bubble
with a caption below it. With partial prose the prose
keeps the bubble and the error stays a caption. */}
{content ? (
stripChartTokens(content)
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning/90">{safeError}</span>
) : (
""
)}
</div>
{safeError && (
{safeError && content && (
<p className="text-warning/90 text-xs">{safeError}</p>
)}
{/* v1.12.0 — collapse the whole provenance block ("what was
@@ -400,10 +400,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId: null,
errorCode: structured ?? `coach.http.${response.status}`,
});
// Drop the optimistic user bubble on the error path too so
// the next refetch — which still has the persisted user
// message — doesn't render the same bubble twice.
setOptimisticUser(null);
// v1.16.4 — KEEP the optimistic user bubble: a rejected turn
// (budget gate, 4xx) has no persisted twin coming, and dropping
// the bubble left the error standing next to an empty thread.
// The content-equality dedupe in <MessageThread> still
// suppresses the optimistic copy if a persisted twin lands.
return;
}
@@ -484,10 +485,16 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
optsRef.current.onDone?.(resolvedConversationId);
}
// v1.4.25 W5 — drop the optimistic user bubble; the persisted
// twin is on its way via the invalidate-refetch above. Drop on
// error too so the user message isn't doubled when the next
// refetch sees the server-persisted row.
setOptimisticUser(null);
// twin is on its way via the invalidate-refetch above.
// v1.16.4 — but only when a twin IS coming (the turn resolved a
// conversation) or the turn succeeded outright. An errored turn
// without a conversation id persisted nothing; dropping the
// bubble then erased the user's message next to the error bubble.
// The content-equality dedupe in <MessageThread> still suppresses
// the optimistic copy if a persisted twin does land later.
if (resolvedConversationId || !lastError) {
setOptimisticUser(null);
}
},
[queryClient],
);
@@ -151,7 +151,7 @@ export function SparklineDeltaTile({
</div>
<div
className="mt-2 flex items-baseline gap-x-1.5"
className="mt-2 flex flex-wrap items-baseline gap-x-1.5"
data-slot="sparkline-delta-tile-value-row"
>
<span
@@ -160,7 +160,10 @@ export function SparklineDeltaTile({
>
{value !== null ? fmt.number(value, precision) : "—"}
</span>
<span className="text-muted-foreground min-w-0 truncate text-sm tabular-nums">
{/* v1.16.4 — the unit is short, load-bearing metadata ("mmHg",
"kg") and must never ellipsis ("m…"); on a too-narrow tile
the row wraps instead (hence `flex-wrap` above). */}
<span className="text-muted-foreground shrink-0 text-sm whitespace-nowrap tabular-nums">
{unit}
</span>
<span
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
@@ -58,8 +59,13 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// hiccup still renders the advisor — the gate only takes effect when
// the operator has explicitly turned the surface off.
const flags = useFeatureFlags();
// v1.16.4 — `mounted` keeps the hydration render in lockstep with the
// SSR HTML: a late-hydrating boundary can see resolved query state on
// its first render (auth + flags settled), which used to flip the
// regenerate affordance on at hydration time and trip React #418.
const mounted = useMounted();
const advisorEnabled =
isAuthenticated && flags.enabled && flags.briefing;
mounted && isAuthenticated && flags.enabled && flags.briefing;
// v1.12.6 — the Coach segment is excluded from the generic disclaimer
// footer (v1.16.1 dropped the in-chat disclaimer line entirely; the
@@ -54,6 +54,7 @@ import { toast } from "sonner";
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
import {
invalidateKeys,
measurementDependentKeys,
@@ -881,7 +882,10 @@ export function MeasurementList({
<>
{isGrouped
? fmt.integer(m.value)
: fmt.number(m.value)}{" "}
: fmt.number(
m.value,
rawDisplayFractionDigits(m.type),
)}{" "}
{m.unit}
{isGrouped && (
<span className="text-muted-foreground ml-2 text-xs font-normal">
@@ -1017,30 +1021,24 @@ export function MeasurementList({
{/* v1.15.13 — multi-select checkbox in a 44px tap
target; absent for synthetic grouped rows. */}
{!isGrouped && (
// v1.15.13 MEDIUM-1 — a Radix Checkbox renders a
// 16px `<button role=checkbox>`; a wrapping
// `<label>` does NOT forward taps to a button (label
// forwarding only works for native form controls),
// so the effective tap target was 16px (fails WCAG
// 2.5.5). The 44px button below owns the whole hit
// area and is the single toggle source; the inner
// Checkbox is a `pointer-events-none` controlled
// visual, so a tap can fire the handler exactly once.
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(m.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
// v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
// (itself a `<button role=checkbox>`) inside a
// 44px wrapper `<button>` for WCAG 2.5.5 — but a
// button may not nest inside a button, and the
// invalid markup made React 19 fail hydration on
// every list paint. The wrapper is now a plain
// layout `<div>`; the Checkbox stays the single
// control and owns the 44px hit area via an
// `after` hit-slop (clicks on a pseudo-element
// hit-test against its host button).
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
onCheckedChange={() => onToggleRow(m.id)}
aria-label={t("dataList.selectRow")}
className="relative after:absolute after:-inset-3.5"
/>
</button>
</div>
)}
{Icon && (
<div
@@ -1078,7 +1076,10 @@ export function MeasurementList({
<>
{isGrouped
? fmt.integer(m.value)
: fmt.number(m.value)}{" "}
: fmt.number(
m.value,
rawDisplayFractionDigits(m.type),
)}{" "}
{m.unit}
</>
)}
@@ -92,7 +92,10 @@ export function MedicationComplianceBars({
<div className="flex items-center gap-4 text-xs">
<span className="text-warning flex items-center gap-1 font-medium">
<Flame className="h-3.5 w-3.5" />
{streak} {t("medications.dayStreak")}
{streak}{" "}
{streak === 1
? t("medications.dayStreakOne")
: t("medications.dayStreak")}
</span>
</div>
)}
@@ -83,7 +83,7 @@ export function MedicationDetailSummary({
date: formatters.dateTime(startsOn),
})
: t("medications.detail.cadence.oneShotPending")
: summariseCadence(hydrateWizardPayload(payload), t);
: summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
return (
<div className="space-y-1.5" data-slot="medication-detail-summary">
@@ -7,7 +7,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -56,7 +56,8 @@ export function Step8Summary({
onAddSchedule,
}: Step8SummaryProps) {
const { t } = useTranslations();
const topSummary = summariseCadence(payload, t);
const formatters = useFormatters();
const topSummary = summariseCadence(payload, t, formatters.date);
const canRemove = payload.schedules.length > 1;
// Reminders need at least one delivery channel. Fetch the channel
@@ -833,6 +833,11 @@ function cadenceLineFor(
export function summariseCadence(
payload: WizardPayload,
t: (key: string, params?: Record<string, string | number>) => string,
// v1.16.4 — UI callers pass `useFormatters().date` so the course-window
// dates render in the active locale ("11.06.2026" / "Jun 11, 2026")
// instead of the raw ISO string. The ISO default keeps the helper pure
// for non-React callers.
formatDate: (d: Date) => string = dateToIsoString,
): string {
const cadenceLine = cadenceLineFor(payload, t);
const timesPhrase =
@@ -843,7 +848,7 @@ export function summariseCadence(
: "";
const startPhrase = payload.startsOn
? t("medications.wizard.summary.startsOn", {
date: dateToIsoString(payload.startsOn),
date: formatDate(payload.startsOn),
})
: "";
const endPhrase =
@@ -851,7 +856,7 @@ export function summariseCadence(
? ""
: payload.endsOn
? t("medications.wizard.summary.endsOn", {
date: dateToIsoString(payload.endsOn),
date: formatDate(payload.endsOn),
})
: t("medications.wizard.summary.noEndDate");
return [cadenceLine, timesPhrase, startPhrase, endPhrase]
+14 -19
View File
@@ -670,28 +670,23 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 MEDIUM-1 — a Radix Checkbox renders a 16px
`<button role=checkbox>`; a wrapping `<label>` does
NOT forward taps to a button, so the effective tap
target was 16px (fails WCAG 2.5.5). The 44px button
owns the whole hit area and is the single toggle
source; the inner Checkbox is a `pointer-events-none`
controlled visual, so a tap fires the handler once. */}
<button
type="button"
role="checkbox"
aria-checked={isSelected}
aria-label={t("dataList.selectRow")}
onClick={() => onToggleRow(entry.id)}
className="focus-visible:ring-ring/50 flex size-11 shrink-0 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
>
{/* v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
(itself a `<button role=checkbox>`) inside a 44px
wrapper `<button>` for WCAG 2.5.5 — but a button may
not nest inside a button, and the invalid markup made
React 19 fail hydration on every list paint. The
wrapper is now a plain layout `<div>`; the Checkbox
stays the single control and owns the 44px hit area
via an `after` hit-slop (clicks on a pseudo-element
hit-test against its host button). */}
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none"
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
className="relative after:absolute after:-inset-3.5"
/>
</button>
</div>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span
data-testid="mood-row-score"
@@ -46,6 +46,13 @@ vi.mock("@tanstack/react-query", () => ({
}));
// Stub the auth hook — sections check `isAuthenticated` to enable queries.
// v1.16.4 — AccountSection gates its content on `useMounted()` so the
// SSR pass and the hydration render agree (React #418 family). The
// smoke test asserts the post-mount content, so pin the flag to true.
vi.mock("@/hooks/use-mounted", () => ({
useMounted: () => true,
}));
// We return `isAuthenticated: true` + a minimal user so the loading-spinner
// branch in AccountSection paints the real form.
vi.mock("@/hooks/use-auth", () => ({
+63 -31
View File
@@ -42,6 +42,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
@@ -110,9 +111,30 @@ export function resolveInitialTimezone(
return shouldAutoSeed ? detectedBrowserTimezone : stored;
}
/**
* v1.16.4 — settings status hints store the i18n KEY (+ params), not
* the translated string: a locale switch re-renders the hint in the
* new language instead of freezing the old-language snapshot. Server-
* provided error text (which has no key) rides `text` verbatim.
*/
type StatusMessage =
| { key: string; params?: Record<string, string | number> }
| { text: string };
function statusText(
msg: StatusMessage,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
return "key" in msg ? t(msg.key, msg.params) : msg.text;
}
export function AccountSection() {
const { t, locale, setLocale, pendingLocale } = useTranslations();
const { user, isLoading, isAuthenticated, refetch } = useAuth();
// v1.16.4 — see `useMounted`: keeps the hydration render identical to
// the SSR HTML when this boundary hydrates after `/api/auth/me`
// settled (React #418 family).
const mounted = useMounted();
const router = useRouter();
const [email, setEmail] = useState("");
@@ -125,7 +147,7 @@ export function AccountSection() {
const [insurerName, setInsurerName] = useState("");
const [insuranceNumber, setInsuranceNumber] = useState("");
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsg, setSaveMsg] = useState<StatusMessage | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
@@ -135,7 +157,7 @@ export function AccountSection() {
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMsg, setPasswordMsg] = useState<string | null>(null);
const [passwordMsg, setPasswordMsg] = useState<StatusMessage | null>(null);
const [passwordMsgType, setPasswordMsgType] = useState<
"success" | "error" | null
>(null);
@@ -153,13 +175,13 @@ export function AccountSection() {
// replay button so the surface stays mirrored).
const [tourRestarting, setTourRestarting] = useState(false);
const [tourFeedback, setTourFeedback] = useState<{
text: string;
key: string;
type: "success" | "error";
} | null>(null);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [passkeyMsg, setPasskeyMsg] = useState<string | null>(null);
const [passkeyMsg, setPasskeyMsg] = useState<StatusMessage | null>(null);
const [passkeyMsgType, setPasskeyMsgType] = useState<
"success" | "error" | null
>(null);
@@ -228,7 +250,7 @@ export function AccountSection() {
]);
if (profileRes.ok && tzRes.ok) {
setSaveMsg(t("settings.profileSaved"));
setSaveMsg({ key: "settings.profileSaved" });
setSaveMsgType("success");
await refetch();
} else if (!tzRes.ok) {
@@ -238,14 +260,20 @@ export function AccountSection() {
// failure copy.
try {
const json = (await (tzRes as Response).json()) as { error?: string };
setSaveMsg(json.error || t("settings.timezoneInvalid"));
setSaveMsg(
json.error
? { text: json.error }
: { key: "settings.timezoneInvalid" },
);
} catch {
setSaveMsg(t("settings.timezoneInvalid"));
setSaveMsg({ key: "settings.timezoneInvalid" });
}
setSaveMsgType("error");
} else {
const json = await profileRes.json();
setSaveMsg(json.error || t("settings.savingError"));
setSaveMsg(
json.error ? { text: json.error } : { key: "settings.savingError" },
);
setSaveMsgType("error");
}
setSaving(false);
@@ -262,7 +290,7 @@ export function AccountSection() {
});
if (!optRes.ok) {
setPasskeyMsg(t("settings.passkeyOptionsError"));
setPasskeyMsg({ key: "settings.passkeyOptionsError" });
setPasskeyMsgType("error");
setPasskeyLoading(false);
return;
@@ -281,18 +309,20 @@ export function AccountSection() {
});
if (verifyRes.ok) {
setPasskeyMsg(t("settings.passkeyAdded"));
setPasskeyMsg({ key: "settings.passkeyAdded" });
setPasskeyMsgType("success");
} else {
const verifyJson = await verifyRes.json();
setPasskeyMsg(
verifyJson.error || t("settings.passkeyRegistrationFailed"),
verifyJson.error
? { text: verifyJson.error }
: { key: "settings.passkeyRegistrationFailed" },
);
setPasskeyMsgType("error");
}
} catch (err) {
const { key, params } = describePasskeyError(err);
setPasskeyMsg(t(key, params));
setPasskeyMsg({ key, params });
setPasskeyMsgType("error");
} finally {
setPasskeyLoading(false);
@@ -313,11 +343,11 @@ export function AccountSection() {
if (result.ok) {
await refetch();
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
key: "onboarding.tour.restartConfirmation",
type: "success",
});
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
setTourFeedback({ key: result.messageKey, type: "error" });
}
setTourRestarting(false);
}
@@ -329,7 +359,7 @@ export function AccountSection() {
setPasswordMsgType(null);
if (newPassword !== confirmPassword) {
setPasswordMsg(t("settings.passwordMismatch"));
setPasswordMsg({ key: "settings.passwordMismatch" });
setPasswordMsgType("error");
setPasswordSaving(false);
return;
@@ -348,25 +378,27 @@ export function AccountSection() {
const json = await res.json();
if (!res.ok) {
setPasswordMsg(json.error || t("settings.savingError"));
setPasswordMsg(
json.error ? { text: json.error } : { key: "settings.savingError" },
);
setPasswordMsgType("error");
return;
}
setPasswordMsg(t("settings.passwordUpdated"));
setPasswordMsg({ key: "settings.passwordUpdated" });
setPasswordMsgType("success");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
setPasswordMsg(t("common.networkError"));
setPasswordMsg({ key: "common.networkError" });
setPasswordMsgType("error");
} finally {
setPasswordSaving(false);
}
}
if (isLoading) {
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
@@ -587,7 +619,7 @@ export function AccountSection() {
saveMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{saveMsg}
{statusText(saveMsg, t)}
</p>
)}
@@ -646,7 +678,7 @@ export function AccountSection() {
passkeyMsgType === "success" ? "text-success" : "text-destructive"
}`}
>
{passkeyMsg}
{statusText(passkeyMsg, t)}
</p>
)}
</div>
@@ -728,7 +760,7 @@ export function AccountSection() {
: "text-destructive"
}`}
>
{tourFeedback.text}
{t(tourFeedback.key)}
</p>
)}
</div>
@@ -797,7 +829,7 @@ export function AccountSection() {
: "text-destructive"
}`}
>
{passwordMsg}
{statusText(passwordMsg, t)}
</p>
)}
@@ -1084,7 +1116,7 @@ function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msg, setMsg] = useState<StatusMessage | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const upload = useMutation({
@@ -1105,12 +1137,12 @@ function AvatarSection() {
},
onSuccess: async () => {
setMsgType("success");
setMsg(t("settings.avatar.uploaded"));
setMsg({ key: "settings.avatar.uploaded" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg(err.message);
setMsg({ text: err.message });
},
});
@@ -1124,12 +1156,12 @@ function AvatarSection() {
},
onSuccess: async () => {
setMsgType("success");
setMsg(t("settings.avatar.removed"));
setMsg({ key: "settings.avatar.removed" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg(err.message);
setMsg({ text: err.message });
},
});
@@ -1142,12 +1174,12 @@ function AvatarSection() {
setMsgType(null);
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
setMsgType("error");
setMsg(t("settings.avatar.invalidType"));
setMsg({ key: "settings.avatar.invalidType" });
return;
}
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
setMsgType("error");
setMsg(t("settings.avatar.tooLarge"));
setMsg({ key: "settings.avatar.tooLarge" });
return;
}
upload.mutate(file);
@@ -1227,7 +1259,7 @@ function AvatarSection() {
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{msg}
{statusText(msg, t)}
</p>
)}
</div>
+7 -3
View File
@@ -289,10 +289,14 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
`/settings/account` (Geschlecht / Gender) and
`/settings/ai` (Aktiver Provider) is not eaten by the
floating mobile bottom-nav. Desktop reverts to the parent's
own padding because the bottom-nav doesn't render on `md+`. */}
<main className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:pb-0">
own padding because the bottom-nav doesn't render on `md+`.
v1.16.4 — a `<div>`, not `<main>`: the surrounding AuthShell
already provides the page's single `<main>` landmark and a
nested second one is an a11y violation. */}
<div className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:pb-0">
{children}
</main>
</div>
</div>
</div>
);