Add PDF export to Activity Log with date range filtering

- Backend: add date_from/date_to query params to timeline, strikes, and logs endpoints
- Frontend: Export PDF button opens a dialog to select child and date range, generates a printable HTML report in a new tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 23:18:49 -07:00
parent 68a5e9cb4f
commit 956df11f49
2 changed files with 234 additions and 2 deletions

View File

@@ -22,6 +22,8 @@ router = APIRouter(prefix="/api/logs", tags=["logs"])
async def get_timeline( async def get_timeline(
child_id: int | None = None, child_id: int | None = None,
log_date: date | None = None, log_date: date | None = None,
date_from: date | None = None,
date_to: date | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -44,6 +46,10 @@ async def get_timeline(
query = query.where(DailySession.child_id == child_id) query = query.where(DailySession.child_id == child_id)
if log_date: if log_date:
query = query.where(DailySession.session_date == log_date) query = query.where(DailySession.session_date == log_date)
if date_from:
query = query.where(DailySession.session_date >= date_from)
if date_to:
query = query.where(DailySession.session_date <= date_to)
result = await db.execute(query) result = await db.execute(query)
events = result.scalars().all() events = result.scalars().all()
@@ -125,6 +131,8 @@ async def delete_timeline_event(
async def get_strike_events( async def get_strike_events(
child_id: int | None = None, child_id: int | None = None,
log_date: date | None = None, log_date: date | None = None,
date_from: date | None = None,
date_to: date | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -139,6 +147,10 @@ async def get_strike_events(
query = query.where(StrikeEvent.child_id == child_id) query = query.where(StrikeEvent.child_id == child_id)
if log_date: if log_date:
query = query.where(func.date(StrikeEvent.occurred_at) == log_date) query = query.where(func.date(StrikeEvent.occurred_at) == log_date)
if date_from:
query = query.where(func.date(StrikeEvent.occurred_at) >= date_from)
if date_to:
query = query.where(func.date(StrikeEvent.occurred_at) <= date_to)
result = await db.execute(query) result = await db.execute(query)
events = result.scalars().all() events = result.scalars().all()
@@ -178,6 +190,8 @@ async def delete_strike_event(
async def list_logs( async def list_logs(
child_id: int | None = None, child_id: int | None = None,
log_date: date | None = None, log_date: date | None = None,
date_from: date | None = None,
date_to: date | None = None,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -191,6 +205,10 @@ async def list_logs(
query = query.where(ActivityLog.child_id == child_id) query = query.where(ActivityLog.child_id == child_id)
if log_date: if log_date:
query = query.where(ActivityLog.log_date == log_date) query = query.where(ActivityLog.log_date == log_date)
if date_from:
query = query.where(ActivityLog.log_date >= date_from)
if date_to:
query = query.where(ActivityLog.log_date <= date_to)
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()

View File

@@ -4,7 +4,10 @@
<main class="container"> <main class="container">
<div class="page-header"> <div class="page-header">
<h1>Activity Log</h1> <h1>Activity Log</h1>
<button class="btn-sm" @click="showForm = !showForm">+ Add Note</button> <div class="header-actions">
<button class="btn-sm btn-export" @click="showExportDialog = true"> Export PDF</button>
<button class="btn-sm" @click="showForm = !showForm">+ Add Note</button>
</div>
</div> </div>
<ChildSelector style="margin-bottom: 1.5rem" /> <ChildSelector style="margin-bottom: 1.5rem" />
@@ -120,6 +123,37 @@
<div v-else class="empty-state">No activity recorded yet.</div> <div v-else class="empty-state">No activity recorded yet.</div>
</main> </main>
</div> </div>
<!-- Export Dialog -->
<div class="dialog-overlay" v-if="showExportDialog" @click.self="showExportDialog = false">
<div class="dialog">
<h2>Export Activity Report</h2>
<div class="export-fields">
<div class="field">
<label>Child</label>
<select v-model="exportChildId">
<option :value="null">All children</option>
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field">
<label>From</label>
<input v-model="exportDateFrom" type="date" />
</div>
<div class="field">
<label>To</label>
<input v-model="exportDateTo" type="date" />
</div>
</div>
<p v-if="exportError" class="export-error">{{ exportError }}</p>
<div class="dialog-actions">
<button class="btn-sm" @click="showExportDialog = false">Cancel</button>
<button class="btn-primary btn-sm" :disabled="exporting" @click="generatePDF">
{{ exporting ? 'Generating' : 'Generate PDF' }}
</button>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
@@ -296,9 +330,165 @@ async function createLog() {
} }
// Export
const showExportDialog = ref(false)
const exportChildId = ref(null)
const exportDateFrom = ref('')
const exportDateTo = ref('')
const exporting = ref(false)
const exportError = ref('')
async function generatePDF() {
if (!exportDateFrom.value || !exportDateTo.value) {
exportError.value = 'Please select both a from and to date.'
return
}
if (exportDateFrom.value > exportDateTo.value) {
exportError.value = '"From" date must be before "To" date.'
return
}
exportError.value = ''
exporting.value = true
try {
const params = { date_from: exportDateFrom.value, date_to: exportDateTo.value }
if (exportChildId.value) params.child_id = exportChildId.value
const [tRes, lRes, sRes] = await Promise.all([
api.get('/api/logs/timeline', { params }),
api.get('/api/logs', { params }),
api.get('/api/logs/strikes', { params }),
])
const events = tRes.data.map(e => ({ ...e, _type: 'event', _key: `event-${e.id}`, child_name: e.child_name }))
const notes = lRes.data.map(l => ({ ...l, _type: 'note', _key: `note-${l.id}`, occurred_at: l.log_date + 'T00:00:00', child_name: childrenStore.children.find(c => c.id === l.child_id)?.name || '' }))
const strikes = sRes.data.map(s => ({ ...s, _type: 'strike', _key: `strike-${s.id}` }))
const all = [...events, ...notes, ...strikes].sort((a, b) => a.occurred_at.localeCompare(b.occurred_at))
// Group by date ascending
const groups = {}
for (const entry of all) {
const d = entry._type === 'event' ? entry.session_date : entry.log_date
if (!groups[d]) groups[d] = []
groups[d].push(entry)
}
const childLabel = exportChildId.value
? childrenStore.children.find(c => c.id === exportChildId.value)?.name || 'Unknown'
: 'All Children'
const dateLabel = (str) => {
const d = new Date(str + 'T12:00:00')
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
const fmtFrom = dateLabel(exportDateFrom.value)
const fmtTo = dateLabel(exportDateTo.value)
const exportedOn = new Date().toLocaleDateString([], { month: 'long', day: 'numeric', year: 'numeric' })
const rowsHtml = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b)).map(([date, entries]) => {
const heading = dateLabel(date)
const rows = entries.map(entry => {
const time = entry._type !== 'note'
? formatTime(entry.occurred_at, authStore.timezone)
: ''
const icon = (() => {
if (entry._type === 'note') return '📝'
if (entry._type === 'strike') return entry.new_strikes > entry.prev_strikes ? '✕' : '↩'
if (entry.event_type === 'complete' && !entry.block_label) return '🏁'
return EVENT_META[entry.event_type]?.icon || '•'
})()
const label = (() => {
if (entry._type === 'note') return 'Note'
if (entry._type === 'strike') return entry.new_strikes > entry.prev_strikes ? `Strike added (${entry.new_strikes}/3)` : `Strike removed (${entry.new_strikes}/3)`
if (entry.event_type === 'complete' && !entry.block_label) return 'Day ended'
if (entry.event_type === 'complete' && entry.block_label) return `Marked Done — ${entry.block_label}`
const action = EVENT_META[entry.event_type]?.label || entry.event_type
return entry.block_label ? `${action}${entry.block_label}` : action
})()
const typeClass = entry._type === 'note' ? 'note' : entry._type === 'strike' ? 'strike' : 'event'
const dotColor = entry.subject_color || (entry._type === 'note' ? '#6366f1' : entry._type === 'strike' ? '#ef4444' : '#94a3b8')
const subjectRow = entry.subject_name ? `<div class="row-subject">${entry.subject_icon || ''} ${entry.subject_name}</div>` : ''
const notesRow = entry.notes ? `<div class="row-notes">${entry.notes}</div>` : ''
const childRow = childrenStore.children.length > 1 ? `<div class="row-child">${entry.child_name}</div>` : ''
return `
<div class="event-row ${typeClass}">
<div class="event-time">${time}</div>
<div class="event-dot" style="background:${dotColor}"></div>
<div class="event-body">
<div class="event-label"><span class="event-icon">${icon}</span>${label}</div>
${subjectRow}${notesRow}${childRow}
</div>
</div>`
}).join('')
return `<div class="day-group"><div class="date-heading">${heading}</div><div class="event-list">${rows}</div></div>`
}).join('')
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Activity Report — ${childLabel}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1e293b; background: #fff; padding: 1.5rem 2rem; font-size: 13px; }
.report-header { border-bottom: 2px solid #4f46e5; padding-bottom: 0.75rem; margin-bottom: 1.25rem; }
.report-title { font-size: 1.3rem; font-weight: 700; color: #0f172a; }
.report-subtitle { font-size: 0.85rem; color: #475569; margin-top: 0.2rem; }
.report-meta { display: flex; gap: 1.5rem; margin-top: 0.5rem; font-size: 0.78rem; color: #64748b; }
.meta-item strong { color: #334155; }
.day-group { margin-bottom: 1.25rem; }
.date-heading { font-size: 1rem; font-weight: 700; color: #0f172a; background: #f1f5f9; border-left: 4px solid #4f46e5; padding: 0.45rem 0.75rem; margin-bottom: 0.6rem; border-radius: 0.25rem; }
.event-list { display: flex; flex-direction: column; gap: 0.3rem; }
.event-row { display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.5rem 0.6rem; border-radius: 0.4rem; border-left: 3px solid #e2e8f0; background: #f8fafc; }
.event-row.note { border-left-color: #6366f1; background: #f5f3ff; }
.event-row.strike { border-left-color: #ef4444; background: #fef2f2; }
.event-time { font-size: 0.72rem; color: #94a3b8; width: 60px; flex-shrink: 0; padding-top: 0.1rem; font-variant-numeric: tabular-nums; }
.event-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; margin-top: 0.3rem; }
.event-body { flex: 1; }
.event-label { font-size: 0.85rem; font-weight: 500; color: #1e293b; display: flex; align-items: center; gap: 0.3rem; }
.event-icon { font-size: 0.8rem; }
.row-subject { font-size: 0.75rem; color: #64748b; margin-top: 0.1rem; }
.row-notes { font-size: 0.8rem; color: #475569; margin-top: 0.15rem; font-style: italic; }
.row-child { font-size: 0.7rem; color: #94a3b8; margin-top: 0.1rem; }
.empty { text-align: center; padding: 3rem; color: #94a3b8; font-size: 0.9rem; }
.report-footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; font-size: 0.72rem; color: #94a3b8; text-align: center; }
@media print {
body { padding: 1rem 1.5rem; }
.event-row { page-break-inside: avoid; }
}
</style>
</head>
<body>
<div class="report-header">
<div class="report-title">🏠 Homeschool Dashboard</div>
<div class="report-subtitle">Activity Report</div>
<div class="report-meta">
<div class="meta-item"><strong>Child:</strong> ${childLabel}</div>
<div class="meta-item"><strong>Period:</strong> ${fmtFrom} ${fmtTo}</div>
<div class="meta-item"><strong>Exported:</strong> ${exportedOn}</div>
</div>
</div>
${rowsHtml || '<div class="empty">No activity recorded for this period.</div>'}
<div class="report-footer">Generated by Homeschool Dashboard</div>
<script>window.onload = () => { window.print() }<\/script>
</body>
</html>`
const win = window.open('', '_blank')
win.document.write(html)
win.document.close()
showExportDialog.value = false
} finally {
exporting.value = false
}
}
onMounted(async () => { onMounted(async () => {
await childrenStore.fetchChildren() await childrenStore.fetchChildren()
if (childrenStore.activeChild) newLog.value.child_id = childrenStore.activeChild.id if (childrenStore.activeChild) {
newLog.value.child_id = childrenStore.activeChild.id
exportChildId.value = childrenStore.activeChild.id
}
await loadData() await loadData()
}) })
@@ -434,6 +624,30 @@ h1 { font-size: 1.75rem; font-weight: 700; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; } .empty-state { text-align: center; padding: 4rem; color: #64748b; }
.header-actions { display: flex; gap: 0.5rem; align-items: center; }
.btn-export { border-color: #4f46e5; color: #818cf8; }
.btn-export:hover { background: #1e1b4b; }
.dialog-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.dialog {
background: #1e293b; border: 1px solid #334155; border-radius: 1rem;
padding: 2rem; width: 400px; max-width: 90vw; display: flex; flex-direction: column; gap: 1.25rem;
}
.dialog h2 { font-size: 1.1rem; font-weight: 700; }
.export-fields { display: flex; flex-direction: column; gap: 0.75rem; }
.export-fields .field { margin-bottom: 0; }
.export-fields .field label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.3rem; }
.export-fields .field input,
.export-fields .field select {
width: 100%; padding: 0.55rem 0.8rem; background: #0f172a;
border: 1px solid #334155; border-radius: 0.5rem; color: #f1f5f9; font-size: 0.875rem;
}
.export-error { color: #f87171; font-size: 0.82rem; margin: 0; }
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
.btn-primary { .btn-primary {
padding: 0.65rem 1.25rem; padding: 0.65rem 1.25rem;
background: #4f46e5; background: #4f46e5;