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:
134
TaskTracker.Web/src/components/analytics/ActivityFeed.tsx
Normal file
134
TaskTracker.Web/src/components/analytics/ActivityFeed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx
Normal file
143
TaskTracker.Web/src/components/analytics/CategoryBreakdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
TaskTracker.Web/src/components/analytics/Timeline.tsx
Normal file
208
TaskTracker.Web/src/components/analytics/Timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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<number>(1440)
|
||||
const [taskId, setTaskId] = useState<number | undefined>(undefined)
|
||||
const { data: tasks } = useTasks()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Analytics</h1>
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user