feat: add analytics page with timeline, category breakdown, and activity feed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:30:47 -05:00
parent 1d7de30fa3
commit dec1757c1e
4 changed files with 565 additions and 2 deletions

View File

@@ -0,0 +1,134 @@
import { useMemo, useState } from 'react'
import { useRecentContext } from '../../api/context'
import { useMappings } from '../../api/mappings'
import { CATEGORY_COLORS } from '../../lib/constants'
interface ActivityFeedProps {
minutes: number
taskId?: number
}
const PAGE_SIZE = 20
function resolveCategory(
appName: string,
mappings: { pattern: string; matchType: string; category: string }[],
): string {
for (const m of mappings) {
if (m.matchType === 'Exact' && m.pattern.toLowerCase() === appName.toLowerCase()) {
return m.category
}
if (m.matchType === 'Contains' && appName.toLowerCase().includes(m.pattern.toLowerCase())) {
return m.category
}
if (m.matchType === 'Regex') {
try {
if (new RegExp(m.pattern, 'i').test(appName)) return m.category
} catch {
// skip invalid regex
}
}
}
return 'Unknown'
}
function formatTimestamp(ts: string): string {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
if (diffMin < 1) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
// Show time for older events
const h = date.getHours()
const m = date.getMinutes()
const ampm = h >= 12 ? 'pm' : 'am'
const hour12 = h % 12 || 12
const mins = m.toString().padStart(2, '0')
return `${hour12}:${mins}${ampm}`
}
export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
const { data: events, isLoading: eventsLoading } = useRecentContext(minutes)
const { data: mappings, isLoading: mappingsLoading } = useMappings()
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
const sortedEvents = useMemo(() => {
if (!events) return []
let filtered = events
if (taskId) {
filtered = events.filter((e) => e.workTaskId === taskId)
}
// Reverse chronological
return [...filtered].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
)
}, [events, taskId])
const visibleEvents = sortedEvents.slice(0, visibleCount)
const hasMore = visibleCount < sortedEvents.length
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
Loading activity...
</div>
)
}
if (sortedEvents.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
No activity events for this time range.
</div>
)
}
return (
<div>
<div className="divide-y divide-white/5">
{visibleEvents.map((evt) => {
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
const detail = evt.url || evt.windowTitle || ''
return (
<div key={evt.id} className="flex items-start gap-3 py-3">
{/* Category dot */}
<span
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
style={{ backgroundColor: color }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-[#94a3b8] shrink-0">
{formatTimestamp(evt.timestamp)}
</span>
<span className="text-sm text-white font-medium truncate">{evt.appName}</span>
</div>
{detail && (
<p className="text-xs text-[#64748b] truncate mt-0.5">{detail}</p>
)}
</div>
</div>
)
})}
</div>
{hasMore && (
<button
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
className="mt-3 w-full py-2 text-sm text-[#94a3b8] hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Load more ({sortedEvents.length - visibleCount} remaining)
</button>
)}
</div>
)
}