+ {/* Left: Donut chart */}
+
+
+
+
+ {categories.map((entry, index) => (
+ |
+ ))}
+
+ {
+ if (!active || !payload || payload.length === 0) return null
+ const d = payload[0].payload as CategoryData
+ return (
+
+
{d.name}
+
+ {d.count} events ({d.percentage}%)
+
+
+ )
+ }}
+ />
+
+
+
+
+ {/* Right: Legend list */}
+
+ {categories.map((cat) => (
+
+ {/* Colored dot */}
+
+
+ {/* Name + bar + stats */}
+
+
+ {cat.name}
+
+ {cat.count} ({cat.percentage}%)
+
+
+ {/* Progress bar */}
+
+
0 ? (cat.count / totalEvents) * 100 : 0}%`,
+ backgroundColor: cat.color,
+ }}
+ />
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/TaskTracker.Web/src/components/analytics/Timeline.tsx b/TaskTracker.Web/src/components/analytics/Timeline.tsx
new file mode 100644
index 0000000..d869823
--- /dev/null
+++ b/TaskTracker.Web/src/components/analytics/Timeline.tsx
@@ -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
}
+ >()
+
+ 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 (
+
+ Loading timeline...
+
+ )
+ }
+
+ if (buckets.length === 0) {
+ return (
+
+ No activity data for this time range.
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {
+ if (!active || !payload || payload.length === 0) return null
+ const d = payload[0].payload as BucketData
+ return (
+
+
{d.timeRange}
+
{d.appName}
+
+ {d.count} events
+
+
+ )
+ }}
+ />
+
+ {buckets.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+ )
+}
diff --git a/TaskTracker.Web/src/pages/Analytics.tsx b/TaskTracker.Web/src/pages/Analytics.tsx
index c3145be..204f80d 100644
--- a/TaskTracker.Web/src/pages/Analytics.tsx
+++ b/TaskTracker.Web/src/pages/Analytics.tsx
@@ -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() {
+ const [minutes, setMinutes] = useState(1440)
+ const [taskId, setTaskId] = useState(undefined)
+ const { data: tasks } = useTasks()
+
return (
-
-
Analytics
+
+ {/* Header + Filters */}
+
+
Analytics
+
+
+ {/* Time range dropdown */}
+
+
+ {/* Task filter dropdown */}
+
+
+
+
+ {/* Timeline */}
+
+
+ Activity Timeline
+
+
+
+
+
+
+ {/* Category Breakdown */}
+
+
+ Category Breakdown
+
+
+
+
+
+
+ {/* Activity Feed */}
+
+
+ Recent Activity
+
+
+
)
}