fabro/apps/fabro-web/app/lib/format.ts
Bryan Helmkamp 581ab41d28
feat(web): add Overview summary panel and promote PR to header pill (#299)
## Summary

Promotes the most useful per-run state into the Overview tab so users no
longer have to tab-hop to read the basics of a run.

- **Adds a horizontal summary panel** above the workflow graph (right
column only — does not span the stages sidebar) with five columns:
**Created by · Changes · Sandbox · Cost · Artifacts**. Quiet-uppercase
labels (`text-[10px] uppercase tracking-[0.08em] text-fg-muted`) over
regular-weight values. Skeleton loaders while queries are in flight; em
dash in muted color for missing/zero data.
- **Promotes the PR chip** out of the meta strip into a
`SECONDARY_BUTTON_CLASS`-style pill next to the Actions menu, visible on
every tab. Pill renders only when a PR exists.
- Lifts `formatBytesAsMemory`, `formatCpuCores`, `formatUsdMicros` to
`lib/format.ts` so the panel can reuse them.
- New `RunSummaryPanel` is split into a smart wrapper (owns the SWR
hooks) + a presentational `RunSummaryPanelView` (prop-driven) for clean
test seams.
- All 7 `Principal` kinds (user / agent / system / slack / webhook /
worker / anonymous) map to glyph + label; user kind uses login-initial
avatar.

## Screenshots

Captured against a real local Fabro server (`fabro server start`) on
demo runs — these only exercise the Created-by column (the other cells
display em dashes because the demo runs have no PR / diff / billing /
artifacts data). The em-dash states **are** the intended empty-state
design.

### Overview tab — full page

![Overview tab](https://files.catbox.moe/2idmv5.png)

### Header + tabs + summary panel close-up

![Header and panel](https://files.catbox.moe/bugbmy.png)

### Summary panel detail

![Summary panel](https://files.catbox.moe/4sbcl0.png)

> The PR pill (mint icon + `#number` next to Actions) is unverified
visually because no demo run on this server has an associated PR — but
the rendering path is the same `SECONDARY_BUTTON_CLASS` markup as the
Actions button and is conditioned on `run.pullRequestUrl && run.number
!= null`. See the [HTML
prototype](https://github.com/fabro-sh/fabro/blob/feat/run-overview-summary-panel/.context/run-overview-options.html)
for the locked design.

## Test plan

- [x] `cd apps/fabro-web && bun run typecheck` clean (only pre-existing
assistant-ui errors)
- [x] `bun test` — +12 new passes, no new failures (387 pass / 5 fail /
2 errors vs baseline 375 / 6 / 3)
- [x] Manual: load `/runs/<id>` against a real server, confirm panel +
em dashes render correctly
- [ ] Manual on a run **with** a PR: verify the pill appears next to
Actions and opens the PR in a new tab
- [ ] Manual on a run **with** rich data (diff / billing / sandbox
resources / artifacts): verify each column populates correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 16:52:48 -04:00

136 lines
4.5 KiB
TypeScript

/**
* Format a number of seconds into a human-readable duration string.
* Examples: "23s", "7m", "2h 15m", "3d"
*/
export function formatElapsedSecs(secs: number): string {
if (secs < 60) return `${Math.round(secs)}s`;
const minutes = Math.floor(secs / 60);
if (minutes < 60) {
const remainSecs = Math.round(secs % 60);
return remainSecs > 0 ? `${minutes}m ${remainSecs}s` : `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
const remainMin = minutes % 60;
return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
}
const days = Math.floor(hours / 24);
const remainHrs = hours % 24;
return remainHrs > 0 ? `${days}d ${remainHrs}h` : `${days}d`;
}
/**
* Format a byte count for display (e.g., "1.23 MB", "247.32 KB", "742 B").
*/
export function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(2)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(2)} KB`;
return `${bytes} B`;
}
/**
* Short relative time string (e.g. "just now", "15s ago", "4m ago", "2h ago", "3d ago").
* Future timestamps clamp to "just now".
*/
export function formatRelativeTime(iso: string, now: number = Date.now()): string {
const then = Date.parse(iso);
if (Number.isNaN(then)) return "";
const secs = Math.max(0, Math.floor((now - then) / 1000));
if (secs < 5) return "just now";
if (secs < 60) return `${secs}s ago`;
const minutes = Math.floor(secs / 60);
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);
return `${days}d ago`;
}
/**
* Format an ISO 8601 timestamp as an absolute, human-readable datetime
* (e.g., "04/24/2026, 1:23:40 PM"). Falls back to the input if unparseable.
*/
export function formatAbsoluteTs(iso: string): string {
const ms = Date.parse(iso);
if (Number.isNaN(ms)) return iso;
return new Date(ms).toLocaleString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
}
/**
* Format seconds into a duration string for display (e.g., "1m 12s", "23s").
*/
export function formatDurationSecs(secs: number): string {
if (secs < 60) return `${Math.round(secs)}s`;
const minutes = Math.floor(secs / 60);
const remainSecs = Math.round(secs % 60);
if (minutes < 60) {
return remainSecs > 0 ? `${minutes}m ${remainSecs}s` : `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainMin = minutes % 60;
return remainMin > 0 ? `${hours}h ${remainMin}m` : `${hours}h`;
}
export function formatDurationMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
export function formatTokenCount(
value: number,
options: { compactDecimal?: boolean } = {},
): string {
if (options.compactDecimal) {
if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (value < 1000) return `${value}`;
if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
return `${Math.round(value / 1_000_000)}M`;
}
const BYTES_PER_GIB = 1024 * 1024 * 1024;
const BYTES_PER_MIB = 1024 * 1024;
/**
* Format a byte count as a memory/disk size (e.g. "8 GiB", "512 MiB", "1024 B").
*/
export function formatBytesAsMemory(bytes: number): string {
if (bytes >= BYTES_PER_GIB) {
const gib = bytes / BYTES_PER_GIB;
return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`;
}
if (bytes >= BYTES_PER_MIB) {
const mib = bytes / BYTES_PER_MIB;
return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
}
return `${bytes} B`;
}
/**
* Format a CPU-core count for display (whole cores as integer; fractional as 2-decimal).
*/
export function formatCpuCores(cores: number): string {
return Number.isInteger(cores) ? cores.toString() : cores.toFixed(2);
}
/**
* Format a USD-micros amount as a dollar string ("$1.23"). Returns null when the
* input is null/undefined so callers can render their own empty placeholder.
*/
export function formatUsdMicros(usdMicros: number | null | undefined): string | null {
if (usdMicros == null) return null;
return `$${(usdMicros / 1_000_000).toFixed(2)}`;
}