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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user