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

View File

@@ -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 })