From ba02349c5a78ee1ce4d828b1ef32654c9f347e3f Mon Sep 17 00:00:00 2001 From: iaj6 Date: Sat, 11 Jul 2026 23:53:59 -0400 Subject: [PATCH] fix(web): make the Anthropic Admin API half of the Usage page actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org-wide section of the Usage page has never functioned. Three compounding defects: 1. Wrong request parameters: the proxies sent start_date/end_date, but the Admin API takes starting_at (RFC 3339, required) — so with the page's default no-date call the upstream request had no time range at all and every request 400'd, rendering a permanent error banner for admins. 2. Wrong response parsing: the page read total_cost/daily/input_tokens/ total_sessions/lines_of_code — none of which exist in the cost_report or usage_report responses (real shape: {data: [time buckets with results[]], has_more, next_page}; amounts are decimal strings in CENTS). 3. No pagination: has_more/next_page were ignored. Fix: the routes now query a last-N-days window (default 30, clamped to the API's 31-bucket max), follow pagination (bounded), and normalize server-side into small typed shapes: cost -> {totalCostUsd, daily[], days, truncated}; analytics -> token totals incl. cache read/creation splits and web-search request counts. The page consumes those shapes with real types instead of Record. The Activity Summary card is removed: its fields (sessions, lines of code, request counts) come from the separate Claude Code Analytics API, not the usage report, so it could only ever render zeros. Re-add it if we integrate that API. Hooks lose the dead start_date/end_date parameterization (never passed, and the params were meaningless upstream) in favor of a working ?days=N. Tests rewritten against the real report shapes: RFC 3339 starting_at, cents->USD aggregation, pagination following, and token normalization. Co-Authored-By: Claude Fable 5 --- .../api/admin/__tests__/admin-routes.test.ts | 155 ++++++++++++++++-- .../web/src/app/api/admin/analytics/route.ts | 145 ++++++++++++---- packages/web/src/app/api/admin/cost/route.ts | 137 ++++++++++++---- packages/web/src/app/usage/page.tsx | 98 +++++------ packages/web/src/hooks/useAdminApi.ts | 37 +++-- 5 files changed, 430 insertions(+), 142 deletions(-) diff --git a/packages/web/src/app/api/admin/__tests__/admin-routes.test.ts b/packages/web/src/app/api/admin/__tests__/admin-routes.test.ts index 539e9bf..4106c6d 100644 --- a/packages/web/src/app/api/admin/__tests__/admin-routes.test.ts +++ b/packages/web/src/app/api/admin/__tests__/admin-routes.test.ts @@ -135,16 +135,106 @@ describe("GET /api/admin/cost", () => { expect(body).not.toHaveProperty("stack"); }); - it("proxies 200 response from upstream when key is present (admin)", async () => { + it("sends starting_at (RFC 3339) and bucket_width to the upstream API", async () => { process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key"; - const fakePayload = { total_cost: 42.5, currency: "USD" }; - stubFetch({ ok: true, status: 200, json: async () => fakePayload }); + const fetchMock = vi.fn<(url: string) => Promise>(async () => ({ + ok: true, + status: 200, + json: async () => ({ data: [], has_more: false, next_page: null }), + text: async () => "", + })); + vi.stubGlobal("fetch", fetchMock); + + const res = await costRoute(adminReq("/api/admin/cost?days=7")); + expect(res.status).toBe(200); + const calledUrl = new URL(fetchMock.mock.calls[0]![0]); + expect(calledUrl.pathname).toBe("/v1/organizations/cost_report"); + const startingAt = calledUrl.searchParams.get("starting_at")!; + // RFC 3339 timestamp snapped to UTC midnight; the old start_date/end_date + // params were unknown to the API and made every request 400. + expect(startingAt).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00/); + expect(calledUrl.searchParams.get("bucket_width")).toBe("1d"); + expect(calledUrl.searchParams.has("start_date")).toBe(false); + }); + + it("aggregates cent-string amounts into a normalized USD summary (admin)", async () => { + process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key"; + // Real cost_report shape: daily buckets, amounts as decimal strings in CENTS. + const report = { + data: [ + { + starting_at: "2026-07-01T00:00:00Z", + ending_at: "2026-07-02T00:00:00Z", + results: [ + { amount: "123.78912", currency: "USD" }, + { amount: "76.21088", currency: "USD" }, + ], + }, + { + starting_at: "2026-07-02T00:00:00Z", + ending_at: "2026-07-03T00:00:00Z", + results: [{ amount: "300", currency: "USD" }], + }, + ], + has_more: false, + next_page: null, + }; + stubFetch({ ok: true, status: 200, json: async () => report }); + + const res = await costRoute(adminReq("/api/admin/cost")); + expect(res.status).toBe(200); + const body = (await jsonOf(res)) as { + totalCostUsd: number; + daily: Array<{ date: string; costUsd: number }>; + truncated: boolean; + }; + // 200 cents + 300 cents = $5.00 + expect(body.totalCostUsd).toBeCloseTo(5, 6); + expect(body.daily).toEqual([ + { date: "2026-07-01", costUsd: 2 }, + { date: "2026-07-02", costUsd: 3 }, + ]); + expect(body.truncated).toBe(false); + }); + + it("follows has_more/next_page pagination (admin)", async () => { + process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key"; + const page1 = { + data: [ + { + starting_at: "2026-07-01T00:00:00Z", + ending_at: "2026-07-02T00:00:00Z", + results: [{ amount: "100", currency: "USD" }], + }, + ], + has_more: true, + next_page: "page_token_2", + }; + const page2 = { + data: [ + { + starting_at: "2026-07-02T00:00:00Z", + ending_at: "2026-07-03T00:00:00Z", + results: [{ amount: "100", currency: "USD" }], + }, + ], + has_more: false, + next_page: null, + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => page1 }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => page2 }); + vi.stubGlobal("fetch", fetchMock); const res = await costRoute(adminReq("/api/admin/cost")); expect(res.status).toBe(200); - const body = (await jsonOf(res)) as typeof fakePayload; - expect(body.total_cost).toBe(42.5); - expect(body.currency).toBe("USD"); + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondUrl = new URL(fetchMock.mock.calls[1]![0] as string); + expect(secondUrl.searchParams.get("page")).toBe("page_token_2"); + const body = (await jsonOf(res)) as { totalCostUsd: number; daily: unknown[] }; + expect(body.totalCostUsd).toBeCloseTo(2, 6); // 100c + 100c + expect(body.daily).toHaveLength(2); }); it("surfaces upstream 401 as same status code (admin)", async () => { @@ -169,16 +259,57 @@ describe("GET /api/admin/analytics", () => { expect(body).not.toHaveProperty("stack"); }); - it("proxies 200 response from upstream when key is present (admin)", async () => { + it("normalizes token totals from the usage report shape (admin)", async () => { process.env["ANTHROPIC_ADMIN_API_KEY"] = "sk-admin-test-key"; - const fakePayload = { data: [{ model: "claude-3-5-sonnet", tokens: 1000 }] }; - stubFetch({ ok: true, status: 200, json: async () => fakePayload }); + // Real usage_report/messages shape. + const report = { + data: [ + { + starting_at: "2026-07-01T00:00:00Z", + ending_at: "2026-07-02T00:00:00Z", + results: [ + { + uncached_input_tokens: 1500, + cache_read_input_tokens: 200, + cache_creation: { + ephemeral_5m_input_tokens: 500, + ephemeral_1h_input_tokens: 1000, + }, + output_tokens: 500, + server_tool_use: { web_search_requests: 10 }, + }, + { + uncached_input_tokens: 500, + cache_read_input_tokens: 100, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 250, + server_tool_use: { web_search_requests: 0 }, + }, + ], + }, + ], + has_more: false, + next_page: null, + }; + stubFetch({ ok: true, status: 200, json: async () => report }); const res = await analyticsRoute(adminReq("/api/admin/analytics")); expect(res.status).toBe(200); - const body = (await jsonOf(res)) as typeof fakePayload; - expect(body.data).toHaveLength(1); - expect(body.data[0]?.model).toBe("claude-3-5-sonnet"); + const body = (await jsonOf(res)) as { + uncachedInputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + outputTokens: number; + webSearchRequests: number; + }; + expect(body.uncachedInputTokens).toBe(2000); + expect(body.cacheReadInputTokens).toBe(300); + expect(body.cacheCreationInputTokens).toBe(1500); + expect(body.outputTokens).toBe(750); + expect(body.webSearchRequests).toBe(10); }); it("surfaces upstream 500 as same status code (admin)", async () => { diff --git a/packages/web/src/app/api/admin/analytics/route.ts b/packages/web/src/app/api/admin/analytics/route.ts index 20a0fa0..db389d4 100644 --- a/packages/web/src/app/api/admin/analytics/route.ts +++ b/packages/web/src/app/api/admin/analytics/route.ts @@ -2,6 +2,59 @@ import { NextResponse, type NextRequest } from "next/server"; import { requireAdmin } from "@/lib/auth"; export const dynamic = "force-dynamic"; +// The Anthropic Admin usage_report/messages endpoint: +// - requires `starting_at` (RFC 3339); `start_date`/`end_date` are not +// parameters it knows about +// - returns time buckets whose results carry uncached_input_tokens, +// cache_read_input_tokens, cache_creation.{ephemeral_5m,1h}_input_tokens, +// output_tokens, and server_tool_use.web_search_requests +// - paginates via has_more/next_page +// We normalize into org-wide token totals for the dashboard. + +const MAX_DAYS = 31; +const DEFAULT_DAYS = 30; +const MAX_PAGES = 8; + +interface UsageResult { + uncached_input_tokens?: number; + cache_read_input_tokens?: number; + cache_creation?: { + ephemeral_5m_input_tokens?: number; + ephemeral_1h_input_tokens?: number; + }; + output_tokens?: number; + server_tool_use?: { web_search_requests?: number }; +} + +interface UsageBucket { + starting_at: string; + ending_at: string; + results: UsageResult[]; +} + +interface UsageReportPage { + data: UsageBucket[]; + has_more: boolean; + next_page: string | null; +} + +export interface AdminUsageSummary { + uncachedInputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + outputTokens: number; + webSearchRequests: number; + days: number; + truncated: boolean; +} + +function windowStart(days: number): string { + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + start.setUTCDate(start.getUTCDate() - (days - 1)); + return start.toISOString(); +} + export async function GET(request: NextRequest) { const user = await requireAdmin(request); if (user instanceof NextResponse) return user; @@ -16,37 +69,73 @@ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); - const startDate = searchParams.get("start_date"); - const endDate = searchParams.get("end_date"); - - const params = new URLSearchParams(); - if (startDate) params.set("start_date", startDate); - if (endDate) params.set("end_date", endDate); - - const queryString = params.toString(); - const url = `https://api.anthropic.com/v1/organizations/usage_report/messages${queryString ? `?${queryString}` : ""}`; - - const response = await fetch(url, { - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error( - `[admin/analytics] Anthropic API ${response.status}: ${errorText.slice(0, 500)}`, - ); - return NextResponse.json( - { error: `Anthropic API error: ${response.status}` }, - { status: response.status }, + const daysRaw = Number(searchParams.get("days") ?? DEFAULT_DAYS); + const days = Number.isFinite(daysRaw) + ? Math.min(Math.max(Math.trunc(daysRaw), 1), MAX_DAYS) + : DEFAULT_DAYS; + + const buckets: UsageBucket[] = []; + let page: string | null = null; + let pagesLeft = MAX_PAGES; + + do { + const params = new URLSearchParams({ + starting_at: windowStart(days), + bucket_width: "1d", + limit: String(MAX_DAYS), + }); + if (page) params.set("page", page); + + const response = await fetch( + `https://api.anthropic.com/v1/organizations/usage_report/messages?${params.toString()}`, + { + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + }, ); + + if (!response.ok) { + const errorText = await response.text(); + console.error( + `[admin/analytics] Anthropic API ${response.status}: ${errorText.slice(0, 500)}`, + ); + return NextResponse.json( + { error: `Anthropic API error: ${response.status}` }, + { status: response.status }, + ); + } + + const json = (await response.json()) as UsageReportPage; + buckets.push(...(json.data ?? [])); + page = json.has_more && json.next_page ? json.next_page : null; + pagesLeft -= 1; + } while (page && pagesLeft > 0); + + const summary: AdminUsageSummary = { + uncachedInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + webSearchRequests: 0, + days, + truncated: page !== null, + }; + for (const bucket of buckets) { + for (const result of bucket.results ?? []) { + summary.uncachedInputTokens += result.uncached_input_tokens ?? 0; + summary.cacheReadInputTokens += result.cache_read_input_tokens ?? 0; + summary.cacheCreationInputTokens += + (result.cache_creation?.ephemeral_5m_input_tokens ?? 0) + + (result.cache_creation?.ephemeral_1h_input_tokens ?? 0); + summary.outputTokens += result.output_tokens ?? 0; + summary.webSearchRequests += + result.server_tool_use?.web_search_requests ?? 0; + } } - const data = await response.json(); - return NextResponse.json(data); + return NextResponse.json(summary); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json( diff --git a/packages/web/src/app/api/admin/cost/route.ts b/packages/web/src/app/api/admin/cost/route.ts index 9dbd1eb..f36aca4 100644 --- a/packages/web/src/app/api/admin/cost/route.ts +++ b/packages/web/src/app/api/admin/cost/route.ts @@ -2,6 +2,44 @@ import { NextResponse, type NextRequest } from "next/server"; import { requireAdmin } from "@/lib/auth"; export const dynamic = "force-dynamic"; +// The Anthropic Admin cost_report endpoint: +// - requires `starting_at` (RFC 3339); `start_date`/`end_date` are not +// parameters it knows about +// - returns daily buckets with `amount` as a decimal string in CENTS +// - paginates via has_more/next_page +// We normalize all of that server-side so the dashboard consumes a small +// stable shape instead of the raw report. + +const MAX_DAYS = 31; // cost_report is 1d-bucketed with a 31-bucket maximum +const DEFAULT_DAYS = 30; +const MAX_PAGES = 8; // safety valve; 31 daily buckets never need this many + +interface CostBucket { + starting_at: string; + ending_at: string; + results: Array<{ amount: string; currency: string }>; +} + +interface CostReportPage { + data: CostBucket[]; + has_more: boolean; + next_page: string | null; +} + +export interface AdminCostSummary { + totalCostUsd: number; + daily: Array<{ date: string; costUsd: number }>; + days: number; + truncated: boolean; +} + +function windowStart(days: number): string { + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + start.setUTCDate(start.getUTCDate() - (days - 1)); + return start.toISOString(); +} + export async function GET(request: NextRequest) { const user = await requireAdmin(request); if (user instanceof NextResponse) return user; @@ -16,39 +54,76 @@ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); - const startDate = searchParams.get("start_date"); - const endDate = searchParams.get("end_date"); - - const params = new URLSearchParams(); - if (startDate) params.set("start_date", startDate); - if (endDate) params.set("end_date", endDate); - - const queryString = params.toString(); - const url = `https://api.anthropic.com/v1/organizations/cost_report${queryString ? `?${queryString}` : ""}`; - - const response = await fetch(url, { - headers: { - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - // Log full body server-side; do not echo upstream text to the browser - // since it may contain request headers or tokens on certain 4xx responses. - const errorText = await response.text(); - console.error( - `[admin/cost] Anthropic API ${response.status}: ${errorText.slice(0, 500)}`, - ); - return NextResponse.json( - { error: `Anthropic API error: ${response.status}` }, - { status: response.status }, + const daysRaw = Number(searchParams.get("days") ?? DEFAULT_DAYS); + const days = Number.isFinite(daysRaw) + ? Math.min(Math.max(Math.trunc(daysRaw), 1), MAX_DAYS) + : DEFAULT_DAYS; + + const buckets: CostBucket[] = []; + let page: string | null = null; + let pagesLeft = MAX_PAGES; + + do { + const params = new URLSearchParams({ + starting_at: windowStart(days), + bucket_width: "1d", + limit: String(MAX_DAYS), + }); + if (page) params.set("page", page); + + const response = await fetch( + `https://api.anthropic.com/v1/organizations/cost_report?${params.toString()}`, + { + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + }, ); - } - const data = await response.json(); - return NextResponse.json(data); + if (!response.ok) { + // Log full body server-side; do not echo upstream text to the browser + // since it may contain request headers or tokens on certain 4xx responses. + const errorText = await response.text(); + console.error( + `[admin/cost] Anthropic API ${response.status}: ${errorText.slice(0, 500)}`, + ); + return NextResponse.json( + { error: `Anthropic API error: ${response.status}` }, + { status: response.status }, + ); + } + + const json = (await response.json()) as CostReportPage; + buckets.push(...(json.data ?? [])); + page = json.has_more && json.next_page ? json.next_page : null; + pagesLeft -= 1; + } while (page && pagesLeft > 0); + + // Amounts are decimal strings in cents; sum in cents, convert once. + let totalCents = 0; + const daily = buckets + .map((bucket) => { + let bucketCents = 0; + for (const result of bucket.results ?? []) { + const amount = Number.parseFloat(result.amount); + if (Number.isFinite(amount)) bucketCents += amount; + } + totalCents += bucketCents; + return { + date: bucket.starting_at.slice(0, 10), + costUsd: bucketCents / 100, + }; + }) + .sort((a, b) => a.date.localeCompare(b.date)); + + const summary: AdminCostSummary = { + totalCostUsd: totalCents / 100, + daily, + days, + truncated: page !== null, + }; + return NextResponse.json(summary); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json( diff --git a/packages/web/src/app/usage/page.tsx b/packages/web/src/app/usage/page.tsx index ce08c1f..bb3149d 100644 --- a/packages/web/src/app/usage/page.tsx +++ b/packages/web/src/app/usage/page.tsx @@ -1,7 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -import { useAdminStatus, useAdminCost, useAdminAnalytics } from "@/hooks/useAdminApi"; +import { + useAdminStatus, + useAdminCost, + useAdminAnalytics, + type CostData, + type AnalyticsData, +} from "@/hooks/useAdminApi"; import { MetricCard } from "@/components/MetricCard"; function formatCost(usd: number): string { @@ -167,52 +173,64 @@ function NotConfiguredBanner() { ); } -function CostOverview({ data }: { data: Record }) { - const totalSpend = - typeof data.total_cost === "number" ? data.total_cost : 0; - const dailyData = Array.isArray(data.daily) ? data.daily : []; - const dailyAvg = - dailyData.length > 0 ? totalSpend / dailyData.length : 0; - const periodDays = dailyData.length || 1; +function CostOverview({ data }: { data: CostData }) { + const totalSpend = data.totalCostUsd; + // Average over days with actual spend rather than the whole window, so a + // week-old org doesn't see its daily rate diluted by 23 empty days. + const activeDays = data.daily.filter((d) => d.costUsd > 0).length || 1; + const dailyAvg = totalSpend / activeDays; return (

- Cost Overview + Cost Overview{" "} + + (org-wide, last {data.days}d) +

- +
+ {data.truncated && ( +

+ Partial data: the Anthropic cost report returned more pages than were + fetched. +

+ )}
); } -function TokenUsage({ data }: { data: Record }) { +function TokenUsage({ data }: { data: AnalyticsData }) { const inputTokens = - typeof data.input_tokens === "number" ? data.input_tokens : 0; - const outputTokens = - typeof data.output_tokens === "number" ? data.output_tokens : 0; + data.uncachedInputTokens + + data.cacheReadInputTokens + + data.cacheCreationInputTokens; + const outputTokens = data.outputTokens; const totalTokens = inputTokens + outputTokens; + const cachedShare = + inputTokens > 0 + ? (data.cacheReadInputTokens / inputTokens) * 100 + : 0; return (

- Token Usage + Token Usage{" "} + + (org-wide, last {data.days}d) +

@@ -220,8 +238,8 @@ function TokenUsage({ data }: { data: Record }) { label="Input Tokens" value={formatTokens(inputTokens)} sub={ - totalTokens > 0 - ? `${((inputTokens / totalTokens) * 100).toFixed(0)}% of total` + inputTokens > 0 + ? `${cachedShare.toFixed(0)}% served from cache` : undefined } /> @@ -239,37 +257,6 @@ function TokenUsage({ data }: { data: Record }) { ); } -function ActivitySummary({ data }: { data: Record }) { - const sessions = - typeof data.total_sessions === "number" ? data.total_sessions : 0; - const linesOfCode = - typeof data.lines_of_code === "number" ? data.lines_of_code : null; - const requests = - typeof data.total_requests === "number" ? data.total_requests : 0; - - return ( -
-

- Activity Summary -

-
- - - {linesOfCode !== null && ( - = 1000 - ? `${(linesOfCode / 1000).toFixed(1)}K` - : String(linesOfCode) - } - /> - )} -
-
- ); -} - function useLocalUsage(): { data: LocalUsage | null; loading: boolean } { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -464,10 +451,7 @@ export default function UsagePage() {

) : analyticsData ? ( - <> - - - + ) : null} )} diff --git a/packages/web/src/hooks/useAdminApi.ts b/packages/web/src/hooks/useAdminApi.ts index d0c8a47..9bdb3af 100644 --- a/packages/web/src/hooks/useAdminApi.ts +++ b/packages/web/src/hooks/useAdminApi.ts @@ -5,12 +5,23 @@ interface AdminStatus { configured: boolean; } -interface CostData { - [key: string]: unknown; +// Normalized shapes returned by /api/admin/{cost,analytics} — the routes +// aggregate the raw Anthropic Admin API reports server-side. +export interface CostData { + totalCostUsd: number; + daily: Array<{ date: string; costUsd: number }>; + days: number; + truncated: boolean; } -interface AnalyticsData { - [key: string]: unknown; +export interface AnalyticsData { + uncachedInputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + outputTokens: number; + webSearchRequests: number; + days: number; + truncated: boolean; } const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -53,7 +64,7 @@ export function useAdminStatus() { return { configured, loading }; } -export function useAdminCost(startDate?: string, endDate?: string) { +export function useAdminCost(days?: number) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -61,7 +72,7 @@ export function useAdminCost(startDate?: string, endDate?: string) { const cacheKeyRef = useRef(""); const fetchCost = useCallback(async () => { - const cacheKey = `cost:${startDate ?? ""}:${endDate ?? ""}`; + const cacheKey = `cost:${days ?? ""}`; // Check cache if ( @@ -79,8 +90,7 @@ export function useAdminCost(startDate?: string, endDate?: string) { try { const params = new URLSearchParams(); - if (startDate) params.set("start_date", startDate); - if (endDate) params.set("end_date", endDate); + if (days !== undefined) params.set("days", String(days)); const queryString = params.toString(); const url = `/api/admin/cost${queryString ? `?${queryString}` : ""}`; @@ -102,7 +112,7 @@ export function useAdminCost(startDate?: string, endDate?: string) { } finally { setLoading(false); } - }, [startDate, endDate]); + }, [days]); useEffect(() => { fetchCost(); @@ -111,7 +121,7 @@ export function useAdminCost(startDate?: string, endDate?: string) { return { data, loading, error }; } -export function useAdminAnalytics(startDate?: string, endDate?: string) { +export function useAdminAnalytics(days?: number) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -119,7 +129,7 @@ export function useAdminAnalytics(startDate?: string, endDate?: string) { const cacheKeyRef = useRef(""); const fetchAnalytics = useCallback(async () => { - const cacheKey = `analytics:${startDate ?? ""}:${endDate ?? ""}`; + const cacheKey = `analytics:${days ?? ""}`; // Check cache if ( @@ -137,8 +147,7 @@ export function useAdminAnalytics(startDate?: string, endDate?: string) { try { const params = new URLSearchParams(); - if (startDate) params.set("start_date", startDate); - if (endDate) params.set("end_date", endDate); + if (days !== undefined) params.set("days", String(days)); const queryString = params.toString(); const url = `/api/admin/analytics${queryString ? `?${queryString}` : ""}`; @@ -160,7 +169,7 @@ export function useAdminAnalytics(startDate?: string, endDate?: string) { } finally { setLoading(false); } - }, [startDate, endDate]); + }, [days]); useEffect(() => { fetchAnalytics();