feat: human + relative dates; fix language/theme toggle stale-context

Dates were raw ISO on printed slips and time-only in the UI (a session from
two days ago showed just "10:48"). Make them human and day-relative. Also fix
a latent toggle bug surfaced while testing.

Dates:
- Printed tickets/receipts/subscription cards now show "19 Qershor 2026
  10:48:25" (Albanian month, 24h with seconds) instead of YYYY-MM-DD HH:MM.
  stamp() exported as formatStampSq so the shift Z-report shares it.
- Shift Z-report is now Albanian (Operatori/Nga/Deri/Para në dorë/Arka…),
  was English-only with ISO dates.
- Web sessions/logs/history show relative days: "Sot 10:48" / "Dje 17:33" /
  "17 Qershor 10:48" via formatRelativeDateTime(). Month names come from the
  i18n catalog (common.months), NOT Intl — the appliance browser's ICU lacks
  Albanian locale data and Intl silently falls back to English month names.

Toggle fix:
- The language + theme toggles read the active value from the TanStack Router
  context `user`, which is captured at route-resolution time and does not
  re-render on setUser. After one switch the highlight froze and the equality
  guard blocked switching back until a page refresh. Drive them off live state
  instead: language from i18n.language (useTranslation subscribes to
  languageChanged), theme from local useState. (Bug dated to 040c0ff.)

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 11:14:06 +02:00
parent f31e57b4ae
commit 00f3d141b6
10 changed files with 175 additions and 51 deletions
+21 -7
View File
@@ -99,10 +99,17 @@ function LanguageToggle({
user: SessionUser;
setUser: (u: SessionUser | null) => void;
}) {
// The ACTIVE language is i18n's own state, not the router-context `user` — the
// latter is captured at route-resolution time and does NOT re-render when we call
// setUser, so reading `user.language` here goes stale after the first switch (the
// highlight froze and the equality guard blocked switching back until a refresh).
// useTranslation() subscribes to i18n's languageChanged, so this stays live.
const { i18n } = useTranslation();
const active = i18n.language as Lang;
async function pick(lang: Lang) {
if (lang === user.language) return;
setLanguage(lang); // instant UI
setUser({ ...user, language: lang });
if (lang === active) return;
setLanguage(lang); // instant UI (fires i18n languageChanged → re-render)
setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state
try {
await setLanguagePref(lang); // persist
} catch {
@@ -117,7 +124,7 @@ function LanguageToggle({
type="button"
onClick={() => pick(l)}
className={`rounded-term px-1.5 py-0.5 ${
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
active === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{l}
@@ -138,10 +145,17 @@ function ThemeToggle({
setUser: (u: SessionUser | null) => void;
}) {
const { t } = useTranslation();
// Local state for the ACTIVE theme — same reason as LanguageToggle: the router
// context `user` doesn't re-render on setUser, so reading `user.theme` here froze
// the highlight after one switch and blocked toggling back until a refresh. Seed
// from the prop; update optimistically on pick. App's effect keeps the DOM in sync
// with the persisted user on (re)login.
const [active, setActive] = useState<Theme>(user.theme);
async function pick(theme: Theme) {
if (theme === user.theme) return;
if (theme === active) return;
setActive(theme);
applyTheme(theme); // instant UI
setUser({ ...user, theme });
setUser({ ...user, theme }); // keep context eventually-consistent + persisted state
try {
await setThemePref(theme); // persist
} catch {
@@ -156,7 +170,7 @@ function ThemeToggle({
type="button"
onClick={() => pick(th)}
className={`rounded-term px-1.5 py-0.5 ${
user.theme === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
active === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
}`}
>
{t(th === "dark" ? "common.themeDark" : "common.themeLight")}