Pass full locale from Accept-Language to ClientDate

Add detectLocale() that extracts the full locale tag (e.g. "de-DE")
from the request, not just the language. The journal root loader passes
it via LocaleContext so ClientDate renders with the correct locale on
both server and client — no more hardcoded "en-US" fallback.

Also fixes settings page hydration mismatch by using ClientDate for
passkey creation dates instead of inline toLocaleDateString().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-29 11:46:21 +02:00
parent 79f000fcc5
commit 38ada3bd4f
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 54 additions and 9 deletions

View file

@ -1,11 +1,16 @@
import { useState, useEffect } from "react";
import { useLocale } from "./LocaleContext";
/**
* Renders a date only on the client to avoid SSR hydration mismatches.
* Server renders empty, client fills in with user's locale.
* Renders a date using the server-detected locale for the initial render
* (matching SSR output), then updates to the browser's native locale
* after hydration.
*/
export function ClientDate({ iso }: { iso: string }) {
const [formatted, setFormatted] = useState(new Date(iso).toLocaleDateString("en-US"));
const locale = useLocale();
const [formatted, setFormatted] = useState(
new Date(iso).toLocaleDateString(locale),
);
useEffect(() => {
setFormatted(new Date(iso).toLocaleDateString());

View file

@ -0,0 +1,17 @@
import { createContext, useContext } from "react";
const LocaleContext = createContext<string>("en");
export function LocaleProvider({
locale,
children,
}: {
locale: string;
children: React.ReactNode;
}) {
return <LocaleContext value={locale}>{children}</LocaleContext>;
}
export function useLocale() {
return useContext(LocaleContext);
}

View file

@ -4,7 +4,9 @@ import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react";
import { useTranslation } from "react-i18next";
import { detectLocale } from "@trails-cool/i18n";
import { getSessionUser } from "~/lib/auth.server";
import { LocaleProvider } from "~/components/LocaleContext";
import stylesheet from "@trails-cool/ui/styles.css?url";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
@ -33,7 +35,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
return { user: user ? { id: user.id, username: user.username } : null };
const locale = detectLocale(request);
return { user: user ? { id: user.id, username: user.username } : null, locale };
}
function NavBar({ user }: { user: { id: string; username: string } | null }) {
@ -110,6 +113,7 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
export default function App({ loaderData }: Route.ComponentProps) {
const user = loaderData?.user;
const locale = loaderData?.locale ?? "en";
useEffect(() => {
if (user) {
Sentry.setUser({ id: user.id, username: user.username });
@ -119,10 +123,10 @@ export default function App({ loaderData }: Route.ComponentProps) {
}, [user]);
return (
<>
<LocaleProvider locale={locale}>
<NavBar user={user ?? null} />
<Outlet />
</>
</LocaleProvider>
);
}

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { data, redirect, useFetcher } from "react-router";
import { useTranslation } from "react-i18next";
import { ClientDate } from "~/components/ClientDate";
import { eq } from "drizzle-orm";
import type { Route } from "./+types/settings";
import { getSessionUser } from "~/lib/auth.server";
@ -217,9 +218,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
{transportLabel(passkey.transports, t)}
</p>
<p className="text-xs text-gray-500">
{t("settings.security.addedOn", {
date: new Date(passkey.createdAt).toLocaleDateString(),
})}
{t("settings.security.addedOn", { date: "" })}
<ClientDate iso={passkey.createdAt} />
</p>
</div>
<deleteFetcher.Form