From 6b7e4f1dee104274f68a324b10111ebfbff352e3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 12:14:21 -0400 Subject: [PATCH] feat(web): send timezone on login + bootstrap + weekly Web client posts Intl.DateTimeFormat().resolvedOptions().timeZone to PUT /api/me/timezone after every successful login, register, and bootstrap when the locally-stored tz_last_sent_at is >7 days old. Cadence tracked in localStorage keeps the server stateless on the "is this stale?" check. Failures swallowed: the server's UTC default + last-known value keep the scheduler functioning until the next successful attempt. SSR- safe via the typeof window guard. For #392 Half B. Companion Flutter change in next commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/lib/api/me.ts | 9 +++++++++ web/src/lib/auth/store.svelte.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/web/src/lib/api/me.ts b/web/src/lib/api/me.ts index 9134d778..f6004a31 100644 --- a/web/src/lib/api/me.ts +++ b/web/src/lib/api/me.ts @@ -53,3 +53,12 @@ export async function getAPIToken(): Promise { export async function regenerateAPIToken(): Promise { return api.post('/api/me/api-token', {}); } + +// Submits the browser's current IANA timezone for the authenticated +// user. Called from the auth store on login + bootstrap + once weekly +// (cadence tracked client-side in localStorage). Failures are +// non-fatal — the server's UTC fallback keeps the scheduler working +// until the next attempt. +export async function putMyTimezone(timezone: string): Promise { + await api.put('/api/me/timezone', { timezone }); +} diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts index 990439fa..49903a20 100644 --- a/web/src/lib/auth/store.svelte.ts +++ b/web/src/lib/auth/store.svelte.ts @@ -1,4 +1,5 @@ import { api, type User, type LoginResponse } from '$lib/api/client'; +import { putMyTimezone } from '$lib/api/me'; import { queryClient } from '$lib/query/client'; import { signalSessionEnd } from './sessionEnd.svelte'; import { user, setUser } from './user.svelte'; @@ -7,9 +8,34 @@ import { user, setUser } from './user.svelte'; // callers keep working. New code can import directly from auth/user.svelte. export { user }; +// Weekly client-driven cadence for sending the browser's current IANA +// timezone to PUT /api/me/timezone (#392 Half B). Tracked in +// localStorage so the cadence survives tab restarts; bumped after each +// successful PUT. Failures are swallowed — server keeps its previous +// value (or 'UTC' default). +const TZ_LAST_SENT_KEY = 'minstrel.tz_last_sent_at'; +const WEEKLY_MS = 7 * 24 * 60 * 60 * 1000; + +async function sendTimezoneIfStale(): Promise { + if (typeof window === 'undefined') return; // SSR-safe no-op + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!tz) return; + const lastStr = window.localStorage.getItem(TZ_LAST_SENT_KEY); + const last = lastStr ? Number(lastStr) : 0; + if (Number.isFinite(last) && Date.now() - last < WEEKLY_MS) return; + await putMyTimezone(tz); + window.localStorage.setItem(TZ_LAST_SENT_KEY, String(Date.now())); + } catch (err) { + console.warn('tz send failed:', err); + } +} + export async function bootstrap(): Promise { try { setUser(await api.get('/api/me')); + // ignore: best-effort, runs in background + void sendTimezoneIfStale(); } catch { setUser(null); } @@ -18,6 +44,7 @@ export async function bootstrap(): Promise { export async function login(username: string, password: string): Promise { const res = await api.post('/api/auth/login', { username, password }); setUser(res.user); + void sendTimezoneIfStale(); } export async function register(opts: { @@ -35,6 +62,7 @@ export async function register(opts: { const res = await api.post('/api/auth/register', body); setUser(res.user); await bootstrap(); + void sendTimezoneIfStale(); } export async function forgotPassword(email: string): Promise {