Skip to content

Commit c686f42

Browse files
refactor: extract findingsLevel/formatFindingCount/clampPercent dashboard helpers
Follow-up to the dashboard loop list/section restructure (#62): consolidate duplicated finding-severity, pluralization, and percentage-clamp logic into shared helpers, drop the unused formatUsageTotal helper, and remove dead CSS.
1 parent a3d0de9 commit c686f42

5 files changed

Lines changed: 99 additions & 96 deletions

File tree

‎src/dashboard/app-bundle.ts‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎src/dashboard/app/components.ts‎

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
deriveSidebarLabel,
99
formatSectionDuration,
1010
splitFindings,
11+
findingsLevel,
12+
formatFindingCount,
13+
clampPercent,
1114
formatFinding,
1215
formatModelUsage,
1316
formatTokenCount,
@@ -108,11 +111,7 @@ export function Sidebar(props: {
108111
// ── LoopTable ─────────────────────────────────────────────────────────────
109112

110113
function MiniMeter(props: { current: () => number; total: () => number }) {
111-
const pct = () => {
112-
const t = props.total()
113-
if (!t || t <= 0) return 0
114-
return Math.max(0, Math.min(100, (props.current() / t) * 100))
115-
}
114+
const pct = () => clampPercent(props.current(), props.total())
116115
return html`<span class="lt-meter-cell">
117116
<span class="lt-meter"><span class="lt-meter-fill" style=${() => 'width:' + pct() + '%'}></span></span>
118117
<span class="lt-meter-text">${() => props.current()}/${() => props.total()}</span>
@@ -147,7 +146,7 @@ function LoopTableRow(props: { dashLoop: DashboardLoop; onOpen: (name: string) =
147146
const c = counts()
148147
if (c.bugs.length === 0 && c.warnings.length === 0) return html`<span class="dim">—</span>`
149148
return html`<span>
150-
${c.bugs.length > 0 ? html`<span class="finding-bug">${c.bugs.length} bug${c.bugs.length === 1 ? '' : 's'}</span>` : ''}
149+
${c.bugs.length > 0 ? html`<span class="finding-bug">${formatFindingCount(c.bugs.length, 'bug')}</span>` : ''}
151150
${c.bugs.length > 0 && c.warnings.length > 0 ? ' · ' : ''}
152151
${c.warnings.length > 0 ? html`<span class="finding-warning">${c.warnings.length} warn</span>` : ''}
153152
</span>`
@@ -290,11 +289,7 @@ function LoopDetailStat(props: { label: string; value: () => string }) {
290289
// A progress bar with a count, clamped to 0–100%. Both current and total are
291290
// accessors so the fill width tracks live loop updates.
292291
function LoopDetailProgress(props: { label: string; current: () => number; total: () => number }) {
293-
const pct = () => {
294-
const total = props.total()
295-
if (!total || total <= 0) return 0
296-
return Math.max(0, Math.min(100, (props.current() / total) * 100))
297-
}
292+
const pct = () => clampPercent(props.current(), props.total())
298293
return html`<div class="ldh-bar-group">
299294
<div class="ldh-bar-head">
300295
<span class="ldh-bar-label">${props.label}</span>
@@ -310,30 +305,27 @@ function LoopDetailProgress(props: { label: string; current: () => number; total
310305
// title row, a labeled stat grid, iteration/section progress bars, and a
311306
// status-tinted outcome banner. Built once per selected loop; every dynamic
312307
// field is read reactively so it updates in place on polls.
313-
export function LoopDetailHeader(props: { dashLoop: DashboardLoop }) {
308+
export function LoopDetailHeader(props: {
309+
dashLoop: DashboardLoop
310+
split: () => { bugs: DashboardLoop['findings']; warnings: DashboardLoop['findings'] }
311+
}) {
314312
const lp = () => props.dashLoop.loop
315313
const dl = () => props.dashLoop
316314
const hasCompletedAt = createMemo(() => !!lp().completedAt)
317315
const hasDuration = createMemo(() => !!dl().duration)
318316
const hasSectionsTotal = createMemo(() => lp().totalSections > 0)
319317
const hasReason = createMemo(() => !!lp().terminationReason)
320-
const split = createMemo(() => splitFindings(props.dashLoop.findings))
321-
const findingsLevel = createMemo(() => {
322-
const s = split()
323-
if (s.bugs.length > 0) return 'bug'
324-
if (s.warnings.length > 0) return 'warn'
325-
return 'clean'
326-
})
318+
const level = createMemo(() => findingsLevel(props.split()))
327319
const hasUsage = createMemo(() => !!props.dashLoop.usage)
328320

329321
return html`<div class="loop-detail-header">
330-
<div class=${() => 'ldh-findings ldh-findings-' + findingsLevel()}>
322+
<div class=${() => 'ldh-findings ldh-findings-' + level()}>
331323
${() => {
332-
const s = split()
324+
const s = props.split()
333325
if (s.bugs.length === 0 && s.warnings.length === 0) return 'No findings'
334326
const parts = []
335-
if (s.bugs.length > 0) parts.push(s.bugs.length + (s.bugs.length === 1 ? ' bug' : ' bugs'))
336-
if (s.warnings.length > 0) parts.push(s.warnings.length + (s.warnings.length === 1 ? ' warning' : ' warnings'))
327+
if (s.bugs.length > 0) parts.push(formatFindingCount(s.bugs.length, 'bug'))
328+
if (s.warnings.length > 0) parts.push(formatFindingCount(s.warnings.length, 'warning'))
337329
return parts.join(' · ')
338330
}}
339331
</div>
@@ -459,7 +451,7 @@ export function LoopDetail(props: { dashLoop: DashboardLoop; onBack: () => void
459451
</div>
460452
461453
<!-- Header summary -->
462-
${LoopDetailHeader({ dashLoop: props.dashLoop })}
454+
${LoopDetailHeader({ dashLoop: props.dashLoop, split })}
463455
464456
<!-- Detail body -->
465457
<div class="loop-detail">

‎src/dashboard/app/helpers.ts‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,27 @@ export function splitFindings(
9797
return { bugs, warnings }
9898
}
9999

100+
/** Classifies split findings into the severity tier used for badge styling. */
101+
export function findingsLevel(split: {
102+
bugs: DashboardLoop['findings']
103+
warnings: DashboardLoop['findings']
104+
}): 'bug' | 'warn' | 'clean' {
105+
if (split.bugs.length > 0) return 'bug'
106+
if (split.warnings.length > 0) return 'warn'
107+
return 'clean'
108+
}
109+
110+
/** Pluralizes a finding count, e.g. (1, 'bug') -> '1 bug', (2, 'bug') -> '2 bugs'. */
111+
export function formatFindingCount(count: number, noun: string): string {
112+
return count + ' ' + noun + (count === 1 ? '' : 's')
113+
}
114+
115+
/** Clamps current/total to a 0–100 fill percentage, guarding non-positive totals. */
116+
export function clampPercent(current: number, total: number): number {
117+
if (!total || total <= 0) return 0
118+
return Math.max(0, Math.min(100, (current / total) * 100))
119+
}
120+
100121
export function formatFinding(f: DashboardLoop['findings'][number]): string {
101122
let text = f.file + ':' + f.line + ' — ' + f.description
102123
if (f.scenario) {
@@ -105,25 +126,6 @@ export function formatFinding(f: DashboardLoop['findings'][number]): string {
105126
return text
106127
}
107128

108-
export function formatUsageTotal(u: NonNullable<DashboardLoop['usage']>): string {
109-
return (
110-
'Total cost: $' +
111-
u.totalCost.toFixed(6) +
112-
', tokens: ' +
113-
u.totalInputTokens +
114-
' in / ' +
115-
u.totalOutputTokens +
116-
' out (reasoning: ' +
117-
u.totalReasoningTokens +
118-
', cache R: ' +
119-
u.totalCacheReadTokens +
120-
' W: ' +
121-
u.totalCacheWriteTokens +
122-
'), messages: ' +
123-
u.totalMessageCount
124-
)
125-
}
126-
127129
export function formatModelUsage(
128130
model: string,
129131
m: NonNullable<DashboardLoop['usage']>['byModel'][string],

‎src/dashboard/render.ts‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,25 +69,13 @@ export function renderDashboardHtml(): string {
6969
.status-cancelled { background: #6e7681; color: #fff; }
7070
.status-errored { background: #da3633; color: #fff; }
7171
.status-stalled { background: #d29922; color: #fff; }
72-
.loop-info { font-size: 0.85rem; color: #8b949e; flex: 1; }
73-
.loop-info strong { color: #c9d1d9; }
7472
.loop-detail { padding: 8px 12px 12px; border-top: 1px solid #30363d; font-size: 0.85rem; }
7573
.loop-detail h4 { color: #f0f6fc; margin: 8px 0 4px; font-size: 0.95rem; }
7674
.loop-detail h4:first-child { margin-top: 0; }
77-
.sections-group { display: flex; flex-direction: column; gap: 6px; }
78-
.section-item {
79-
border: 1px solid #21262d; border-left: 3px solid #30363d;
80-
border-radius: 4px; background: #0d1117; overflow: hidden;
81-
}
8275
.section-item-pending { border-left-color: #6e7681; }
8376
.section-item-in_progress { border-left-color: #1f6feb; }
8477
.section-item-completed { border-left-color: #3fb950; }
8578
.section-item-failed { border-left-color: #f85149; }
86-
.section-head {
87-
display: flex; align-items: center; gap: 8px;
88-
padding: 7px 10px; cursor: pointer; user-select: none;
89-
}
90-
.section-head:hover { background: #161b22; }
9179
.section-caret { color: #8b949e; font-size: 0.7rem; width: 10px; flex-shrink: 0; }
9280
.section-status {
9381
font-size: 0.66rem; font-weight: 600; text-transform: uppercase;

‎test/dashboard/app-helpers.test.ts‎

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
dataHash,
1010
deriveSidebarLabel,
1111
splitFindings,
12+
findingsLevel,
13+
formatFindingCount,
14+
clampPercent,
1215
formatFinding,
13-
formatUsageTotal,
1416
formatModelUsage,
1517
formatTokenCount,
1618
formatUsageCost,
@@ -386,63 +388,82 @@ describe('splitFindings', () => {
386388
})
387389

388390
// ---------------------------------------------------------------------------
389-
// formatFinding
391+
// findingsLevel
390392
// ---------------------------------------------------------------------------
391393

392-
describe('formatFinding', () => {
393-
test('formats with file:line — description', () => {
394-
const f = mockFinding({ file: 'src/util.ts', line: 15, description: 'Unused variable', scenario: null })
395-
expect(formatFinding(f)).toBe('src/util.ts:15 — Unused variable')
394+
describe('findingsLevel', () => {
395+
test('returns bug when any bug present', () => {
396+
expect(findingsLevel({ bugs: [mockFinding({ severity: 'bug' })], warnings: [] })).toBe('bug')
397+
expect(
398+
findingsLevel({ bugs: [mockFinding({ severity: 'bug' })], warnings: [mockFinding({ severity: 'warning' })] }),
399+
).toBe('bug')
396400
})
397401

398-
test('includes scenario in parentheses when present', () => {
399-
const f = mockFinding({ file: 'src/app.ts', line: 99, description: 'Memory leak', scenario: 'when data > 1MB' })
400-
expect(formatFinding(f)).toBe('src/app.ts:99 — Memory leak (when data > 1MB)')
402+
test('returns warn when only warnings present', () => {
403+
expect(findingsLevel({ bugs: [], warnings: [mockFinding({ severity: 'warning' })] })).toBe('warn')
401404
})
402405

403-
test('handles line number 0', () => {
404-
const f = mockFinding({ file: 'config.ts', line: 0, description: 'Syntax error', scenario: null })
405-
expect(formatFinding(f)).toBe('config.ts:0 — Syntax error')
406+
test('returns clean when no findings', () => {
407+
expect(findingsLevel({ bugs: [], warnings: [] })).toBe('clean')
406408
})
407409
})
408410

409411
// ---------------------------------------------------------------------------
410-
// formatUsageTotal
412+
// formatFindingCount
411413
// ---------------------------------------------------------------------------
412414

413-
describe('formatUsageTotal', () => {
414-
const usage: NonNullable<DashboardLoop['usage']> = {
415-
loopName: 'test-loop',
416-
totalCost: 1.234567,
417-
totalInputTokens: 100,
418-
totalOutputTokens: 50,
419-
totalReasoningTokens: 10,
420-
totalCacheReadTokens: 20,
421-
totalCacheWriteTokens: 5,
422-
totalMessageCount: 3,
423-
byModel: {},
424-
}
415+
describe('formatFindingCount', () => {
416+
test('singular for count of 1', () => {
417+
expect(formatFindingCount(1, 'bug')).toBe('1 bug')
418+
expect(formatFindingCount(1, 'warning')).toBe('1 warning')
419+
})
425420

426-
test('includes cost with 6 decimal places', () => {
427-
const result = formatUsageTotal(usage)
428-
expect(result).toContain('$1.234567')
421+
test('plural for counts other than 1', () => {
422+
expect(formatFindingCount(0, 'bug')).toBe('0 bugs')
423+
expect(formatFindingCount(2, 'warning')).toBe('2 warnings')
429424
})
425+
})
430426

431-
test('includes all token breakdowns', () => {
432-
const result = formatUsageTotal(usage)
433-
expect(result).toContain('tokens: 100 in / 50 out')
434-
expect(result).toContain('reasoning: 10')
435-
expect(result).toContain('cache R: 20')
436-
expect(result).toContain('W: 5')
427+
// ---------------------------------------------------------------------------
428+
// clampPercent
429+
// ---------------------------------------------------------------------------
430+
431+
describe('clampPercent', () => {
432+
test('returns 0 for non-positive or missing total', () => {
433+
expect(clampPercent(3, 0)).toBe(0)
434+
expect(clampPercent(3, -5)).toBe(0)
435+
expect(clampPercent(3, NaN)).toBe(0)
437436
})
438437

439-
test('includes message count', () => {
440-
const result = formatUsageTotal(usage)
441-
expect(result).toContain('messages: 3')
438+
test('computes percentage within range', () => {
439+
expect(clampPercent(1, 4)).toBe(25)
440+
expect(clampPercent(0, 10)).toBe(0)
442441
})
443442

444-
test('starts with Total cost', () => {
445-
expect(formatUsageTotal(usage)).toMatch(/^Total cost:/)
443+
test('clamps to 0–100 when out of range', () => {
444+
expect(clampPercent(10, 5)).toBe(100)
445+
expect(clampPercent(-2, 5)).toBe(0)
446+
})
447+
})
448+
449+
// ---------------------------------------------------------------------------
450+
// formatFinding
451+
// ---------------------------------------------------------------------------
452+
453+
describe('formatFinding', () => {
454+
test('formats with file:line — description', () => {
455+
const f = mockFinding({ file: 'src/util.ts', line: 15, description: 'Unused variable', scenario: null })
456+
expect(formatFinding(f)).toBe('src/util.ts:15 — Unused variable')
457+
})
458+
459+
test('includes scenario in parentheses when present', () => {
460+
const f = mockFinding({ file: 'src/app.ts', line: 99, description: 'Memory leak', scenario: 'when data > 1MB' })
461+
expect(formatFinding(f)).toBe('src/app.ts:99 — Memory leak (when data > 1MB)')
462+
})
463+
464+
test('handles line number 0', () => {
465+
const f = mockFinding({ file: 'config.ts', line: 0, description: 'Syntax error', scenario: null })
466+
expect(formatFinding(f)).toBe('config.ts:0 — Syntax error')
446467
})
447468
})
448469

0 commit comments

Comments
 (0)