Rework day progress bar to use block-duration time instead of wall clock

Both the TV and parent dashboard progress bars now calculate % complete
based on total scheduled block time vs. remaining block + break time,
so the bar only advances while blocks are actively being worked.

TV bar labels changed to "🟢 Start" and "Finish 🏁".
Parent dashboard shows first block's scheduled start time on the left
and a live estimated finish time (now + remaining block/break time) on
the right.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 13:38:40 -08:00
parent 87e5ab7b5b
commit c05543d855
3 changed files with 88 additions and 57 deletions

View File

@@ -6,11 +6,12 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
## Features ## Features
- **TV Dashboard** — Full-screen display for the living room TV. Shows the current subject, countdown timer, day progress, activity options, and the schedule block list. Updates live without page refresh via WebSocket. - **TV Dashboard** — Full-screen display for the living room TV. Shows the current subject, countdown timer, day progress bar, activity options, and the schedule block list. Updates live without page refresh via WebSocket.
- **Morning Routine** — Define a list of morning routine items in Admin. They appear in the TV dashboard Activities panel during the "Good Morning" greeting before the first block starts, then switch to subject-specific activities once a block begins. - **Morning Routine** — Define a list of morning routine items in Admin. They appear in the TV dashboard Activities panel during the "Good Morning" greeting before the first block starts, then switch to subject-specific activities once a block begins.
- **Break Time** — Each schedule block can optionally include a break at the end. Enable the checkbox and set a duration (in minutes) when building a block in Admin. Once the block's main timer is done, a **Break Time** section appears on the Dashboard with its own **Start / Pause / Resume / Reset** controls — the break does not start automatically. While break is active the TV left column switches to an amber break badge and countdown timer, and the center column shows the configurable **Break Activities** list instead of subject options. - **Break Time** — Each schedule block can optionally include a break at the end. Enable the checkbox and set a duration (in minutes) when building a block in Admin. Once the block's main timer is done, a **Break Time** section appears on the Dashboard with its own **Start / Pause / Resume / Reset** controls — the break does not start automatically. While break is active the TV left column switches to an amber break badge and countdown timer, and the center column shows the configurable **Break Activities** list instead of subject options.
- **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break. - **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break.
- **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Set optional school day start/end hours for a day-progress bar. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page. - **Day Progress Bar** — Both the TV dashboard and the parent dashboard display a progress bar showing how far through the day the child is. Progress is calculated from total scheduled block time vs. remaining block time — not wall-clock time — so it advances only as blocks are actively worked. On the TV the bar is labeled **🟢 Start** and **Finish 🏁**. On the parent dashboard the left label shows the scheduled start time of the first block and the right label shows a live-updating **estimated finish time** computed as the current time plus all remaining block time and break time for incomplete blocks.
- **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page.
- **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, and **Reset** to clear the elapsed time and restart the timer from zero. Elapsed time per block is remembered across switches, so returning to a block picks up where it left off. - **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, and **Reset** to clear the elapsed time and restart the timer from zero. Elapsed time per block is remembered across switches, so returning to a block picks up where it left off.
- **Block Timer Remaining** — Each block in the schedule list shows time remaining (allocated duration minus elapsed), counting down live on both the parent dashboard and the TV sidebar. Shows "< 1 min" when under a minute, and "Done!" when the full duration is elapsed. - **Block Timer Remaining** — Each block in the schedule list shows time remaining (allocated duration minus elapsed), counting down live on both the parent dashboard and the TV sidebar. Shows "< 1 min" when under a minute, and "Done!" when the full duration is elapsed.
- **Activity Log** — Automatically records every timer event (day started, block start/pause/resume/complete/skip/reset, break start/pause/resume/reset) and every strike change as a timestamped timeline. Includes which schedule template was used. Supports manual notes with free text. Browse and filter history by child and date. - **Activity Log** — Automatically records every timer event (day started, block start/pause/resume/complete/skip/reset, break start/pause/resume/reset) and every strike change as a timestamped timeline. Includes which schedule template was used. Supports manual notes with free text. Browse and filter history by child and date.
@@ -192,7 +193,7 @@ While a session is active, clicking a block in the schedule list **selects** it
| URL | Description | | URL | Description |
|-----|-------------| |-----|-------------|
| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress, schedule sidebar | | `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar (🟢 Start → Finish 🏁), schedule sidebar |
Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.

View File

@@ -47,16 +47,16 @@
<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 v-if="scheduleStore.dayStartTime && scheduleStore.dayEndTime" class="day-progress-section"> <div v-if="scheduleStore.blocks.length > 0" class="day-progress-section">
<div class="day-progress-header"> <div class="day-progress-header">
<span class="badge-active">Active</span> <span class="badge-active">Active</span>
<span class="day-progress-pct">{{ dayProgressPercent }}%</span> <span class="day-progress-pct">{{ dayProgressPercent }}%</span>
</div> </div>
<ProgressBar :percent="dayProgressPercent" /> <ProgressBar :percent="dayProgressPercent" />
<div class="day-progress-times"> <div class="day-progress-times">
<span>{{ formatDayTime(scheduleStore.dayStartTime) }}</span> <span>{{ firstBlockStartTime }}</span>
<span>{{ currentTimeDisplay }}</span> <span>{{ currentTimeDisplay }}</span>
<span>{{ formatDayTime(scheduleStore.dayEndTime) }}</span> <span>{{ estimatedFinishTime }}</span>
</div> </div>
</div> </div>
<div v-else class="session-info"> <div v-else class="session-info">
@@ -73,6 +73,32 @@
/> />
</div> </div>
<div class="session-actions">
<div class="session-actions-left">
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
@click="sendAction('pause')"
>Pause</button>
<button
class="btn-sm btn-start"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
>Start</button>
<button
class="btn-sm"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
@click="sendAction('resume')"
>Resume</button>
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
>Reset</button>
</div>
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
</div>
<!-- Break Time section --> <!-- Break Time section -->
<div <div
v-if="scheduleStore.currentBlock?.break_time_enabled" v-if="scheduleStore.currentBlock?.break_time_enabled"
@@ -117,32 +143,6 @@
>Reset</button> >Reset</button>
</div> </div>
</div> </div>
<div class="session-actions">
<div class="session-actions-left">
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
@click="sendAction('pause')"
>Pause</button>
<button
class="btn-sm btn-start"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
>Start</button>
<button
class="btn-sm"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
@click="sendAction('resume')"
>Resume</button>
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
>Reset</button>
</div>
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
</div>
</div> </div>
<div v-else class="no-session"> <div v-else class="no-session">
<p>No active session.</p> <p>No active session.</p>
@@ -221,12 +221,6 @@ const templates = ref([])
const now = ref(new Date()) const now = ref(new Date())
setInterval(() => { now.value = new Date() }, 1000) setInterval(() => { now.value = new Date() }, 1000)
function timeStrToMinutes(str) {
if (!str) return null
const [h, m] = str.split(':').map(Number)
return h * 60 + m
}
function formatDayTime(str) { function formatDayTime(str) {
if (!str) return '' if (!str) return ''
const [h, m] = str.split(':').map(Number) const [h, m] = str.split(':').map(Number)
@@ -240,11 +234,17 @@ const currentTimeDisplay = computed(() =>
) )
const dayProgressPercent = computed(() => { const dayProgressPercent = computed(() => {
const start = timeStrToMinutes(scheduleStore.dayStartTime) const allBlocks = scheduleStore.blocks
const end = timeStrToMinutes(scheduleStore.dayEndTime) if (!allBlocks.length) return 0
if (start === null || end === null || end <= start) return 0 const totalSeconds = allBlocks.reduce((sum, b) => sum + (b.duration_minutes || 0) * 60, 0)
const nowMin = now.value.getHours() * 60 + now.value.getMinutes() if (totalSeconds === 0) return 0
return Math.max(0, Math.min(100, Math.round((nowMin - start) / (end - start) * 100))) const remainingSeconds = allBlocks.reduce((sum, b) => {
if (scheduleStore.completedBlockIds.includes(b.id)) return sum
const blockTotal = (b.duration_minutes || 0) * 60
const elapsed = blockElapsed(b)
return sum + Math.max(0, blockTotal - elapsed)
}, 0)
return Math.max(0, Math.min(100, Math.round((totalSeconds - remainingSeconds) / totalSeconds * 100)))
}) })
let wsDisconnect = null let wsDisconnect = null
@@ -291,6 +291,36 @@ function blockElapsed(block) {
return scheduleStore.blockElapsedCache[block.id] || 0 return scheduleStore.blockElapsedCache[block.id] || 0
} }
function breakElapsed(block) {
if (scheduleStore.isBreakMode && block.id === scheduleStore.session?.current_block_id && scheduleStore.breakStartedAt) {
return scheduleStore.breakElapsedOffset + Math.floor((now.value - scheduleStore.breakStartedAt) / 1000)
}
return scheduleStore.breakElapsedCache[block.id] || 0
}
const firstBlockStartTime = computed(() => {
const first = scheduleStore.blocks[0]
return first?.time_start ? formatDayTime(first.time_start) : ''
})
const estimatedFinishTime = computed(() => {
const allBlocks = scheduleStore.blocks
if (!allBlocks.length) return ''
let remainingSeconds = 0
for (const b of allBlocks) {
if (scheduleStore.completedBlockIds.includes(b.id)) continue
remainingSeconds += Math.max(0, (b.duration_minutes || 0) * 60 - blockElapsed(b))
if (b.break_time_enabled && b.break_time_minutes) {
remainingSeconds += Math.max(0, b.break_time_minutes * 60 - breakElapsed(b))
}
}
const finish = new Date(now.value.getTime() + remainingSeconds * 1000)
const h = finish.getHours()
const period = h >= 12 ? 'PM' : 'AM'
const hour = h % 12 || 12
return `${hour}:${String(finish.getMinutes()).padStart(2, '0')} ${period}`
})
function selectBlock(block) { function selectBlock(block) {
if (!scheduleStore.session) return if (!scheduleStore.session) return
// Clicking the current block does nothing — use Start/Pause/Resume buttons // Clicking the current block does nothing — use Start/Pause/Resume buttons

View File

@@ -17,11 +17,11 @@
</header> </header>
<!-- Day progress bar full width, always visible when day hours are set --> <!-- 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" v-if="scheduleStore.blocks.length > 0">
<div class="tv-day-progress-meta"> <div class="tv-day-progress-meta">
<span class="tv-day-start">{{ formatDayTime(scheduleStore.dayStartTime) }}</span> <span class="tv-day-start">🟢 Start</span>
<span class="tv-day-pct">{{ dayProgressPercent }}% through the day</span> <span class="tv-day-pct">{{ dayProgressPercent }}% through the day</span>
<span class="tv-day-end">{{ formatDayTime(scheduleStore.dayEndTime) }}</span> <span class="tv-day-end">Finish 🏁</span>
</div> </div>
<ProgressBar :percent="dayProgressPercent" /> <ProgressBar :percent="dayProgressPercent" />
</div> </div>
@@ -167,12 +167,6 @@ const dateDisplay = computed(() =>
) )
// Day progress helpers // Day progress helpers
function timeStrToMinutes(str) {
if (!str) return null
const [h, m] = str.split(':').map(Number)
return h * 60 + m
}
function formatDayTime(str) { function formatDayTime(str) {
if (!str) return '' if (!str) return ''
const [h, m] = str.split(':').map(Number) const [h, m] = str.split(':').map(Number)
@@ -182,11 +176,17 @@ function formatDayTime(str) {
} }
const dayProgressPercent = computed(() => { const dayProgressPercent = computed(() => {
const start = timeStrToMinutes(scheduleStore.dayStartTime) const allBlocks = scheduleStore.blocks
const end = timeStrToMinutes(scheduleStore.dayEndTime) if (!allBlocks.length) return 0
if (start === null || end === null || end <= start) return 0 const totalSeconds = allBlocks.reduce((sum, b) => sum + (b.duration_minutes || 0) * 60, 0)
const nowMin = now.value.getHours() * 60 + now.value.getMinutes() if (totalSeconds === 0) return 0
return Math.max(0, Math.min(100, Math.round((nowMin - start) / (end - start) * 100))) const remainingSeconds = allBlocks.reduce((sum, b) => {
if (scheduleStore.completedBlockIds.includes(b.id)) return sum
const blockTotal = (b.duration_minutes || 0) * 60
const elapsed = blockElapsed(b)
return sum + Math.max(0, blockTotal - elapsed)
}, 0)
return Math.max(0, Math.min(100, Math.round((totalSeconds - remainingSeconds) / totalSeconds * 100)))
}) })
// Countdown to first block // Countdown to first block