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:
@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const timezone = computed(() => user.value?.timezone || 'UTC')
|
||||
|
||||
function setToken(token) {
|
||||
accessToken.value = token
|
||||
@@ -67,6 +68,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
accessToken,
|
||||
user,
|
||||
isAuthenticated,
|
||||
timezone,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
|
||||
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' },
|
||||
]
|
||||
@@ -250,6 +250,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings section -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-label">
|
||||
<div class="settings-title">Timezone</div>
|
||||
<div class="settings-hint">Times in the activity log will display in this timezone.</div>
|
||||
</div>
|
||||
<div class="settings-control">
|
||||
<select v-model="selectedTimezone" class="tz-select">
|
||||
<option v-for="tz in TIMEZONES" :key="tz.value" :value="tz.value">
|
||||
{{ tz.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn-primary btn-sm" @click="saveTimezone" :disabled="tzSaved">
|
||||
{{ tzSaved ? 'Saved!' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -257,12 +282,27 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useChildrenStore } from '@/stores/children'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/composables/useApi'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import { TIMEZONES } from '@/utils/time'
|
||||
|
||||
const childrenStore = useChildrenStore()
|
||||
const authStore = useAuthStore()
|
||||
const subjects = ref([])
|
||||
|
||||
// Settings — Timezone
|
||||
const selectedTimezone = ref(authStore.timezone)
|
||||
const tzSaved = ref(false)
|
||||
|
||||
async function saveTimezone() {
|
||||
await api.patch('/api/users/me', { timezone: selectedTimezone.value })
|
||||
await authStore.fetchMe()
|
||||
localStorage.setItem('tz', selectedTimezone.value)
|
||||
tzSaved.value = true
|
||||
setTimeout(() => { tzSaved.value = false }, 2000)
|
||||
}
|
||||
|
||||
// Children
|
||||
const showChildForm = ref(false)
|
||||
const newChild = ref({ name: '', color: '#4F46E5' })
|
||||
@@ -471,6 +511,7 @@ async function saveDayHours(template, which, value) {
|
||||
onMounted(async () => {
|
||||
await childrenStore.fetchChildren()
|
||||
await Promise.all([loadSubjects(), loadTemplates()])
|
||||
selectedTimezone.value = authStore.timezone
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -664,6 +705,31 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
|
||||
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
|
||||
|
||||
.settings-card {
|
||||
background: #1e293b;
|
||||
border-radius: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.settings-label { flex: 1; min-width: 180px; }
|
||||
.settings-title { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.2rem; }
|
||||
.settings-hint { font-size: 0.8rem; color: #64748b; }
|
||||
.settings-control { display: flex; align-items: center; gap: 0.75rem; flex-shrink: 0; }
|
||||
.tz-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.5rem;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.875rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #4f46e5;
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
<!-- Display row -->
|
||||
<div v-else class="event-row" :class="entry._type">
|
||||
<div class="event-time">{{ formatTime(entry.occurred_at) }}</div>
|
||||
<div class="event-time">{{ fmtTime(entry.occurred_at) }}</div>
|
||||
<div
|
||||
class="event-dot"
|
||||
:style="entry.subject_color ? { background: entry.subject_color } : {}"
|
||||
@@ -119,11 +119,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useChildrenStore } from '@/stores/children'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/composables/useApi'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import ChildSelector from '@/components/ChildSelector.vue'
|
||||
import { formatTime, getHHMM, getDateInTZ, tzDateTimeToUTC } from '@/utils/time'
|
||||
|
||||
const childrenStore = useChildrenStore()
|
||||
const authStore = useAuthStore()
|
||||
const timeline = ref([])
|
||||
const manualLogs = ref([])
|
||||
const showForm = ref(false)
|
||||
@@ -175,10 +178,8 @@ function formatDate(dateStr) {
|
||||
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatTime(isoStr) {
|
||||
if (!isoStr) return ''
|
||||
const d = new Date(isoStr)
|
||||
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
||||
function fmtTime(isoStr) {
|
||||
return formatTime(isoStr, authStore.timezone)
|
||||
}
|
||||
|
||||
const EVENT_META = {
|
||||
@@ -205,10 +206,10 @@ function eventLabel(entry) {
|
||||
function startEdit(entry) {
|
||||
editingEntry.value = entry
|
||||
if (entry._type === 'event') {
|
||||
const d = new Date(entry.occurred_at)
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
editDraft.value = { event_type: entry.event_type, time: `${hh}:${mm}` }
|
||||
editDraft.value = {
|
||||
event_type: entry.event_type,
|
||||
time: getHHMM(entry.occurred_at, authStore.timezone),
|
||||
}
|
||||
} else {
|
||||
editDraft.value = { notes: entry.notes || '' }
|
||||
}
|
||||
@@ -216,12 +217,11 @@ function startEdit(entry) {
|
||||
|
||||
async function saveEdit(entry) {
|
||||
if (entry._type === 'event') {
|
||||
const original = new Date(entry.occurred_at)
|
||||
const [hh, mm] = editDraft.value.time.split(':').map(Number)
|
||||
original.setHours(hh, mm, 0, 0)
|
||||
const dateInTZ = getDateInTZ(entry.occurred_at, authStore.timezone)
|
||||
const utcDate = tzDateTimeToUTC(dateInTZ, editDraft.value.time, authStore.timezone)
|
||||
await api.patch(`/api/logs/timeline/${entry.id}`, {
|
||||
event_type: editDraft.value.event_type,
|
||||
occurred_at: original.toISOString(),
|
||||
occurred_at: utcDate.toISOString(),
|
||||
})
|
||||
} else {
|
||||
await api.patch(`/api/logs/${entry.id}`, { notes: editDraft.value.notes })
|
||||
|
||||
@@ -112,15 +112,18 @@ const route = useRoute()
|
||||
const scheduleStore = useScheduleStore()
|
||||
const childId = parseInt(route.params.childId)
|
||||
|
||||
// Read timezone from localStorage (set by the parent in Admin → Settings)
|
||||
const tz = localStorage.getItem('tz') || undefined
|
||||
|
||||
// Clock
|
||||
const now = ref(new Date())
|
||||
setInterval(() => { now.value = new Date() }, 1000)
|
||||
|
||||
const clockDisplay = computed(() =>
|
||||
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: tz })
|
||||
)
|
||||
const dateDisplay = computed(() =>
|
||||
now.value.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
|
||||
now.value.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', timeZone: tz })
|
||||
)
|
||||
|
||||
// Day progress helpers
|
||||
|
||||
Reference in New Issue
Block a user