Files
TaskTracker/TaskTracker.Web/src/components/analytics/ActivityFeed.tsx

141 lines
4.5 KiB
TypeScript

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-[var(--color-text-secondary)] text-sm">
Loading activity...
</div>
)
}
if (sortedEvents.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
No activity events for this time range.
</div>
)
}
return (
<div>
<div className="relative">
{visibleEvents.map((evt, idx) => {
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
const detail = evt.url || evt.windowTitle || ''
const isLast = idx === visibleEvents.length - 1
return (
<div key={evt.id} className="flex items-start gap-3 relative">
{/* Timeline connector + dot */}
<div className="flex flex-col items-center shrink-0">
<span
className="w-2 h-2 rounded-full mt-1.5 shrink-0 relative z-10"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="w-px flex-1 bg-[var(--color-border)]" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-3">
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-secondary)] shrink-0">
{formatTimestamp(evt.timestamp)}
</span>
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{evt.appName}</span>
</div>
{detail && (
<p className="text-xs text-[var(--color-text-tertiary)] 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-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Load more ({sortedEvents.length - visibleCount} remaining)
</button>
)}
</div>
)
}