wulf-pulse/components/analytics/TimelineView.tsx
root 6eee14f8af Add comprehensive admin features and multi-system integration
- Add admin dashboard with sync controls and data browser
- Implement RMM, Auvik, and Addigy organization mappings
- Add chunked ticket sync with progress tracking
- Implement entity sync service with rate limiting
- Add analytics engine and performance optimizer
- Create data browser for all PSA entities
- Add navigation components and UI improvements
- Implement background processing and sync services
- Add comprehensive documentation and migration scripts
- Update configuration items with multi-system support
- Enhance contact management and purchase history
- Add issue type assignment and LLM analyzer
- Improve error handling and logging utilities
2025-11-19 14:18:16 -05:00

335 lines
12 KiB
TypeScript

'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar, Clock, Users, ChevronDown, ChevronRight, Activity, AlertCircle, CheckCircle } from 'lucide-react';
import { TimelineEvent, TimelineView as TimelineViewType } from '@/lib/types/analytics';
import { cn } from '@/lib/utils';
interface TimelineViewProps {
events: TimelineEvent[];
timeRange: 'hour' | 'day' | 'week' | 'month';
onTimeRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void;
loading?: boolean;
className?: string;
}
export function TimelineView({
events,
timeRange,
onTimeRangeChange,
loading = false,
className
}: TimelineViewProps) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
// Group events by time period based on timeRange
const groupedEvents = useMemo(() => {
const groups = new Map<string, TimelineEvent[]>();
events.forEach(event => {
const eventDate = new Date(event.timestamp);
let groupKey: string;
switch (timeRange) {
case 'hour':
groupKey = eventDate.toISOString().substring(0, 13); // YYYY-MM-DDTHH
break;
case 'day':
groupKey = eventDate.toISOString().substring(0, 10); // YYYY-MM-DD
break;
case 'week':
const weekStart = new Date(eventDate);
weekStart.setDate(eventDate.getDate() - eventDate.getDay());
groupKey = `Week of ${weekStart.toISOString().substring(0, 10)}`;
break;
case 'month':
groupKey = eventDate.toISOString().substring(0, 7); // YYYY-MM
break;
default:
groupKey = eventDate.toISOString().substring(0, 10);
}
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(event);
});
// Sort events within each group by timestamp
groups.forEach(group => {
group.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
});
return groups;
}, [events, timeRange]);
// Calculate summary statistics
const summary = useMemo(() => {
const humanActivities = events.filter(e => e.isHumanActivity).length;
const systemActivities = events.filter(e => !e.isHumanActivity).length;
const totalHours = events.reduce((sum, e) => sum + (e.duration || 0), 0);
const averageScore = events.length > 0
? events.reduce((sum, e) => sum + (e.score || 0), 0) / events.length
: 0;
return {
totalEvents: events.length,
humanActivities,
systemActivities,
totalHours,
averageScore,
};
}, [events]);
const toggleSection = (sectionKey: string) => {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(sectionKey)) {
newExpanded.delete(sectionKey);
} else {
newExpanded.add(sectionKey);
}
setExpandedSections(newExpanded);
};
const getEventIcon = (event: TimelineEvent) => {
switch (event.type) {
case 'key_moment':
return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'milestone':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'time_entry':
default:
if (event.isHumanActivity) {
return <Users className="h-4 w-4 text-blue-500" />;
} else {
return <Activity className="h-4 w-4 text-gray-500" />;
}
}
};
const getImportanceColor = (importance: string) => {
switch (importance) {
case 'critical':
return 'bg-red-100 text-red-800 border-red-200';
case 'high':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'low':
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const formatGroupTitle = (groupKey: string) => {
switch (timeRange) {
case 'hour':
return new Date(groupKey + ':00:00').toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
hour12: true,
});
case 'day':
return new Date(groupKey).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
case 'week':
return groupKey;
case 'month':
return new Date(groupKey + '-01').toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
});
default:
return groupKey;
}
};
if (loading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Timeline View
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Timeline View
</CardTitle>
<Select value={timeRange} onValueChange={onTimeRangeChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hour">Hour</SelectItem>
<SelectItem value="day">Day</SelectItem>
<SelectItem value="week">Week</SelectItem>
<SelectItem value="month">Month</SelectItem>
</SelectContent>
</Select>
</div>
{/* Summary Statistics */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mt-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{summary.totalEvents}</div>
<div className="text-sm text-gray-600">Total Events</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{summary.humanActivities}</div>
<div className="text-sm text-gray-600">Human Activities</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-600">{summary.systemActivities}</div>
<div className="text-sm text-gray-600">System Activities</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">{Number(summary.totalHours).toFixed(1)}</div>
<div className="text-sm text-gray-600">Total Hours</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{(summary.averageScore * 100).toFixed(0)}%
</div>
<div className="text-sm text-gray-600">Avg Score</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{groupedEvents.size === 0 ? (
<div className="text-center py-8 text-gray-500">
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No events found for the selected time range</p>
</div>
) : (
Array.from(groupedEvents.entries())
.sort(([a], [b]) => b.localeCompare(a)) // Sort by date descending
.map(([groupKey, groupEvents]) => (
<Collapsible
key={groupKey}
open={expandedSections.has(groupKey)}
onOpenChange={() => toggleSection(groupKey)}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between p-4 h-auto hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{expandedSections.has(groupKey) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="font-medium">{formatGroupTitle(groupKey)}</span>
<Badge variant="secondary">{groupEvents.length} events</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>
{groupEvents.filter(e => e.isHumanActivity).length} human
</span>
<span></span>
<span>
{groupEvents.reduce((sum, e) => sum + (Number(e.duration) || 0), 0).toFixed(1)}h
</span>
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 px-4 pb-4">
{groupEvents.map((event) => (
<div
key={event.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border transition-colors cursor-pointer",
selectedEvent?.id === event.id
? "border-blue-300 bg-blue-50"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50",
!event.isHumanActivity && "opacity-60"
)}
onClick={() => setSelectedEvent(event)}
>
<div className="mt-0.5">
{getEventIcon(event)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium truncate">{event.title}</h4>
<Badge
variant="outline"
className={cn("text-xs", getImportanceColor(event.importance))}
>
{event.importance}
</Badge>
</div>
{event.description && (
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{event.description}
</p>
)}
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(event.timestamp).toLocaleTimeString()}
</span>
{event.duration && (
<span className="flex items-center gap-1">
<Activity className="h-3 w-3" />
{event.duration}h
</span>
)}
{event.score && (
<span className="flex items-center gap-1">
<div className="w-8 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500"
style={{ width: `${event.score * 100}%` }}
/>
</div>
{(event.score * 100).toFixed(0)}%
</span>
)}
</div>
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
))
)}
</CardContent>
</Card>
);
}