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