From 4e52bdd4ca405b42f781c7b268f490075dc91943 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 15 Sep 2026 23:45:13 +0200 Subject: [PATCH 01/26] fix: a signed-out tab no longer asks for saved views The providers mount for the public landing too, and the saved-views query had no enabled gate, so every signed-out load logged a 401 for /api/saved-views. The query now waits for a signed-in account in BootGate's me cache, like WizardProvider. --- .../SavedViewsProvider.dom.test.tsx | 34 +++++++++++++++++-- .../src/components/SavedViewsProvider.tsx | 5 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx index ea9d1459..da28f489 100644 --- a/frontend/src/components/SavedViewsProvider.dom.test.tsx +++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx @@ -67,12 +67,19 @@ function Probe() { ); } +// The views query waits for a signed-in account in the cache (BootGate's `me`). +function signedInClient() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: ID, preferences: {} }); + return qc; +} + async function mountResolved(views: SavedView[]) { // Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's // identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing // in the re-seed test; a shared reference would skip the effect and hide a missing guard). vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v }))); - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const qc = signedInClient(); render( @@ -141,7 +148,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => { calls += 1; return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })]; }); - const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const qc = signedInClient(); render( @@ -174,3 +181,26 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => { expect(h.setPlexFilters).not.toHaveBeenCalled(); }); }); + +describe("SavedViewsProvider — signed-out tab", () => { + it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => { + const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), null); // BootGate's "nobody" + render( + + + + + , + ); + // Let any fetch the mount would start get going before asserting it never happened. + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(spy).not.toHaveBeenCalled(); + // …and it asks as soon as an account signs in. + act(() => qc.setQueryData(qk.me(), { id: ID, preferences: {} })); + await waitFor(() => expect(spy).toHaveBeenCalledWith("feed")); + }); +}); diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index 47ae71db..6b2ab11f 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -155,9 +155,14 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { }, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]); const source = cfg.source; + // Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too + // (the public landing), and saved views are per-account, so asking for "nobody's" views only logs + // a 401 on the landing page. Enabled once a signed-in account is cached. + const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); const viewsQuery = useQuery({ queryKey: qk.savedViews(source), queryFn: () => api.savedViews(source), + enabled: !!me, }); const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]); From 11aef4ab7aef97c71e5205a5bbf1cebe7fdb5e79 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 16 Sep 2026 00:34:50 +0200 Subject: [PATCH 02/26] feat: record when an account last signed in users.last_login_at (migration 0085, no backfill) is stamped by _establish_session, which the Google callback now shares with password login instead of repeating its session setup inline. The demo entry does not stamp its shared account. The admin user list carries the field. --- .../versions/0085_users_last_login_at.py | 26 +++++ backend/app/models.py | 4 + backend/app/routes/admin.py | 2 + backend/app/routes/auth.py | 20 ++-- backend/tests/test_last_login.py | 97 +++++++++++++++++++ frontend/src/lib/api/admin.ts | 2 + 6 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 backend/alembic/versions/0085_users_last_login_at.py create mode 100644 backend/tests/test_last_login.py diff --git a/backend/alembic/versions/0085_users_last_login_at.py b/backend/alembic/versions/0085_users_last_login_at.py new file mode 100644 index 00000000..fb050e91 --- /dev/null +++ b/backend/alembic/versions/0085_users_last_login_at.py @@ -0,0 +1,26 @@ +"""add users.last_login_at — when the account last signed in (E22 S1) + +Revision ID: 0085_users_last_login_at +Revises: 0084_dl_job_error_detail +Create Date: 2026-09-16 + +Stamped by the one sign-in path (`routes/auth._establish_session`), which the Google callback and +password login share. No backfill is possible: the signal exists only at sign-in, so existing rows +read NULL until each account signs in again, and the admin UI says so rather than inventing a date. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0085_users_last_login_at" +down_revision: str | None = "0084_dl_job_error_detail" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "last_login_at") diff --git a/backend/app/models.py b/backend/app/models.py index f29695d9..94203c9b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -102,6 +102,10 @@ class User(Base, TimestampMixin, CanonicalEmailMixin): # current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those # events (client-side signed cookies otherwise stay valid until they expire). session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + # When this account last signed in (Google or password), stamped by the one sign-in path, + # routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is + # possible). The shared demo account is never stamped — its "last login" would mean nobody. + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 86ad5ade..63e070da 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -408,6 +408,8 @@ def _serialize_user(u: User) -> dict: # confusing sign-in. "google_email": u.google_email if u.google_email != u.email else None, "created_at": iso(u.created_at), + # NULL = no sign-in since the column existed (0085), or the shared demo account. + "last_login_at": iso(u.last_login_at), } diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 18c25e67..97bae871 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -479,16 +479,8 @@ async def callback( db.flush() _store_token(db, user, token) + _establish_session(request, user) db.commit() - - # Multi-session: remember every account that has authenticated in this browser so the - # user can switch between them without going through Google again. Only accounts that - # actually completed OAuth here land in this list (it's the proof they may be switched to). - accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id] - accounts.append(user.id) - request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] - request.session["user_id"] = user.id - _remember_epoch(request, user) log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role) return RedirectResponse(url="/") @@ -675,13 +667,18 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) - def _establish_session(request: Request, user: User) -> None: - """Sign `user` in for this browser session, mirroring the Google callback's multi-account - handling so password sign-in joins the same account switcher.""" + """Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback + and password login, so both join the same account switcher and both stamp `last_login_at` (the + caller commits). Multi-session: every account that authenticated in this browser is remembered so + the user can switch between them; only accounts that actually proved themselves HERE land in that + wallet (it is the proof they may be switched to). The demo entry deliberately does not come + through here: it clears the wallet instead, and its shared account has no meaningful last login.""" accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id] accounts.append(user.id) request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["user_id"] = user.id _remember_epoch(request, user) + user.last_login_at = datetime.now(timezone.utc) def _app_base() -> str: @@ -1051,6 +1048,7 @@ def password_login( if not user.is_active: raise _login_refused(403, "pending_approval", user) _establish_session(request, user) + db.commit() log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role) return {"ok": True} diff --git a/backend/tests/test_last_login.py b/backend/tests/test_last_login.py new file mode 100644 index 00000000..b2a48d6d --- /dev/null +++ b/backend/tests/test_last_login.py @@ -0,0 +1,97 @@ +"""`users.last_login_at` (E22 S1), DB-lane. + +The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It +would not have: only password login called it, and the Google callback carried its own copy of the +same four lines. So these pin BOTH doors through the real routes — plus the two that must not stamp +(a refused password, the shared demo account) and the admin list that shows it. +""" + +from datetime import datetime, timedelta, timezone + +from app.config import settings +from app.models import DemoWhitelist +from app.routes import auth as auth_routes + +_PW = "correct horse battery" + + +def _recent(ts: datetime | None) -> bool: + return ts is not None and datetime.now(timezone.utc) - ts < timedelta(minutes=1) + + +def test_a_password_sign_in_stamps_last_login(client, db, make_user): + user = make_user(email="pw@example.com", password=_PW) + assert user.last_login_at is None + + r = client.post("/auth/password-login", json={"email": "pw@example.com", "password": _PW}) + + assert r.status_code == 200 + db.refresh(user) + assert _recent(user.last_login_at) + + +def test_a_refused_password_does_not_stamp(client, db, make_user): + user = make_user(email="wrong@example.com", password=_PW) + + r = client.post( + "/auth/password-login", json={"email": "wrong@example.com", "password": "not it"} + ) + + assert r.status_code == 401 + db.refresh(user) + assert user.last_login_at is None + + +class _FakeGoogle: + """authlib's client, reduced to the one call the callback makes after Google redirects back.""" + + def __init__(self, userinfo: dict): + self._userinfo = userinfo + + async def authorize_access_token(self, _request): + return {"userinfo": self._userinfo, "access_token": "at", "scope": "openid email profile"} + + +def test_a_google_sign_in_stamps_last_login(client, db, make_user, monkeypatch): + from cryptography.fernet import Fernet + + from app import security + + # The callback stores the OAuth grant encrypted; a throwaway key stands in for TOKEN_ENCRYPTION_KEY. + monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key())) + user = make_user(email="g@example.com", password=None, google_sub="sub-g") + monkeypatch.setattr(settings, "admin_emails", "g@example.com") # the bootstrap allow-list + fake = _FakeGoogle({"sub": "sub-g", "email": "g@example.com", "email_verified": True}) + monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: fake) + + r = client.get("/auth/callback", follow_redirects=False) + + assert r.status_code in (302, 307) and r.headers["location"] == "/" + db.refresh(user) + assert _recent(user.last_login_at) + # …and it is a real sign-in, not only a stamp: the session now answers as this account. + assert client.get("/api/me").json()["id"] == user.id + + +def test_the_demo_entry_does_not_stamp_the_shared_account(client, db): + db.add(DemoWhitelist(email="visitor@example.com")) + db.commit() + + r = client.post("/auth/demo", json={"email": "visitor@example.com"}) + + assert r.json() == {"authenticated": True} + demo = auth_routes.get_or_create_demo_user(db) + db.refresh(demo) + assert demo.last_login_at is None + + +def test_the_admin_user_list_carries_last_login(as_user, db, make_user): + admin = make_user(email="boss@example.com", role="admin") + never = make_user(email="never@example.com") + admin.last_login_at = datetime(2026, 9, 16, 8, 30, tzinfo=timezone.utc) + db.commit() + + rows = {u["email"]: u for u in as_user(admin).get("/api/admin/users").json()} + + assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30") + assert rows[never.email]["last_login_at"] is None diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index a9ae05e7..8b9b82b9 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -82,6 +82,8 @@ export interface AdminUserRow { /** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */ google_email: string | null; created_at: string | null; + /** Last sign-in (Google or password). Null = not since 0.88 (no backfill), or the demo account. */ + last_login_at: string | null; } /** The account panel's view of why someone can or cannot get in (E12 S5). */ From e299c6612194f7c67196227b9c95a4abe4f413c3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 16 Sep 2026 00:45:03 +0200 Subject: [PATCH 03/26] feat: show each account's last sign-in in Users A right-aligned column at the end of every Users & roles row says how long ago the account signed in, with the exact moment as the hint and as a line in the account panel; a dash where nothing is recorded, and the demo account says it is not recorded. formatDateTime formats that moment in the UI language rather than the browser's. --- frontend/src/components/AccountPanel.tsx | 17 +++++++++++++- .../src/components/AdminUsers.dom.test.tsx | 22 +++++++++++++++++++ frontend/src/components/AdminUsers.tsx | 18 +++++++++++++++ frontend/src/i18n/locales/en/users.json | 6 +++++ frontend/src/i18n/locales/hu/users.json | 6 +++++ frontend/src/lib/format.ts | 6 +++++ 6 files changed, 74 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx index 897099cc..b93377e4 100644 --- a/frontend/src/components/AccountPanel.tsx +++ b/frontend/src/components/AccountPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react"; +import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } from "lucide-react"; import Modal from "./ui/Modal"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { api } from "../lib/api"; @@ -9,6 +9,7 @@ import { qk } from "../lib/queryKeys"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; import { copyText } from "../lib/clipboard"; +import { formatDateTime } from "../lib/format"; import { invalidateUserAccess } from "../lib/adminUsers"; import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; @@ -168,6 +169,20 @@ export default function AccountPanel({ } tone={a.has_google ? "ok" : "muted"} /> + {/* The exact moment; the list row beside it only says how long ago. Null has two different + meanings, so it says which: nothing recorded yet, or the shared demo account, which is + deliberately never stamped. */} + {/* The gate that explains a Google sign-in silently becoming a fresh access request. */} {!a.is_allowed && ( ({ reportError: vi.fn() })); import AdminUsers from "./AdminUsers"; import { api, type AdminUserRow } from "../lib/api"; +import { relativeTime } from "../lib/format"; import i18n from "../i18n"; const row = { @@ -76,6 +77,27 @@ function mount() { return users; } +describe("the last sign-in column (E22 S1)", () => { + it("says how long ago each account signed in, and a dash where nothing is recorded", async () => { + const at = new Date(Date.now() - 3 * 86_400_000).toISOString(); + vi.spyOn(api, "adminUsers").mockResolvedValue([ + { ...row, id: 3, email: "seen@x", last_login_at: at }, + { ...row, id: 4, email: "never@x", last_login_at: null }, + { ...row, id: 5, email: "demo@x", is_demo: true, last_login_at: null }, + ] as never); + render( + + + , + ); + + expect(await screen.findByText(relativeTime(at))).toBeTruthy(); + expect(screen.getAllByText("—")).toHaveLength(2); + }); +}); + describe("a user action that fails", () => { it("says a user who is gone, and refetches the list", async () => { const users = mount(); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 916d5508..833b8052 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -24,6 +24,7 @@ import { type PendingAccessRow, } from "../lib/api"; import { notify } from "../lib/notifications"; +import { formatDateTime, relativeTime } from "../lib/format"; import { invalidateUserAccess } from "../lib/adminUsers"; import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import { Button } from "./ui/Button"; @@ -310,6 +311,23 @@ function UsersRoles({ me }: { me: Me }) { icon={} /> + {/* How long ago this account last signed in. LAST in the row on purpose: the badges and + the role button before it vary in width row to row, so only the row's own right + edge lines the values up into one column. The exact moment is the hint here and a + line in the account panel; below `sm` the row has no room, and the panel still says it. */} + + + {u.last_login_at ? relativeTime(u.last_login_at) : "—"} + + ); })} diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json index 8d2d2a34..da41c7fb 100644 --- a/frontend/src/i18n/locales/en/users.json +++ b/frontend/src/i18n/locales/en/users.json @@ -30,6 +30,9 @@ "suspended": "Suspended", "pending": "Pending approval", "unverified": "Unverified email", + "lastLogin": "Last sign-in: {{time}}", + "lastLoginNone": "No sign-in recorded yet", + "lastLoginDemo": "Not recorded for the demo account", "demoLocked": "The demo account's role can't be changed.", "selfLocked": "You can't change your own role.", "makeAdmin": "Make admin", @@ -81,6 +84,9 @@ "googleConnected": "connected", "googleNone": "not connected", "invite": "Invite", + "lastLogin": "Last sign-in", + "lastLoginNone": "none recorded yet", + "lastLoginDemo": "not recorded for the demo account", "inviteMissing": "this address is not on the allow-list", "linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.", "mailOff": "no mail configured", diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json index 48d6e7ef..3e41e878 100644 --- a/frontend/src/i18n/locales/hu/users.json +++ b/frontend/src/i18n/locales/hu/users.json @@ -30,6 +30,9 @@ "suspended": "Felfüggesztve", "pending": "Jóváhagyásra vár", "unverified": "Nem igazolt e-mail", + "lastLogin": "Utolsó belépés: {{time}}", + "lastLoginNone": "Nincs még rögzített belépés", + "lastLoginDemo": "A demo fióknál nem rögzítjük", "demoLocked": "A demo fiók szerepe nem módosítható.", "selfLocked": "A saját szerepedet nem módosíthatod.", "makeAdmin": "Adminná tesz", @@ -81,6 +84,9 @@ "googleConnected": "összekötve", "googleNone": "nincs összekötve", "invite": "Meghívó", + "lastLogin": "Utolsó belépés", + "lastLoginNone": "még nincs rögzítve", + "lastLoginDemo": "a demo fióknál nem rögzítjük", "inviteMissing": "ez a cím nincs az engedélyezési listán", "linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.", "mailOff": "nincs beállítva levelezés", diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 2e2a0958..16e7bf9d 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -148,6 +148,12 @@ export function formatCountOrDash(n: number | null | undefined): string { return n != null ? formatViews(n) : "—"; } +// Absolute date AND time in the UI language (e.g. "16 Sept 2026, 08:30:14"). A bare `toLocaleString()` +// formats in the browser's locale instead, so an English UI showed a Hungarian-shaped date. Empty for null. +export function formatDateTime(iso: string | null | undefined): string { + return iso ? new Date(iso).toLocaleString(i18n.language) : ""; +} + // Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null. export function formatDate(iso: string | null | undefined, lang: string): string { return iso From 618ef812e633c379b206494b8a313797142fb412 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 16 Sep 2026 00:49:23 +0200 Subject: [PATCH 04/26] fix: dates follow the UI language, not the browser's Seven sites formatted with a bare toLocaleString()/toLocaleDateString(), so an English UI on a Hungarian machine printed Hungarian-shaped dates (and the reverse). They now go through formatDateTime/formatDate, which take the i18n language. --- frontend/src/components/About.tsx | 3 ++- frontend/src/components/AdminUsers.tsx | 11 +++++++---- frontend/src/components/ShareDialog.tsx | 5 +++-- frontend/src/components/downloadRow.tsx | 11 +++++++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 9da74868..7eb4078e 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -4,13 +4,14 @@ import { useTranslation } from "react-i18next"; import { Info, Sparkles } from "lucide-react"; import { api } from "../lib/api"; import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version"; +import { formatDateTime } from "../lib/format"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; function fmtDate(d?: string | null): string { if (!d) return "—"; const t = new Date(d); - return isNaN(t.getTime()) ? d : t.toLocaleString(); + return isNaN(t.getTime()) ? d : formatDateTime(d); } // About dialog: app + build + schema versions (frontend/backend/database), plus a diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 833b8052..28495f6e 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -431,7 +431,7 @@ function PendingAccessRowView({ {row.created_at && ( {t("users.pendingAccess.signedUp", { - time: new Date(row.created_at).toLocaleString(), + time: formatDateTime(row.created_at), })} )} @@ -619,7 +619,10 @@ function AdminInvites() { inv.invited_at ? "settings.invites.inviteConfirmAgain" : "settings.invites.inviteConfirm", - { email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() }, + { + email: inv.email, + time: formatDateTime(inv.invited_at ?? new Date(0).toISOString()), + }, )}

(lang = v)} /> @@ -718,7 +721,7 @@ function AdminInvites() { hint={ i.invited_at ? t("settings.invites.invitedAt", { - time: new Date(i.invited_at).toLocaleString(), + time: formatDateTime(i.invited_at), }) : t("settings.invites.inviteHint") } @@ -916,7 +919,7 @@ function InviteRow({ {inv.requested_at && (
{t("settings.invites.requested", { - time: new Date(inv.requested_at).toLocaleString(), + time: formatDateTime(inv.requested_at), })}
)} diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index bae70fc6..a9caf014 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -9,6 +9,7 @@ import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyActionError } from "../lib/actionErrors"; import { copyText } from "../lib/clipboard"; +import { formatDate } from "../lib/format"; import { api, type DownloadJob, type ShareLink } from "../lib/api"; import { inputCls } from "./ui/form"; import { Button } from "./ui/Button"; @@ -88,7 +89,7 @@ function UserShare({ job }: { job: DownloadJob }) { // --- share by public link ------------------------------------------------------------------- function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const confirm = useConfirm(); const [copied, setCopied] = useState(false); const [editPass, setEditPass] = useState(false); @@ -130,7 +131,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } if (link.allow_download) badges.push(t("downloads.share.downloadable")); if (link.expires_at) badges.push( - t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }), + t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }), ); return ( diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 23699847..60570395 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -7,7 +7,14 @@ import { useLiveQuery } from "../lib/useLiveQuery"; import { api, type DownloadJob } from "../lib/api"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; -import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; +import { + formatBytes, + formatDateTime, + formatDuration, + formatEta, + formatSpeed, + relativeTime, +} from "../lib/format"; import { navItemAttrs } from "../lib/listNav"; import { failCopyKeys } from "../lib/downloadFailure"; @@ -250,7 +257,7 @@ export function DownloadRow({ {job.status === "done" && job.created_at ? ( · {relativeTime(job.created_at)} From a5bcd9fca2674ab8b40b1c0a1ef09248c2678917 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 16 Sep 2026 01:05:53 +0200 Subject: [PATCH 05/26] fix: numbers follow the UI language, not the browser's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a Hungarian browser printed "1 234 567" while a Hungarian UI in an English browser printed "1,234,567" — the number half of the date defect fixed in 618ef812. One `formatNumber` in lib/format.ts (a per-language cached Intl.NumberFormat, like the relative-time formatters beside it) now serves every grouped figure: Stats, Scheduler, the channel columns and the stats popover, VideoCard's view tooltip, the ItemCount pill, and formatTotalHours. ReleaseNotes' `toLocaleDateString(undefined, …)` was the one DATE site the previous sweep missed; it moves onto formatDate. The new cases assert both languages and that the two differ, instead of comparing against the runtime's own default: that default is Hungarian on the dev box and English on a CI image, so a one-language assertion would pass under the old bug on half the machines. Verified in Chromium on :5173 with the browser forced to hu-HU and the UI in English — Stats, Scheduler and the channel table all keep comma grouping. --- .../ChannelStatsPopover.dom.test.tsx | 4 +- .../src/components/ChannelStatsPopover.tsx | 6 +-- frontend/src/components/ReleaseNotes.tsx | 15 +++--- frontend/src/components/Scheduler.tsx | 24 ++++----- frontend/src/components/Stats.tsx | 24 ++++----- frontend/src/components/VideoCard.tsx | 4 +- frontend/src/components/channelColumns.tsx | 4 +- .../src/components/ui/ItemCount.dom.test.tsx | 32 +++++++++--- frontend/src/components/ui/ItemCount.tsx | 5 +- frontend/src/lib/format.test.ts | 51 ++++++++++++++++++- frontend/src/lib/format.ts | 23 ++++++++- 11 files changed, 141 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/ChannelStatsPopover.dom.test.tsx b/frontend/src/components/ChannelStatsPopover.dom.test.tsx index f94f6ebb..b477c91f 100644 --- a/frontend/src/components/ChannelStatsPopover.dom.test.tsx +++ b/frontend/src/components/ChannelStatsPopover.dom.test.tsx @@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => { import ChannelStatsPopover from "./ChannelStatsPopover"; import { type MyStatus } from "../lib/api"; -import { formatEta } from "../lib/format"; +import { formatEta, formatNumber } from "../lib/format"; beforeAll(() => { class Stub { @@ -74,7 +74,7 @@ describe("ChannelStatsPopover", () => { expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy(); expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio - expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic + expect(screen.getByText(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic }); it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => { diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx index 3d20d933..7b0b9b8e 100644 --- a/frontend/src/components/ChannelStatsPopover.tsx +++ b/frontend/src/components/ChannelStatsPopover.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import { type MyStatus } from "../lib/api"; -import { formatEta } from "../lib/format"; +import { formatEta, formatNumber } from "../lib/format"; import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; // The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip @@ -63,13 +63,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) { { icon: Film, label: t("channels.stats.myVideos"), - value: status.my_videos.toLocaleString(), + value: formatNumber(status.my_videos), hint: t("channels.stats.myVideosHint"), }, { icon: Zap, label: t("channels.stats.quotaLeft"), - value: status.quota_remaining_today.toLocaleString(), + value: formatNumber(status.quota_remaining_today), hint: t("channels.stats.quotaLeftHint"), }, ]; diff --git a/frontend/src/components/ReleaseNotes.tsx b/frontend/src/components/ReleaseNotes.tsx index 70e0631f..e0dc3120 100644 --- a/frontend/src/components/ReleaseNotes.tsx +++ b/frontend/src/components/ReleaseNotes.tsx @@ -1,13 +1,14 @@ import { useTranslation } from "react-i18next"; import { Bug, Sparkles } from "lucide-react"; import { RELEASE_NOTES } from "../lib/releaseNotes"; +import { formatDate } from "../lib/format"; import Modal from "./ui/Modal"; -function fmtDate(d: string): string { - const t = new Date(d); - return isNaN(t.getTime()) - ? d - : t.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +// The release date in the UI language — `toLocaleDateString(undefined, …)` formats in the BROWSER's +// locale, which put Hungarian-shaped dates in an English release-notes list. Unparseable input falls +// back to the raw string (a hand-written note could carry anything). +function fmtDate(d: string, lang: string): string { + return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang); } // Public release notes (chores are intentionally omitted here). `highlight` rings the @@ -19,7 +20,7 @@ export default function ReleaseNotes({ onClose: () => void; highlight?: string; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); // Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build // where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet — // so the banner always rings *something* current instead of nothing. @@ -36,7 +37,7 @@ export default function ReleaseNotes({ >

v{r.version}

- {fmtDate(r.date)} + {fmtDate(r.date, i18n.language)} {r.sha && ( {r.sha} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 5f0e62be..a586bd95 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -22,7 +22,7 @@ import { useLiveQuery } from "../lib/useLiveQuery"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import { useConfirm } from "./ConfirmProvider"; -import { relativeTime } from "../lib/format"; +import { formatNumber, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; import { notifyActionError } from "../lib/actionErrors"; @@ -103,9 +103,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) { {phase} {p?.total != null - ? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}` + ? `${formatNumber(p.current)} / ${formatNumber(p.total)}` : p - ? p.current.toLocaleString() + ? formatNumber(p.current) : ""}
@@ -291,7 +291,7 @@ function MaintenanceSettings({
{t("scheduler.maintenance.batchNote", { - default: m.revalidate_batch_default.toLocaleString(), + default: formatNumber(m.revalidate_batch_default), })}
@@ -540,31 +540,31 @@ export default function Scheduler() { icon={Database} label={t("scheduler.queue.recentPending")} hint={t("scheduler.queue.recentPendingHint")} - value={data.queue.channels_recent_pending.toLocaleString()} + value={formatNumber(data.queue.channels_recent_pending)} /> @@ -579,15 +579,15 @@ export default function Scheduler() { {t("scheduler.quotaUsed", { - used: data.quota.used_today.toLocaleString(), - budget: data.quota.daily_budget.toLocaleString(), + used: formatNumber(data.quota.used_today), + budget: formatNumber(data.quota.daily_budget), })} {t("scheduler.quotaRemaining", { count: data.quota.remaining_today, - formatted: data.quota.remaining_today.toLocaleString(), + formatted: formatNumber(data.quota.remaining_today), })} diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 21513e9d..db502a3a 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { History, Pause, Play, RefreshCw } from "lucide-react"; import { api, type AdminQuotaRow, type Me } from "../lib/api"; -import { formatEta, quotaActionLabel } from "../lib/format"; +import { formatEta, formatNumber, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { useWizard } from "./WizardProvider"; @@ -115,10 +115,10 @@ function Overview({ me }: { me: Me }) { )} - {s.my_videos.toLocaleString()} + {formatNumber(s.my_videos)} - {s.quota_remaining_today.toLocaleString()} + {formatNumber(s.quota_remaining_today)} ) : status.isError ? ( @@ -135,16 +135,16 @@ function Overview({ me }: { me: Me }) { <>
- {usage.data.today.toLocaleString()} + {formatNumber(usage.data.today)} - {usage.data.last_7d.toLocaleString()} + {formatNumber(usage.data.last_7d)} - {usage.data.last_30d.toLocaleString()} + {formatNumber(usage.data.last_30d)} - {usage.data.all_time.toLocaleString()} + {formatNumber(usage.data.all_time)}
{Object.keys(usage.data.by_action).length > 0 && ( @@ -155,7 +155,7 @@ function Overview({ me }: { me: Me }) { .map(([action, units]) => (
{quotaActionLabel(action)} - {units.toLocaleString()} + {formatNumber(units)}
))} @@ -274,7 +274,7 @@ function SystemStats() {
{t("stats.dailyTotal", { count: data.range_days, - units: grandTotal.toLocaleString(), + units: formatNumber(grandTotal), })}
{data.daily.length === 0 ? ( @@ -285,7 +285,7 @@ function SystemStats() {
- {row.total.toLocaleString()} + {formatNumber(row.total)} {/* Proportion bar */} @@ -343,7 +343,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) { {actions.map(([action, units]) => (
{quotaActionLabel(action)} - {units.toLocaleString()} + {formatNumber(units)}
))}
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index b24f3d89..12bd40b2 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -22,7 +22,7 @@ import { navItemAttrs } from "../lib/listNav"; import clsx from "clsx"; import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; -import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format"; +import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format"; import { isPlainLeftClick } from "../lib/nav"; import ChannelNameLink from "./ChannelNameLink"; @@ -423,7 +423,7 @@ function VideoCard({ // on every line and the tooltip carries the figure regardless. const viewsCell = (withWord: boolean) => video.view_count != null && ( - + {formatViews(video.view_count)} {withWord && ` ${t("card.views")}`} diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index b8b831db..f8b3b0dc 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -3,7 +3,7 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react"; import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; -import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format"; +import { formatCountOrDash, formatNumber, formatTotalHours, relativeTime } from "../lib/format"; import type { Column } from "./ui/DataTable"; import Tooltip from "./ui/Tooltip"; import { PriorityStepper } from "./ui/PriorityStepper"; @@ -66,7 +66,7 @@ function StoredCell({ c }: { c: ManagedChannel }) { /> )} - {c.stored_videos.toLocaleString()} + {formatNumber(c.stored_videos)} ); } diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx index 5295edb4..8fd0c962 100644 --- a/frontend/src/components/ui/ItemCount.dom.test.tsx +++ b/frontend/src/components/ui/ItemCount.dom.test.tsx @@ -8,11 +8,15 @@ import { render, screen, cleanup } from "@testing-library/react"; import "../../i18n"; // side-effect: initialise the shared i18next instance import i18n from "i18next"; import ItemCount from "./ItemCount"; +import { formatNumber } from "../../lib/format"; beforeAll(async () => { await i18n.changeLanguage("en"); }); -afterEach(cleanup); +afterEach(async () => { + cleanup(); + await i18n.changeLanguage("en"); // a case that switches language must not leak it to the next +}); const text = () => screen.getByTestId("item-count").textContent; @@ -24,17 +28,31 @@ describe("ItemCount", () => { it("pluralises and thousands-separates a normal count", () => { render(); - // Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed - // en-US string, so the test asserts ItemCount's formatting path rather than the CI locale. - expect(text()).toBe(`${(192045).toLocaleString()} items`); + // Separators come from the shared formatNumber — compare against it, not a fixed en-US string, + // so the test asserts ItemCount's formatting path rather than the CI locale. + expect(text()).toBe(`${formatNumber(192045)} items`); + }); + + // The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on + // an English browser showed "192,045" instead of "192 045". The title carries the pure number (no + // plural string), and both languages are asserted — comparing against the runtime's own default + // would pass under the old bug on a machine whose locale happens to match the UI language. + it("groups in the UI language, not the browser's", async () => { + const title = () => screen.getByTestId("item-count").getAttribute("title"); + render(); + const asEnglish = title(); + cleanup(); + await i18n.changeLanguage("hu"); + render(); + expect(asEnglish).toBe(new Intl.NumberFormat("en").format(192045)); + expect(title()).toBe(new Intl.NumberFormat("hu").format(192045)); + expect(title()).not.toBe(asEnglish); }); it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => { render(); expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent - expect(screen.getByTestId("item-count").getAttribute("title")).toBe( - (1_000_000).toLocaleString(), - ); + expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000)); }); it("shows the icon-only fixed box (empty label) while the count is undefined", () => { diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx index 58f07710..f185ce5d 100644 --- a/frontend/src/components/ui/ItemCount.tsx +++ b/frontend/src/components/ui/ItemCount.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { Layers } from "lucide-react"; +import { formatNumber } from "../../lib/format"; // At a million and above we stop printing the exact figure — six digits is the readable ceiling, so // the pill shows "1M+" and the tooltip carries the precise number. @@ -20,12 +21,12 @@ export default function ItemCount({ count }: { count?: number }) { ? "" : t("common.itemCount", { count, - formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(), + formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count), }); return (