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

@@ -9,7 +9,9 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
- **TV Dashboard** — Full-screen display for the living room TV. Shows the current subject, countdown timer, day progress, and upcoming schedule blocks. Updates live without page refresh. - **TV Dashboard** — Full-screen display for the living room TV. Shows the current subject, countdown timer, day progress, and upcoming schedule blocks. Updates live without page refresh.
- **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Assign templates per-child or share across all children. - **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Assign templates per-child or share across all children.
- **Daily Sessions** — Start a school day against a schedule template. Track which blocks are active, paused, or complete. - **Daily Sessions** — Start a school day against a schedule template. Track which blocks are active, paused, or complete.
- **Activity Log** — Manually log school activities with subject, duration, and notes. Browse and filter history by date. - **Activity Log** — Automatically records every timer event (start, pause, resume, complete, skip) as a timeline. Supports manual notes with subject, duration, and free text. Browse and filter history by child and date.
- **Behavior Tracking (Strikes)** — Issue up to 3 strikes per child from the Dashboard. Strike count is shown on the TV dashboard and resets automatically when a new school day begins.
- **Timezone Support** — Set your local timezone in Admin → Settings. All activity log timestamps display in your timezone, including the TV dashboard clock.
- **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history. - **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history.
- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). - **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required).
@@ -123,9 +125,10 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
1. **Admin** (`/admin`) → Add each child, pick a color 1. **Admin** (`/admin`) → Add each child, pick a color
2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors 2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors
3. **Schedules** (`/schedules`) → Create a schedule template, add time blocks assigned to subjects 3. **Admin** → Scroll to **Settings** and select your local timezone — this ensures activity log times and the TV clock display correctly
4. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template 4. **Schedules** (`/schedules`) → Create a schedule template, add time blocks assigned to subjects
5. **TV** → Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID) 5. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
6. **TV** → Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID)
--- ---
@@ -135,10 +138,10 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
| URL | Description | | URL | Description |
|-----|-------------| |-----|-------------|
| `/dashboard` | Overview, start/stop sessions, timer controls, link to TV view | | `/dashboard` | Overview, start/stop sessions, timer controls, issue behavior strikes |
| `/schedules` | Create and edit schedule templates and time blocks | | `/schedules` | Create and edit schedule templates and time blocks |
| `/logs` | Browse and add activity log entries | | `/logs` | Browse timer event history and manual activity notes; filter by child and date |
| `/admin` | Manage children and subjects | | `/admin` | Manage children, subjects, schedule templates, and account settings (timezone) |
### TV Dashboard (no login) ### TV Dashboard (no login)
@@ -207,6 +210,7 @@ The TV dashboard connects to `ws://host/ws/{child_id}` and receives JSON events:
| `resume` | Timer resumed | `session_id`, `block_id` | | `resume` | Timer resumed | `session_id`, `block_id` |
| `complete` | Block completed | `session_id`, `block_id` | | `complete` | Block completed | `session_id`, `block_id` |
| `skip` | Block skipped | `session_id`, `block_id` | | `skip` | Block skipped | `session_id`, `block_id` |
| `strikes_update` | Strike issued/cleared | `child_id`, `strikes` |
--- ---

View File

@@ -33,6 +33,7 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "schedule_templates", "day_end_time", "TIME NULL") await _add_column_if_missing(conn, "schedule_templates", "day_end_time", "TIME NULL")
await _add_column_if_missing(conn, "schedule_blocks", "duration_minutes", "INT NULL") await _add_column_if_missing(conn, "schedule_blocks", "duration_minutes", "INT NULL")
await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0") await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'")
yield yield

View File

@@ -12,6 +12,7 @@ class User(TimestampMixin, Base):
full_name: Mapped[str] = mapped_column(String(255), nullable=False) full_name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False) is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821 children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821
subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821 subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821

View File

