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>
)
}

View File

@@ -0,0 +1,143 @@
import { useMemo } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
import { useContextSummary } from '../../api/context'
import { CATEGORY_COLORS } from '../../lib/constants'
interface CategoryBreakdownProps {
minutes: number
taskId?: number
}
interface CategoryData {
name: string
count: number
color: string
percentage: number
}
export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }: CategoryBreakdownProps) {
const { data: summary, isLoading } = useContextSummary()
const categories = useMemo(() => {
if (!summary) return []
// Aggregate by category
const catMap = new Map<string, number>()
for (const item of summary) {
const cat = item.category || 'Unknown'
catMap.set(cat, (catMap.get(cat) ?? 0) + item.eventCount)
}
const total = Array.from(catMap.values()).reduce((s, c) => s + c, 0)
const result: CategoryData[] = Array.from(catMap.entries())
.map(([name, count]) => ({
name,
count,
color: CATEGORY_COLORS[name] ?? CATEGORY_COLORS['Unknown'],
percentage: total > 0 ? Math.round((count / total) * 100) : 0,
}))
.sort((a, b) => b.count - a.count)
return result
}, [summary])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
Loading category breakdown...
</div>
)
}
if (categories.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
No category data available.
</div>
)
}
const totalEvents = categories.reduce((s, c) => s + c.count, 0)
return (
<div className="flex gap-8 items-start">
{/* Left: Donut chart */}
<div className="w-56 h-56 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categories}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="80%"
paddingAngle={2}
stroke="none"
>
{categories.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null
const d = payload[0].payload as CategoryData
return (
<div
style={{
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-white text-sm font-medium">{d.name}</div>
<div className="text-[#94a3b8] text-xs mt-0.5">
{d.count} events ({d.percentage}%)
</div>
</div>
)
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Right: Legend list */}
<div className="flex-1 space-y-3 pt-2">
{categories.map((cat) => (
<div key={cat.name} className="flex items-center gap-3">
{/* Colored dot */}
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
{/* Name + bar + stats */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-white font-medium truncate">{cat.name}</span>
<span className="text-xs text-[#94a3b8] ml-2 shrink-0">
{cat.count} ({cat.percentage}%)
</span>
</div>
{/* Progress bar */}
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${totalEvents > 0 ? (cat.count / totalEvents) * 100 : 0}%`,
backgroundColor: cat.color,
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import { useMemo } from 'react'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { useRecentContext } from '../../api/context'
import { useMappings } from '../../api/mappings'
import { CATEGORY_COLORS } from '../../lib/constants'
interface TimelineProps {
minutes: number
taskId?: number
}
interface BucketData {
label: string
count: number
category: string
color: string
appName: string
timeRange: string
}
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 formatHour(date: Date): string {
const h = date.getHours()
const ampm = h >= 12 ? 'pm' : 'am'
const hour12 = h % 12 || 12
return `${hour12}${ampm}`
}
function formatTimeRange(date: Date): string {
const start = formatHour(date)
const next = new Date(date)
next.setHours(next.getHours() + 1)
const end = formatHour(next)
return `${start} - ${end}`
}
export default function Timeline({ minutes, taskId }: TimelineProps) {
const { data: events, isLoading: eventsLoading } = useRecentContext(minutes)
const { data: mappings, isLoading: mappingsLoading } = useMappings()
const buckets = useMemo(() => {
if (!events || !mappings) return []
let filtered = events
if (taskId) {
filtered = events.filter((e) => e.workTaskId === taskId)
}
if (filtered.length === 0) return []
// Group by hour bucket
const hourMap = new Map<
string,
{ date: Date; apps: Map<string, { count: number; category: string }> }
>()
for (const evt of filtered) {
const ts = new Date(evt.timestamp)
const bucketDate = new Date(ts)
bucketDate.setMinutes(0, 0, 0)
const key = bucketDate.toISOString()
if (!hourMap.has(key)) {
hourMap.set(key, { date: bucketDate, apps: new Map() })
}
const bucket = hourMap.get(key)!
const category = resolveCategory(evt.appName, mappings)
const appKey = `${evt.appName}|${category}`
if (!bucket.apps.has(appKey)) {
bucket.apps.set(appKey, { count: 0, category })
}
bucket.apps.get(appKey)!.count++
}
// Sort by time and determine dominant app per bucket
const sorted = Array.from(hourMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, { date, apps }]): BucketData => {
let dominantApp = ''
let dominantCategory = 'Unknown'
let maxCount = 0
for (const [key, { count, category }] of apps) {
if (count > maxCount) {
maxCount = count
dominantCategory = category
dominantApp = key.split('|')[0]
}
}
const totalCount = Array.from(apps.values()).reduce((s, a) => s + a.count, 0)
return {
label: formatHour(date),
count: totalCount,
category: dominantCategory,
color: CATEGORY_COLORS[dominantCategory] ?? CATEGORY_COLORS['Unknown'],
appName: dominantApp,
timeRange: formatTimeRange(date),
}
})
return sorted
}, [events, mappings, taskId])
if (eventsLoading || mappingsLoading) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
Loading timeline...
</div>
)
}
if (buckets.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
No activity data for this time range.
</div>
)
}
return (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
<XAxis
dataKey="label"
tick={{ fill: '#94a3b8', fontSize: 12 }}
axisLine={{ stroke: '#1e293b' }}
tickLine={false}
/>
<YAxis
tick={{ fill: '#94a3b8', fontSize: 12 }}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
padding: '8px 12px',
}}
labelStyle={{ display: 'none' }}
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null
const d = payload[0].payload as BucketData
return (
<div
style={{
backgroundColor: '#1e293b',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
padding: '8px 12px',
}}
>
<div className="text-[#94a3b8] text-xs">{d.timeRange}</div>
<div className="text-white text-sm font-medium mt-0.5">{d.appName}</div>
<div className="text-xs mt-0.5" style={{ color: d.color }}>
{d.count} events
</div>
</div>
)
}}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
{buckets.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -1,7 +1,85 @@
import { useState } from 'react'
import { useTasks } from '../api/tasks'
import Timeline from '../components/analytics/Timeline'
import CategoryBreakdown from '../components/analytics/CategoryBreakdown'
import ActivityFeed from '../components/analytics/ActivityFeed'
const TIME_RANGES = [
{ label: 'Today', minutes: 1440 },
{ label: '7 days', minutes: 10080 },
{ label: '30 days', minutes: 43200 },
] as const
export default function Analytics() { export default function Analytics() {
const [minutes, setMinutes] = useState<number>(1440)
const [taskId, setTaskId] = useState<number | undefined>(undefined)
const { data: tasks } = useTasks()
return ( return (
<div> <div className="max-w-6xl mx-auto space-y-8">
<h1 className="text-xl font-semibold text-white">Analytics</h1> {/* Header + Filters */}
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-xl font-semibold text-white">Analytics</h1>
<div className="flex items-center gap-3">
{/* Time range dropdown */}
<select
value={minutes}
onChange={(e) => setMinutes(Number(e.target.value))}
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer"
>
{TIME_RANGES.map((r) => (
<option key={r.minutes} value={r.minutes}>
{r.label}
</option>
))}
</select>
{/* Task filter dropdown */}
<select
value={taskId ?? ''}
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
className="bg-[#1e293b] text-white text-sm rounded-lg border border-white/10 px-3 py-1.5 focus:outline-none focus:border-indigo-500 transition-colors appearance-none cursor-pointer max-w-[200px]"
>
<option value="">All Tasks</option>
{tasks?.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</div>
</div>
{/* Timeline */}
<section>
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
Activity Timeline
</h2>
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
<Timeline minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Category Breakdown */}
<section>
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
Category Breakdown
</h2>
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
<CategoryBreakdown minutes={minutes} taskId={taskId} />
</div>
</section>
{/* Activity Feed */}
<section>
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
Recent Activity
</h2>
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
<ActivityFeed minutes={minutes} taskId={taskId} />
</div>
</section>
</div> </div>
) )
} }