Files
fusion/packages/dashboard/app/utils/__tests__/taskTokenCost.test.ts
gsxdsm 937650472a FN-7820: add Cost tab to task detail and optional card cost badge
Adds a shared cost-derivation utility and surfaces token/cost info in a new Cost tab on the task detail modal, plus an opt-in per-card cost badge on the board.

- Extract token-cost calculation into a shared taskTokenCost helper (read-time costFor derivation) reused by the Summary tab, new Cost tab, and card badge
- Add TaskDetailModal Cost tab (TaskCostTab.tsx/.css) showing cost breakdown for a task
- Simplify TaskSummaryTab by delegating cost math to the shared helper
- Add default-off project setting showCostBadgeOnCards (settings-schema.ts, types.ts) with a SettingsModal/AppearanceSection toggle
- Add CostBadgeContext to thread the setting into TaskCard without prop drilling
- Show an optional cost badge on TaskCard when the setting is enabled
- Update i18n strings across en/es/fr/ko/zh-CN/zh-TW locales
- Update docs (dashboard-guide.md, settings-reference.md) and add changeset fn-7820-cost-tab-and-card-badge.md

Files changed:
 .changeset/fn-7820-cost-tab-and-card-badge.md      |   7 ++
 docs/dashboard-guide.md                            |   4 +
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/settings-defaults.test.ts   |  13 ++
 packages/core/src/settings-schema.ts               |   5 +
 packages/core/src/types.ts                         |   5 +
 packages/dashboard/app/App.tsx                     |   5 +
 .../dashboard/app/components/SettingsModal.tsx     |   6 +
 packages/dashboard/app/components/TaskCard.css     |   7 +-
 packages/dashboard/app/components/TaskCard.tsx     |  27 +++-
 packages/dashboard/app/components/TaskCostTab.css  |  51 ++++++++
 packages/dashboard/app/components/TaskCostTab.tsx  |  91 ++++++++++++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  14 ++-
 .../dashboard/app/components/TaskSummaryTab.tsx    | 123 +-----------------
 .../app/components/__tests__/TaskCard.test.tsx     |  81 +++++++++++-
 .../app/components/__tests__/TaskCostTab.test.tsx  |  55 ++++++++
 .../TaskDetailModal.attachments-and-tabs.test.tsx  |  11 +-
 .../settings/sections/AppearanceSection.tsx        |   8 ++
 .../sections/__tests__/AppearanceSection.test.tsx  |  20 +++
 .../settings-default-descriptions.test.tsx         |   1 +
 .../dashboard/app/context/CostBadgeContext.tsx     |  19 +++
 packages/dashboard/app/hooks/useAppSettings.ts     |  15 +++
 .../app/utils/__tests__/taskTokenCost.test.ts      |  62 +++++++++
 packages/dashboard/app/utils/taskTokenCost.ts      | 139 +++++++++++++++++++++
 packages/i18n/locales/en/app.json                  |  29 ++++-
 packages/i18n/locales/es/app.json                  |  30 ++++-
 packages/i18n/locales/fr/app.json                  |  27 +++-
 packages/i18n/locales/ko/app.json                  |  30 ++++-
 packages/i18n/locales/zh-CN/app.json               |  30 ++++-
 packages/i18n/locales/zh-TW/app.json               |  30 ++++-
 30 files changed, 789 insertions(+), 157 deletions(-)

Fusion-Task-Id: FN-7820

Fusion-Task-Lineage: d33c5678-a68c-4b29-9db1-8ff0369dfd72

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-11 18:07:00 -07:00

63 lines
3.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TaskDetail, TaskTokenUsage } from "@fusion/core";
import { buildTokenCostRows, formatCost, hasTaskCost, taskTotalCost, totalCostForRows } from "../taskTokenCost";
function usage(overrides: Partial<TaskTokenUsage> = {}): TaskTokenUsage {
return {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
cacheWriteTokens: 0,
totalTokens: 0,
firstUsedAt: "2026-01-01T00:00:00Z",
lastUsedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
function task(tokenUsage?: TaskTokenUsage): TaskDetail {
return { id: "FN-7820", title: "Cost", column: "done", steps: [], dependencies: [], tokenUsage } as TaskDetail;
}
describe("taskTokenCost", () => {
it("prices and merges per-model buckets", () => {
const rows = buildTokenCostRows(task(usage({
perModel: [
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 2_000_000 },
{ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 500_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 500_000 },
],
})), "(unknown)");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_500_000, outputTokens: 1_000_000, totalTokens: 2_500_000 });
expect(formatCost(rows[0].cost.usd, rows[0].cost.unavailable)).toBe("$2.38");
expect(formatCost(totalCostForRows(rows).usd, totalCostForRows(rows).unavailable)).toBe("$2.38");
});
it("prices aggregate-only fallback usage", () => {
const result = taskTotalCost(task(usage({ modelProvider: "openai", modelId: "gpt-5-mini", inputTokens: 1_000_000, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1_000_000 })));
expect(formatCost(result.usd, result.unavailable)).toBe("$0.25");
});
it("marks unpriceable positive-token rows and totals unavailable", () => {
const rows = buildTokenCostRows(task(usage({ modelProvider: "unknown", modelId: "no-price", inputTokens: 10, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 10 })), "(unknown)");
expect(rows[0].cost.unavailable).toBe(true);
expect(formatCost(rows[0].cost.usd, rows[0].cost.unavailable)).toBe("—");
expect(formatCost(totalCostForRows(rows).usd, totalCostForRows(rows).unavailable)).toBe("—");
});
it("does not fabricate cost for zero usage", () => {
const zeroTask = task(usage({ modelProvider: "unknown", modelId: "no-price" }));
const total = taskTotalCost(zeroTask);
expect(hasTaskCost(zeroTask)).toBe(false);
expect(formatCost(total.usd, total.unavailable)).toBe("—");
});
it("detects positive task cost availability without requiring pricing", () => {
expect(hasTaskCost(task())).toBe(false);
expect(hasTaskCost(task(usage()))).toBe(false);
expect(hasTaskCost(task(usage({ totalTokens: 1 })))).toBe(true);
expect(hasTaskCost(task(usage({ perModel: [{ modelProvider: "x", modelId: "y", inputTokens: 1, outputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 1 }] })))).toBe(true);
});
});