feat: add sidebar API client, stats bar, activity feed components

New components: SidebarStatsBar, SidebarActivityFeed, ActivityItem.
New API client for sidebar stats endpoint. Pulse-dot CSS animation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-15 15:13:56 -04:00
parent 87f1d17699
commit c1f36ce375
6 changed files with 294 additions and 0 deletions

View File

@@ -23,3 +23,4 @@ export { flowTransferApi } from './flowTransfer'
export { kbAcceleratorApi } from './kbAccelerator'
export { scriptsApi } from './scripts'
export { integrationsApi, sessionPsaApi } from './integrations'
export { sidebarApi } from './sidebar'

View File

@@ -0,0 +1,45 @@
import apiClient from './client'
export interface SidebarActiveSession {
session_id: string
tree_name: string
tree_id: string
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
started_at: string
ticket_number: string | null
psa_ticket_id: string | null
}
export interface SidebarRecentSession {
session_id: string
tree_name: string
tree_id: string
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
completed_at: string
}
export interface SidebarTreeCounts {
total: number
troubleshooting: number
procedural: number
maintenance: number
}
export interface SidebarStatsResponse {
resolved_today: number
active_count: number
total_session_minutes_today: number
tree_counts: SidebarTreeCounts
active_sessions: SidebarActiveSession[]
recent_completions: SidebarRecentSession[]
}
export const sidebarApi = {
getStats: async (): Promise<SidebarStatsResponse> => {
const tzOffset = new Date().getTimezoneOffset()
const response = await apiClient.get<SidebarStatsResponse>(
`/sessions/sidebar-stats?tz_offset=${tzOffset}`
)
return response.data
},
}

View File

@@ -0,0 +1,105 @@
import { useNavigate } from 'react-router-dom'
import { getTreeNavigatePath } from '@/lib/routing'
import { cn } from '@/lib/utils'
interface ActivityItemProps {
sessionId: string
treeName: string
treeId: string
treeType: 'troubleshooting' | 'procedural' | 'maintenance'
status: 'active' | 'paused' | 'recent'
ticketNumber?: string | null
timestamp?: string | null
}
function formatRelativeTime(dateString: string): string {
const now = Date.now()
const then = new Date(dateString).getTime()
const diffMinutes = Math.floor((now - then) / 60000)
if (diffMinutes < 1) return 'just now'
if (diffMinutes < 60) return `${diffMinutes}m ago`
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `${diffHours}h ago`
return 'yesterday'
}
export function ActivityItem({
sessionId,
treeName,
treeId,
treeType,
status,
ticketNumber,
timestamp,
}: ActivityItemProps) {
const navigate = useNavigate()
const handleClick = () => {
navigate(getTreeNavigatePath(treeId, treeType), {
state: { sessionId },
})
}
const isRecent = status === 'recent'
return (
<button
onClick={handleClick}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors',
'hover:bg-[rgba(255,255,255,0.03)]',
isRecent ? 'text-[#6b7280] text-[0.72rem]' : 'text-[#e2e8f0] text-[0.8rem]'
)}
title={`${treeName}${ticketNumber ? ` (${ticketNumber})` : ''} — click to resume`}
aria-label={
status === 'active'
? `Active session: ${treeName}`
: status === 'paused'
? `Paused session: ${treeName}`
: `Recent session: ${treeName}`
}
>
{/* Status dot */}
{status === 'active' && (
<span
className="h-[7px] w-[7px] shrink-0 rounded-full"
style={{
background: '#34d399',
boxShadow: '0 0 6px rgba(52,211,153,0.5)',
animation: 'pulse-dot 2s ease-in-out infinite',
}}
aria-label="Active session"
/>
)}
{status === 'paused' && (
<span
className="h-[7px] w-[7px] shrink-0 rounded-full"
style={{
background: '#f59e0b',
boxShadow: '0 0 4px rgba(245,158,11,0.3)',
}}
aria-label="Paused session"
/>
)}
{status === 'recent' && (
<span className="h-1 w-1 shrink-0 rounded-full bg-[#3d4350]" />
)}
{/* Flow name */}
<span className="flex-1 truncate">{treeName}</span>
{/* Ticket number or timestamp */}
{ticketNumber && !isRecent && (
<span className="shrink-0 font-label text-[0.5625rem] text-[#60a5fa]">
{ticketNumber}
</span>
)}
{isRecent && timestamp && (
<span className="shrink-0 font-label text-[0.5625rem] text-[#5a6170]">
{formatRelativeTime(timestamp)}
</span>
)}
</button>
)
}

