Skip to content

Commit 280d2c7

Browse files
authored
feat(dashboard): improve cost breakdowns (#1181)
Cost reporting in the dashboard now keeps long conversation cost tooltips aligned with their trigger instead of opening below it. The Memories overview replaces extraction-only bars with a stacked extraction-and-recall chart, including per-series totals and accessible labels across the existing time ranges. The memory dashboard endpoint now returns a required 90-day `recallDays` series sourced from `memories_recalled`. The strict server and client schemas move together in this repository, and the updated contract is covered by the memory REST integration test. Dashboard tests, package typechecks, memory lint, and the Chromium memory-page journey pass.
1 parent 53d0929 commit 280d2c7

11 files changed

Lines changed: 184 additions & 39 deletions

File tree

packages/junior-dashboard/e2e/harness.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,11 @@ export async function mockDashboardApis(page: Page) {
223223
events: index % 3 === 0 ? 2 : 1,
224224
})),
225225
generatedAt: "2026-07-30T12:00:00.000Z",
226+
recallDays: days.map((day, index) => ({
227+
costUsd: index % 5 === 0 ? 0.04 : index % 7 === 0 ? 0.015 : 0,
228+
date: day.date,
229+
events: index % 4 === 0 ? 3 : 1,
230+
})),
226231
stats: {
227232
active: 210,
228233
automatic: 189,

packages/junior-dashboard/e2e/user-pages.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ test("opens a registered plugin page from primary navigation", async ({
4747
page.getByRole("heading", { name: "Activity over time" }),
4848
).toBeVisible();
4949
await expect(page.getByRole("heading", { name: /^\$/ })).toBeVisible();
50+
await expect(page.getByText(/^Extraction \$/)).toBeVisible();
51+
await expect(page.getByText(/^Recall \$/)).toBeVisible();
52+
await expect(
53+
page.getByRole("img", {
54+
name: "Memory extraction and recall cost during the last 30 days",
55+
}),
56+
).toBeVisible();
5057
await expect(
5158
page.getByRole("heading", { name: "What Junior remembers" }),
5259
).toBeVisible();
@@ -59,6 +66,11 @@ test("opens a registered plugin page from primary navigation", async ({
5966
await expect(sevenDays).toHaveAttribute("aria-pressed", "true");
6067
await ninetyDays.click();
6168
await expect(ninetyDays).toHaveAttribute("aria-pressed", "true");
69+
const costRange = page.getByLabel("Memory cost range");
70+
await expect(costRange.getByRole("button", { name: "30d" })).toHaveAttribute(
71+
"aria-pressed",
72+
"true",
73+
);
6274
await page
6375
.getByRole("navigation", { name: "Memory navigation" })
6476
.getByRole("link", { name: "Memories" })

packages/junior-dashboard/src/client/components/Metric.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ function clamp(value: number, min: number, max: number): number {
3333
function tooltipPosition(
3434
trigger: HTMLElement,
3535
align: "left" | "right" | undefined,
36+
topAligned: boolean,
3637
wide: boolean,
3738
): TooltipPosition {
3839
const margin = 16;
@@ -45,7 +46,7 @@ function tooltipPosition(
4546
left: Math.round(
4647
clamp(preferredLeft, margin, viewportWidth - width - margin),
4748
),
48-
top: Math.round(rect.bottom + 8),
49+
top: Math.round(topAligned ? rect.top : rect.bottom + 8),
4950
width,
5051
};
5152
}
@@ -100,6 +101,7 @@ export function MetricValue(props: {
100101
className?: string;
101102
tooltip?: MetricTooltipLine[];
102103
tooltipColumns?: MetricTooltipLine[][];
104+
tooltipTopAligned?: boolean;
103105
}) {
104106
const tooltipId = useId();
105107
const triggerRef = useRef<HTMLSpanElement>(null);
@@ -118,6 +120,7 @@ export function MetricValue(props: {
118120
tooltipPosition(
119121
triggerRef.current,
120122
props.align,
123+
Boolean(props.tooltipTopAligned),
121124
Boolean(tooltipColumns?.length),
122125
),
123126
);

packages/junior-dashboard/src/client/conversations/TelemetryMetrics.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export function CostMetric(props: {
228228
return (
229229
<MetricValue
230230
align={props.align}
231+
tooltipTopAligned
231232
{...costTooltip(props.summary, props.modelUsage, props.auxiliaryCosts)}
232233
>
233234
{formatCostSummary(total)}

packages/junior-dashboard/src/client/pages/memory/MemoryExtractionCost.tsx renamed to packages/junior-dashboard/src/client/pages/memory/MemoryCostChart.tsx

Lines changed: 87 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,40 @@ import { useState } from "react";
33
import { Card } from "../../components/layout/Card";
44
import { formatCostSummary } from "../../format";
55
import { cn } from "../../styles";
6-
import type { MemoryExtractionDay } from "./memoryDashboard";
6+
import type { MemoryCostDay } from "./memoryDashboard";
77

88
type MemoryRange = 7 | 30 | 90;
99

10-
/** Render passive memory extraction cost from durable plugin events. */
11-
export function MemoryExtractionCost(props: { days: MemoryExtractionDay[] }) {
10+
/** Render stacked memory extraction and recall cost from durable plugin events. */
11+
export function MemoryCostChart(props: {
12+
extractionDays: MemoryCostDay[];
13+
recallDays: MemoryCostDay[];
14+
}) {
1215
const [range, setRange] = useState<MemoryRange>(30);
13-
const days = props.days.slice(-range);
14-
const total = days.reduce((sum, day) => sum + day.costUsd, 0);
15-
const runs = days.reduce((sum, day) => sum + day.events, 0);
16-
const maximum = Math.max(0.01, ...days.map((day) => day.costUsd));
16+
const recallByDate = new Map(props.recallDays.map((day) => [day.date, day]));
17+
const days = props.extractionDays.slice(-range).map((extraction) => ({
18+
date: extraction.date,
19+
extraction,
20+
recall: recallByDate.get(extraction.date) ?? {
21+
costUsd: 0,
22+
date: extraction.date,
23+
events: 0,
24+
},
25+
}));
26+
const extractionTotal = days.reduce(
27+
(sum, day) => sum + day.extraction.costUsd,
28+
0,
29+
);
30+
const recallTotal = days.reduce((sum, day) => sum + day.recall.costUsd, 0);
31+
const total = extractionTotal + recallTotal;
32+
const runs = days.reduce(
33+
(sum, day) => sum + day.extraction.events + day.recall.events,
34+
0,
35+
);
36+
const maximum = Math.max(
37+
0.01,
38+
...days.map((day) => day.extraction.costUsd + day.recall.costUsd),
39+
);
1740
const width = 720;
1841
const height = 200;
1942
const left = 48;
@@ -29,17 +52,34 @@ export function MemoryExtractionCost(props: { days: MemoryExtractionDay[] }) {
2952
<div className="flex flex-wrap items-start justify-between gap-4">
3053
<div>
3154
<div className="font-mono text-[0.6rem] uppercase tracking-[0.16em] text-cyan-200/65">
32-
Extraction cost
55+
Memory cost
3356
</div>
3457
<h2 className="mt-1 mb-0 font-display text-xl font-medium text-dashboard-text">
3558
{formatCostSummary({ total })}
3659
</h2>
3760
<p className="mt-1 mb-0 font-mono text-[0.64rem] leading-relaxed text-dashboard-text-muted">
38-
System-wide estimate across {runs.toLocaleString()} passive runs.
61+
System-wide estimate across {formatRunCount(runs)} spanning
62+
extraction and recall.
3963
</p>
64+
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[0.6rem] text-dashboard-text-muted">
65+
<span className="inline-flex items-center gap-1.5">
66+
<span
67+
aria-hidden="true"
68+
className="h-2 w-2 rounded-[1px] bg-cyan-300"
69+
/>
70+
Extraction {formatCostSummary({ total: extractionTotal })}
71+
</span>
72+
<span className="inline-flex items-center gap-1.5">
73+
<span
74+
aria-hidden="true"
75+
className="h-2 w-2 rounded-[1px] bg-fuchsia-400"
76+
/>
77+
Recall {formatCostSummary({ total: recallTotal })}
78+
</span>
79+
</div>
4080
</div>
4181
<div
42-
aria-label="Memory extraction cost range"
82+
aria-label="Memory cost range"
4383
className="inline-flex rounded border border-white/[0.08] bg-black/20 p-0.5"
4484
>
4585
{([7, 30, 90] as const).map((days) => (
@@ -63,7 +103,7 @@ export function MemoryExtractionCost(props: { days: MemoryExtractionDay[] }) {
63103

64104
<div className="relative mt-4 overflow-hidden">
65105
<svg
66-
aria-label={`Memory extraction cost during the last ${range} days`}
106+
aria-label={`Memory extraction and recall cost during the last ${range} days`}
67107
className="block h-auto min-h-40 w-full"
68108
role="img"
69109
viewBox={`0 0 ${width} ${height}`}
@@ -94,22 +134,39 @@ export function MemoryExtractionCost(props: { days: MemoryExtractionDay[] }) {
94134
);
95135
})}
96136
{days.map((day, index) => {
97-
const height = (day.costUsd / maximum) * plotHeight;
137+
const extractionHeight =
138+
(day.extraction.costUsd / maximum) * plotHeight;
139+
const recallHeight = (day.recall.costUsd / maximum) * plotHeight;
98140
const x = left + index * step + (step - barWidth) / 2;
141+
const extractionLabel = `${formatDate(day.date)} extraction: ${formatCostSummary({ total: day.extraction.costUsd })}, ${formatRunCount(day.extraction.events)}`;
142+
const recallLabel = `${formatDate(day.date)} recall: ${formatCostSummary({ total: day.recall.costUsd })}, ${formatRunCount(day.recall.events)}`;
99143
return (
100-
<rect
101-
aria-label={`${formatDate(day.date)}: ${formatCostSummary({ total: day.costUsd })}, ${day.events} runs`}
102-
fill="#67e8f9"
103-
height={height}
104-
key={day.date}
105-
opacity={day.costUsd > 0 ? 0.82 : 0.1}
106-
rx="1"
107-
width={barWidth}
108-
x={x}
109-
y={top + plotHeight - height}
110-
>
111-
<title>{`${formatDate(day.date)}: ${formatCostSummary({ total: day.costUsd })}, ${day.events} runs`}</title>
112-
</rect>
144+
<g key={day.date}>
145+
<rect
146+
aria-label={extractionLabel}
147+
fill="#67e8f9"
148+
height={extractionHeight}
149+
opacity={day.extraction.costUsd > 0 ? 0.82 : 0.1}
150+
rx="1"
151+
width={barWidth}
152+
x={x}
153+
y={top + plotHeight - extractionHeight}
154+
>
155+
<title>{extractionLabel}</title>
156+
</rect>
157+
<rect
158+
aria-label={recallLabel}
159+
fill="#e879f9"
160+
height={recallHeight}
161+
opacity={day.recall.costUsd > 0 ? 0.82 : 0.1}
162+
rx="1"
163+
width={barWidth}
164+
x={x}
165+
y={top + plotHeight - extractionHeight - recallHeight}
166+
>
167+
<title>{recallLabel}</title>
168+
</rect>
169+
</g>
113170
);
114171
})}
115172
{[0, Math.floor((days.length - 1) / 2), days.length - 1].map(
@@ -146,14 +203,18 @@ export function MemoryExtractionCost(props: { days: MemoryExtractionDay[] }) {
146203
</svg>
147204
{runs === 0 ? (
148205
<div className="pointer-events-none absolute inset-0 grid place-items-center pt-12 font-mono text-[0.68rem] text-dashboard-text-muted">
149-
No passive extractions ran in this period.
206+
No memory extraction or recall ran in this period.
150207
</div>
151208
) : null}
152209
</div>
153210
</Card>
154211
);
155212
}
156213

214+
function formatRunCount(count: number): string {
215+
return `${count.toLocaleString()} ${count === 1 ? "run" : "runs"}`;
216+
}
217+
157218
function formatDate(date: string): string {
158219
return new Intl.DateTimeFormat("en-US", {
159220
day: "numeric",

packages/junior-dashboard/src/client/pages/memory/MemoryPage.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
useMemoryDashboardData,
3636
} from "./memoryDashboard";
3737
import { MemoryTimeline } from "./MemoryTimeline";
38-
import { MemoryExtractionCost } from "./MemoryExtractionCost";
38+
import { MemoryCostChart } from "./MemoryCostChart";
3939

4040
/** Render the temporary first-class dashboard experience for memory. */
4141
export function MemoryPage(props: { page: PluginUserPageLink }) {
@@ -108,7 +108,10 @@ function MemoryOverview() {
108108
<>
109109
<section className="grid gap-4 xl:grid-cols-2">
110110
<MemoryTimeline days={dashboardQuery.data.days} />
111-
<MemoryExtractionCost days={dashboardQuery.data.extractionDays} />
111+
<MemoryCostChart
112+
extractionDays={dashboardQuery.data.extractionDays}
113+
recallDays={dashboardQuery.data.recallDays}
114+
/>
112115
</section>
113116
<MemorySummary data={dashboardQuery.data} />
114117
<section className="grid gap-4 md:grid-cols-2">

packages/junior-dashboard/src/client/pages/memory/memoryDashboard.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const memoryDaySchema = z
1111
})
1212
.strict();
1313

14-
const memoryExtractionDaySchema = z
14+
const memoryCostDaySchema = z
1515
.object({
1616
costUsd: z.number().finite().min(0),
1717
date: z.iso.date(),
@@ -22,8 +22,9 @@ const memoryExtractionDaySchema = z
2222
export const memoryDashboardSchema = z
2323
.object({
2424
days: z.array(memoryDaySchema).length(90),
25-
extractionDays: z.array(memoryExtractionDaySchema).length(90),
25+
extractionDays: z.array(memoryCostDaySchema).length(90),
2626
generatedAt: z.iso.datetime(),
27+
recallDays: z.array(memoryCostDaySchema).length(90),
2728
stats: z
2829
.object({
2930
active: z.number().int().min(0),
@@ -43,7 +44,7 @@ export const memoryDashboardSchema = z
4344

4445
export type MemoryDashboardData = z.output<typeof memoryDashboardSchema>;
4546
export type MemoryDay = MemoryDashboardData["days"][number];
46-
export type MemoryExtractionDay = MemoryDashboardData["extractionDays"][number];
47+
export type MemoryCostDay = MemoryDashboardData["extractionDays"][number];
4748

4849
/** Load the viewer-scoped Memory dashboard summary. */
4950
export function useMemoryDashboardData() {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { MemoryCostChart } from "../src/client/pages/memory/MemoryCostChart";
5+
6+
function costDays(costUsd: number, events: number) {
7+
const start = Date.parse("2026-05-02T00:00:00.000Z");
8+
return Array.from({ length: 90 }, (_, index) => ({
9+
costUsd: index === 89 ? costUsd : 0,
10+
date: new Date(start + index * 24 * 60 * 60 * 1_000)
11+
.toISOString()
12+
.slice(0, 10),
13+
events: index === 89 ? events : 0,
14+
}));
15+
}
16+
17+
describe("MemoryCostChart", () => {
18+
it("stacks extraction and recall cost for each day", () => {
19+
const html = renderToStaticMarkup(
20+
<MemoryCostChart
21+
extractionDays={costDays(0.005, 1)}
22+
recallDays={costDays(0.005, 2)}
23+
/>,
24+
);
25+
26+
expect(html).toContain("Memory cost");
27+
expect(html).toContain("Extraction $0.005");
28+
expect(html).toContain("Recall $0.005");
29+
expect(html).toMatch(
30+
/aria-label="Jul 30 extraction: \$0\.005, 1 run"[^>]+height="76"[^>]+y="90"/,
31+
);
32+
expect(html).toMatch(
33+
/aria-label="Jul 30 recall: \$0\.005, 2 runs"[^>]+height="76"[^>]+y="14"/,
34+
);
35+
});
36+
});

packages/junior-dashboard/tests/telemetry-metrics.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ vi.mock("../src/client/components/Metric", () => ({
77
children: ReactNode;
88
tooltip?: Array<{ label?: string; value: string }>;
99
tooltipColumns?: Array<Array<{ label?: string; value: string }>>;
10+
tooltipTopAligned?: boolean;
1011
}) => (
11-
<span>
12+
<span data-tooltip-top-aligned={props.tooltipTopAligned || undefined}>
1213
{props.children}
1314
{[...(props.tooltip ?? []), ...(props.tooltipColumns?.flat() ?? [])].map(
1415
(line) => (
@@ -89,5 +90,6 @@ describe("CostMetric", () => {
8990
expect(html).toContain("total: $0.0018");
9091
expect(html).toContain("Memory recall (2): $0.0004");
9192
expect(html).toContain("Guardian (1): $0.0014");
93+
expect(html).toContain('data-tooltip-top-aligned="true"');
9294
});
9395
});

packages/junior-memory/src/api.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const memoryDashboardDaySchema = z
4848
})
4949
.strict();
5050

51-
const memoryExtractionDaySchema = z
51+
const memoryCostDaySchema = z
5252
.object({
5353
costUsd: z.number().finite().nonnegative(),
5454
date: z.iso.date(),
@@ -59,8 +59,9 @@ const memoryExtractionDaySchema = z
5959
export const memoryDashboardResponseSchema = z
6060
.object({
6161
days: z.array(memoryDashboardDaySchema).length(90),
62-
extractionDays: z.array(memoryExtractionDaySchema).length(90),
62+
extractionDays: z.array(memoryCostDaySchema).length(90),
6363
generatedAt: z.iso.datetime(),
64+
recallDays: z.array(memoryCostDaySchema).length(90),
6465
stats: z
6566
.object({
6667
active: z.number().int().min(0),
@@ -155,18 +156,23 @@ export function createMemoryApi(options: MemoryApiOptions): PluginRouteApp {
155156
isDashboard &&
156157
(request.method === "GET" || request.method === "HEAD")
157158
) {
158-
const [stats, days, extractionDays] = await Promise.all([
159+
const [stats, days, extractionDays, recallDays] = await Promise.all([
159160
memories.stats(),
160161
memories.timeline({ days: 90 }),
161162
options.eventStats.costsByDay({
162163
days: 90,
163164
eventName: "memories_captured",
164165
}),
166+
options.eventStats.costsByDay({
167+
days: 90,
168+
eventName: "memories_recalled",
169+
}),
165170
]);
166171
const body = memoryDashboardResponseSchema.parse({
167172
days,
168173
extractionDays,
169174
generatedAt: new Date().toISOString(),
175+
recallDays,
170176
stats,
171177
});
172178
return request.method === "HEAD"

0 commit comments

Comments
 (0)