diff --git a/README.md b/README.md index b05fa4a..804d551 100644 --- a/README.md +++ b/README.md @@ -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. - **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. -- **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. - **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 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 -4. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template -5. **TV** → Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID) +3. **Admin** → Scroll to **Settings** and select your local timezone — this ensures activity log times and the TV clock display correctly +4. **Schedules** (`/schedules`) → Create a schedule template, add time blocks assigned to subjects +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 | |-----|-------------| -| `/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 | -| `/logs` | Browse and add activity log entries | -| `/admin` | Manage children and subjects | +| `/logs` | Browse timer event history and manual activity notes; filter by child and date | +| `/admin` | Manage children, subjects, schedule templates, and account settings (timezone) | ### 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` | | `complete` | Block completed | `session_id`, `block_id` | | `skip` | Block skipped | `session_id`, `block_id` | +| `strikes_update` | Strike issued/cleared | `child_id`, `strikes` | --- diff --git a/backend/app/main.py b/backend/app/main.py index bea932a..f23d745 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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_blocks", "duration_minutes", "INT NULL") 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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8ccd294..cdc48c5 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -12,6 +12,7 @@ class User(TimestampMixin, Base): full_name: Mapped[str] = mapped_column(String(255), nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True) 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 subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821 diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 587432e..1c4f08f 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -23,6 +23,8 @@ async def update_me( current_user.full_name = body.full_name if body.email is not None: current_user.email = body.email + if body.timezone is not None: + current_user.timezone = body.timezone await db.commit() await db.refresh(current_user) return current_user diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py index 665d474..914daff 100644 --- a/backend/app/schemas/activity.py +++ b/backend/app/schemas/activity.py @@ -1,5 +1,5 @@ -from datetime import date, datetime -from pydantic import BaseModel +from datetime import date, datetime, timezone +from pydantic import BaseModel, field_serializer class ActivityLogCreate(BaseModel): @@ -45,3 +45,9 @@ class TimelineEventOut(BaseModel): subject_name: str | None = None subject_icon: 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() diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 071309b..260c7d7 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -7,6 +7,7 @@ class UserOut(BaseModel): full_name: str is_active: bool is_admin: bool + timezone: str = "UTC" model_config = {"from_attributes": True} @@ -14,3 +15,4 @@ class UserOut(BaseModel): class UserUpdate(BaseModel): full_name: str | None = None email: EmailStr | None = None + timezone: str | None = None diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 21d4b6a..14c32fc 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -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, diff --git a/frontend/src/utils/time.js b/frontend/src/utils/time.js new file mode 100644 index 0000000..b5fcbe0 --- /dev/null +++ b/frontend/src/utils/time.js @@ -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 . + * @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' }, +] diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index cfe2661..91cfdad 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -250,6 +250,31 @@ + + +
+
+

Settings

+
+
+
+
+
Timezone
+
Times in the activity log will display in this timezone.
+
+
+ + +
+
+
+
@@ -257,12 +282,27 @@ @@ -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; diff --git a/frontend/src/views/LogView.vue b/frontend/src/views/LogView.vue index c39de24..fbc4fe4 100644 --- a/frontend/src/views/LogView.vue +++ b/frontend/src/views/LogView.vue @@ -81,7 +81,7 @@
-
{{ formatTime(entry.occurred_at) }}
+
{{ fmtTime(entry.occurred_at) }}
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 }) diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index ee0755e..f817441 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -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