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() {
|
export default function Analytics() {
|
||||||
|
const [minutes, setMinutes] = useState<number>(1440)
|
||||||
|
const [taskId, setTaskId] = useState<number | undefined>(undefined)
|
||||||
|
const { data: tasks } = useTasks()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
<h1 className="text-xl font-semibold text-white">Analytics</h1>
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user