From 684d5eafb540b28c075bafd952bf3d3f49932a0a Mon Sep 17 00:00:00 2001 From: otmooper12 Date: Wed, 22 Jul 2026 14:03:26 -0400 Subject: [PATCH] Add distraction analytics timeline --- package.json | 2 +- src/components/DistractionAnalytics.tsx | 381 ++++++++++++++++++++++++ src/data/db.ts | 5 + src/data/store.ts | 49 +++ src/lib/distractionAnalytics.ts | 106 +++++++ src/pages/DogProfile.tsx | 7 + src/types.ts | 12 + tests/distractionAnalytics.test.ts | 82 +++++ worker/src/index.ts | 1 + 9 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 src/components/DistractionAnalytics.tsx create mode 100644 src/lib/distractionAnalytics.ts create mode 100644 tests/distractionAnalytics.test.ts diff --git a/package.json b/package.json index 9fa6ac7..bd1b946 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", - "test": "node --experimental-strip-types --test tests/sessionDate.test.ts tests/phaseGroups.test.ts tests/outcomeConfig.test.ts tests/dailyWork.test.ts tests/trainerSince.test.ts", + "test": "node --experimental-strip-types --test tests/sessionDate.test.ts tests/phaseGroups.test.ts tests/outcomeConfig.test.ts tests/dailyWork.test.ts tests/trainerSince.test.ts tests/distractionAnalytics.test.ts", "preview": "vite preview", "deploy": "wrangler deploy" }, diff --git a/src/components/DistractionAnalytics.tsx b/src/components/DistractionAnalytics.tsx new file mode 100644 index 0000000..314391e --- /dev/null +++ b/src/components/DistractionAnalytics.tsx @@ -0,0 +1,381 @@ +import { useMemo, useState } from 'react'; +import { calendarDateAtLocalNoon, localSessionDate } from '../../shared/sessionDate'; +import { + createDogEvent, + deleteDogEvent, + updateDogEvent, + useDogEvents, +} from '../data/store'; +import { + distractionSeverityRank, + distractionTimeline, + observedSeverityLabels, + summarizeDistractions, + type DistractionTimelinePoint, +} from '../lib/distractionAnalytics'; +import { + DISTRACTION_SEVERITIES, + type DistractionSeverity, + type DistractionTemplate, + type DogEvent, + type TrainingReport, +} from '../types'; +import { PencilIcon, TrashIcon } from './icons'; + +const CHART_WIDTH = 680; +const CHART_HEIGHT = 260; +const CHART_MARGIN = { top: 28, right: 18, bottom: 42, left: 82 }; +const SEVERITY_COLORS: Record = { + Absent: '#94a3b8', + Mild: '#38bdf8', + Moderate: '#f59e0b', + Severe: '#ef4444', +}; + +function displayDate(date: string): string { + return calendarDateAtLocalNoon(date).toLocaleDateString(); +} + +function DistractionTimelineChart({ + points, + events, +}: { + points: DistractionTimelinePoint[]; + events: DogEvent[]; +}) { + const allDates = [...points.map((point) => point.date), ...events.map((event) => event.eventDate)]; + if (points.length === 0) return null; + + const dateValues = allDates.map((date) => calendarDateAtLocalNoon(date).getTime()); + const minDate = Math.min(...dateValues); + const maxDate = Math.max(...dateValues); + const plotWidth = CHART_WIDTH - CHART_MARGIN.left - CHART_MARGIN.right; + const plotHeight = CHART_HEIGHT - CHART_MARGIN.top - CHART_MARGIN.bottom; + const x = (date: string) => { + if (minDate === maxDate) return CHART_MARGIN.left + plotWidth / 2; + return ( + CHART_MARGIN.left + + ((calendarDateAtLocalNoon(date).getTime() - minDate) / (maxDate - minDate)) * plotWidth + ); + }; + const y = (severity: DistractionSeverity) => + CHART_MARGIN.top + ((3 - distractionSeverityRank(severity)) / 3) * plotHeight; + const path = points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${x(point.date)} ${y(point.severity)}`) + .join(' '); + + return ( +
+ + {DISTRACTION_SEVERITIES.map((severity) => { + const lineY = y(severity); + return ( + + + + {severity} + + + ); + })} + {events.map((event, index) => { + const eventX = x(event.eventDate); + const labelY = CHART_MARGIN.top + 10 + (index % 3) * 13; + return ( + + + + {event.label.length > 22 ? `${event.label.slice(0, 19)}...` : event.label} + + + ); + })} + + {points.map((point, index) => ( + + {`${displayDate(point.date)}: ${point.severity}`} + + ))} + + {displayDate(allDates[dateValues.indexOf(minDate)])} + + + {displayDate(allDates[dateValues.indexOf(maxDate)])} + + +
+ ); +} + +export function DistractionAnalytics({ + dogId, + reports, + templates, +}: { + dogId: string; + reports: TrainingReport[]; + templates: DistractionTemplate[]; +}) { + const events = useDogEvents(dogId); + const summaries = useMemo(() => summarizeDistractions(reports), [reports]); + const summaryRows = summaries.map((summary) => ({ + ...summary, + title: + templates.find((template) => template.id === summary.distractionId)?.title ?? + 'Unknown distraction', + })); + const [selectedId, setSelectedId] = useState(''); + const effectiveSelectedId = summaryRows.some((summary) => summary.distractionId === selectedId) + ? selectedId + : (summaryRows[0]?.distractionId ?? ''); + const points = useMemo( + () => distractionTimeline(reports, effectiveSelectedId), + [effectiveSelectedId, reports], + ); + const [newEventDate, setNewEventDate] = useState(localSessionDate); + const [newEventLabel, setNewEventLabel] = useState(''); + const [editingEventId, setEditingEventId] = useState(null); + const [editingEventDate, setEditingEventDate] = useState(''); + const [editingEventLabel, setEditingEventLabel] = useState(''); + + function handleAddEvent(e: React.FormEvent) { + e.preventDefault(); + if (createDogEvent(dogId, newEventDate, newEventLabel)) { + setNewEventLabel(''); + } + } + + function beginEdit(event: DogEvent) { + setEditingEventId(event.id); + setEditingEventDate(event.eventDate); + setEditingEventLabel(event.label); + } + + function saveEdit(e: React.FormEvent) { + e.preventDefault(); + if (!editingEventId) return; + if (updateDogEvent(editingEventId, editingEventDate, editingEventLabel)) { + setEditingEventId(null); + } + } + + return ( +
+
+

+ Distraction trends +

+

+ Summaries use an observed ordinal distribution and median category, never a decimal + average. Only explicitly logged observations appear; missing categories are not treated + as Absent. +

+
+ + {summaryRows.length === 0 ? ( +

+ No distraction observations have been logged for this dog yet. +

+ ) : ( + <> +
+ {summaryRows.map((summary) => ( + + ))} +
+ +
+ + +
+ + )} + +
+
+

+ Contextual dog events +

+

+ Add dated context such as surgery, medication changes, or foster-home visits. +

+
+
+ setNewEventDate(e.target.value)} + className="rounded-md border border-gray-300 bg-transparent px-2 py-1.5 text-sm dark:border-gray-600" + /> + setNewEventLabel(e.target.value)} + placeholder="Event label" + className="min-w-[180px] flex-1 rounded-md border border-gray-300 bg-transparent px-3 py-1.5 text-sm dark:border-gray-600" + /> + +
+
    + {events.map((event) => ( +
  • + {editingEventId === event.id ? ( +
    + setEditingEventDate(e.target.value)} + className="rounded-md border border-gray-300 bg-transparent px-2 py-1 text-sm dark:border-gray-600" + /> + setEditingEventLabel(e.target.value)} + className="min-w-[160px] flex-1 rounded-md border border-gray-300 bg-transparent px-2 py-1 text-sm dark:border-gray-600" + /> + + +
    + ) : ( +
    + + {event.label}{' '} + {displayDate(event.eventDate)} + + + + + +
    + )} +
  • + ))} + {events.length === 0 && ( +
  • No contextual events added yet.
  • + )} +
