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

@@ -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 {