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:
119
frontend/src/utils/time.js
Normal file
119
frontend/src/utils/time.js
Normal 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' },
|
||||
]
|
||||
Reference in New Issue
Block a user