From 3e7ff2a50b059269246b02ea3685a10d580b7300 Mon Sep 17 00:00:00 2001 From: derekc Date: Sat, 28 Feb 2026 10:08:07 -0800 Subject: [PATCH] Add time-based day progress bar to dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces block-count progress with a wall-clock progress bar driven by configurable day start/end hours on each schedule template. - ScheduleTemplate: add day_start_time / day_end_time (TIME, nullable) - Startup migration: idempotent ALTER TABLE for existing DBs - Dashboard snapshot: includes day_start_time / day_end_time from template - Admin → Schedules: time pickers in block editor to set day hours - Dashboard view: time-based progress bar with start/current/end labels - TV view: full-width day progress strip between header and main content Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 14 +++++ backend/app/models/schedule.py | 2 + backend/app/routers/dashboard.py | 14 ++++- backend/app/routers/schedules.py | 2 + backend/app/schemas/schedule.py | 6 ++ backend/app/schemas/session.py | 4 +- frontend/src/stores/schedule.js | 6 ++ frontend/src/views/AdminView.vue | 46 ++++++++++++++ frontend/src/views/DashboardView.vue | 65 ++++++++++++++++++-- frontend/src/views/TVView.vue | 92 ++++++++++++++++++---------- 10 files changed, 213 insertions(+), 38 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 6f7cbcb..9a84fa5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,8 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text +from sqlalchemy.exc import OperationalError from app.config import get_settings from app.database import engine @@ -12,11 +14,23 @@ from app.websocket.manager import manager settings = get_settings() +async def _add_column_if_missing(conn, table: str, column: str, definition: str): + """Add a column to a table, silently ignoring if it already exists (MySQL 1060).""" + try: + await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")) + except OperationalError as e: + if e.orig.args[0] != 1060: # 1060 = Duplicate column name + raise + + @asynccontextmanager async def lifespan(app: FastAPI): # Create tables on startup (Alembic handles migrations in prod, this is a safety net) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # Idempotent column additions for schema migrations + await _add_column_if_missing(conn, "schedule_templates", "day_start_time", "TIME NULL") + await _add_column_if_missing(conn, "schedule_templates", "day_end_time", "TIME NULL") yield diff --git a/backend/app/models/schedule.py b/backend/app/models/schedule.py index d5cad38..9061f22 100644 --- a/backend/app/models/schedule.py +++ b/backend/app/models/schedule.py @@ -14,6 +14,8 @@ class ScheduleTemplate(TimestampMixin, Base): ) name: Mapped[str] = mapped_column(String(100), nullable=False) is_default: Mapped[bool] = mapped_column(Boolean, default=False) + day_start_time: Mapped[time | None] = mapped_column(Time, nullable=True) + day_end_time: Mapped[time | None] = mapped_column(Time, nullable=True) user: Mapped["User"] = relationship("User", back_populates="schedule_templates") # noqa: F821 child: Mapped["Child | None"] = relationship("Child") # noqa: F821 diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index d19f4f0..a32ba33 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_db from app.models.child import Child -from app.models.schedule import ScheduleBlock +from app.models.schedule import ScheduleBlock, ScheduleTemplate from app.models.session import DailySession, TimerEvent from app.schemas.session import DashboardSnapshot @@ -41,6 +41,8 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): blocks = [] completed_ids = [] block_elapsed_seconds = 0 + day_start_time = None + day_end_time = None if session and session.template_id: blocks_result = await db.execute( @@ -50,6 +52,14 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): ) blocks = blocks_result.scalars().all() + template_result = await db.execute( + select(ScheduleTemplate).where(ScheduleTemplate.id == session.template_id) + ) + template = template_result.scalar_one_or_none() + if template: + day_start_time = template.day_start_time + day_end_time = template.day_end_time + events_result = await db.execute( select(TimerEvent).where( TimerEvent.session_id == session.id, @@ -88,4 +98,6 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): blocks=blocks, completed_block_ids=completed_ids, block_elapsed_seconds=block_elapsed_seconds, + day_start_time=day_start_time, + day_end_time=day_end_time, ) diff --git a/backend/app/routers/schedules.py b/backend/app/routers/schedules.py index 95d38e8..3f6e31b 100644 --- a/backend/app/routers/schedules.py +++ b/backend/app/routers/schedules.py @@ -42,6 +42,8 @@ async def create_template( name=body.name, child_id=body.child_id, is_default=body.is_default, + day_start_time=body.day_start_time, + day_end_time=body.day_end_time, ) db.add(template) await db.flush() # get template.id before adding blocks diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py index cd162b3..6fafef5 100644 --- a/backend/app/schemas/schedule.py +++ b/backend/app/schemas/schedule.py @@ -27,6 +27,8 @@ class ScheduleTemplateCreate(BaseModel): name: str child_id: int | None = None is_default: bool = False + day_start_time: time | None = None + day_end_time: time | None = None blocks: list[ScheduleBlockCreate] = [] @@ -34,6 +36,8 @@ class ScheduleTemplateUpdate(BaseModel): name: str | None = None child_id: int | None = None is_default: bool | None = None + day_start_time: time | None = None + day_end_time: time | None = None class ScheduleTemplateOut(BaseModel): @@ -41,6 +45,8 @@ class ScheduleTemplateOut(BaseModel): name: str child_id: int | None is_default: bool + day_start_time: time | None + day_end_time: time | None blocks: list[ScheduleBlockOut] = [] model_config = {"from_attributes": True} diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 5a3aca9..ce592da 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, time from pydantic import BaseModel from app.schemas.schedule import ScheduleBlockOut from app.schemas.child import ChildOut @@ -43,3 +43,5 @@ class DashboardSnapshot(BaseModel): blocks: list[ScheduleBlockOut] = [] completed_block_ids: list[int] = [] block_elapsed_seconds: int = 0 # seconds already elapsed for the current block + day_start_time: time | None = None + day_end_time: time | None = None diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index 8055a8a..6a19bb3 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -10,6 +10,8 @@ export const useScheduleStore = defineStore('schedule', () => { const isPaused = ref(false) const blockStartedAt = ref(null) // Date.now() ms when current counting period started const blockElapsedOffset = ref(0) // seconds already elapsed before blockStartedAt + const dayStartTime = ref(null) // "HH:MM:SS" string or null + const dayEndTime = ref(null) // "HH:MM:SS" string or null const currentBlock = computed(() => session.value?.current_block_id @@ -28,6 +30,8 @@ export const useScheduleStore = defineStore('schedule', () => { completedBlockIds.value = snapshot.completed_block_ids || [] isPaused.value = false if (snapshot.child) child.value = snapshot.child + dayStartTime.value = snapshot.day_start_time || null + dayEndTime.value = snapshot.day_end_time || null // Restore elapsed time from server-computed value const serverElapsed = snapshot.block_elapsed_seconds || 0 if (snapshot.session?.current_block_id && serverElapsed > 0) { @@ -111,6 +115,8 @@ export const useScheduleStore = defineStore('schedule', () => { isPaused, blockStartedAt, blockElapsedOffset, + dayStartTime, + dayEndTime, currentBlock, progressPercent, applySnapshot, diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 64c87bb..8919345 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -135,6 +135,24 @@
+ +
+ School day hours + + to + +
+
{{ block.time_start }} – {{ block.time_end }} @@ -300,6 +318,14 @@ async function deleteBlock(templateId, blockId) { await loadTemplates() } +async function saveDayHours(template, which, value) { + const payload = which === 'start' + ? { day_start_time: value || null } + : { day_end_time: value || null } + await api.patch(`/api/schedules/${template.id}`, payload) + await loadTemplates() +} + onMounted(async () => { await childrenStore.fetchChildren() await Promise.all([loadSubjects(), loadTemplates()]) @@ -391,6 +417,26 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin .template-actions { display: flex; gap: 0.5rem; flex-shrink: 0; } .block-editor { margin-top: 1.25rem; border-top: 1px solid #334155; padding-top: 1.25rem; } + +.day-hours-row { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 1rem; + background: #0f172a; + padding: 0.6rem 0.85rem; + border-radius: 0.5rem; +} +.day-hours-label { font-size: 0.8rem; color: #64748b; flex: 1; } +.day-hours-sep { font-size: 0.8rem; color: #475569; } +.day-hours-row input[type="time"] { + padding: 0.35rem 0.5rem; + background: #1e293b; + border: 1px solid #334155; + border-radius: 0.4rem; + color: #f1f5f9; + font-size: 0.85rem; +} .block-list { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; } .block-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: #0f172a; border-radius: 0.5rem; } .block-time { font-size: 0.8rem; color: #64748b; font-variant-numeric: tabular-nums; } diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 513fdbe..3e85ea7 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -16,11 +16,21 @@
Today's Session
-
- Active - {{ scheduleStore.progressPercent }}% complete +
+
+ Active + {{ dayProgressPercent }}% +
+ +
+ {{ formatDayTime(scheduleStore.dayStartTime) }} + {{ currentTimeDisplay }} + {{ formatDayTime(scheduleStore.dayEndTime) }} +
+
+
+ Active
-
{ now.value = new Date() }, 60000) + +function timeStrToMinutes(str) { + if (!str) return null + const [h, m] = str.split(':').map(Number) + return h * 60 + m +} + +function formatDayTime(str) { + if (!str) return '' + const [h, m] = str.split(':').map(Number) + const period = h >= 12 ? 'PM' : 'AM' + const hour = h % 12 || 12 + return `${hour}:${String(m).padStart(2, '0')} ${period}` +} + +const currentTimeDisplay = computed(() => + now.value.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) +) + +const dayProgressPercent = computed(() => { + const start = timeStrToMinutes(scheduleStore.dayStartTime) + const end = timeStrToMinutes(scheduleStore.dayEndTime) + if (start === null || end === null || end <= start) return 0 + const nowMin = now.value.getHours() * 60 + now.value.getMinutes() + return Math.max(0, Math.min(100, Math.round((nowMin - start) / (end - start) * 100))) +}) + let wsDisconnect = null async function loadDashboard() { @@ -197,6 +237,23 @@ h1 { font-size: 1.75rem; font-weight: 700; } color: #94a3b8; } +.day-progress-section { margin-bottom: 0.75rem; } +.day-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} +.day-progress-pct { font-size: 0.9rem; color: #94a3b8; } +.day-progress-times { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: #475569; + margin-top: 0.35rem; + font-variant-numeric: tabular-nums; +} + .badge-active { background: #14532d; color: #4ade80; diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index 604f069..3e01636 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -7,6 +7,16 @@
{{ dateDisplay }}
+ +
+
+ {{ formatDayTime(scheduleStore.dayStartTime) }} + {{ dayProgressPercent }}% through the day + {{ formatDayTime(scheduleStore.dayEndTime) }} +
+ +
+
🌟
@@ -36,17 +46,6 @@
- -
-
- Day Progress — {{ scheduleStore.progressPercent }}% -
- -
- {{ scheduleStore.completedBlockIds.length }} of {{ scheduleStore.blocks.length }} blocks -
-
-
now.value.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }) ) +// Day progress helpers +function timeStrToMinutes(str) { + if (!str) return null + const [h, m] = str.split(':').map(Number) + return h * 60 + m +} + +function formatDayTime(str) { + if (!str) return '' + const [h, m] = str.split(':').map(Number) + const period = h >= 12 ? 'PM' : 'AM' + const hour = h % 12 || 12 + return `${hour}:${String(m).padStart(2, '0')} ${period}` +} + +const dayProgressPercent = computed(() => { + const start = timeStrToMinutes(scheduleStore.dayStartTime) + const end = timeStrToMinutes(scheduleStore.dayEndTime) + if (start === null || end === null || end <= start) return 0 + const nowMin = now.value.getHours() * 60 + now.value.getMinutes() + return Math.max(0, Math.min(100, Math.round((nowMin - start) / (end - start) * 100))) +}) + // Subject display helpers const currentSubjectColor = computed(() => { const block = scheduleStore.currentBlock @@ -193,33 +215,39 @@ onMounted(async () => { max-width: 600px; } +.tv-day-progress { + background: #1e293b; + border-radius: 1rem; + padding: 1rem 1.5rem 1.25rem; +} + +.tv-day-progress-meta { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.6rem; +} + +.tv-day-start, +.tv-day-end { + font-size: 1rem; + color: #64748b; + font-variant-numeric: tabular-nums; +} + +.tv-day-pct { + font-size: 1.1rem; + font-weight: 600; + color: #94a3b8; + letter-spacing: 0.02em; +} + .tv-sidebar { display: flex; flex-direction: column; gap: 1.5rem; } -.tv-progress-section { - background: #1e293b; - border-radius: 1rem; - padding: 1.25rem; -} - -.tv-progress-label { - font-size: 0.9rem; - color: #64748b; - margin-bottom: 0.5rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.tv-block-count { - font-size: 0.85rem; - color: #475569; - margin-top: 0.5rem; - text-align: right; -} - .tv-schedule-list { overflow-y: auto; display: flex;