refactor: dashboard design critique — eliminate redundancy, differentiate sections

- Remove GreetingStatStrip (duplicated PerformanceCards data)
- Strip left-border accent from stat cards (AI slop pattern)
- Redesign KnowledgeBaseCards: icon grid → compact row list with icon badges
- Redesign TeamSummary: distinct inline-row layout, no longer identical twin
- Differentiate hover: stat cards use subtle border-hover, sessions keep springy lift
- Add loading skeletons to PerformanceCards, KnowledgeBaseCards, TeamSummary
- Add error state to PerformanceCards
- Extract timeAgo() to shared lib/timeAgo.ts (replaced 4 duplicates)
- Fix Skeleton bg-brand-border (undefined CSS var) → border-default
- Fix double text-xs text-[0.5625rem] class conflicts across dashboard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-29 17:06:30 -04:00
parent 677c8f88ea
commit 912075cd43
11 changed files with 105 additions and 85 deletions

View File

@@ -34,7 +34,7 @@
**Theme:** Dark mode primary (charcoal palette). Light mode planned but not yet implemented.
**Accent:** Ember orange (#f97316) — conveys urgency fitting a troubleshooting context. Used sparingly (max 5% of UI). Warning uses yellow (#eab308), not amber, to stay distinct.
**Accent:** Electric blue (#60a5fa dark / #2563eb light) — conveys trust, precision, and reliability fitting a troubleshooting tool MSP engineers depend on during outages. Used sparingly (max 5% of UI). Warning uses amber (#fbbf24), info uses cyan (#67e8f9).
**Hard rules:** No glassmorphism, no gradient surfaces, no ambient orbs, no backdrop blur, no decorative shadows at rest. Elevation = lighter surface + border, not shadow.

View File

@@ -4,16 +4,7 @@ import { Clock, ArrowRight, Route, MessageCircle } from 'lucide-react'
import { aiSessionsApi } from '@/api/aiSessions'
import type { AISessionSummary } from '@/types/ai-session'
import { cn } from '@/lib/utils'
function timeAgo(dateStr: string): string {
const diffMs = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diffMs / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
import { timeAgo } from '@/lib/timeAgo'
export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) {
const [sessions, setSessions] = useState<AISessionSummary[]>([])
@@ -68,7 +59,7 @@ export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: b
)}
<span
className={cn(
'font-sans text-xs text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
'font-sans text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
session.confidence_tier === 'guided' && 'bg-emerald-400/10 text-emerald-400',
session.confidence_tier === 'exploring' && 'bg-amber-400/10 text-amber-400',
session.confidence_tier === 'discovery' && 'bg-blue-400/10 text-blue-400',

View File

@@ -1,16 +1,19 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Network, Code2, ListChecks, ArrowRight } from 'lucide-react'
import { Network, Code2, ListChecks, ArrowRight, ChevronRight } from 'lucide-react'
import { sidebarApi } from '@/api'
import { Skeleton } from '@/components/ui/Skeleton'
export function KnowledgeBaseCards() {
const navigate = useNavigate()
const [flowCount, setFlowCount] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
sidebarApi.getStats()
.then((stats) => setFlowCount(stats.tree_counts.total))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const items = [
@@ -33,23 +36,35 @@ export function KnowledgeBaseCards() {
Browse <ArrowRight size={10} />
</button>
</div>
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
{items.map((item) => (
<div className="py-1">
{loading ? Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3" style={{ borderBottom: i < 2 ? '1px solid var(--color-border-default)' : undefined }}>
<Skeleton className="h-8 w-8 rounded-md shrink-0" />
<Skeleton className="h-4 flex-1 max-w-24" />
<Skeleton className="h-5 w-8" />
</div>
)) : items.map((item, i) => (
<button
key={item.label}
onClick={() => navigate(item.href)}
className="flex flex-col items-center gap-2 py-5 rounded-lg hover:bg-card-hover transition-all duration-350"
style={{ transition: 'transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1), background 200ms ease' }}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)' }}
className="flex w-full items-center gap-3 px-5 py-3 text-left hover:bg-[var(--color-bg-card-hover)] transition-colors group"
style={{
borderBottom: i < items.length - 1 ? '1px solid var(--color-border-default)' : undefined,
}}
>
<item.icon size={20} style={{ color: item.color }} />
<p className="font-heading text-xl font-extrabold text-foreground">{item.value}</p>
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
{item.label}
</p>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md"
style={{ backgroundColor: `${item.color}15` }}
>
<item.icon size={15} style={{ color: item.color }} />
</span>
<span className="flex-1 text-sm font-medium text-foreground">{item.label}</span>
<span className="font-heading text-base font-bold text-foreground tabular-nums mr-1">
{item.value}
</span>
<ChevronRight size={14} className="text-muted-foreground opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
</button>
))}
)))}
</div>
</div>
)

View File