View File

@@ -0,0 +1,80 @@
import { Clock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { ActivityItem } from './ActivityItem'
import type { SidebarActiveSession, SidebarRecentSession } from '@/api/sidebar'
interface SidebarActivityFeedProps {
activeSessions: SidebarActiveSession[]
recentCompletions: SidebarRecentSession[]
totalActive: number
}
export function SidebarActivityFeed({
activeSessions,
recentCompletions,
totalActive,
}: SidebarActivityFeedProps) {
const navigate = useNavigate()
const hasActivity = activeSessions.length > 0 || recentCompletions.length > 0
return (
<div className="px-3 pb-1">
{/* Header */}
<div className="flex items-center gap-1.5 px-2.5 py-1 mb-0.5">
<Clock size={10} style={{ color: '#34d399' }} />
<span className="font-label text-[0.5625rem] uppercase tracking-[0.12em] text-[#5a6170]">
Activity
</span>
</div>
{!hasActivity ? (
<p className="px-2.5 py-2 text-xs text-muted-foreground">
No activity today
</p>
) : (
<div className="space-y-0.5">
{/* Active sessions */}
{activeSessions.map((session) => (
<ActivityItem
key={session.session_id}
sessionId={session.session_id}
treeName={session.tree_name}
treeId={session.tree_id}
treeType={session.tree_type}
status="active"
ticketNumber={session.ticket_number || session.psa_ticket_id}
/>
))}
{/* Overflow link */}
{totalActive > 5 && (
<button
onClick={() => navigate('/sessions')}
className="w-full px-2.5 py-1 text-left text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors"
>
View all in Sessions
</button>
)}
{/* Divider between active and recent */}
{activeSessions.length > 0 && recentCompletions.length > 0 && (
<div className="mx-2.5 my-1" style={{ height: '1px', background: 'rgba(255,255,255,0.03)' }} />
)}
{/* Recent completions */}
{recentCompletions.map((session) => (
<ActivityItem
key={session.session_id}
sessionId={session.session_id}
treeName={session.tree_name}
treeId={session.tree_id}
treeType={session.tree_type}
status="recent"
timestamp={session.completed_at}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
interface SidebarStatsBarProps {
resolved: number
active: number
sessionMinutes: number
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes}m`
const h = Math.floor(minutes / 60)
const m = minutes % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
export function SidebarStatsBar({ resolved, active, sessionMinutes }: SidebarStatsBarProps) {
return (
<div
className="flex gap-0.5 px-3 pt-2 pb-1"
role="group"
aria-label="Today's stats"
>
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
<div
className="font-label text-sm font-semibold leading-none"
style={{ color: '#34d399' }}
aria-label={`${resolved} resolved today`}
>
{resolved}
</div>
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
Resolved
</div>
</div>
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
<div
className="font-label text-sm font-semibold leading-none"
style={{ color: '#22d3ee' }}
aria-label={`${active} active sessions`}
>
{active}
</div>
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
Active
</div>
</div>
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
<div
className="font-label text-sm font-semibold leading-none text-muted-foreground"
aria-label={`${formatDuration(sessionMinutes)} in session today`}
>
{formatDuration(sessionMinutes)}
</div>
<div className="mt-1 font-label text-[7px] uppercase tracking-[0.1em] text-[#3d4350]">
In Session
</div>
</div>
</div>
)
}

View File

@@ -153,6 +153,11 @@
100% { transform: rotate(0deg); }
}
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 4px rgba(52,211,153,0.4); }
50% { box-shadow: 0 0 8px rgba(52,211,153,0.7); }
}
@keyframes stagger-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }