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