Auto-populate activity log from timer events with edit and delete

- New GET /api/logs/timeline endpoint joins TimerEvent with block/subject/session data
- New PATCH and DELETE /api/logs/timeline/{id} endpoints for editing/removing events
- LogView redesigned as a chronological timeline grouped by date
- Edit inline: timer events support type + time correction; notes support text edit
- Delete works for both auto events and manual notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 18:15:35 -08:00
parent fc9413924d
commit fef03ec538
3 changed files with 403 additions and 72 deletions

View File

@@ -3,16 +3,112 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user
from app.models.activity import ActivityLog
from app.models.child import Child
from app.models.session import DailySession, TimerEvent
from app.models.schedule import ScheduleBlock
from app.models.subject import Subject # noqa: F401
from app.models.user import User
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate, TimelineEventOut, TimelineEventUpdate
router = APIRouter(prefix="/api/logs", tags=["logs"])
@router.get("/timeline", response_model=list[TimelineEventOut])
async def get_timeline(
child_id: int | None = None,
log_date: date | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = (
select(TimerEvent)
.join(DailySession, TimerEvent.session_id == DailySession.id)
.join(Child, DailySession.child_id == Child.id)
.where(Child.user_id == current_user.id)
.options(
selectinload(TimerEvent.block).selectinload(ScheduleBlock.subject),
selectinload(TimerEvent.session).selectinload(DailySession.child),
)
.order_by(TimerEvent.occurred_at.desc())
)
if child_id:
query = query.where(DailySession.child_id == child_id)
if log_date:
query = query.where(DailySession.session_date == log_date)
result = await db.execute(query)
events = result.scalars().all()
return [_to_timeline_out(e) for e in events]
def _to_timeline_out(e: TimerEvent) -> TimelineEventOut:
blk = e.block
sub = blk.subject if blk else None
return TimelineEventOut(
id=e.id,
event_type=e.event_type,
occurred_at=e.occurred_at,
session_date=e.session.session_date,
child_id=e.session.child_id,
child_name=e.session.child.name,
block_label=(blk.label or sub.name) if blk and sub else (blk.label if blk else None),
subject_name=sub.name if sub else None,
subject_icon=sub.icon if sub else None,
subject_color=sub.color if sub else None,
)
async def _get_timer_event(event_id: int, current_user: User, db: AsyncSession) -> TimerEvent:
result = await db.execute(
select(TimerEvent)
.join(DailySession, TimerEvent.session_id == DailySession.id)
.join(Child, DailySession.child_id == Child.id)
.where(TimerEvent.id == event_id, Child.user_id == current_user.id)
.options(
selectinload(TimerEvent.block).selectinload(ScheduleBlock.subject),
selectinload(TimerEvent.session).selectinload(DailySession.child),
)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
return event
@router.patch("/timeline/{event_id}", response_model=TimelineEventOut)
async def update_timeline_event(
event_id: int,
body: TimelineEventUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
event = await _get_timer_event(event_id, current_user, db)
if body.event_type is not None:
event.event_type = body.event_type
if body.occurred_at is not None:
event.occurred_at = body.occurred_at
await db.commit()
await db.refresh(event)
event = await _get_timer_event(event_id, current_user, db)
return _to_timeline_out(event)
@router.delete("/timeline/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_timeline_event(
event_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
event = await _get_timer_event(event_id, current_user, db)
await db.delete(event)
await db.commit()
@router.get("", response_model=list[ActivityLogOut])
async def list_logs(
child_id: int | None = None,

View File

@@ -1,4 +1,4 @@
from datetime import date
from datetime import date, datetime
from pydantic import BaseModel
@@ -27,3 +27,21 @@ class ActivityLogOut(BaseModel):
duration_minutes: int | None
model_config = {"from_attributes": True}
class TimelineEventUpdate(BaseModel):
event_type: str | None = None
occurred_at: datetime | None = None
class TimelineEventOut(BaseModel):
id: int
event_type: str
occurred_at: datetime
session_date: date
child_id: int
child_name: str
block_label: str | None = None
subject_name: str | None = None
subject_icon: str | None = None
subject_color: str | None = None

View File

@@ -4,14 +4,14 @@
<main class="container">
<div class="page-header">
<h1>Activity Log</h1>
<button class="btn-primary" @click="showForm = !showForm">+ Log Activity</button>
<button class="btn-sm" @click="showForm = !showForm">+ Add Note</button>
</div>
<ChildSelector style="margin-bottom: 1.5rem" />
<!-- Add form -->
<!-- Manual note form -->
<div class="card" v-if="showForm">
<h3>Log an Activity</h3>
<h3>Add a Note</h3>
<form @submit.prevent="createLog">
<div class="field-row">
<div class="field">
@@ -25,110 +25,250 @@
<input v-model="newLog.log_date" type="date" required />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Subject (optional)</label>
<select v-model="newLog.subject_id">
<option :value="null">None</option>
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
</select>
</div>
<div class="field">
<label>Duration (minutes)</label>
<input v-model.number="newLog.duration_minutes" type="number" min="0" placeholder="e.g. 30" />
</div>
</div>
<div class="field">
<label>Notes</label>
<textarea v-model="newLog.notes" placeholder="What did they do?" rows="3"></textarea>
<textarea v-model="newLog.notes" placeholder="Add a note..." rows="3"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn-sm" @click="showForm = false">Cancel</button>
<button type="submit" class="btn-primary">Save Log</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<input v-model="filterDate" type="date" placeholder="Filter by date" />
<input v-model="filterDate" type="date" />
<button v-if="filterDate" class="btn-sm" @click="filterDate = ''">Clear</button>
</div>
<!-- Logs -->
<div class="log-list">
<div v-for="log in filteredLogs" :key="log.id" class="log-row">
<div class="log-date">{{ log.log_date }}</div>
<div class="log-content">
<div class="log-subject" v-if="log.subject_id">
{{ subjectDisplay(log.subject_id) }}
</div>
<div class="log-notes" v-if="log.notes">{{ log.notes }}</div>
<div class="log-meta" v-if="log.duration_minutes">
{{ log.duration_minutes }} min
</div>
<!-- Timeline -->
<div class="timeline" v-if="combinedEntries.length">
<template v-for="(group, date) in groupedByDate" :key="date">
<div class="date-heading">{{ formatDate(date) }}</div>
<div class="event-list">
<template v-for="entry in group" :key="entry._key">
<!-- Edit form -->
<div v-if="editingEntry?._key === entry._key" class="event-row editing">
<template v-if="entry._type === 'event'">
<div class="edit-field">
<label>Type</label>
<select v-model="editDraft.event_type">
<option value="start"> Started</option>
<option value="pause"> Paused</option>
<option value="resume"> Resumed</option>
<option value="complete"> Completed</option>
<option value="skip"> Skipped</option>
</select>
</div>
<div class="edit-field">
<label>Time</label>
<input type="time" v-model="editDraft.time" />
</div>
</template>
<template v-else>
<div class="edit-field edit-field-full">
<label>Notes</label>
<textarea v-model="editDraft.notes" rows="2"></textarea>
</div>
</template>
<div class="edit-actions">
<button class="btn-sm btn-primary" @click="saveEdit(entry)">Save</button>
<button class="btn-sm" @click="editingEntry = null">Cancel</button>
</div>
</div>
<!-- Display row -->
<div v-else class="event-row" :class="entry._type">
<div class="event-time">{{ formatTime(entry.occurred_at) }}</div>
<div
class="event-dot"
:style="entry.subject_color ? { background: entry.subject_color } : {}"
:class="{ 'dot-note': entry._type === 'note' }"
></div>
<div class="event-body">
<div class="event-label">
<span class="event-icon">{{ eventIcon(entry) }}</span>
{{ eventLabel(entry) }}
</div>
<div class="event-subject" v-if="entry.subject_name">
{{ entry.subject_icon }} {{ entry.subject_name }}
</div>
<div class="event-notes" v-if="entry.notes">{{ entry.notes }}</div>
<div class="event-child" v-if="childrenStore.children.length > 1">
{{ entry.child_name }}
</div>
</div>
<div class="row-actions">
<button class="btn-sm" @click="startEdit(entry)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteEntry(entry)"></button>
</div>
</div>
</template>
</div>
<button class="btn-sm btn-danger" @click="deleteLog(log.id)"></button>
</div>
<div v-if="filteredLogs.length === 0" class="empty-state">
No activity logs yet.
</div>
</template>
</div>
<div v-else class="empty-state">No activity recorded yet.</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useChildrenStore } from '@/stores/children'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue'
const childrenStore = useChildrenStore()
const logs = ref([])
const subjects = ref([])
const timeline = ref([])
const manualLogs = ref([])
const showForm = ref(false)
const filterDate = ref('')
const filterDate = ref(new Date().toISOString().split('T')[0])
const today = new Date().toISOString().split('T')[0]
const newLog = ref({ child_id: null, subject_id: null, log_date: today, notes: '', duration_minutes: null })
const newLog = ref({ child_id: null, log_date: today, notes: '' })
const filteredLogs = computed(() => {
if (!filterDate.value) return logs.value
return logs.value.filter((l) => l.log_date === filterDate.value)
const activeChildId = computed(() => childrenStore.activeChild?.id || null)
const editingEntry = ref(null)
const editDraft = ref({})
// Merge and sort timeline events + manual notes
const combinedEntries = computed(() => {
const events = timeline.value.map(e => ({
...e,
_type: 'event',
_key: `event-${e.id}`,
occurred_at: e.occurred_at,
child_name: e.child_name,
}))
const notes = manualLogs.value.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 || '',
subject_name: null,
subject_icon: null,
subject_color: null,
}))
return [...events, ...notes].sort((a, b) => b.occurred_at.localeCompare(a.occurred_at))
})
function subjectDisplay(id) {
const s = subjects.value.find((s) => s.id === id)
return s ? `${s.icon} ${s.name}` : ''
// Group by session_date (events) or log_date (notes)
const groupedByDate = computed(() => {
const groups = {}
for (const entry of combinedEntries.value) {
const d = entry._type === 'event' ? entry.session_date : entry.log_date
if (!groups[d]) groups[d] = []
groups[d].push(entry)
}
return groups
})
function formatDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00')
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
async function loadLogs() {
const res = await api.get('/api/logs')
logs.value = res.data
function formatTime(isoStr) {
if (!isoStr) return ''
const d = new Date(isoStr)
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
const EVENT_META = {
start: { icon: '▶', label: 'Started' },
pause: { icon: '⏸', label: 'Paused' },
resume: { icon: '↺', label: 'Resumed' },
complete: { icon: '✓', label: 'Completed' },
skip: { icon: '⟶', label: 'Skipped' },
}
function eventIcon(entry) {
if (entry._type === 'note') return '📝'
if (entry.event_type === 'complete' && !entry.block_label) return '🏁'
return EVENT_META[entry.event_type]?.icon || '•'
}
function eventLabel(entry) {
if (entry._type === 'note') return 'Note'
if (entry.event_type === 'complete' && !entry.block_label) return 'Day ended'
const action = EVENT_META[entry.event_type]?.label || entry.event_type
return entry.block_label ? `${action}${entry.block_label}` : action
}
function startEdit(entry) {
editingEntry.value = entry
if (entry._type === 'event') {
const d = new Date(entry.occurred_at)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
editDraft.value = { event_type: entry.event_type, time: `${hh}:${mm}` }
} else {
editDraft.value = { notes: entry.notes || '' }
}
}
async function saveEdit(entry) {
if (entry._type === 'event') {
const original = new Date(entry.occurred_at)
const [hh, mm] = editDraft.value.time.split(':').map(Number)
original.setHours(hh, mm, 0, 0)
await api.patch(`/api/logs/timeline/${entry.id}`, {
event_type: editDraft.value.event_type,
occurred_at: original.toISOString(),
})
} else {
await api.patch(`/api/logs/${entry.id}`, { notes: editDraft.value.notes })
}
editingEntry.value = null
await loadData()
}
async function deleteEntry(entry) {
if (!confirm('Delete this entry?')) return
if (entry._type === 'event') {
await api.delete(`/api/logs/timeline/${entry.id}`)
timeline.value = timeline.value.filter(e => e.id !== entry.id)
} else {
await api.delete(`/api/logs/${entry.id}`)
manualLogs.value = manualLogs.value.filter(l => l.id !== entry.id)
}
}
async function loadData() {
const params = {}
if (activeChildId.value) params.child_id = activeChildId.value
if (filterDate.value) params.log_date = filterDate.value
const [tRes, lRes] = await Promise.all([
api.get('/api/logs/timeline', { params }),
api.get('/api/logs', { params }),
])
timeline.value = tRes.data
manualLogs.value = lRes.data
}
async function createLog() {
await api.post('/api/logs', newLog.value)
newLog.value = { child_id: newLog.value.child_id, subject_id: null, log_date: today, notes: '', duration_minutes: null }
await api.post('/api/logs', { ...newLog.value, session_id: null, subject_id: null, duration_minutes: null })
newLog.value = { child_id: newLog.value.child_id, log_date: today, notes: '' }
showForm.value = false
await loadLogs()
await loadData()
}
async function deleteLog(id) {
if (confirm('Delete this log entry?')) {
await api.delete(`/api/logs/${id}`)
logs.value = logs.value.filter((l) => l.id !== id)
}
}
onMounted(async () => {
await childrenStore.fetchChildren()
if (childrenStore.activeChild) newLog.value.child_id = childrenStore.activeChild.id
const [sRes] = await Promise.all([api.get('/api/subjects'), loadLogs()])
subjects.value = sRes.data
await loadData()
})
watch([activeChildId, filterDate], loadData)
</script>
<style scoped>
@@ -165,20 +305,97 @@ h1 { font-size: 1.75rem; font-weight: 700; }
font-size: 0.875rem;
}
.log-list { display: flex; flex-direction: column; gap: 0.5rem; }
.log-row {
.date-heading {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
margin: 1.5rem 0 0.75rem;
}
.event-list { display: flex; flex-direction: column; gap: 0.4rem; }
.event-row {
display: flex;
align-items: flex-start;
gap: 1rem;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1e293b;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border-left: 3px solid transparent;
}
.event-row.event { border-left-color: #334155; }
.event-row.note { border-left-color: #4f46e5; }
.event-row.editing { border-left-color: #4f46e5; flex-wrap: wrap; align-items: center; gap: 0.6rem; }
.edit-field { display: flex; flex-direction: column; gap: 0.25rem; }
.edit-field label { font-size: 0.75rem; color: #64748b; }
.edit-field select,
.edit-field input,
.edit-field textarea {
padding: 0.4rem 0.6rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.4rem;
color: #f1f5f9;
font-size: 0.85rem;
}
.edit-field textarea { resize: vertical; }
.edit-field-full { flex: 1; min-width: 200px; }
.edit-actions { display: flex; gap: 0.4rem; align-items: flex-end; margin-left: auto; }
.row-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.event-time {
font-size: 0.78rem;
color: #475569;
width: 68px;
flex-shrink: 0;
padding-top: 0.15rem;
font-variant-numeric: tabular-nums;
}
.event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #334155;
flex-shrink: 0;
margin-top: 0.4rem;
}
.event-dot.dot-note { background: #4f46e5; }
.event-body { flex: 1; min-width: 0; }
.event-label {
font-size: 0.9rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.4rem;
}
.event-icon { font-size: 0.85rem; }
.event-subject {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.15rem;
}
.event-notes {
font-size: 0.85rem;
color: #94a3b8;
margin-top: 0.2rem;
}
.event-child {
font-size: 0.75rem;
color: #475569;
margin-top: 0.15rem;
}
.log-date { font-size: 0.8rem; color: #64748b; width: 90px; flex-shrink: 0; padding-top: 0.1rem; }
.log-content { flex: 1; }
.log-subject { font-size: 0.85rem; color: #818cf8; margin-bottom: 0.2rem; }
.log-notes { font-size: 0.9rem; }
.log-meta { font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; }