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 fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user from app.dependencies import get_db, get_current_user
from app.models.activity import ActivityLog from app.models.activity import ActivityLog
from app.models.child import Child 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.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 = 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]) @router.get("", response_model=list[ActivityLogOut])
async def list_logs( async def list_logs(
child_id: int | None = None, child_id: int | None = None,

View File

@@ -1,4 +1,4 @@
from datetime import date from datetime import date, datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -27,3 +27,21 @@ class ActivityLogOut(BaseModel):
duration_minutes: int | None duration_minutes: int | None
model_config = {"from_attributes": True} 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"> <main class="container">
<div class="page-header"> <div class="page-header">
<h1>Activity Log</h1> <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> </div>
<ChildSelector style="margin-bottom: 1.5rem" /> <ChildSelector style="margin-bottom: 1.5rem" />
<!-- Add form --> <!-- Manual note form -->
<div class="card" v-if="showForm"> <div class="card" v-if="showForm">
<h3>Log an Activity</h3> <h3>Add a Note</h3>
<form @submit.prevent="createLog"> <form @submit.prevent="createLog">
<div class="field-row"> <div class="field-row">
<div class="field"> <div class="field">
@@ -25,110 +25,250 @@
<input v-model="newLog.log_date" type="date" required /> <input v-model="newLog.log_date" type="date" required />
</div> </div>
</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"> <div class="field">
<label>Notes</label> <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>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn-sm" @click="showForm = false">Cancel</button> <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> </div>
</form> </form>
</div> </div>
<!-- Filter bar --> <!-- Filter bar -->
<div class="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> <button v-if="filterDate" class="btn-sm" @click="filterDate = ''">Clear</button>
</div> </div>
<!-- Logs --> <!-- Timeline -->
<div class="log-list"> <div class="timeline" v-if="combinedEntries.length">
<div v-for="log in filteredLogs" :key="log.id" class="log-row"> <template v-for="(group, date) in groupedByDate" :key="date">
<div class="log-date">{{ log.log_date }}</div> <div class="date-heading">{{ formatDate(date) }}</div>
<div class="log-content"> <div class="event-list">
<div class="log-subject" v-if="log.subject_id"> <template v-for="entry in group" :key="entry._key">
{{ subjectDisplay(log.subject_id) }}
<!-- 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>
<div class="log-notes" v-if="log.notes">{{ log.notes }}</div> <div class="edit-field">
<div class="log-meta" v-if="log.duration_minutes"> <label>Time</label>
{{ log.duration_minutes }} min <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>
</div> </div>
<button class="btn-sm btn-danger" @click="deleteLog(log.id)"></button>
<!-- 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>
<div v-if="filteredLogs.length === 0" class="empty-state"> <div class="event-subject" v-if="entry.subject_name">
No activity logs yet. {{ 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> </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>
</template>
</div>
<div v-else class="empty-state">No activity recorded yet.</div>
</main> </main>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useChildrenStore } from '@/stores/children' import { useChildrenStore } from '@/stores/children'
import api from '@/composables/useApi' import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue' import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue' import ChildSelector from '@/components/ChildSelector.vue'
const childrenStore = useChildrenStore() const childrenStore = useChildrenStore()
const logs = ref([]) const timeline = ref([])
const subjects = ref([]) const manualLogs = ref([])
const showForm = ref(false) 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 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(() => { const activeChildId = computed(() => childrenStore.activeChild?.id || null)
if (!filterDate.value) return logs.value
return logs.value.filter((l) => l.log_date === filterDate.value) 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) { // Group by session_date (events) or log_date (notes)
const s = subjects.value.find((s) => s.id === id) const groupedByDate = computed(() => {
return s ? `${s.icon} ${s.name}` : '' 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() { function formatTime(isoStr) {
const res = await api.get('/api/logs') if (!isoStr) return ''
logs.value = res.data 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() { async function createLog() {
await api.post('/api/logs', newLog.value) await api.post('/api/logs', { ...newLog.value, session_id: null, subject_id: null, duration_minutes: null })
newLog.value = { child_id: newLog.value.child_id, subject_id: null, log_date: today, notes: '', duration_minutes: null } newLog.value = { child_id: newLog.value.child_id, log_date: today, notes: '' }
showForm.value = false 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 () => { 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
const [sRes] = await Promise.all([api.get('/api/subjects'), loadLogs()]) await loadData()
subjects.value = sRes.data
}) })
watch([activeChildId, filterDate], loadData)
</script> </script>
<style scoped> <style scoped>
@@ -165,20 +305,97 @@ h1 { font-size: 1.75rem; font-weight: 700; }
font-size: 0.875rem; font-size: 0.875rem;
} }
.log-list { display: flex; flex-direction: column; gap: 0.5rem; } .date-heading {
.log-row { 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; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 0.75rem;
padding: 0.75rem 1rem;
background: #1e293b; background: #1e293b;
border-radius: 0.75rem; 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; } .empty-state { text-align: center; padding: 4rem; color: #64748b; }