Add Reset button and move End Day to right side of actions bar

Reset clears the current block's elapsed time to zero and immediately
starts the timer. A shared compute_block_elapsed() utility (utils/timer.py)
handles the elapsed calculation in both the sessions and dashboard routers,
and correctly treats "reset" events as zero-elapsed restart markers so
page reloads after a reset show accurate times.

Layout: Start/Pause/Resume/Reset are grouped on the left; End Day sits
on the right via justify-content: space-between.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 00:17:02 -08:00
parent cc599603cf
commit 1420d57e7e
6 changed files with 104 additions and 86 deletions

View File

@@ -2,7 +2,7 @@
Public dashboard endpoint — no authentication required.
Used by the TV view to get the initial session snapshot before WebSocket connects.
"""
from datetime import date, datetime
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +16,7 @@ from app.models.schedule import ScheduleBlock, ScheduleTemplate
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
from app.models.session import DailySession, TimerEvent
from app.schemas.session import DashboardSnapshot
from app.utils.timer import compute_block_elapsed
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -74,29 +75,9 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
# Compute elapsed seconds and paused state for the current block from timer_events
is_paused = False
if session and session.current_block_id:
tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session.id,
TimerEvent.block_id == session.current_block_id,
TimerEvent.event_type.in_(["start", "resume", "pause"]),
)
.order_by(TimerEvent.occurred_at)
block_elapsed_seconds, is_paused = await compute_block_elapsed(
db, session.id, session.current_block_id
)
tick_events = tick_result.scalars().all()
last_start = None
elapsed = 0.0
for e in tick_events:
if e.event_type in ("start", "resume"):
last_start = e.occurred_at
elif e.event_type == "pause" and last_start:
elapsed += (e.occurred_at - last_start).total_seconds()
last_start = None
if last_start:
elapsed += (datetime.utcnow() - last_start).total_seconds()
block_elapsed_seconds = int(elapsed)
# Paused if the last tick event was a pause (last_start is None but events exist)
is_paused = bool(tick_events) and tick_events[-1].event_type == "pause"
routine_result = await db.execute(
select(MorningRoutineItem)

View File

@@ -1,4 +1,4 @@
from datetime import date, datetime
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,6 +13,7 @@ from app.models.subject import Subject # noqa: F401 — needed for selectinload
from app.models.session import DailySession, TimerEvent
from app.models.user import User
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
from app.utils.timer import compute_block_elapsed
from app.websocket.manager import manager
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@@ -184,11 +185,11 @@ async def timer_action(
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# When starting or selecting a different block, implicitly pause the previous
# one so the activity log stays accurate and elapsed time is preserved.
# When switching to a different block (start / select / reset), implicitly
# pause the previous block so the activity log stays accurate.
prev_block_id = None
prev_block_elapsed_seconds = 0
if body.event_type in ("start", "select") and body.block_id is not None:
if body.event_type in ("start", "select", "reset") and body.block_id is not None:
prev_block_id = session.current_block_id
if prev_block_id and prev_block_id != body.block_id:
db.add(TimerEvent(
@@ -196,28 +197,10 @@ async def timer_action(
block_id=prev_block_id,
event_type="pause",
))
# Compute prev block's accumulated elapsed so all clients (especially TV)
# can update their local cache without relying on a separate pause broadcast.
prev_tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session.id,
TimerEvent.block_id == prev_block_id,
TimerEvent.event_type.in_(["start", "resume", "pause"]),
)
.order_by(TimerEvent.occurred_at)
# Autoflush means the implicit pause above is visible to the helper.
prev_block_elapsed_seconds, _ = await compute_block_elapsed(
db, session.id, prev_block_id
)
prev_tick_events = prev_tick_result.scalars().all()
_last_start = None
for e in prev_tick_events:
if e.event_type in ("start", "resume"):
_last_start = e.occurred_at
elif e.event_type == "pause" and _last_start:
prev_block_elapsed_seconds += int((e.occurred_at - _last_start).total_seconds())
_last_start = None
# Add the current open interval (up to the implicit pause just recorded)
if _last_start:
prev_block_elapsed_seconds += int((datetime.utcnow() - _last_start).total_seconds())
# Update current block if provided
if body.block_id is not None:
@@ -238,30 +221,13 @@ async def timer_action(
await db.commit()
await db.refresh(session)
# For 'start' and 'select' events, compute accumulated elapsed for the new
# block from previous intervals so every client can restore the correct offset.
# For start / select / reset, compute elapsed for the new block so every
# client can restore the correct offset without a local cache.
block_elapsed_seconds = 0
if body.event_type in ("start", "select") and event.block_id:
tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session.id,
TimerEvent.block_id == event.block_id,
TimerEvent.event_type.in_(["start", "resume", "pause"]),
)
.order_by(TimerEvent.occurred_at)
if body.event_type in ("start", "select", "reset") and event.block_id:
block_elapsed_seconds, _ = await compute_block_elapsed(
db, session.id, event.block_id
)
tick_events = tick_result.scalars().all()
last_start_time = None
for e in tick_events:
if e.event_type in ("start", "resume"):
last_start_time = e.occurred_at
elif e.event_type == "pause" and last_start_time:
block_elapsed_seconds += int((e.occurred_at - last_start_time).total_seconds())
last_start_time = None
# For 'start' also count the current open interval
if body.event_type == "start" and last_start_time:
block_elapsed_seconds += int((datetime.utcnow() - last_start_time).total_seconds())
# Broadcast the timer event to all TV clients
ws_payload = {

View File

View File

@@ -0,0 +1,44 @@
"""Shared timer-elapsed computation used by sessions and dashboard routers."""
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.session import TimerEvent
async def compute_block_elapsed(
db: AsyncSession, session_id: int, block_id: int
) -> tuple[int, bool]:
"""Return (elapsed_seconds, is_paused) for a block.
'reset' events are treated as zero-elapsed restart markers: any elapsed
time accumulated before a reset is discarded.
"""
tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session_id,
TimerEvent.block_id == block_id,
TimerEvent.event_type.in_(["start", "resume", "pause", "reset"]),
)
.order_by(TimerEvent.occurred_at)
)
tick_events = tick_result.scalars().all()
elapsed = 0.0
last_start = None
for e in tick_events:
if e.event_type == "reset":
elapsed = 0.0
last_start = e.occurred_at
elif e.event_type in ("start", "resume"):
last_start = e.occurred_at
elif e.event_type == "pause" and last_start:
elapsed += (e.occurred_at - last_start).total_seconds()
last_start = None
if last_start:
elapsed += (datetime.utcnow() - last_start).total_seconds()
is_paused = bool(tick_events) and tick_events[-1].event_type == "pause"
return int(elapsed), is_paused