Add strike events to activity log

Record a StrikeEvent row whenever a strike is added or removed,
and surface them in the activity log timeline with timestamp,
child name, and whether the strike was added or removed.

- New strike_events table (auto-created on startup)
- children router records prev/new strikes on every update
- GET /api/logs/strikes and DELETE /api/logs/strikes/:id endpoints
- Log view merges strike entries into the timeline (red dot,
  "✕ Strike added (2/3)" / "↩ Strike removed (1/3)")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:07:41 -08:00
parent f730e9edf9
commit b5f4188396
6 changed files with 127 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ from app.models.subject import Subject, SubjectOption
from app.models.schedule import ScheduleTemplate, ScheduleBlock
from app.models.session import DailySession, TimerEvent, TimerEventType
from app.models.activity import ActivityLog
from app.models.strike import StrikeEvent
__all__ = [
"Base",
@@ -20,4 +21,5 @@ __all__ = [
"TimerEvent",
"TimerEventType",
"ActivityLog",
"StrikeEvent",
]

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class StrikeEvent(Base):
__tablename__ = "strike_events"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
child_id: Mapped[int] = mapped_column(ForeignKey("children.id", ondelete="CASCADE"), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
prev_strikes: Mapped[int] = mapped_column(Integer, nullable=False)
new_strikes: Mapped[int] = mapped_column(Integer, nullable=False)
child: Mapped["Child"] = relationship("Child") # noqa: F821

View File

@@ -5,6 +5,7 @@ from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.child import Child
from app.models.strike import StrikeEvent
from app.models.user import User
from app.schemas.child import ChildCreate, ChildOut, ChildUpdate
from app.websocket.manager import manager
@@ -89,7 +90,9 @@ async def update_strikes(
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
prev = child.strikes
child.strikes = body.strikes
db.add(StrikeEvent(child_id=child.id, prev_strikes=prev, new_strikes=body.strikes))
await db.commit()
await db.refresh(child)
await manager.broadcast(child_id, {"event": "strikes_update", "strikes": child.strikes})

View File

@@ -2,7 +2,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user
@@ -10,9 +10,10 @@ 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.strike import StrikeEvent
from app.models.subject import Subject # noqa: F401
from app.models.user import User
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate, TimelineEventOut, TimelineEventUpdate
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate, StrikeEventOut, TimelineEventOut, TimelineEventUpdate
router = APIRouter(prefix="/api/logs", tags=["logs"])
@@ -119,6 +120,59 @@ async def delete_timeline_event(
await db.commit()
@router.get("/strikes", response_model=list[StrikeEventOut])
async def get_strike_events(
child_id: int | None = None,
log_date: date | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = (
select(StrikeEvent)
.join(Child, StrikeEvent.child_id == Child.id)
.where(Child.user_id == current_user.id)
.options(selectinload(StrikeEvent.child))
.order_by(StrikeEvent.occurred_at.desc())
)
if child_id:
query = query.where(StrikeEvent.child_id == child_id)
if log_date:
query = query.where(func.date(StrikeEvent.occurred_at) == log_date)
result = await db.execute(query)
events = result.scalars().all()
return [
StrikeEventOut(
id=e.id,
child_id=e.child_id,
child_name=e.child.name,
occurred_at=e.occurred_at,
log_date=e.occurred_at.date(),
prev_strikes=e.prev_strikes,
new_strikes=e.new_strikes,
)
for e in events
]
@router.delete("/strikes/{strike_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_strike_event(
strike_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(StrikeEvent)
.join(Child, StrikeEvent.child_id == Child.id)
.where(StrikeEvent.id == strike_id, Child.user_id == current_user.id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Strike event not found")
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,5 +1,5 @@
from datetime import date, datetime, timezone
from pydantic import BaseModel, field_serializer
from pydantic import BaseModel, computed_field, field_serializer
class ActivityLogCreate(BaseModel):
@@ -51,3 +51,21 @@ class TimelineEventOut(BaseModel):
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
class StrikeEventOut(BaseModel):
id: int
child_id: int
child_name: str
occurred_at: datetime
log_date: date
prev_strikes: int
new_strikes: int
model_config = {"from_attributes": True}
@field_serializer("occurred_at")
def serialize_occurred_at(self, dt: datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()