Add timezone selector to Admin settings with full-stack support

- Add `timezone` column to User model (VARCHAR 64, default UTC) with
  idempotent startup migration
- Expose and persist timezone via PATCH /api/users/me
- Fix TimerEvent.occurred_at serialization to include UTC offset marker
  (+00:00) so JavaScript correctly parses timestamps as UTC
- Add frontend utility (src/utils/time.js) with timezone-aware
  formatTime, getHHMM, getDateInTZ, tzDateTimeToUTC helpers and a
  curated IANA timezone list
- Add Settings section to Admin page with timezone dropdown; saves to
  both the API and localStorage for the unauthenticated TV view
- Update Activity Log to display and edit times in the user's timezone
- Update TV dashboard clock to respect the saved timezone
- Update README: features, setup steps, usage table, WebSocket events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 14:16:37 -08:00
parent fef03ec538
commit 823260cdd8
11 changed files with 230 additions and 24 deletions

119
frontend/src/utils/time.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* Format a UTC ISO timestamp for display in the given IANA timezone.
* @param {string} isoStr - ISO string from the backend (e.g. "2025-01-01T14:00:00+00:00")
* @param {string} timezone - IANA timezone (e.g. "America/New_York")
*/
export function formatTime(isoStr, timezone) {
if (!isoStr) return ''
const d = new Date(isoStr)
return d.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
timeZone: timezone || undefined,
})
}
/**
* Get "HH:MM" in the given timezone — suitable for <input type="time">.
* @param {string} isoStr - ISO string from the backend
* @param {string} timezone - IANA timezone
*/
export function getHHMM(isoStr, timezone) {
if (!isoStr) return ''
const d = new Date(isoStr)
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone || undefined,
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return fmt.format(d) // "14:30"
}
/**
* Get "YYYY-MM-DD" of a UTC ISO string in the given timezone.
* @param {string} isoStr - ISO string from the backend
* @param {string} timezone - IANA timezone
*/
export function getDateInTZ(isoStr, timezone) {
const d = new Date(isoStr)
return new Intl.DateTimeFormat('en-CA', {
timeZone: timezone || 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d) // "2025-01-01"
}
/**
* Convert a local date + time string in a given timezone to a UTC Date object.
* Uses a probe-and-shift approach to find the UTC equivalent.
* @param {string} dateStr - "YYYY-MM-DD"
* @param {string} timeStr - "HH:MM"
* @param {string} timezone - IANA timezone
* @returns {Date} UTC Date
*/
export function tzDateTimeToUTC(dateStr, timeStr, timezone) {
// Step 1: treat the given date+time as UTC (just a probe point)
const asUTC = new Date(`${dateStr}T${timeStr}:00Z`)
// Step 2: see what that UTC instant looks like in the target timezone
const inTZ = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone || 'UTC',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(asUTC) // "14:30"
// Step 3: compute the difference between what we want and what we got
const [wantH, wantM] = timeStr.split(':').map(Number)
const [gotH, gotM] = inTZ.split(':').map(Number)
const diffMin = (wantH * 60 + wantM) - (gotH * 60 + gotM)
// Step 4: shift the probe UTC date by the difference
return new Date(asUTC.getTime() + diffMin * 60 * 1000)
}
/** Curated list of common IANA timezones for the selector. */
export const TIMEZONES = [
{ label: 'UTC', value: 'UTC' },
// Americas
{ label: 'Eastern Time (US & Canada)', value: 'America/New_York' },
{ label: 'Central Time (US & Canada)', value: 'America/Chicago' },
{ label: 'Mountain Time (US & Canada)', value: 'America/Denver' },
{ label: 'Pacific Time (US & Canada)', value: 'America/Los_Angeles' },
{ label: 'Alaska', value: 'America/Anchorage' },
{ label: 'Hawaii', value: 'Pacific/Honolulu' },
{ label: 'Atlantic Time (Canada)', value: 'America/Halifax' },
{ label: 'Newfoundland', value: 'America/St_Johns' },
{ label: 'Mexico City', value: 'America/Mexico_City' },
{ label: 'Toronto', value: 'America/Toronto' },
{ label: 'Vancouver', value: 'America/Vancouver' },
{ label: 'Bogotá', value: 'America/Bogota' },
{ label: 'Buenos Aires', value: 'America/Argentina/Buenos_Aires' },
{ label: 'São Paulo', value: 'America/Sao_Paulo' },
// Europe
{ label: 'London', value: 'Europe/London' },
{ label: 'Paris', value: 'Europe/Paris' },
{ label: 'Berlin', value: 'Europe/Berlin' },
{ label: 'Amsterdam', value: 'Europe/Amsterdam' },
{ label: 'Rome', value: 'Europe/Rome' },
{ label: 'Moscow', value: 'Europe/Moscow' },
// Africa / Middle East
{ label: 'Cairo', value: 'Africa/Cairo' },
{ label: 'Nairobi', value: 'Africa/Nairobi' },
{ label: 'Dubai', value: 'Asia/Dubai' },
// Asia
{ label: 'Karachi', value: 'Asia/Karachi' },
{ label: 'Kolkata', value: 'Asia/Kolkata' },
{ label: 'Dhaka', value: 'Asia/Dhaka' },
{ label: 'Bangkok', value: 'Asia/Bangkok' },
{ label: 'Singapore', value: 'Asia/Singapore' },
{ label: 'Hong Kong', value: 'Asia/Hong_Kong' },
{ label: 'Shanghai', value: 'Asia/Shanghai' },
{ label: 'Tokyo', value: 'Asia/Tokyo' },
{ label: 'Seoul', value: 'Asia/Seoul' },
// Australia / Pacific
{ label: 'Perth', value: 'Australia/Perth' },
{ label: 'Adelaide', value: 'Australia/Adelaide' },
{ label: 'Sydney', value: 'Australia/Sydney' },
{ label: 'Melbourne', value: 'Australia/Melbourne' },
{ label: 'Auckland', value: 'Pacific/Auckland' },
]