+
+
+ ); +} diff --git a/src/data/db.ts b/src/data/db.ts index a95d795..b698557 100644 --- a/src/data/db.ts +++ b/src/data/db.ts @@ -2,6 +2,7 @@ import { legacySessionDate, storedLocalCalendarDate } from '../../shared/session import type { Dog, DogChecklistCompletion, + DogEvent, DogMilestoneCompletion, DistractionTemplate, Folder, @@ -30,6 +31,7 @@ export interface Database { // that have never flagged a milestone repeatable. milestoneOutcomeAttempts: MilestoneOutcomeAttempt[]; distractionTemplates: DistractionTemplate[]; + dogEvents: DogEvent[]; // One-time gate for migrateLegacyDefaultTemplates() (#30) — true means this // account's checklist/milestones either started on, or have already been // upgraded to, Abby's real defaults, so the migration must never touch them @@ -57,6 +59,7 @@ export function emptyDatabase(): Database { dogMilestoneCompletions: [], milestoneOutcomeAttempts: [], distractionTemplates: [], + dogEvents: [], templatesMigratedToAbbyDefaults: true, pinnedFolderId: null, }; @@ -257,6 +260,7 @@ export function normalizeDatabase( database.milestoneOutcomeAttempts = database.milestoneOutcomeAttempts ?? []; // Accounts predating distraction templates (#36) won't have this field at all. database.distractionTemplates = database.distractionTemplates ?? []; + database.dogEvents = database.dogEvents ?? []; // Accounts persisted before #30 won't have this field at all — treat its // absence as "not yet migrated" so migrateLegacyDefaultTemplates() runs // for them exactly once. @@ -286,6 +290,7 @@ export function normalizeDatabase( milestoneOutcomeAttempts: (parsed.milestoneOutcomeAttempts as MilestoneOutcomeAttempt[]) ?? [], distractionTemplates: (parsed.distractionTemplates as DistractionTemplate[]) ?? [], + dogEvents: (parsed.dogEvents as DogEvent[]) ?? [], templatesMigratedToAbbyDefaults: (parsed.templatesMigratedToAbbyDefaults as boolean | undefined) ?? false, pinnedFolderId: (parsed.pinnedFolderId as string | null | undefined) ?? null, }; diff --git a/src/data/store.ts b/src/data/store.ts index e4efcf8..49803ec 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -9,6 +9,7 @@ import type { DistractionObservation, DistractionTemplate, DogChecklistCompletion, + DogEvent, DogMilestoneCompletion, FinalOutcome, Folder, @@ -904,6 +905,7 @@ export function deleteDog(id: string): void { db.reports = db.reports.filter((r) => r.dogId !== id); db.completions = db.completions.filter((c) => c.dogId !== id); db.dogMilestoneCompletions = db.dogMilestoneCompletions.filter((c) => c.dogId !== id); + db.dogEvents = db.dogEvents.filter((event) => event.dogId !== id); notify(); logEvent('Dog deleted', id); } @@ -1651,6 +1653,53 @@ export function deleteMostRecentMilestoneAttempt( return persisted; } +// ---- Contextual dog events (#55) ---- + +export function useDogEvents(dogId: string): DogEvent[] { + return useDatabase() + .dogEvents.filter((event) => event.dogId === dogId) + .sort( + (a, b) => + a.eventDate.localeCompare(b.eventDate) || a.createdDate.localeCompare(b.createdDate), + ); +} + +export function createDogEvent(dogId: string, eventDate: string, label: string): DogEvent | null { + const normalizedLabel = label.trim(); + if (!normalizedLabel || !isValidCalendarDate(eventDate)) return null; + const timestamp = now(); + const event: DogEvent = { + id: uid(), + dogId, + eventDate, + label: normalizedLabel, + createdDate: timestamp, + updatedDate: timestamp, + }; + db.dogEvents.push(event); + notify(); + logEvent('Dog context event created', `dog ${dogId}, ${eventDate}: ${normalizedLabel}`); + return event; +} + +export function updateDogEvent(id: string, eventDate: string, label: string): boolean { + const event = db.dogEvents.find((candidate) => candidate.id === id); + const normalizedLabel = label.trim(); + if (!event || !normalizedLabel || !isValidCalendarDate(eventDate)) return false; + event.eventDate = eventDate; + event.label = normalizedLabel; + event.updatedDate = now(); + const persisted = notify(); + logEvent('Dog context event updated', `${id} -> ${eventDate}: ${normalizedLabel}`); + return persisted; +} + +export function deleteDogEvent(id: string): void { + db.dogEvents = db.dogEvents.filter((event) => event.id !== id); + notify(); + logEvent('Dog context event deleted', id); +} + // ---- Distraction Templates (global, shared across phases) (#36) ---- export function useDistractionTemplates(): DistractionTemplate[] { diff --git a/src/lib/distractionAnalytics.ts b/src/lib/distractionAnalytics.ts new file mode 100644 index 0000000..092483e --- /dev/null +++ b/src/lib/distractionAnalytics.ts @@ -0,0 +1,106 @@ +import { + DISTRACTION_SEVERITIES, + type DistractionSeverity, + type TrainingReport, +} from '../types.ts'; + +const SEVERITY_RANK: Record = { + Absent: 0, + Mild: 1, + Moderate: 2, + Severe: 3, +}; + +export interface DistractionTimelinePoint { + reportId: string; + date: string; + severity: DistractionSeverity; +} + +export interface DistractionSummary { + distractionId: string; + observations: number; + medianSeverity: DistractionSeverity; + distribution: Record; +} + +function emptyDistribution(): Record { + return { + Absent: 0, + Mild: 0, + Moderate: 0, + Severe: 0, + }; +} + +export function distractionSeverityRank(severity: DistractionSeverity): number { + return SEVERITY_RANK[severity]; +} + +export function distractionTimeline( + reports: readonly TrainingReport[], + distractionId: string, +): DistractionTimelinePoint[] { + return reports + .flatMap((report) => + report.distractions + .filter((observation) => observation.distractionId === distractionId) + .map((observation) => ({ + reportId: report.id, + date: report.sessionDate, + severity: observation.severity, + createdDate: report.createdDate, + })), + ) + .sort( + (a, b) => + a.date.localeCompare(b.date) || + a.createdDate.localeCompare(b.createdDate) || + a.reportId.localeCompare(b.reportId), + ) + .map(({ createdDate: _createdDate, ...point }) => point); +} + +export function summarizeDistractions( + reports: readonly TrainingReport[], +): DistractionSummary[] { + const severitiesById = new Map(); + + reports.forEach((report) => { + report.distractions.forEach((observation) => { + const existing = severitiesById.get(observation.distractionId) ?? []; + existing.push(observation.severity); + severitiesById.set(observation.distractionId, existing); + }); + }); + + return [...severitiesById.entries()] + .map(([distractionId, severities]) => { + const sorted = [...severities].sort( + (a, b) => distractionSeverityRank(a) - distractionSeverityRank(b), + ); + const distribution = emptyDistribution(); + severities.forEach((severity) => { + distribution[severity] += 1; + }); + + // Ordinal data has no meaningful decimal midpoint. For an even sample, + // use the lower central observed category rather than inventing a value. + const medianSeverity = sorted[Math.floor((sorted.length - 1) / 2)]; + return { + distractionId, + observations: severities.length, + medianSeverity, + distribution, + }; + }) + .sort((a, b) => a.distractionId.localeCompare(b.distractionId)); +} + +export function observedSeverityLabels( + distribution: Record, +): string { + return DISTRACTION_SEVERITIES.filter((severity) => distribution[severity] > 0) + .map((severity) => `${severity} ${distribution[severity]}`) + .join(' / '); +} diff --git a/src/pages/DogProfile.tsx b/src/pages/DogProfile.tsx index 35abf9d..5f1773e 100644 --- a/src/pages/DogProfile.tsx +++ b/src/pages/DogProfile.tsx @@ -7,6 +7,7 @@ import { } from '../../shared/sessionDate'; import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; +import { DistractionAnalytics } from '../components/DistractionAnalytics'; import { MoveDialog } from '../components/MoveDialog'; import { DailyWorkBadge } from '../components/DailyWorkStatus'; import { PencilIcon, TrashIcon } from '../components/icons'; @@ -1130,6 +1131,12 @@ export function DogProfile() { + +