@@ -23,6 +23,8 @@ async def update_me(
current_user.full_name = body.full_name current_user.full_name = body.full_name
if body.email is not None: if body.email is not None:
current_user.email = body.email current_user.email = body.email
if body.timezone is not None:
current_user.timezone = body.timezone
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
return current_user return current_user

View File

@@ -1,5 +1,5 @@
from datetime import date, datetime from datetime import date, datetime, timezone
from pydantic import BaseModel from pydantic import BaseModel, field_serializer
class ActivityLogCreate(BaseModel): class ActivityLogCreate(BaseModel):
@@ -45,3 +45,9 @@ class TimelineEventOut(BaseModel):
subject_name: str | None = None subject_name: str | None = None
subject_icon: str | None = None subject_icon: str | None = None
subject_color: str | None = None subject_color: str | None = None
@field_serializer("occurred_at")
def serialize_occurred_at(self, dt: datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()

View File

@@ -7,6 +7,7 @@ class UserOut(BaseModel):
full_name: str full_name: str
is_active: bool is_active: bool
is_admin: bool is_admin: bool
timezone: str = "UTC"
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -14,3 +15,4 @@ class UserOut(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
full_name: str | None = None full_name: str | None = None
email: EmailStr | None = None email: EmailStr | None = None
timezone: str | None = None

View File

@@ -7,6 +7,7 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref(null) const user = ref(null)
const isAuthenticated = computed(() => !!accessToken.value) const isAuthenticated = computed(() => !!accessToken.value)
const timezone = computed(() => user.value?.timezone || 'UTC')
function setToken(token) { function setToken(token) {
accessToken.value = token accessToken.value = token
@@ -67,6 +68,7 @@ export const useAuthStore = defineStore('auth', () => {
accessToken, accessToken,
user, user,
isAuthenticated, isAuthenticated,
timezone,
login, login,
register, register,
logout, logout,

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' },
]

View File

@@ -250,6 +250,31 @@
</div> </div>
</div> </div>
</section> </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> </main>
</div> </div>
</template> </template>
@@ -257,12 +282,27 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useChildrenStore } from '@/stores/children' import { useChildrenStore } from '@/stores/children'
import { useAuthStore } from '@/stores/auth'
import api from '@/composables/useApi' import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue' import NavBar from '@/components/NavBar.vue'
import { TIMEZONES } from '@/utils/time'
const childrenStore = useChildrenStore() const childrenStore = useChildrenStore()
const authStore = useAuthStore()
const subjects = ref([]) 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 // Children
const showChildForm = ref(false) const showChildForm = ref(false)
const newChild = ref({ name: '', color: '#4F46E5' }) const newChild = ref({ name: '', color: '#4F46E5' })
@@ -471,6 +511,7 @@ async function saveDayHours(template, which, value) {
onMounted(async () => { onMounted(async () => {
await childrenStore.fetchChildren() await childrenStore.fetchChildren()
await Promise.all([loadSubjects(), loadTemplates()]) await Promise.all([loadSubjects(), loadTemplates()])
selectedTimezone.value = authStore.timezone
}) })
</script> </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; } .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 { .btn-primary {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #4f46e5; background: #4f46e5;

View File

@@ -81,7 +81,7 @@
<!-- Display row --> <!-- Display row -->
<div v-else class="event-row" :class="entry._type"> <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 <div
class="event-dot" class="event-dot"
:style="entry.subject_color ? { background: entry.subject_color } : {}" :style="entry.subject_color ? { background: entry.subject_color } : {}"
@@ -119,11 +119,14 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useChildrenStore } from '@/stores/children' import { useChildrenStore } from '@/stores/children'
import { useAuthStore } from '@/stores/auth'
import api from '@/composables/useApi' import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue' import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue' import ChildSelector from '@/components/ChildSelector.vue'
import { formatTime, getHHMM, getDateInTZ, tzDateTimeToUTC } from '@/utils/time'
const childrenStore = useChildrenStore() const childrenStore = useChildrenStore()
const authStore = useAuthStore()
const timeline = ref([]) const timeline = ref([])
const manualLogs = ref([]) const manualLogs = ref([])
const showForm = ref(false) const showForm = ref(false)
@@ -175,10 +178,8 @@ function formatDate(dateStr) {
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
} }
function formatTime(isoStr) { function fmtTime(isoStr) {
if (!isoStr) return '' return formatTime(isoStr, authStore.timezone)
const d = new Date(isoStr)
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
} }
const EVENT_META = { const EVENT_META = {
@@ -205,10 +206,10 @@ function eventLabel(entry) {
function startEdit(entry) { function startEdit(entry) {
editingEntry.value = entry editingEntry.value = entry
if (entry._type === 'event') { if (entry._type === 'event') {
const d = new Date(entry.occurred_at) editDraft.value = {
const hh = String(d.getHours()).padStart(2, '0') event_type: entry.event_type,
const mm = String(d.getMinutes()).padStart(2, '0') time: getHHMM(entry.occurred_at, authStore.timezone),
editDraft.value = { event_type: entry.event_type, time: `${hh}:${mm}` } }
} else { } else {
editDraft.value = { notes: entry.notes || '' } editDraft.value = { notes: entry.notes || '' }
} }
@@ -216,12 +217,11 @@ function startEdit(entry) {
async function saveEdit(entry) { async function saveEdit(entry) {
if (entry._type === 'event') { if (entry._type === 'event') {
const original = new Date(entry.occurred_at) const dateInTZ = getDateInTZ(entry.occurred_at, authStore.timezone)
const [hh, mm] = editDraft.value.time.split(':').map(Number) const utcDate = tzDateTimeToUTC(dateInTZ, editDraft.value.time, authStore.timezone)
original.setHours(hh, mm, 0, 0)
await api.patch(`/api/logs/timeline/${entry.id}`, { await api.patch(`/api/logs/timeline/${entry.id}`, {
event_type: editDraft.value.event_type, event_type: editDraft.value.event_type,
occurred_at: original.toISOString(), occurred_at: utcDate.toISOString(),
}) })
} else { } else {
await api.patch(`/api/logs/${entry.id}`, { notes: editDraft.value.notes }) await api.patch(`/api/logs/${entry.id}`, { notes: editDraft.value.notes })

View File

@@ -112,15 +112,18 @@ const route = useRoute()
const scheduleStore = useScheduleStore() const scheduleStore = useScheduleStore()
const childId = parseInt(route.params.childId) const childId = parseInt(route.params.childId)
// Read timezone from localStorage (set by the parent in Admin → Settings)
const tz = localStorage.getItem('tz') || undefined
// Clock // Clock
const now = ref(new Date()) const now = ref(new Date())
setInterval(() => { now.value = new Date() }, 1000) setInterval(() => { now.value = new Date() }, 1000)
const clockDisplay = computed(() => 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(() => 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 // Day progress helpers