@@ -3,16 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { AlertTriangle } from 'lucide-react'
import { aiSessionsApi } from '@/api/aiSessions'
import type { AISessionSummary } from '@/types/ai-session'
function timeAgo(dateStr: string): string {
const diffMs = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diffMs / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
}
import { timeAgo } from '@/lib/timeAgo'
export function PendingEscalations() {
const [escalations, setEscalations] = useState<AISessionSummary[]>([])

View File

@@ -4,6 +4,7 @@ import { CheckCircle, Clock, TrendingUp, Timer } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { sidebarApi } from '@/api'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/Skeleton'
interface StatCard {
label: string
@@ -19,6 +20,8 @@ export function PerformanceCards() {
const [resolved, setResolved] = useState(0)
const [active, setActive] = useState(0)
const [totalMinutes, setTotalMinutes] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
useEffect(() => {
sidebarApi.getStats()
@@ -27,9 +30,31 @@ export function PerformanceCards() {
setActive(stats.active_count)
setTotalMinutes(stats.total_session_minutes_today)
})
.catch(() => {})
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card-flat p-4 space-y-2">
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-7 w-1/2" />
</div>
))}
</div>
)
}
if (error) {
return (
<div className="card-flat p-4 text-center">
<p className="text-sm text-muted-foreground">Unable to load performance data</p>
</div>
)
}
const avgMttr = resolved > 0 ? Math.round(totalMinutes / resolved) : 0
const cards: StatCard[] = [
@@ -70,14 +95,13 @@ export function PerformanceCards() {
<button
key={card.label}
onClick={() => navigate(card.href)}
className="card-interactive p-4 text-left fade-in"
className="card-flat p-4 text-left fade-in hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
style={{
animationDelay: `${400 + i * 60}ms`,
borderLeft: `3px solid ${card.iconColor}`,
}}
>
<div className="flex items-center justify-between mb-2">
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
<p className="font-sans text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
{card.label}
</p>
<card.icon size={14} style={{ color: card.iconColor }} />

View File

@@ -3,18 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { CheckCircle, AlertTriangle, XCircle, ArrowRight, MessageCircle } from 'lucide-react'
import { aiSessionsApi } from '@/api/aiSessions'
import type { AISessionSummary } from '@/types/ai-session'
function timeAgo(dateStr: string): string {
const diffMs = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diffMs / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days === 1) return 'yesterday'
return `${days}d ago`
}
import { timeAgo } from '@/lib/timeAgo'
const STATUS_CONFIG: Record<string, { icon: typeof CheckCircle; color: string }> = {
resolved: { icon: CheckCircle, color: '#34d399' },

View File

@@ -3,17 +3,20 @@ import { useNavigate } from 'react-router-dom'
import { Users, AlertTriangle, Activity, ArrowRight } from 'lucide-react'
import { usePermissions } from '@/hooks/usePermissions'
import { aiSessionsApi } from '@/api/aiSessions'
import { Skeleton } from '@/components/ui/Skeleton'
export function TeamSummary() {
const { isAccountOwner } = usePermissions()
const navigate = useNavigate()
const [escalationCount, setEscalationCount] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isAccountOwner) return
if (!isAccountOwner) { setLoading(false); return }
aiSessionsApi.getEscalationQueue()
.then((esc) => setEscalationCount(esc.length))
.catch(() => {})
.finally(() => setLoading(false))
}, [isAccountOwner])
if (!isAccountOwner) return null
@@ -38,23 +41,28 @@ export function TeamSummary() {
Manage <ArrowRight size={10} />
</button>
</div>
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
{items.map((item) => (
<div className="p-4 space-y-3">
{loading ? Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="h-4 w-4 rounded shrink-0" />
<Skeleton className="h-4 flex-1 max-w-28" />
<Skeleton className="h-5 w-8" />
</div>
)) : items.map((item) => (
<button
key={item.label}
onClick={() => navigate(item.href)}
className="flex flex-col items-center gap-2 py-5 rounded-lg hover:bg-card-hover transition-all duration-350"
style={{ transition: 'transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1), background 200ms ease' }}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)' }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)' }}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-[var(--color-bg-card-hover)] transition-colors group"
>
<item.icon size={20} style={{ color: item.color }} />
<p className="font-heading text-xl font-extrabold text-foreground">{item.value}</p>
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
<item.icon size={15} style={{ color: item.color }} className="shrink-0" />
<span className="flex-1 text-left text-[0.8125rem] text-muted-foreground group-hover:text-foreground transition-colors">
{item.label}
</p>
</span>
<span className="font-heading text-lg font-bold text-foreground tabular-nums">
{item.value}
</span>
</button>
))}
)))}
</div>
</div>
)

View File

@@ -10,14 +10,7 @@ import {
} from 'lucide-react'
import { notificationsApi } from '@/api/notifications'
import type { AppNotification } from '@/types/notification'
function timeAgo(dateStr: string): string {
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
import { timeAgo } from '@/lib/timeAgo'
function EventIcon({ event }: { event: string }) {
switch (event) {

View File

@@ -6,7 +6,7 @@ export function Skeleton({ className, ...props }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded-lg bg-brand-border',
'animate-pulse rounded-lg bg-[var(--color-border-default)]',
className
)}
{...props}

View File

@@ -0,0 +1,14 @@
/**
* Formats a date string as a relative time (e.g., "5m ago", "2h ago", "yesterday").
*/
export function timeAgo(dateStr: string): string {
const diffMs = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diffMs / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days === 1) return 'yesterday'
return `${days}d ago`
}

View File

@@ -6,7 +6,6 @@ import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotS
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
import { TeamSummary } from '@/components/dashboard/TeamSummary'
import { GreetingStatStrip } from '@/components/dashboard/GreetingStatStrip'
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
return (
@@ -37,18 +36,14 @@ export function QuickStartPage() {
<div className="overflow-y-auto h-full">
<PageMeta title="ResolutionFlow" />
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12">
{/* Hero: Greeting + Stat Strip */}
<div className="flex items-end justify-between mb-8 animate-fade-in-up">
<div>
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
{dayOfWeek}, {formattedDate}
</p>
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-[#f0f2f5] leading-tight">
Good {greeting},<br className="hidden sm:block" />
{firstName}.
</h1>
</div>
<GreetingStatStrip />
{/* Hero: Greeting */}
<div className="mb-8 animate-fade-in-up">
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
{dayOfWeek}, {formattedDate}
</p>
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-[#f0f2f5] leading-tight">
Good {greeting}, {firstName}.
</h1>
</div>
{/* Chat-style input */}