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
+