From fef03ec53894ece52d2e16d601398b8f522590b6 Mon Sep 17 00:00:00 2001 From: derekc Date: Sat, 28 Feb 2026 18:15:35 -0800 Subject: [PATCH] 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 --- backend/app/routers/logs.py | 98 ++++++++- backend/app/schemas/activity.py | 20 +- frontend/src/views/LogView.vue | 357 +++++++++++++++++++++++++------- 3 files changed, 403 insertions(+), 72 deletions(-) diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py index e1729db..df596ff 100644 --- a/backend/app/routers/logs.py +++ b/backend/app/routers/logs.py @@ -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, diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py index fb410db..665d474 100644 --- a/backend/app/schemas/activity.py +++ b/backend/app/schemas/activity.py @@ -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 diff --git a/frontend/src/views/LogView.vue b/frontend/src/views/LogView.vue index 1231155..c39de24 100644 --- a/frontend/src/views/LogView.vue +++ b/frontend/src/views/LogView.vue @@ -4,14 +4,14 @@
- +
-

Log an Activity

+

Add a Note

@@ -25,110 +25,250 @@
-
-
- - -
-
- - -
-
- +
- +
- +
- -
-
-
{{ log.log_date }}
-
-
- {{ subjectDisplay(log.subject_id) }} -
-
{{ log.notes }}
-
- {{ log.duration_minutes }} min -
+ +
+
+ +
No activity recorded yet.