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:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user