diff --git a/TaskTracker.Web/src/components/analytics/ActivityFeed.tsx b/TaskTracker.Web/src/components/analytics/ActivityFeed.tsx new file mode 100644 index 0000000..fe64cf5 --- /dev/null +++ b/TaskTracker.Web/src/components/analytics/ActivityFeed.tsx @@ -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 ( +
+ Loading activity... +
+ ) + } + + if (sortedEvents.length === 0) { + return ( +
+ No activity events for this time range. +
+ ) + } + + return ( +
+
+ {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 ( +
+ {/* Category dot */} + + + {/* Content */} +
+
+ + {formatTimestamp(evt.timestamp)} + + {evt.appName} +
+ {detail && ( +

{detail}

+ )} +
+
+ ) + })} +
+ + {hasMore && ( + + )} +
+ ) +} diff --git a/TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx b/TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx new file mode 100644 index 0000000..50fcf52 --- /dev/null +++ b/TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx @@ -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() + 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 ( +
+ Loading category breakdown... +
+ ) + } + + if (categories.length === 0) { + return ( +
+ No category data available. +
+ ) + } + + const totalEvents = categories.reduce((s, c) => s + c.count, 0) + + return ( +
+ {/* 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 +

+
+ +
+
) }