Skip to content

Commit 507cf27

Browse files
committed
Redesign FinOps review UI
1 parent 5c0f4eb commit 507cf27

2 files changed

Lines changed: 609 additions & 168 deletions

File tree

packages/finops/src/web/app.ts

Lines changed: 90 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,6 @@ root.addEventListener('click', (event) => {
3939
if (!actionTarget) return
4040

4141
const action = actionTarget.dataset.action
42-
if (action === 'navigate-home') {
43-
navigate({ view: 'home' })
44-
}
45-
if (action === 'select-product') {
46-
navigate({ view: 'project', productId: actionTarget.dataset.productId ?? firstProductId() })
47-
}
4842
if (action === 'select-repo') {
4943
selectedRepo = actionTarget.dataset.repo ?? selectedRepo
5044
selectedEnvVar = ''
@@ -61,27 +55,60 @@ render()
6155
function render(): void {
6256
const summary = dashboard.companySummary[0] ?? emptySummary()
6357
const product = selectedProduct()
58+
const activeSummary = route.view === 'home' ? summary : product.summary
6459

6560
root.innerHTML = `
66-
<header class="topbar">
67-
<div class="brand-block">
68-
<p class="eyebrow">wanman FinOps</p>
69-
<h1>${escapeHtml(dashboard.company.name ?? dashboard.company.id)}</h1>
61+
<a class="skip-link" href="#main">Skip to main content</a>
62+
<div class="app-frame">
63+
<aside class="sidebar" aria-label="FinOps navigation">
64+
<div class="brand-block">
65+
<p class="eyebrow">wanman FinOps</p>
66+
<h1>${escapeHtml(dashboard.company.name ?? dashboard.company.id)}</h1>
67+
</div>
68+
<div class="status-pill">
69+
<span>Sanitized demo data</span>
70+
<strong>${dashboard.inventory.references.length} credential refs / no key values</strong>
71+
</div>
72+
<nav class="view-nav" aria-label="FinOps views">
73+
<a class="nav-item${route.view === 'home' ? ' is-active' : ''}" href="${routeToHash({ view: 'home' })}"${route.view === 'home' ? ' aria-current="page"' : ''}>
74+
<span>Dashboard</span>
75+
<small>Company rollup</small>
76+
</a>
77+
${dashboard.products.map((item) => {
78+
const active = route.view === 'project' && item.productId === product.productId
79+
return `
80+
<a class="nav-item${active ? ' is-active' : ''}" href="${routeToHash({ view: 'project', productId: item.productId })}"${active ? ' aria-current="page"' : ''}>
81+
<span>${escapeHtml(item.name)}</span>
82+
<small>${formatMoney(item.summary.grossProfit, item.summary.currency)} gross profit</small>
83+
</a>
84+
`
85+
}).join('')}
86+
</nav>
87+
<div class="sidebar-note">
88+
<small>Pricing feeds</small>
89+
<strong>${dashboard.pricing.sources.filter((source) => source.ok).length}/${dashboard.pricing.sources.length} healthy</strong>
90+
</div>
91+
</aside>
92+
<div class="workspace">
93+
<header class="topbar">
94+
<div>
95+
<p class="eyebrow">${route.view === 'home' ? 'Company console' : 'Project console'}</p>
96+
<h2>${route.view === 'home' ? 'Cost, revenue, and ROI control plane' : escapeHtml(product.name)}</h2>
97+
</div>
98+
<div class="topbar-metrics" aria-label="Current view totals">
99+
<span><small>Revenue</small><strong>${formatMoney(activeSummary.revenue, activeSummary.currency)}</strong></span>
100+
<span><small>Cost</small><strong>${formatMoney(activeSummary.cost, activeSummary.currency)}</strong></span>
101+
<span><small>ROI</small><strong>${formatRoi(activeSummary.roi)}</strong></span>
102+
</div>
103+
</header>
104+
<main id="main" class="shell" tabindex="-1">
105+
${route.view === 'home' ? homeView(summary) : projectView(product)}
106+
</main>
70107
</div>
71-
<div class="status-pill">Sanitized demo data · ${dashboard.inventory.references.length} credential refs · no key values</div>
72-
</header>
73-
<nav class="view-nav" aria-label="FinOps views">
74-
<button class="nav-item${route.view === 'home' ? ' is-active' : ''}" data-action="navigate-home">Dashboard</button>
75-
${dashboard.products.map((item) => `
76-
<button class="nav-item${route.view === 'project' && item.productId === product.productId ? ' is-active' : ''}" data-action="select-product" data-product-id="${escapeAttr(item.productId)}">
77-
${escapeHtml(item.name)}
78-
</button>
79-
`).join('')}
80-
</nav>
81-
<main class="shell">
82-
${route.view === 'home' ? homeView(summary) : projectView(product)}
83-
</main>
108+
</div>
84109
`
110+
111+
root.querySelector('.view-nav .is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
85112
}
86113

87114
function homeView(summary: AccountingSummary): string {
@@ -146,7 +173,7 @@ function projectView(product: ProductDashboardSummary): string {
146173
return `
147174
<section class="project-heading">
148175
<div>
149-
<p class="eyebrow">Project View</p>
176+
<p class="eyebrow"><a href="${routeToHash({ view: 'home' })}">Dashboard</a> / Project View</p>
150177
<h2>${escapeHtml(product.name)}</h2>
151178
<p>${escapeHtml(product.description ?? 'Sanitized FinOps project')}</p>
152179
</div>
@@ -237,14 +264,27 @@ function profitabilityChart(points: ProfitabilityTrendPoint[], currency: string)
237264
const profitPath = buildLineChartPath(points.map((point) => point.grossProfit), dimensions, domain)
238265
const zeroY = zeroLineY(domain, dimensions)
239266
const latest = points.at(-1)
267+
const gridLines = [0.25, 0.5, 0.75].map((ratio) => {
268+
const y = dimensions.padding + ratio * (dimensions.height - dimensions.padding * 2)
269+
return `<line x1="${dimensions.padding}" y1="${y}" x2="${dimensions.width - dimensions.padding}" y2="${y}" class="chart-gridline" />`
270+
}).join('')
271+
const revenueMarkers = chartMarkers(points.map((point) => point.revenue), dimensions, domain, 'revenue-marker')
272+
const costMarkers = chartMarkers(points.map((point) => point.cost), dimensions, domain, 'cost-marker')
273+
const profitMarkers = chartMarkers(points.map((point) => point.grossProfit), dimensions, domain, 'profit-marker')
240274

241275
return `
242276
<div class="chart-wrap">
243-
<svg class="trend-chart" viewBox="0 0 ${dimensions.width} ${dimensions.height}" role="img" aria-label="Revenue cost and gross profit trend">
277+
<svg class="trend-chart" viewBox="0 0 ${dimensions.width} ${dimensions.height}" role="img" aria-labelledby="profitability-chart-title profitability-chart-desc">
278+
<title id="profitability-chart-title">Revenue, cost, and gross profit trend</title>
279+
<desc id="profitability-chart-desc">Line chart comparing revenue, provider cost, and gross profit across the selected accounting periods.</desc>
280+
${gridLines}
244281
<line x1="${dimensions.padding}" y1="${zeroY}" x2="${dimensions.width - dimensions.padding}" y2="${zeroY}" class="zero-line" />
245282
<path d="${revenuePath}" class="chart-line revenue-line" />
246283
<path d="${costPath}" class="chart-line cost-line" />
247284
<path d="${profitPath}" class="chart-line profit-line" />
285+
${revenueMarkers}
286+
${costMarkers}
287+
${profitMarkers}
248288
</svg>
249289
<div class="chart-legend">
250290
${legendItem('Revenue', 'revenue')}
@@ -269,10 +309,32 @@ function zeroLineY(domain: { min: number, max: number }, dimensions: { height: n
269309
return Math.round(y * 100) / 100
270310
}
271311

312+
function chartMarkers(
313+
values: number[],
314+
dimensions: { width: number, height: number, padding: number },
315+
domain: { min: number, max: number },
316+
className: string,
317+
): string {
318+
const innerWidth = dimensions.width - dimensions.padding * 2
319+
const innerHeight = dimensions.height - dimensions.padding * 2
320+
const xStep = values.length > 1 ? innerWidth / (values.length - 1) : 0
321+
const range = domain.max - domain.min || 1
322+
323+
return values.map((value, index) => {
324+
const x = dimensions.padding + index * xStep
325+
const y = dimensions.padding + (1 - ((value - domain.min) / range)) * innerHeight
326+
return `<circle cx="${roundChartValue(x)}" cy="${roundChartValue(y)}" r="3.4" class="chart-marker ${className}" />`
327+
}).join('')
328+
}
329+
330+
function roundChartValue(value: number): number {
331+
return Math.round(value * 100) / 100
332+
}
333+
272334
function portfolioRow(product: ProductDashboardSummary): string {
273335
const latest = product.profitabilityTrend.at(-1)
274336
return `
275-
<button class="portfolio-row" data-action="select-product" data-product-id="${escapeAttr(product.productId)}">
337+
<a class="portfolio-row" href="${routeToHash({ view: 'project', productId: product.productId })}" aria-label="Open ${escapeAttr(product.name)} project">
276338
<span>
277339
<strong>${escapeHtml(product.name)}</strong>
278340
<small>${escapeHtml(product.owner ?? 'Demo')} · ${escapeHtml(product.lifecycle ?? 'Demo')}</small>
@@ -281,7 +343,7 @@ function portfolioRow(product: ProductDashboardSummary): string {
281343
<b>${formatMoney(product.summary.grossProfit, product.summary.currency)}</b>
282344
<small>${latest ? `${formatPeriod(latest.period)} ${formatRoi(latest.roi)}` : formatRoi(product.summary.roi)}</small>
283345
</span>
284-
</button>
346+
</a>
285347
`
286348
}
287349

@@ -364,19 +426,6 @@ function normalizeRoute(next: FinopsRoute): FinopsRoute {
364426
return { view: 'home' }
365427
}
366428

367-
function navigate(next: FinopsRoute): void {
368-
const normalized = normalizeRoute(next)
369-
const hash = routeToHash(normalized)
370-
if (window.location.hash === hash) {
371-
route = normalized
372-
selectedRepo = ''
373-
selectedEnvVar = ''
374-
render()
375-
return
376-
}
377-
window.location.hash = hash
378-
}
379-
380429
function firstProductId(): string {
381430
return dashboard.products[0]?.productId ?? ''
382431
}
@@ -517,7 +566,7 @@ function metadataPill(label: string, value: string): string {
517566
}
518567

519568
function legendItem(label: string, tone: 'revenue' | 'cost' | 'profit'): string {
520-
return `<span class="legend-item ${tone}"><i></i>${escapeHtml(label)}</span>`
569+
return `<span class="legend-item ${tone}"><i aria-hidden="true"></i>${escapeHtml(label)}</span>`
521570
}
522571

523572
function trendRange(points: ProfitabilityTrendPoint[]): string {

0 commit comments

Comments
 (0)