Add time-based day progress bar to dashboards

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 10:08:07 -08:00
parent 462205cdc1
commit 3e7ff2a50b
10 changed files with 213 additions and 38 deletions

View File

@@ -2,6 +2,8 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from app.config import get_settings from app.config import get_settings
from app.database import engine from app.database import engine
@@ -12,11 +14,23 @@ from app.websocket.manager import manager
settings = get_settings() 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Create tables on startup (Alembic handles migrations in prod, this is a safety net) # Create tables on startup (Alembic handles migrations in prod, this is a safety net)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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 yield

View File

@@ -14,6 +14,8 @@ class ScheduleTemplate(TimestampMixin, Base):
) )
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=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 user: Mapped["User"] = relationship("User", back_populates="schedule_templates") # noqa: F821
child: Mapped["Child | None"] = relationship("Child") # noqa: F821 child: Mapped["Child | None"] = relationship("Child") # noqa: F821

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
from app.dependencies import get_db from app.dependencies import get_db
from app.models.child import Child 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.models.session import DailySession, TimerEvent
from app.schemas.session import DashboardSnapshot from app.schemas.session import DashboardSnapshot
@@ -41,6 +41,8 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
blocks = [] blocks = []
completed_ids = [] completed_ids = []
block_elapsed_seconds = 0 block_elapsed_seconds = 0
day_start_time = None
day_end_time = None
if session and session.template_id: if session and session.template_id:
blocks_result = await db.execute( 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() 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( events_result = await db.execute(
select(TimerEvent).where( select(TimerEvent).where(
TimerEvent.session_id == session.id, TimerEvent.session_id == session.id,
@@ -88,4 +98,6 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
blocks=blocks, blocks=blocks,
completed_block_ids=completed_ids, completed_block_ids=completed_ids,
block_elapsed_seconds=block_elapsed_seconds, block_elapsed_seconds=block_elapsed_seconds,
day_start_time=day_start_time,
day_end_time=day_end_time,
) )

View File

@@ -42,6 +42,8 @@ async def create_template(
name=body.name, name=body.name,
child_id=body.child_id, child_id=body.child_id,
is_default=body.is_default, is_default=body.is_default,
day_start_time=body.day_start_time,
day_end_time=body.day_end_time,
) )
db.add(template) db.add(template)
await db.flush() # get template.id before adding blocks await db.flush() # get template.id before adding blocks

View File

@@ -27,6 +27,8 @@ class ScheduleTemplateCreate(BaseModel):
name: str name: str
child_id: int | None = None child_id: int | None = None
is_default: bool = False is_default: bool = False
day_start_time: time | None = None
day_end_time: time | None = None
blocks: list[ScheduleBlockCreate] = [] blocks: list[ScheduleBlockCreate] = []
@@ -34,6 +36,8 @@ class ScheduleTemplateUpdate(BaseModel):
name: str | None = None name: str | None = None
child_id: int | None = None child_id: int | None = None
is_default: bool | None = None is_default: bool | None = None
day_start_time: time | None = None
day_end_time: time | None = None
class ScheduleTemplateOut(BaseModel): class ScheduleTemplateOut(BaseModel):
@@ -41,6 +45,8 @@ class ScheduleTemplateOut(BaseModel):
name: str name: str
child_id: int | None child_id: int | None
is_default: bool is_default: bool
day_start_time: time | None
day_end_time: time | None
blocks: list[ScheduleBlockOut] = [] blocks: list[ScheduleBlockOut] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -1,4 +1,4 @@
from datetime import date, datetime from datetime import date, datetime, time
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.schedule import ScheduleBlockOut from app.schemas.schedule import ScheduleBlockOut
from app.schemas.child import ChildOut from app.schemas.child import ChildOut
@@ -43,3 +43,5 @@ class DashboardSnapshot(BaseModel):
blocks: list[ScheduleBlockOut] = [] blocks: list[ScheduleBlockOut] = []
completed_block_ids: list[int] = [] completed_block_ids: list[int] = []
block_elapsed_seconds: int = 0 # seconds already elapsed for the current block block_elapsed_seconds: int = 0 # seconds already elapsed for the current block
day_start_time: time | None = None
day_end_time: time | None = None

View File

@@ -10,6 +10,8 @@ export const useScheduleStore = defineStore('schedule', () => {
const isPaused = ref(false) const isPaused = ref(false)
const blockStartedAt = ref(null) // Date.now() ms when current counting period started const blockStartedAt = ref(null) // Date.now() ms when current counting period started
const blockElapsedOffset = ref(0) // seconds already elapsed before blockStartedAt 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(() => const currentBlock = computed(() =>
session.value?.current_block_id session.value?.current_block_id
@@ -28,6 +30,8 @@ export const useScheduleStore = defineStore('schedule', () => {
completedBlockIds.value = snapshot.completed_block_ids || [] completedBlockIds.value = snapshot.completed_block_ids || []
isPaused.value = false isPaused.value = false
if (snapshot.child) child.value = snapshot.child 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 // Restore elapsed time from server-computed value
const serverElapsed = snapshot.block_elapsed_seconds || 0 const serverElapsed = snapshot.block_elapsed_seconds || 0
if (snapshot.session?.current_block_id && serverElapsed > 0) { if (snapshot.session?.current_block_id && serverElapsed > 0) {
@@ -111,6 +115,8 @@ export const useScheduleStore = defineStore('schedule', () => {
isPaused, isPaused,
blockStartedAt, blockStartedAt,
blockElapsedOffset, blockElapsedOffset,
dayStartTime,
dayEndTime,
currentBlock, currentBlock,
progressPercent, progressPercent,
applySnapshot, applySnapshot,

View File

@@ -135,6 +135,24 @@
<!-- Block editor --> <!-- Block editor -->
<div v-if="editingTemplate === template.id" class="block-editor"> <div v-if="editingTemplate === template.id" class="block-editor">
<!-- Day hours -->
<div class="day-hours-row">
<span class="day-hours-label">School day hours</span>
<input
type="time"
:value="template.day_start_time ? template.day_start_time.slice(0,5) : ''"
@change="e => saveDayHours(template, 'start', e.target.value)"
placeholder="Start"
/>
<span class="day-hours-sep">to</span>
<input
type="time"
:value="template.day_end_time ? template.day_end_time.slice(0,5) : ''"
@change="e => saveDayHours(template, 'end', e.target.value)"
placeholder="End"
/>
</div>
<div class="block-list"> <div class="block-list">
<div v-for="block in template.blocks" :key="block.id" class="block-row"> <div v-for="block in template.blocks" :key="block.id" class="block-row">
<span class="block-time">{{ block.time_start }} {{ block.time_end }}</span> <span class="block-time">{{ block.time_start }} {{ block.time_end }}</span>
@@ -300,6 +318,14 @@ async function deleteBlock(templateId, blockId) {
await loadTemplates() 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 () => { onMounted(async () => {
await childrenStore.fetchChildren() await childrenStore.fetchChildren()
await Promise.all([loadSubjects(), loadTemplates()]) 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; } .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; } .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-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-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; } .block-time { font-size: 0.8rem; color: #64748b; font-variant-numeric: tabular-nums; }

View File

@@ -16,11 +16,21 @@
<div class="card session-card"> <div class="card session-card">
<div class="card-title">Today's Session</div> <div class="card-title">Today's Session</div>
<div v-if="scheduleStore.session"> <div v-if="scheduleStore.session">
<div class="session-info"> <div v-if="scheduleStore.dayStartTime && scheduleStore.dayEndTime" class="day-progress-section">
<div class="day-progress-header">
<span class="badge-active">Active</span>
<span class="day-progress-pct">{{ dayProgressPercent }}%</span>
</div>
<ProgressBar :percent="dayProgressPercent" />
<div class="day-progress-times">
<span>{{ formatDayTime(scheduleStore.dayStartTime) }}</span>
<span>{{ currentTimeDisplay }}</span>
<span>{{ formatDayTime(scheduleStore.dayEndTime) }}</span>
</div>
</div>
<div v-else class="session-info">
<span class="badge-active">Active</span> <span class="badge-active">Active</span>
<span>{{ scheduleStore.progressPercent }}% complete</span>
</div> </div>
<ProgressBar :percent="scheduleStore.progressPercent" />
<div v-if="scheduleStore.currentBlock" class="current-block-timer"> <div v-if="scheduleStore.currentBlock" class="current-block-timer">
<TimerDisplay <TimerDisplay
compact compact
@@ -119,6 +129,36 @@ const showStartDialog = ref(false)
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const templates = ref([]) const templates = ref([])
// Day progress clock (minute precision is enough)
const now = ref(new Date())
setInterval(() => { 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 let wsDisconnect = null
async function loadDashboard() { async function loadDashboard() {
@@ -197,6 +237,23 @@ h1 { font-size: 1.75rem; font-weight: 700; }
color: #94a3b8; 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 { .badge-active {
background: #14532d; background: #14532d;
color: #4ade80; color: #4ade80;

View File

@@ -7,6 +7,16 @@
<div class="tv-date">{{ dateDisplay }}</div> <div class="tv-date">{{ dateDisplay }}</div>
</header> </header>
<!-- Day progress bar full width, always visible when day hours are set -->
<div class="tv-day-progress" v-if="scheduleStore.dayStartTime && scheduleStore.dayEndTime">
<div class="tv-day-progress-meta">
<span class="tv-day-start">{{ formatDayTime(scheduleStore.dayStartTime) }}</span>
<span class="tv-day-pct">{{ dayProgressPercent }}% through the day</span>
<span class="tv-day-end">{{ formatDayTime(scheduleStore.dayEndTime) }}</span>
</div>
<ProgressBar :percent="dayProgressPercent" />
</div>
<!-- No session state --> <!-- No session state -->
<div v-if="!scheduleStore.session" class="tv-idle"> <div v-if="!scheduleStore.session" class="tv-idle">
<div class="tv-idle-icon">🌟</div> <div class="tv-idle-icon">🌟</div>
@@ -36,17 +46,6 @@
</div> </div>
<div class="tv-sidebar"> <div class="tv-sidebar">
<!-- Progress -->
<div class="tv-progress-section">
<div class="tv-progress-label">
Day Progress {{ scheduleStore.progressPercent }}%
</div>
<ProgressBar :percent="scheduleStore.progressPercent" />
<div class="tv-block-count">
{{ scheduleStore.completedBlockIds.length }} of {{ scheduleStore.blocks.length }} blocks
</div>
</div>
<!-- Schedule list --> <!-- Schedule list -->
<div class="tv-schedule-list"> <div class="tv-schedule-list">
<ScheduleBlock <ScheduleBlock
@@ -92,6 +91,29 @@ const dateDisplay = computed(() =>
now.value.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }) 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 // Subject display helpers
const currentSubjectColor = computed(() => { const currentSubjectColor = computed(() => {
const block = scheduleStore.currentBlock const block = scheduleStore.currentBlock
@@ -193,33 +215,39 @@ onMounted(async () => {
max-width: 600px; 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 { .tv-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; 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 { .tv-schedule-list {
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;