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:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"files": ["frontend/index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html",
|
||||
"cspChecked": true
|
||||
}
|
||||
+47
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user