feat(ui): redesign analytics page — stat cards, styled charts, git-log activity feed
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -74,7 +74,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
if (eventsLoading || mappingsLoading) {
|
if (eventsLoading || mappingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
|
||||||
Loading activity...
|
Loading activity...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -82,7 +82,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
if (sortedEvents.length === 0) {
|
if (sortedEvents.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-32 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-32 text-[var(--color-text-secondary)] text-sm">
|
||||||
No activity events for this time range.
|
No activity events for this time range.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -90,30 +90,36 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="divide-y divide-white/5">
|
<div className="relative">
|
||||||
{visibleEvents.map((evt) => {
|
{visibleEvents.map((evt, idx) => {
|
||||||
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
|
const category = mappings ? resolveCategory(evt.appName, mappings) : 'Unknown'
|
||||||
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
|
const color = CATEGORY_COLORS[category] ?? CATEGORY_COLORS['Unknown']
|
||||||
const detail = evt.url || evt.windowTitle || ''
|
const detail = evt.url || evt.windowTitle || ''
|
||||||
|
const isLast = idx === visibleEvents.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={evt.id} className="flex items-start gap-3 py-3">
|
<div key={evt.id} className="flex items-start gap-3 relative">
|
||||||
{/* Category dot */}
|
{/* Timeline connector + dot */}
|
||||||
<span
|
<div className="flex flex-col items-center shrink-0">
|
||||||
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
|
<span
|
||||||
style={{ backgroundColor: color }}
|
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 */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-[#94a3b8] shrink-0">
|
<span className="text-xs text-[var(--color-text-secondary)] shrink-0">
|
||||||
{formatTimestamp(evt.timestamp)}
|
{formatTimestamp(evt.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-white font-medium truncate">{evt.appName}</span>
|
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{evt.appName}</span>
|
||||||
</div>
|
</div>
|
||||||
{detail && (
|
{detail && (
|
||||||
<p className="text-xs text-[#64748b] truncate mt-0.5">{detail}</p>
|
<p className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">{detail}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +130,7 @@ export default function ActivityFeed({ minutes, taskId }: ActivityFeedProps) {
|
|||||||
{hasMore && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
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"
|
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)
|
Load more ({sortedEvents.length - visibleCount} remaining)
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
Loading category breakdown...
|
Loading category breakdown...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -52,7 +52,7 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
No category data available.
|
No category data available.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -88,14 +88,14 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-white text-sm font-medium">{d.name}</div>
|
<div className="text-[var(--color-text-primary)] text-sm font-medium">{d.name}</div>
|
||||||
<div className="text-[#94a3b8] text-xs mt-0.5">
|
<div className="text-[var(--color-text-secondary)] text-xs mt-0.5">
|
||||||
{d.count} events ({d.percentage}%)
|
{d.count} events ({d.percentage}%)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,13 +119,13 @@ export default function CategoryBreakdown({ minutes: _minutes, taskId: _taskId }
|
|||||||
{/* Name + bar + stats */}
|
{/* Name + bar + stats */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-sm text-white font-medium truncate">{cat.name}</span>
|
<span className="text-sm text-[var(--color-text-primary)] font-medium truncate">{cat.name}</span>
|
||||||
<span className="text-xs text-[#94a3b8] ml-2 shrink-0">
|
<span className="text-xs text-[var(--color-text-secondary)] ml-2 shrink-0">
|
||||||
{cat.count} ({cat.percentage}%)
|
{cat.count} ({cat.percentage}%)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
|
<div className="h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full transition-all duration-300"
|
className="h-full rounded-full transition-all duration-300"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
|
|
||||||
if (eventsLoading || mappingsLoading) {
|
if (eventsLoading || mappingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
Loading timeline...
|
Loading timeline...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -144,7 +144,7 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
|
|
||||||
if (buckets.length === 0) {
|
if (buckets.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-[#94a3b8] text-sm">
|
<div className="flex items-center justify-center h-64 text-[var(--color-text-secondary)] text-sm">
|
||||||
No activity data for this time range.
|
No activity data for this time range.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -156,12 +156,12 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
<BarChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||||
axisLine={{ stroke: '#1e293b' }}
|
axisLine={{ stroke: 'var(--color-border)' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: '#94a3b8', fontSize: 12 }}
|
tick={{ fill: 'var(--color-text-secondary)', fontSize: 12 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
@@ -169,8 +169,8 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
@@ -181,14 +181,14 @@ export default function Timeline({ minutes, taskId }: TimelineProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#1e293b',
|
backgroundColor: 'var(--color-elevated)',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-[#94a3b8] text-xs">{d.timeRange}</div>
|
<div className="text-[var(--color-text-secondary)] text-xs">{d.timeRange}</div>
|
||||||
<div className="text-white text-sm font-medium mt-0.5">{d.appName}</div>
|
<div className="text-[var(--color-text-primary)] text-sm font-medium mt-0.5">{d.appName}</div>
|
||||||
<div className="text-xs mt-0.5" style={{ color: d.color }}>
|
<div className="text-xs mt-0.5" style={{ color: d.color }}>
|
||||||
{d.count} events
|
{d.count} events
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ export default function Analytics() {
|
|||||||
<div className="max-w-6xl mx-auto space-y-8">
|
<div className="max-w-6xl mx-auto space-y-8">
|
||||||
{/* Header + Filters */}
|
{/* Header + Filters */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
<h1 className="text-xl font-semibold text-white">Analytics</h1>
|
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Analytics</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Time range dropdown */}
|
{/* Time range dropdown */}
|
||||||
<select
|
<select
|
||||||
value={minutes}
|
value={minutes}
|
||||||
onChange={(e) => setMinutes(Number(e.target.value))}
|
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"
|
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
{TIME_RANGES.map((r) => (
|
{TIME_RANGES.map((r) => (
|
||||||
<option key={r.minutes} value={r.minutes}>
|
<option key={r.minutes} value={r.minutes}>
|
||||||
@@ -39,7 +39,7 @@ export default function Analytics() {
|
|||||||
<select
|
<select
|
||||||
value={taskId ?? ''}
|
value={taskId ?? ''}
|
||||||
onChange={(e) => setTaskId(e.target.value ? Number(e.target.value) : undefined)}
|
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]"
|
className="bg-[var(--color-surface)] text-[var(--color-text-primary)] text-sm rounded-lg border border-[var(--color-border)] px-3 py-1.5 focus:outline-none focus:border-[var(--color-accent)] transition-colors appearance-none cursor-pointer max-w-[200px]"
|
||||||
>
|
>
|
||||||
<option value="">All Tasks</option>
|
<option value="">All Tasks</option>
|
||||||
{tasks?.map((t) => (
|
{tasks?.map((t) => (
|
||||||
@@ -51,32 +51,69 @@ export default function Analytics() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stat cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Open Tasks</span>
|
||||||
|
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||||
|
{tasks?.filter(t => t.status !== 'Completed' && t.status !== 'Abandoned').length ?? 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Active Time</span>
|
||||||
|
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||||
|
{(() => {
|
||||||
|
const totalMins = tasks?.reduce((acc, t) => {
|
||||||
|
if (!t.startedAt) return acc
|
||||||
|
const start = new Date(t.startedAt).getTime()
|
||||||
|
const end = t.completedAt ? new Date(t.completedAt).getTime() : (t.status === 'Active' ? Date.now() : start)
|
||||||
|
return acc + (end - start) / 60000
|
||||||
|
}, 0) ?? 0
|
||||||
|
const hours = Math.floor(totalMins / 60)
|
||||||
|
const mins = Math.floor(totalMins % 60)
|
||||||
|
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl p-4">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">Top Category</span>
|
||||||
|
<p className="text-2xl font-semibold text-[var(--color-text-primary)] mt-1">
|
||||||
|
{(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
tasks?.forEach(t => { counts[t.category ?? 'Unknown'] = (counts[t.category ?? 'Unknown'] || 0) + 1 })
|
||||||
|
const top = Object.entries(counts).sort(([,a], [,b]) => b - a)[0]
|
||||||
|
return top ? top[0] : '\u2014'
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||||
Activity Timeline
|
Activity Timeline
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||||
<Timeline minutes={minutes} taskId={taskId} />
|
<Timeline minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Category Breakdown */}
|
{/* Category Breakdown */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||||
Category Breakdown
|
Category Breakdown
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||||
<CategoryBreakdown minutes={minutes} taskId={taskId} />
|
<CategoryBreakdown minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Activity Feed */}
|
{/* Activity Feed */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-medium text-[#94a3b8] uppercase tracking-wider mb-4">
|
<h2 className="text-[11px] font-medium text-[var(--color-text-secondary)] uppercase tracking-wider mb-4">
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[#161922] rounded-xl border border-white/5 p-5">
|
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-5">
|
||||||
<ActivityFeed minutes={minutes} taskId={taskId} />
|
<ActivityFeed minutes={minutes} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user