fix(frontend): keep usage chart date labels in bounds (#730)

## Summary

Fixes the usage page cost-over-time chart so the final date label stays inside the SVG bounds instead of being clipped at the right edge.

The chart previously centered the last x-axis date on the plot boundary while reserving only an 8px right gutter, which was not enough for month-day labels such as `Jun 18`. The chart now has a named right-side x-label gutter and subtracts that gutter from the plot width, preserving the overall SVG size while moving the last tick inward.

The regression coverage mounts the usage chart at the wide layout that exposed the clipping and checks that the rightmost label fits inside the viewBox. Reviewers should look at `CostTimeSeriesChart.svelte` for the layout change and `CostTimeSeriesChart.test.ts` for the focused coverage.

This also adds the product context and live-mode config files required by the frontend design workflow used for the fix.


Co-authored-by: Phillip Cloud <cpcloud@users.noreply.github.com>
This commit is contained in:
Phillip Cloud
2026-06-18 10:53:05 -04:00
committed by GitHub
parent ebf14c1b43
commit 16ff02932f
4 changed files with 212 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"files": ["frontend/index.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}
+47
View File
@@ -0,0 +1,47 @@
# Product
## Register
product
## Users
agentsview is for developers who run multiple AI coding agents and need to
inspect, search, compare, and audit their local session history. They are
usually in a debugging, review, or cost-monitoring workflow where dense,
trustworthy data is more useful than promotional framing.
## Product Purpose
agentsview syncs agent session files into a local archive and serves a fast web
UI for browsing sessions, searching transcripts, tracking usage, and reviewing
costs across projects, models, and agents. Success means users can understand
what happened across their agent runs without sending data to an external
account or re-parsing raw files by hand.
## Brand Personality
Local-first, technical, focused. The interface should feel like an operational
tool: direct, compact, and calm enough for repeated daily use.
## Anti-references
Avoid marketing-site hero patterns, decorative SaaS dashboards, vague
assistant-themed illustrations, and visual treatments that make local developer
data feel like a hosted analytics product. Avoid hiding density behind oversized
cards or ornamental motion.
## Design Principles
- Put the session data first.
- Preserve local-first trust.
- Keep repeated workflows compact and predictable.
- Make freshness, filtering, and state legible.
- Prefer familiar product affordances over novelty.
## Accessibility & Inclusion
No project-specific accessibility profile is documented yet. Default to clear
focus states, keyboard-reachable controls, readable contrast in light and dark
themes, reduced-motion-safe transitions, and chart labels that remain visible
across supported viewport sizes.
@@ -5,6 +5,7 @@
const CHART_H = 180;
const X_LABEL_H = 20;
const Y_LABEL_W = 40;
const X_LABEL_RIGHT_PAD = 24;
// Reserved headroom at the top of the plot area so the
// maximum bar, its grid line, and the top y-axis label's
// ascenders do not clip against the SVG viewBox edge.
@@ -137,7 +138,7 @@
});
const chartWidth = $derived(
Math.max(containerWidth - Y_LABEL_W - 8, 100),
Math.max(containerWidth - Y_LABEL_W - X_LABEL_RIGHT_PAD, 100),
);
const BAR_WIDTH = 40;
@@ -367,7 +368,7 @@
<svg
width="100%"
height={CHART_H + X_LABEL_H}
viewBox="0 0 {chartWidth + Y_LABEL_W + 8} {CHART_H + X_LABEL_H}"
viewBox="0 0 {chartWidth + Y_LABEL_W + X_LABEL_RIGHT_PAD} {CHART_H + X_LABEL_H}"
preserveAspectRatio="xMidYMid meet"
class="chart-svg"
>
@@ -0,0 +1,156 @@
// @vitest-environment jsdom
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "vitest";
import { mount, tick, unmount } from "svelte";
// @ts-ignore
import CostTimeSeriesChart from "./CostTimeSeriesChart.svelte";
import { usage } from "../../stores/usage.svelte.js";
import type {
DailyUsageEntry,
UsageSummaryResponse,
} from "../../api/types/usage.js";
const OBSERVED_WIDTH = 1648;
class ImmediateResizeObserver implements ResizeObserver {
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
}
observe(target: Element): void {
this.callback(
[
{
target,
contentRect: {
width: OBSERVED_WIDTH,
height: 200,
x: 0,
y: 0,
top: 0,
right: OBSERVED_WIDTH,
bottom: 200,
left: 0,
toJSON: () => ({}),
},
} as ResizeObserverEntry,
],
this,
);
}
unobserve(): void {}
disconnect(): void {}
}
function dailyEntry(index: number): DailyUsageEntry {
const date = new Date("2026-06-04T00:00:00");
date.setDate(date.getDate() + index);
const isoDate = date.toISOString().slice(0, 10);
return {
date: isoDate,
inputTokens: 100,
outputTokens: 50,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalCost: 10,
modelsUsed: ["model"],
projectBreakdowns: [
{
project: "agentsview",
inputTokens: 100,
outputTokens: 50,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 10,
},
],
};
}
function usageSummary(): UsageSummaryResponse {
return {
from: "2026-06-04",
to: "2026-06-18",
totals: {
inputTokens: 1500,
outputTokens: 750,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalCost: 150,
},
daily: Array.from({ length: 15 }, (_, i) => dailyEntry(i)),
projectTotals: [
{
project: "agentsview",
inputTokens: 1500,
outputTokens: 750,
cacheCreationTokens: 0,
cacheReadTokens: 0,
cost: 150,
},
],
modelTotals: [],
agentTotals: [],
sessionCounts: {
total: 15,
byProject: { agentsview: 15 },
byAgent: {},
},
cacheStats: {
cacheReadTokens: 0,
cacheCreationTokens: 0,
uncachedInputTokens: 1500,
outputTokens: 750,
hitRate: 0,
savingsVsUncached: 0,
},
};
}
describe("CostTimeSeriesChart", () => {
beforeEach(() => {
globalThis.ResizeObserver =
ImmediateResizeObserver as typeof ResizeObserver;
usage.summary = usageSummary();
usage.toggles.timeSeries.groupBy = "project";
});
afterEach(() => {
usage.summary = null;
document.body.innerHTML = "";
});
it("keeps the rightmost date label inside the SVG viewBox", async () => {
const component = mount(CostTimeSeriesChart, {
target: document.body,
});
await tick();
const svg = document.querySelector("svg.chart-svg");
expect(svg).toBeTruthy();
const viewBox = svg!.getAttribute("viewBox")!.split(" ").map(Number);
const viewBoxRight = viewBox[2]!;
const labels = Array.from(
document.querySelectorAll<SVGTextElement>("text.x-label"),
);
const lastLabel = labels.at(-1);
expect(lastLabel).toBeTruthy();
const x = Number(lastLabel!.getAttribute("x"));
const textWidthEstimate = lastLabel!.textContent!.length * 5;
expect(x + textWidthEstimate / 2).toBeLessThanOrEqual(viewBoxRight);
unmount(component);
});
});