Skip to content

Commit 5d37677

Browse files
committed
fix(web): fix QA bugs and align dashboard/detail with R2 mockup
Fixes: - Session replay crash: extract nested scorecard from API wrapper - Topbar logo: use React Router Link instead of <a href> - Sign-out: navigate to / before logout to avoid OAuth redirect - SkillSelector: remove nested <button> (use <span> for Run) - Add ErrorBoundary wrapping the entire app Design alignment: - ActivityChart: div-based flex bars matching mockup (3px gap, 120px height, accent color with active highlight, 30-day fill, axis labels) - InstallationList: avatar + 2-column status grid + action buttons - InstallationDetail: completion bar with stacked segments + legend, grid-based session table with proper columns, filter buttons
1 parent 4e8d2dc commit 5d37677

9 files changed

Lines changed: 287 additions & 198 deletions

File tree

apps/web/src/app.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
22
import { useAuthStore } from './features/auth/store'
33
import OAuthCallback from './features/auth/OAuthCallback'
44
import ProtectedRoute from './features/auth/ProtectedRoute'
5-
import { AppShell } from './shared/components'
5+
import { AppShell, ErrorBoundary } from './shared/components'
66
import InstallationList from './features/dashboard/InstallationList'
77
import InstallationDetail from './features/dashboard/InstallationDetail'
88
import SessionReplay from './features/dashboard/SessionReplay'
@@ -19,6 +19,7 @@ function AuthRedirect() {
1919

2020
export default function App() {
2121
return (
22+
<ErrorBoundary>
2223
<BrowserRouter>
2324
<Routes>
2425
{/* Public routes — no shell */}
@@ -34,5 +35,6 @@ export default function App() {
3435
<Route path="/session/:installationId/*" element={<ProtectedRoute><AppShell><SessionView /></AppShell></ProtectedRoute>} />
3536
</Routes>
3637
</BrowserRouter>
38+
</ErrorBoundary>
3739
)
3840
}
Lines changed: 48 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* ActivityChart — simple SVG bar chart for session activity over 30 days.
3-
* No external charting library — just proportional <rect> elements.
2+
* ActivityChart — flex-based bar chart matching the R2 redesign mockup.
3+
* 30 bars with 3px gap, 120px height, accent color with active highlight.
44
*/
55

66
interface DailyCount {
@@ -12,63 +12,56 @@ interface ActivityChartProps {
1212
data: DailyCount[]
1313
}
1414

15-
export default function ActivityChart({ data }: ActivityChartProps) {
16-
if (data.length === 0) {
17-
return (
18-
<div className="text-dim text-xs font-mono py-4 text-center">
19-
// no sessions in the last 30 days
20-
</div>
21-
)
15+
/** Fill sparse data to 30 consecutive days ending today. */
16+
function fillToThirtyDays(data: DailyCount[]): DailyCount[] {
17+
const map = new Map(data.map((d) => [d.date.slice(0, 10), d.count]))
18+
const result: DailyCount[] = []
19+
const today = new Date()
20+
for (let i = 29; i >= 0; i--) {
21+
const d = new Date(today)
22+
d.setDate(d.getDate() - i)
23+
const key = d.toISOString().slice(0, 10)
24+
result.push({ date: key, count: map.get(key) ?? 0 })
2225
}
26+
return result
27+
}
2328

24-
const maxCount = Math.max(...data.map((d) => d.count), 1)
25-
const barCount = data.length
26-
const chartWidth = 100
27-
const chartHeight = 80
28-
const barGap = 1
29-
const barWidth = Math.max(1, (chartWidth - barGap * (barCount - 1)) / barCount)
29+
export default function ActivityChart({ data }: ActivityChartProps) {
30+
const days = fillToThirtyDays(data)
31+
const maxCount = Math.max(...days.map((d) => d.count), 1)
3032

3133
return (
32-
<svg
33-
viewBox={`0 0 ${chartWidth} ${chartHeight + 16}`}
34-
className="w-full"
35-
style={{ height: '100px' }}
36-
preserveAspectRatio="none"
37-
>
38-
{data.map((d, i) => {
39-
const barHeight = Math.max(1, (d.count / maxCount) * chartHeight)
40-
const x = i * (barWidth + barGap)
41-
const y = chartHeight - barHeight
34+
<div>
35+
<div className="flex items-end gap-[3px]" style={{ height: 120 }}>
36+
{days.map((d, i) => {
37+
const heightPx = d.count === 0 ? 2 : Math.max(4, (d.count / maxCount) * 110)
38+
const isRecent = i >= 23
39+
const isEmpty = d.count === 0
4240

43-
return (
44-
<g key={d.date}>
45-
<rect
46-
x={x}
47-
y={y}
48-
width={barWidth}
49-
height={barHeight}
50-
rx={0.5}
51-
fill="var(--color-accent)"
52-
opacity={0.65}
53-
>
54-
<title>{`${d.date}: ${d.count} session${d.count !== 1 ? 's' : ''}`}</title>
55-
</rect>
56-
{/* Show label every 7 bars */}
57-
{i % 7 === 0 && (
58-
<text
59-
x={x + barWidth / 2}
60-
y={chartHeight + 10}
61-
textAnchor="middle"
62-
fill="var(--color-dim)"
63-
fontSize="3"
64-
fontFamily="var(--font-family-mono)"
65-
>
66-
{new Date(d.date).toLocaleDateString('en', { month: 'short', day: 'numeric' })}
67-
</text>
68-
)}
69-
</g>
70-
)
71-
})}
72-
</svg>
41+
return (
42+
<div
43+
key={d.date}
44+
className="flex-1 rounded-[2px] transition-opacity hover:opacity-100"
45+
style={{
46+
height: heightPx,
47+
backgroundColor: isEmpty
48+
? 'var(--color-rule)'
49+
: isRecent
50+
? 'var(--color-accent)'
51+
: 'rgba(226,160,57,0.35)',
52+
opacity: isEmpty ? 0.5 : isRecent ? 1 : 0.85,
53+
}}
54+
title={`${d.date}: ${d.count} session${d.count !== 1 ? 's' : ''}`}
55+
/>
56+
)
57+
})}
58+
</div>
59+
<div className="flex justify-between mt-2 font-mono text-[10px] text-dim2 tracking-[0.12em]">
60+
<span>30D AGO</span>
61+
<span>15D</span>
62+
<span>7D</span>
63+
<span>TODAY</span>
64+
</div>
65+
</div>
7366
)
7467
}

apps/web/src/features/dashboard/InstallationDetail.tsx

Lines changed: 104 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
/**
2-
* InstallationDetail — session history list for a single installation.
2+
* InstallationDetail — session history for a single installation.
3+
* Matches R2 redesign: completion bar, grid table, filter buttons.
34
*/
45

56
import { useState, useEffect, useCallback } from 'react'
6-
import { useParams, useNavigate, Link } from 'react-router'
7+
import { useParams, useNavigate } from 'react-router'
78
import { fetchInstallationSessions } from './dashboardApi'
8-
import type { SessionSummary, PaginatedSessionsResponse } from './dashboardApi'
9-
import StatusBadge from './StatusBadge'
9+
import type { PaginatedSessionsResponse } from './dashboardApi'
1010
import { formatDuration, formatRelativeTime } from './formatters'
11-
import { Button, Card, Chip, Dot, Overline } from '../../shared/components'
11+
import { Button, Chip, Dot, Overline } from '../../shared/components'
1212

1313
const STATUS_OPTIONS = ['all', 'completed', 'failed', 'running', 'pending', 'timeout'] as const
1414

1515
export default function InstallationDetail() {
1616
const { installationId } = useParams<{ installationId: string }>()
1717
const navigate = useNavigate()
18-
const [sessions, setSessions] = useState<SessionSummary[]>([])
18+
const [sessions, setSessions] = useState<PaginatedSessionsResponse['items']>([])
1919
const [page, setPage] = useState(1)
2020
const [totalPages, setTotalPages] = useState(0)
2121
const [total, setTotal] = useState(0)
@@ -28,7 +28,7 @@ export default function InstallationDetail() {
2828
setLoading(true)
2929
setError(null)
3030
try {
31-
const data: PaginatedSessionsResponse = await fetchInstallationSessions(
31+
const data = await fetchInstallationSessions(
3232
Number(installationId),
3333
page,
3434
20,
@@ -51,57 +51,63 @@ export default function InstallationDetail() {
5151
setPage(1)
5252
}
5353

54+
// Completion stats from loaded sessions (approximation from current page)
55+
const completed = sessions.filter((s) => s.status === 'completed').length
56+
const failed = sessions.filter((s) => s.status === 'failed').length
57+
const timeout = sessions.filter((s) => s.status === 'timeout').length
58+
const completionPct = total > 0 ? Math.round((completed / Math.max(sessions.length, 1)) * 100) : 0
59+
5460
return (
5561
<div>
56-
{/* Breadcrumb */}
57-
<div className="flex items-center gap-2 mb-6 text-sm">
58-
<Link to="/installations" className="text-dim hover:text-ink2 transition-colors font-mono">
59-
&larr; Installations
60-
</Link>
61-
<span className="text-dim2">|</span>
62-
<span className="text-ink font-mono font-medium">
63-
Installation #{installationId}
64-
</span>
65-
<div className="ml-auto">
66-
<Link
67-
to={`/installations/${installationId}/settings`}
68-
className="text-dim text-xs font-mono hover:text-ink2 transition-colors"
69-
>
70-
Settings
71-
</Link>
72-
</div>
73-
</div>
74-
75-
{/* Title + filter */}
76-
<div className="flex items-center justify-between mb-6">
62+
{/* Header + filters */}
63+
<div className="flex justify-between items-end mb-5">
7764
<div>
78-
<h1 className="font-mono text-lg font-bold">
79-
Session History
80-
{!loading && (
81-
<span className="text-dim text-sm font-normal ml-2">({total})</span>
82-
)}
83-
</h1>
84-
<Overline className="mt-1">// every time Claude ran against one of your PRs</Overline>
65+
<Overline className="mb-1.5">{'\u25b8'} SESSIONS {'\u00b7'} {total} TOTAL</Overline>
66+
<h1 className="font-mono text-2xl font-bold tracking-[-0.02em]">Session History</h1>
67+
<p className="font-mono text-sm text-dim mt-1">// every time Claude ran against one of your PRs</p>
8568
</div>
86-
87-
<select
88-
value={statusFilter}
89-
onChange={(e) => handleStatusChange(e.target.value)}
90-
className="font-mono text-xs px-3 py-1.5 rounded-button bg-card border border-rule text-ink cursor-pointer outline-none"
91-
>
69+
<div className="flex gap-2">
9270
{STATUS_OPTIONS.map((opt) => (
93-
<option key={opt} value={opt} className="bg-card">
94-
{opt.charAt(0).toUpperCase() + opt.slice(1)}
95-
</option>
71+
<button
72+
key={opt}
73+
onClick={() => handleStatusChange(opt)}
74+
className={`font-mono text-[13px] font-medium px-4 py-2 rounded-[7px] border transition-colors cursor-pointer ${
75+
statusFilter === opt
76+
? 'bg-accent/15 border-accent/30 text-accent'
77+
: 'bg-card border-rule-str text-ink hover:bg-card-hi'
78+
}`}
79+
>
80+
{opt === 'all' ? 'Status: all' : opt.charAt(0).toUpperCase() + opt.slice(1)}
81+
</button>
9682
))}
97-
</select>
83+
</div>
9884
</div>
9985

86+
{/* Completion bar */}
87+
{!loading && sessions.length > 0 && (
88+
<div className="flex items-center gap-5 px-4 py-3.5 bg-card border border-rule rounded-card mb-5">
89+
<div className="flex items-baseline gap-1.5">
90+
<span className="font-mono text-[22px] font-bold">{completionPct}%</span>
91+
<span className="font-mono text-[10px] text-dim tracking-[0.12em] uppercase">completion</span>
92+
</div>
93+
<div className="flex-1 h-1.5 bg-bg2 rounded-full flex gap-0.5 overflow-hidden">
94+
{completed > 0 && <div className="bg-ok" style={{ flex: completed }} />}
95+
{timeout > 0 && <div className="bg-warn" style={{ flex: timeout }} />}
96+
{failed > 0 && <div className="bg-danger" style={{ flex: failed }} />}
97+
</div>
98+
<div className="flex gap-4 font-mono text-[11px] text-dim">
99+
<span><Dot color="ok" className="mr-1" />{completed} done</span>
100+
<span><Dot color="warn" className="mr-1" />{timeout} timeout</span>
101+
<span><Dot color="danger" className="mr-1" />{failed} failed</span>
102+
</div>
103+
</div>
104+
)}
105+
100106
{/* Error */}
101107
{error && (
102-
<Card className="mb-6 border-danger/30">
103-
<p className="text-danger text-sm">{error}</p>
104-
</Card>
108+
<div className="mb-4 px-4 py-3 bg-danger/8 border border-danger/20 rounded-card text-danger text-sm">
109+
{error}
110+
</div>
105111
)}
106112

107113
{/* Loading */}
@@ -113,39 +119,64 @@ export default function InstallationDetail() {
113119

114120
{/* Empty */}
115121
{!loading && !error && sessions.length === 0 && (
116-
<Card className="text-center py-12">
122+
<div className="text-center py-12 bg-card border border-rule rounded-card">
117123
<p className="text-ink2 text-sm">
118124
{statusFilter === 'all' ? 'No sessions yet.' : `No ${statusFilter} sessions.`}
119125
</p>
120-
</Card>
126+
</div>
121127
)}
122128

123129
{/* Session table */}
124130
{!loading && sessions.length > 0 && (
125131
<>
126-
<div className="rounded-card border border-rule overflow-hidden">
127-
{sessions.map((session, idx) => (
128-
<button
129-
key={session.id}
130-
onClick={() => navigate(`/installations/${installationId}/sessions/${session.id}`)}
131-
className={`w-full text-left px-5 py-3.5 flex items-center gap-4 transition-colors cursor-pointer hover:bg-card-hi ${idx % 2 === 0 ? 'bg-card/50' : 'bg-transparent'} ${idx < sessions.length - 1 ? 'border-b border-rule/50' : ''}`}
132-
>
133-
<div className="flex-1 min-w-0">
134-
<span className="text-ink text-[13px]">{session.repo_full_name}</span>
135-
<span className="text-dim text-xs ml-1.5">#{session.pr_number}</span>
136-
</div>
137-
<Chip variant="accent">{session.skill_name}</Chip>
138-
<div className="shrink-0 w-20 text-center">
139-
<StatusBadge status={session.status} />
140-
</div>
141-
<span className="text-dim text-xs font-mono shrink-0 w-16 text-right">
142-
{formatDuration(session.started_at, session.completed_at)}
143-
</span>
144-
<span className="text-dim text-xs shrink-0 w-16 text-right">
145-
{formatRelativeTime(session.created_at)}
146-
</span>
147-
</button>
148-
))}
132+
<div className="border border-rule-str rounded-card overflow-hidden">
133+
{/* Header row */}
134+
<div
135+
className="grid bg-bg2 px-4 py-2.5 font-mono text-[10px] text-dim tracking-[0.18em] uppercase border-b border-rule"
136+
style={{ gridTemplateColumns: '36px 1fr 140px 110px 80px 80px' }}
137+
>
138+
<span>#</span>
139+
<span>REPO / PR</span>
140+
<span>SKILL</span>
141+
<span>STATUS</span>
142+
<span className="text-right">DURATION</span>
143+
<span className="text-right">RAN</span>
144+
</div>
145+
146+
{/* Data rows */}
147+
{sessions.map((session, idx) => {
148+
const statusColor =
149+
session.status === 'completed' ? 'ok' as const :
150+
session.status === 'timeout' ? 'warn' as const :
151+
session.status === 'failed' ? 'danger' as const :
152+
'default' as const
153+
154+
return (
155+
<button
156+
key={session.id}
157+
onClick={() => navigate(`/installations/${installationId}/sessions/${session.id}`)}
158+
className={`w-full grid items-center px-4 py-3 text-[13px] transition-colors cursor-pointer hover:bg-card-hi ${
159+
idx < sessions.length - 1 ? 'border-b border-rule' : ''
160+
}`}
161+
style={{ gridTemplateColumns: '36px 1fr 140px 110px 80px 80px' }}
162+
>
163+
<span className="font-mono text-[11px] text-dim2">
164+
{String(idx + 1 + (page - 1) * 20).padStart(2, '0')}
165+
</span>
166+
<span className="font-mono text-ink truncate">
167+
{session.repo_full_name} <span className="text-accent">#{session.pr_number}</span>
168+
</span>
169+
<span><Chip variant="accent">{session.skill_name}</Chip></span>
170+
<span><Chip variant={statusColor}>{session.status}</Chip></span>
171+
<span className="font-mono text-xs text-ink2 text-right">
172+
{formatDuration(session.started_at, session.completed_at)}
173+
</span>
174+
<span className="font-mono text-xs text-dim text-right">
175+
{formatRelativeTime(session.created_at)}
176+
</span>
177+
</button>
178+
)
179+
})}
149180
</div>
150181

151182
{/* Pagination */}

0 commit comments

Comments
 (0)