diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0d01900..0df98ae 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/strike.py b/backend/app/models/strike.py new file mode 100644 index 0000000..cce6080 --- /dev/null +++ b/backend/app/models/strike.py @@ -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 diff --git a/backend/app/routers/children.py b/backend/app/routers/children.py index 9a9507a..60cea7d 100644 --- a/backend/app/routers/children.py +++ b/backend/app/routers/children.py @@ -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}) diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py index 9c1dac8..207e0bf 100644 --- a/backend/app/routers/logs.py +++ b/backend/app/routers/logs.py @@ -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, diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py index 914daff..31885bc 100644 --- a/backend/app/schemas/activity.py +++ b/backend/app/schemas/activity.py @@ -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() diff --git a/frontend/src/views/LogView.vue b/frontend/src/views/LogView.vue index 4b66a5d..f8fdd65 100644 --- a/frontend/src/views/LogView.vue +++ b/frontend/src/views/LogView.vue @@ -85,7 +85,7 @@
@@ -101,7 +101,8 @@
- + +
@@ -129,6 +130,7 @@ const childrenStore = useChildrenStore() const authStore = useAuthStore() const timeline = ref([]) const manualLogs = ref([]) +const strikeEvents = ref([]) const showForm = ref(false) const filterDate = ref(new Date().toISOString().split('T')[0]) @@ -140,7 +142,7 @@ const activeChildId = computed(() => childrenStore.activeChild?.id || null) const editingEntry = ref(null) const editDraft = ref({}) -// Merge and sort timeline events + manual notes +// Merge and sort timeline events + manual notes + strike events const combinedEntries = computed(() => { const events = timeline.value.map(e => ({ ...e, @@ -159,10 +161,18 @@ const combinedEntries = computed(() => { subject_icon: null, subject_color: null, })) - return [...events, ...notes].sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)) + const strikes = strikeEvents.value.map(s => ({ + ...s, + _type: 'strike', + _key: `strike-${s.id}`, + subject_name: null, + subject_icon: null, + subject_color: null, + })) + return [...events, ...notes, ...strikes].sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)) }) -// Group by session_date (events) or log_date (notes) +// Group by session_date (events) or log_date (notes/strikes) const groupedByDate = computed(() => { const groups = {} for (const entry of combinedEntries.value) { @@ -193,12 +203,19 @@ const EVENT_META = { function eventIcon(entry) { 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 || '•' } function eventLabel(entry) { if (entry._type === 'note') return 'Note' + if (entry._type === 'strike') { + const added = entry.new_strikes > entry.prev_strikes + return added + ? `Strike added (${entry.new_strikes}/3)` + : `Strike removed (${entry.new_strikes}/3)` + } 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 @@ -236,6 +253,9 @@ async function deleteEntry(entry) { if (entry._type === 'event') { await api.delete(`/api/logs/timeline/${entry.id}`) timeline.value = timeline.value.filter(e => e.id !== entry.id) + } else if (entry._type === 'strike') { + await api.delete(`/api/logs/strikes/${entry.id}`) + strikeEvents.value = strikeEvents.value.filter(s => s.id !== entry.id) } else { await api.delete(`/api/logs/${entry.id}`) manualLogs.value = manualLogs.value.filter(l => l.id !== entry.id) @@ -247,12 +267,14 @@ async function loadData() { if (activeChildId.value) params.child_id = activeChildId.value if (filterDate.value) params.log_date = filterDate.value - const [tRes, lRes] = await Promise.all([ + const [tRes, lRes, sRes] = await Promise.all([ api.get('/api/logs/timeline', { params }), api.get('/api/logs', { params }), + api.get('/api/logs/strikes', { params }), ]) timeline.value = tRes.data manualLogs.value = lRes.data + strikeEvents.value = sRes.data } async function createLog() { @@ -367,6 +389,7 @@ h1 { font-size: 1.75rem; font-weight: 700; } } .event-dot.dot-note { background: #4f46e5; } +.event-dot.dot-strike { background: #ef4444; } .event-body { flex: 1; min-width: 0; }