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:
@@ -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",
|
||||
]
|
||||
|
||||
18
backend/app/models/strike.py
Normal file
18
backend/app/models/strike.py
Normal 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
|
||||
@@ -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})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<div
|
||||
class="event-dot"
|
||||
:style="entry.subject_color ? { background: entry.subject_color } : {}"
|
||||
:class="{ 'dot-note': entry._type === 'note' }"
|
||||
:class="{ 'dot-note': entry._type === 'note', 'dot-strike': entry._type === 'strike' }"
|
||||
></div>
|
||||
<div class="event-body">
|
||||
<div class="event-label">
|
||||
@@ -101,7 +101,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button class="btn-sm" v-if="entry.event_type !== 'session_start'" @click="startEdit(entry)">Edit</button>
|
||||
<button class="btn-sm" v-if="entry._type === 'event' && entry.event_type !== 'session_start'" @click="startEdit(entry)">Edit</button>
|
||||
<button class="btn-sm" v-if="entry._type === 'note'" @click="startEdit(entry)">Edit</button>
|
||||
<button class="btn-sm btn-danger" @click="deleteEntry(entry)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user