Log History diff --git a/src/types.ts b/src/types.ts index f51d8c2..5218ab3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,6 +94,18 @@ export interface Dog { updatedDate: string; } +// A dated piece of context for interpreting a dog's longitudinal training +// data (#55), such as surgery or the start of foster-home visits. The domain +// date is independent from the audit timestamps. +export interface DogEvent { + id: string; + dogId: string; + eventDate: string; + label: string; + createdDate: string; + updatedDate: string; +} + export interface DistractionObservation { distractionId: string; severity: DistractionSeverity; diff --git a/tests/distractionAnalytics.test.ts b/tests/distractionAnalytics.test.ts new file mode 100644 index 0000000..28b6883 --- /dev/null +++ b/tests/distractionAnalytics.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + distractionTimeline, + summarizeDistractions, +} from '../src/lib/distractionAnalytics.ts'; +import type { + DistractionObservation, + TrainingReport, +} from '../src/types.ts'; + +function report( + id: string, + sessionDate: string, + distractions: DistractionObservation[], +): TrainingReport { + return { + id, + dogId: 'dog-1', + phase: 'Phase 1', + redFlag: false, + locationId: null, + notes: '', + picture: null, + skillIds: [], + milestoneIds: [], + distractions, + authorInstructorId: 'trainer-1', + visibility: 'shared', + sessionDate, + createdDate: `${sessionDate}T12:00:00.000Z`, + updatedDate: `${sessionDate}T12:00:00.000Z`, + }; +} + +test('summaries use observed ordinal distributions and a real median category', () => { + const reports = [ + report('r1', '2026-07-01', [{ distractionId: 'traffic', severity: 'Mild' }]), + report('r2', '2026-07-02', [{ distractionId: 'traffic', severity: 'Severe' }]), + report('r3', '2026-07-03', [{ distractionId: 'traffic', severity: 'Moderate' }]), + report('r4', '2026-07-04', [{ distractionId: 'dogs', severity: 'Absent' }]), + ]; + + assert.deepEqual(summarizeDistractions(reports), [ + { + distractionId: 'dogs', + observations: 1, + medianSeverity: 'Absent', + distribution: { Absent: 1, Mild: 0, Moderate: 0, Severe: 0 }, + }, + { + distractionId: 'traffic', + observations: 3, + medianSeverity: 'Moderate', + distribution: { Absent: 0, Mild: 1, Moderate: 1, Severe: 1 }, + }, + ]); +}); + +test('even samples use a lower observed middle category instead of a decimal mean', () => { + const reports = [ + report('r1', '2026-07-01', [{ distractionId: 'traffic', severity: 'Mild' }]), + report('r2', '2026-07-02', [{ distractionId: 'traffic', severity: 'Severe' }]), + ]; + + assert.equal(summarizeDistractions(reports)[0]?.medianSeverity, 'Mild'); +}); + +test('timeline contains only explicitly logged observations and sorts chronologically', () => { + const reports = [ + report('later', '2026-07-03', [{ distractionId: 'traffic', severity: 'Severe' }]), + report('unlogged', '2026-07-02', []), + report('absent', '2026-07-01', [{ distractionId: 'traffic', severity: 'Absent' }]), + report('other', '2026-07-04', [{ distractionId: 'dogs', severity: 'Moderate' }]), + ]; + + assert.deepEqual(distractionTimeline(reports, 'traffic'), [ + { reportId: 'absent', date: '2026-07-01', severity: 'Absent' }, + { reportId: 'later', date: '2026-07-03', severity: 'Severe' }, + ]); + assert.deepEqual(distractionTimeline(reports, 'unknown'), []); +}); diff --git a/worker/src/index.ts b/worker/src/index.ts index 9d947ac..2163266 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -18,6 +18,7 @@ const EMPTY_BLOB = JSON.stringify({ completions: [], milestoneTemplates: [], dogMilestoneCompletions: [], + dogEvents: [], }); // Minimal shapes for the specific blob fields this worker actually reads or