141 lines
4.5 KiB
TypeScript
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>
|
|
)
|
|
}
|