Add per-block agenda overrides for daily sessions

- Add 📝 Agenda button to each block in Today's Schedule on the Dashboard
- Dialog allows setting a free-text activity/note for that block for the current day
- Agenda replaces subject options in the TV center panel while set; clears on session end
- Backend: new SessionBlockAgenda model, PUT /api/sessions/{id}/blocks/{block_id}/agenda
- Agendas included in dashboard snapshot and session_update WS broadcast
- New agenda_update WS event keeps TV in sync live when agenda is saved or cleared
- Update README with feature description, project structure, and WS event table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 07:58:30 -07:00
parent d724262e27
commit fdd85d3df5
9 changed files with 246 additions and 17 deletions

View File

@@ -20,6 +20,7 @@ export const useScheduleStore = defineStore('schedule', () => {
const breakElapsedCache = ref({}) // blockId → total break elapsed seconds
const showRulesOverlay = ref(false) // whether the rules overlay is visible on TV
const rulesOverlayItems = ref([]) // list of rule text strings to display
const blockAgendas = ref({}) // blockId (string) → agenda text override
const currentBlock = computed(() =>
session.value?.current_block_id
@@ -47,6 +48,7 @@ export const useScheduleStore = defineStore('schedule', () => {
if (snapshot.child) child.value = snapshot.child
morningRoutine.value = snapshot.morning_routine || []
breakActivities.value = snapshot.break_activities || []
blockAgendas.value = snapshot.block_agendas || {}
// Restore elapsed time from server-computed value and seed the per-block cache
const serverElapsed = snapshot.block_elapsed_seconds || 0
if (snapshot.session?.current_block_id) {
@@ -82,6 +84,17 @@ export const useScheduleStore = defineStore('schedule', () => {
}
function applyWsEvent(event) {
if (event.event === 'agenda_update') {
const key = String(event.block_id)
if (event.text) {
blockAgendas.value = { ...blockAgendas.value, [key]: event.text }
} else {
const updated = { ...blockAgendas.value }
delete updated[key]
blockAgendas.value = updated
}
return
}
if (event.event === 'show_rules') {
rulesOverlayItems.value = event.rules || []
showRulesOverlay.value = true
@@ -112,6 +125,7 @@ export const useScheduleStore = defineStore('schedule', () => {
breakStartedAt.value = null
breakElapsedOffset.value = 0
breakElapsedCache.value = {}
blockAgendas.value = {}
return
}
// Break timer events
@@ -407,6 +421,7 @@ export const useScheduleStore = defineStore('schedule', () => {
breakElapsedCache,
showRulesOverlay,
rulesOverlayItems,
blockAgendas,
currentBlock,
progressPercent,
applySnapshot,

View File

@@ -183,21 +183,50 @@
No blocks loaded.
</div>
<div class="block-list" v-else>
<ScheduleBlock
v-for="block in scheduleStore.blocks"
:key="block.id"
:block="block"
:is-current="block.id === scheduleStore.session?.current_block_id"
:is-completed="scheduleStore.completedBlockIds.includes(block.id)"
:elapsed-seconds="blockElapsed(block)"
@click="selectBlock(block)"
/>
<div v-for="block in scheduleStore.blocks" :key="block.id" class="block-row-wrap">
<ScheduleBlock
:block="block"
:is-current="block.id === scheduleStore.session?.current_block_id"
:is-completed="scheduleStore.completedBlockIds.includes(block.id)"
:elapsed-seconds="blockElapsed(block)"
@click="selectBlock(block)"
/>
<button
class="agenda-btn"
:class="{ 'agenda-btn-set': scheduleStore.blockAgendas[String(block.id)] }"
@click.stop="openAgendaDialog(block)"
title="Set agenda for this block"
>📝</button>
</div>
</div>
</div>
</div><!-- end bottom-row -->
</div>
<!-- Agenda dialog -->
<div class="dialog-overlay" v-if="agendaDialog.open" @click.self="closeAgendaDialog">
<div class="dialog">
<h2>Block Agenda</h2>
<p class="dialog-hint">{{ agendaDialog.blockLabel }}</p>
<div class="field">
<label>Today's activity or note</label>
<textarea
v-model="agendaDialog.text"
class="agenda-textarea"
placeholder="e.g. Chapter 5 reading, worksheet page 12..."
rows="4"
@keydown.ctrl.enter="saveAgenda"
></textarea>
</div>
<div class="dialog-actions">
<button class="btn-sm btn-danger" v-if="agendaDialog.existing" @click="clearAgenda">Clear</button>
<button class="btn-sm" @click="closeAgendaDialog">Cancel</button>
<button class="btn-primary" @click="saveAgenda">Save</button>
</div>
</div>
</div>
<!-- Start session dialog -->
<div class="dialog-overlay" v-if="showStartDialog" @click.self="showStartDialog = false">
<div class="dialog">
@@ -370,6 +399,38 @@ const estimatedFinishTime = computed(() => {
return `${hour}:${String(finish.getMinutes()).padStart(2, '0')} ${period}`
})
// Agenda dialog
const agendaDialog = ref({ open: false, blockId: null, blockLabel: '', text: '', existing: false })
function openAgendaDialog(block) {
if (!scheduleStore.session) return
const label = block.label || block.subject?.name || 'Block'
const existing = scheduleStore.blockAgendas[String(block.id)] || ''
agendaDialog.value = { open: true, blockId: block.id, blockLabel: label, text: existing, existing: !!existing }
}
function closeAgendaDialog() {
agendaDialog.value = { open: false, blockId: null, blockLabel: '', text: '', existing: false }
}
async function saveAgenda() {
if (!scheduleStore.session || agendaDialog.value.blockId === null) return
await api.put(
`/api/sessions/${scheduleStore.session.id}/blocks/${agendaDialog.value.blockId}/agenda`,
{ text: agendaDialog.value.text }
)
closeAgendaDialog()
}
async function clearAgenda() {
if (!scheduleStore.session || agendaDialog.value.blockId === null) return
await api.put(
`/api/sessions/${scheduleStore.session.id}/blocks/${agendaDialog.value.blockId}/agenda`,
{ text: '' }
)
closeAgendaDialog()
}
async function toggleRulesOverlay() {
if (!activeChild.value) return
const endpoint = scheduleStore.showRulesOverlay
@@ -529,6 +590,53 @@ h1 { font-size: 1.75rem; font-weight: 700; }
.block-list { display: flex; flex-direction: column; gap: 0.5rem; }
.block-row-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
}
.block-row-wrap > :first-child { flex: 1; min-width: 0; }
.agenda-btn {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border: 1px solid #334155;
background: transparent;
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.95rem;
opacity: 0.4;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.agenda-btn:hover { opacity: 1; background: #334155; }
.agenda-btn-set { opacity: 1; border-color: #6366f1; background: #1e1b4b; }
.agenda-textarea {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
resize: vertical;
font-family: inherit;
line-height: 1.5;
box-sizing: border-box;
}
.agenda-textarea:focus { outline: none; border-color: #818cf8; }
.dialog-hint {
font-size: 0.82rem;
color: #64748b;
margin: -0.5rem 0 0.25rem;
}
.strikes-list { display: flex; flex-direction: column; gap: 0.6rem; }
.strikes-row {

View File

@@ -107,12 +107,20 @@
<!-- Subject options during active block -->
<template v-else>
<div class="tv-options-title">Activities</div>
<div v-if="currentSubjectOptions.length" class="tv-options-list">
<div v-for="opt in currentSubjectOptions" :key="opt.id" class="tv-option-item">
{{ opt.text }}
<!-- Agenda override takes priority over subject options -->
<template v-if="scheduleStore.blockAgendas[String(scheduleStore.currentBlock?.id)]">
<div class="tv-option-item tv-agenda-text">
{{ scheduleStore.blockAgendas[String(scheduleStore.currentBlock?.id)] }}
</div>
</div>
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
</template>
<template v-else>
<div v-if="currentSubjectOptions.length" class="tv-options-list">
<div v-for="opt in currentSubjectOptions" :key="opt.id" class="tv-option-item">
{{ opt.text }}
</div>
</div>
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
</template>
</template>
</div>
@@ -483,6 +491,12 @@ onMounted(async () => {
font-style: italic;
}
.tv-agenda-text {
white-space: pre-wrap;
line-height: 1.5;
border-bottom: none !important;
}
.tv-day-progress {
background: #1e293b;
border-radius: 1rem;