mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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", () => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user