Conversation
|
@saphitv is attempting to deploy a commit to the Piebald Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis pull request introduces a comprehensive analytics dashboard with statistics visualization, data aggregation, and per-application breakdowns. It adds database schema fields for tracking conversations and model usage, creates new API endpoints for stats retrieval and model-level metrics, and implements client-side components for displaying tables and charts. The changes span the database layer, backend aggregation logic, and a full frontend analytics interface. Changes
Sequence DiagramsequenceDiagram
actor User
participant Browser
participant Auth
participant API
participant Database
participant Formatter
User->>Browser: Navigate to /analytics
Browser->>Auth: Check session
Auth-->>Browser: Return session (authenticated)
Browser->>API: GET /api/user/{id}/stats?period=daily&timezone=UTC
API->>Database: Query user_stats for date range
Database-->>API: Return DailyStatsRow[] (aggregated stats)
API->>API: Accumulate totals by application<br/>Merge per-app accumulators<br/>Compute grandTotal
API-->>Browser: Return { stats, grandTotal }
Browser->>Browser: Fetch exchange rates if needed<br/>Load user preferences
Browser->>Formatter: formatConvertedCurrency(cost)<br/>formatLargeNumber(tokens)
Formatter-->>Browser: Formatted values
Browser->>Browser: Build SourceBadges, StatsOverview<br/>Render StatsCharts, Tables
Browser-->>User: Display analytics dashboard
User->>Browser: Select app source / change period
Browser->>API: GET /api/user/{id}/stats?period=weekly&application=X
API->>Database: Re-query with filters
Database-->>API: Return filtered results
API-->>Browser: Return updated stats
Browser-->>User: Update charts and tables
Estimated code review effort🎯 4 (Complex) | ⏱️ ~70 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR proposes a redesigned dashboard/analytics UI for Splitrail Cloud (Issue #34) and extends the backend stats pipeline to persist and serve additional metrics (conversations + model lists) for richer tables/charts.
Changes:
- Persist
conversationsandmodelsonuser_statsand populate them during upload + recalculation flows. - Replace the legacy stats page implementation with a modular dashboard (source badges, overview cards, charts, totals table) and introduce an
/analyticsroute with daily/weekly/monthly grouping. - Add reusable page loading skeletons and wire route-level
loading.tsxfallbacks across key pages.
Reviewed changes
Copilot reviewed 42 out of 42 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/stats-recalculation.ts | Extends period aggregation to compute/store conversations + distinct models. |
| src/hooks/use-format-converted-currency.ts | New hook to centralize preference + exchange-rate fetching and currency formatting. |
| src/components/ui/page-loading.tsx | New two-phase loading wrapper and reusable skeleton layouts. |
| src/components/navbar.tsx | Adds authenticated navigation links and active-state styling. |
| src/components/main.tsx | Tweaks container padding/layout for new UI density. |
| src/components/cli-token-display.tsx | Refactors token management to React Query + adds CLI setup dialog content. |
| src/components/auth/session-provider.tsx | Passes server-fetched session into SessionProvider. |
| src/app/stats-page.tsx | Rebuilds dashboard page using new modular _stats components. |
| src/app/settings/page.tsx | Switches settings loading UI to skeleton + adds fade-in transitions. |
| src/app/settings/loading.tsx | Adds route-level settings skeleton loading boundary. |
| src/app/loading.tsx | Adds route-level dashboard skeleton loading boundary. |
| src/app/leaderboard/page.tsx | Adds fade-in transition wrapper. |
| src/app/leaderboard/loading.tsx | Adds route-level leaderboard skeleton loading boundary. |
| src/app/layout.tsx | Fetches session server-side and hydrates auth provider with it. |
| src/app/api/user/[userId]/stats/types.ts | Adds typed helpers for stats shaping and totals merging. |
| src/app/api/user/[userId]/stats/route.ts | Serves stats from user_stats with period support + totals/grand totals shaping. |
| src/app/api/user/[userId]/stats/models/route.ts | New endpoint for per-model breakdown used by charts. |
| src/app/api/user/[userId]/recalculate-stats/route.ts | Adds conversation + model aggregation during rebuild. |
| src/app/api/upload-stats/route.ts | Adds per-bucket conversation/model computation when uploading. |
| src/app/api/leaderboard/route.ts | Removes email from leaderboard response/query. |
| src/app/analytics/page.tsx | Adds authenticated analytics route wrapper + metadata. |
| src/app/analytics/loading.tsx | Adds route-level analytics skeleton loading boundary. |
| src/app/analytics/analytics-page.tsx | Implements analytics UI with period toggle and per-app/total tables. |
| src/app/_stats/types.ts | Shared client-side stats types for dashboard/analytics UI. |
| src/app/_stats/total-stats-table.tsx | Totals-by-source table for dashboard. |
| src/app/_stats/total-daily-stats-table.tsx | “All sources” daily/weekly/monthly breakdown table. |
| src/app/_stats/stats-table-shell.tsx | Shared sortable table shell wrapper (TanStack Table). |
| src/app/_stats/stats-overview.tsx | Overview cards + “Advanced insights” section. |
| src/app/_stats/stats-footer-cells.tsx | Shared footer metric cells for stats tables. |
| src/app/_stats/stats-charts.tsx | Activity area chart + model breakdown bar chart. |
| src/app/_stats/stats-charts-utils.ts | Chart data builders and UTC date range helpers. |
| src/app/_stats/stats-charts-tooltips.tsx | Custom tooltips for charts. |
| src/app/_stats/stats-charts-legends.tsx | Interactive legends for area/bar series toggles. |
| src/app/_stats/stats-charts-config.ts | Chart constants/types (series config, palettes, period options). |
| src/app/_stats/stat-card.tsx | Reusable stat card UI primitive. |
| src/app/_stats/source-badges.tsx | Source selection chips (“All Sources” + per-app). |
| src/app/_stats/setup-instructions.tsx | Setup instructions shown when no stats exist. |
| src/app/_stats/date-helpers.ts | Shared date formatting/grouping helpers for periods. |
| src/app/_stats/app-stats-table.tsx | Per-app breakdown table w/ empty-days + day-over-day deltas. |
| prisma/schema.prisma | Adds conversations + models fields to UserStats. |
| prisma/migrations/20260224154500_add_user_stats_conversations_models/migration.sql | Migration to add new columns with defaults. |
| pnpm-workspace.yaml | Defines workspace packages root entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const copyToken = async (token: string, tokenId: string) => { | ||
| try { | ||
| if (navigator.clipboard && navigator.clipboard.writeText) { | ||
| await navigator.clipboard.writeText(token); | ||
| } else { | ||
| const textArea = document.createElement("textarea"); | ||
| textArea.value = token; | ||
| textArea.style.position = "fixed"; | ||
| textArea.style.opacity = "0"; | ||
| document.body.appendChild(textArea); | ||
| textArea.focus(); | ||
| textArea.select(); | ||
| document.execCommand("copy"); | ||
| document.body.removeChild(textArea); | ||
| } | ||
| await navigator.clipboard.writeText(token); | ||
| setCopiedTokens((prev) => new Set([...prev, tokenId])); | ||
| toast.success("Copied to clipboard"); |
There was a problem hiding this comment.
navigator.clipboard.writeText can be unavailable or throw (e.g., non-secure contexts, some browsers/permissions). Previously there was a fallback copy implementation; removing it can make “Copy” silently fail for some users. Consider feature-detecting navigator.clipboard?.writeText and falling back to a textarea/execCommand path when unsupported.
| const { data: statsData } = useQuery({ | ||
| queryKey: ["userStats", session?.user?.id], | ||
| queryFn: async () => { | ||
| if (!session?.user?.id) throw new Error("No user session"); | ||
|
|
||
| const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; | ||
| const response = await fetch( | ||
| `/api/user/${session.user.id}/stats?timezone=${encodeURIComponent(timezone)}` | ||
| ); | ||
| if (!response.ok) throw new Error("Failed to fetch stats"); | ||
|
|
||
| const data = await response.json(); | ||
| if (data.success) { | ||
| return data.data as StatsData; | ||
| } | ||
| if (data.success) return data.data as StatsData; | ||
| throw new Error("Failed to fetch stats"); | ||
| }, | ||
| enabled: !!session?.user?.id, | ||
| }); |
There was a problem hiding this comment.
The stats query result isn’t handling error states. If /api/user/:id/stats fails, statsData stays undefined and the page will keep rendering DashboardSkeleton, which looks like a permanent loading state. Consider destructuring isError/error (and possibly isLoading) from the stats query and showing an error message + retry action similar to the profile query.
| queryFn: async () => { | ||
| const response = await fetch("/api/exchange-rates"); | ||
| const data = await response.json(); | ||
| if (data.success) return data as ExchangeRatesResponse; | ||
| throw new Error("Failed to fetch exchange rates"); | ||
| }, |
There was a problem hiding this comment.
The exchange-rates query parses JSON without checking response.ok. If the endpoint returns a non-JSON error body (or an error status), response.json() may throw and the surfaced error won’t be very actionable. Consider checking response.ok first and throwing a more specific error (optionally including status) before attempting to parse JSON.
| const dailyRows = await db.$queryRaw<DailyStatsRow[]>` | ||
| SELECT | ||
| ("periodStart" AT TIME ZONE 'UTC' AT TIME ZONE ${timezone})::date AS day, | ||
| application, | ||
| cost AS total_cost, | ||
| "cachedTokens" AS cached_tokens, | ||
| "inputTokens" AS input_tokens, | ||
| "outputTokens" AS output_tokens, | ||
| "reasoningTokens" AS reasoning_tokens, | ||
| "cacheCreationTokens" AS cache_creation_tokens, | ||
| "cacheReadTokens" AS cache_read_tokens, | ||
| "toolCalls" AS tool_calls, | ||
| "terminalCommands" AS terminal_commands, | ||
| "fileSearches" AS file_searches, | ||
| "fileContentSearches" AS file_content_searches, | ||
| "filesRead" AS files_read, | ||
| "filesAdded" AS files_added, | ||
| "filesEdited" AS files_edited, | ||
| "filesDeleted" AS files_deleted, | ||
| "linesRead" AS lines_read, | ||
| "linesEdited" AS lines_edited, | ||
| "linesAdded" AS lines_added, | ||
| conversations, | ||
| models | ||
| FROM user_stats | ||
| WHERE "userId" = ${userId} | ||
| AND period = ${period} | ||
| ORDER BY application, day | ||
| `; | ||
|
|
There was a problem hiding this comment.
For period=weekly/monthly, periodStart values in user_stats are computed using UTC boundaries (see getPeriodStartForDateInTimezone, which only applies timezone logic for daily). Converting periodStart into a local ::date here can shift the bucket key by a day for negative/positive offsets, which can throw off week/month labels and client-side range-filling logic (e.g., ISO week calculations). Consider only applying the timezone AT TIME ZONE conversion for daily, or alternatively make weekly/monthly periodStart generation timezone-aware end-to-end so the stored boundaries match what the API returns.
| const dailyRows = await db.$queryRaw<DailyStatsRow[]>` | |
| SELECT | |
| ("periodStart" AT TIME ZONE 'UTC' AT TIME ZONE ${timezone})::date AS day, | |
| application, | |
| cost AS total_cost, | |
| "cachedTokens" AS cached_tokens, | |
| "inputTokens" AS input_tokens, | |
| "outputTokens" AS output_tokens, | |
| "reasoningTokens" AS reasoning_tokens, | |
| "cacheCreationTokens" AS cache_creation_tokens, | |
| "cacheReadTokens" AS cache_read_tokens, | |
| "toolCalls" AS tool_calls, | |
| "terminalCommands" AS terminal_commands, | |
| "fileSearches" AS file_searches, | |
| "fileContentSearches" AS file_content_searches, | |
| "filesRead" AS files_read, | |
| "filesAdded" AS files_added, | |
| "filesEdited" AS files_edited, | |
| "filesDeleted" AS files_deleted, | |
| "linesRead" AS lines_read, | |
| "linesEdited" AS lines_edited, | |
| "linesAdded" AS lines_added, | |
| conversations, | |
| models | |
| FROM user_stats | |
| WHERE "userId" = ${userId} | |
| AND period = ${period} | |
| ORDER BY application, day | |
| `; | |
| let dailyRows: DailyStatsRow[]; | |
| if (period === "daily") { | |
| dailyRows = await db.$queryRaw<DailyStatsRow[]>` | |
| SELECT | |
| ("periodStart" AT TIME ZONE 'UTC' AT TIME ZONE ${timezone})::date AS day, | |
| application, | |
| cost AS total_cost, | |
| "cachedTokens" AS cached_tokens, | |
| "inputTokens" AS input_tokens, | |
| "outputTokens" AS output_tokens, | |
| "reasoningTokens" AS reasoning_tokens, | |
| "cacheCreationTokens" AS cache_creation_tokens, | |
| "cacheReadTokens" AS cache_read_tokens, | |
| "toolCalls" AS tool_calls, | |
| "terminalCommands" AS terminal_commands, | |
| "fileSearches" AS file_searches, | |
| "fileContentSearches" AS file_content_searches, | |
| "filesRead" AS files_read, | |
| "filesAdded" AS files_added, | |
| "filesEdited" AS files_edited, | |
| "filesDeleted" AS files_deleted, | |
| "linesRead" AS lines_read, | |
| "linesEdited" AS lines_edited, | |
| "linesAdded" AS lines_added, | |
| conversations, | |
| models | |
| FROM user_stats | |
| WHERE "userId" = ${userId} | |
| AND period = ${period} | |
| ORDER BY application, day | |
| `; | |
| } else { | |
| dailyRows = await db.$queryRaw<DailyStatsRow[]>` | |
| SELECT | |
| "periodStart"::date AS day, | |
| application, | |
| cost AS total_cost, | |
| "cachedTokens" AS cached_tokens, | |
| "inputTokens" AS input_tokens, | |
| "outputTokens" AS output_tokens, | |
| "reasoningTokens" AS reasoning_tokens, | |
| "cacheCreationTokens" AS cache_creation_tokens, | |
| "cacheReadTokens" AS cache_read_tokens, | |
| "toolCalls" AS tool_calls, | |
| "terminalCommands" AS terminal_commands, | |
| "fileSearches" AS file_searches, | |
| "fileContentSearches" AS file_content_searches, | |
| "filesRead" AS files_read, | |
| "filesAdded" AS files_added, | |
| "filesEdited" AS files_edited, | |
| "filesDeleted" AS files_deleted, | |
| "linesRead" AS lines_read, | |
| "linesEdited" AS lines_edited, | |
| "linesAdded" AS lines_added, | |
| conversations, | |
| models | |
| FROM user_stats | |
| WHERE "userId" = ${userId} | |
| AND period = ${period} | |
| ORDER BY application, day | |
| `; | |
| } |
| WITH base AS ( | ||
| SELECT | ||
| date, | ||
| application, | ||
| role, | ||
| "inputTokens", | ||
| "outputTokens", | ||
| "cacheCreationTokens", | ||
| "cacheReadTokens", | ||
| "cachedTokens", | ||
| "reasoningTokens", | ||
| cost, | ||
| "toolCalls", | ||
| "filesRead", | ||
| "filesAdded", | ||
| "filesEdited", | ||
| "filesDeleted", | ||
| "linesRead", | ||
| "linesAdded", | ||
| "linesEdited", | ||
| "linesDeleted", | ||
| "bytesRead", | ||
| "bytesAdded", | ||
| "bytesEdited", | ||
| "bytesDeleted", | ||
| "codeLines", | ||
| "docsLines", | ||
| "dataLines", | ||
| "mediaLines", | ||
| "configLines", | ||
| "otherLines", | ||
| "terminalCommands", | ||
| "fileSearches", | ||
| "fileContentSearches", | ||
| "todosCreated", | ||
| "todosCompleted", | ||
| "todosInProgress", | ||
| "todoWrites", | ||
| "todoReads", | ||
| "conversationHash", | ||
| model | ||
| FROM message_stats | ||
| WHERE "userId" = ${userId} | ||
| AND application = ANY(${applications}) | ||
| ), | ||
| aggregated_stats AS ( | ||
| SELECT | ||
| date_trunc(${config.truncUnit}, date) AS period_start, | ||
| application, | ||
| COALESCE(SUM("toolCalls"), 0)::bigint AS tool_calls, | ||
| COALESCE(SUM(CASE WHEN role = 'assistant' THEN 1 ELSE 0 END), 0)::bigint AS assistant_messages, | ||
| COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0)::bigint AS user_messages, | ||
| COALESCE(SUM("inputTokens"), 0)::bigint AS input_tokens, | ||
| COALESCE(SUM("outputTokens"), 0)::bigint AS output_tokens, | ||
| COALESCE(SUM("cacheCreationTokens"), 0)::bigint AS cache_creation_tokens, | ||
| COALESCE(SUM("cacheReadTokens"), 0)::bigint AS cache_read_tokens, | ||
| COALESCE(SUM("cachedTokens"), 0)::bigint AS cached_tokens, | ||
| COALESCE(SUM("reasoningTokens"), 0)::bigint AS reasoning_tokens, | ||
| COALESCE(SUM(cost), 0)::float AS cost, | ||
| COALESCE(SUM("filesRead"), 0)::bigint AS files_read, | ||
| COALESCE(SUM("filesAdded"), 0)::bigint AS files_added, | ||
| COALESCE(SUM("filesEdited"), 0)::bigint AS files_edited, | ||
| COALESCE(SUM("filesDeleted"), 0)::bigint AS files_deleted, | ||
| COALESCE(SUM("linesRead"), 0)::bigint AS lines_read, | ||
| COALESCE(SUM("linesAdded"), 0)::bigint AS lines_added, | ||
| COALESCE(SUM("linesEdited"), 0)::bigint AS lines_edited, | ||
| COALESCE(SUM("linesDeleted"), 0)::bigint AS lines_deleted, | ||
| COALESCE(SUM("bytesRead"), 0)::bigint AS bytes_read, | ||
| COALESCE(SUM("bytesAdded"), 0)::bigint AS bytes_added, | ||
| COALESCE(SUM("bytesEdited"), 0)::bigint AS bytes_edited, | ||
| COALESCE(SUM("bytesDeleted"), 0)::bigint AS bytes_deleted, | ||
| COALESCE(SUM("codeLines"), 0)::bigint AS code_lines, | ||
| COALESCE(SUM("docsLines"), 0)::bigint AS docs_lines, | ||
| COALESCE(SUM("dataLines"), 0)::bigint AS data_lines, | ||
| COALESCE(SUM("mediaLines"), 0)::bigint AS media_lines, | ||
| COALESCE(SUM("configLines"), 0)::bigint AS config_lines, | ||
| COALESCE(SUM("otherLines"), 0)::bigint AS other_lines, | ||
| COALESCE(SUM("terminalCommands"), 0)::bigint AS terminal_commands, | ||
| COALESCE(SUM("fileSearches"), 0)::bigint AS file_searches, | ||
| COALESCE(SUM("fileContentSearches"), 0)::bigint AS file_content_searches, | ||
| COALESCE(SUM("todosCreated"), 0)::bigint AS todos_created, | ||
| COALESCE(SUM("todosCompleted"), 0)::bigint AS todos_completed, | ||
| COALESCE(SUM("todosInProgress"), 0)::bigint AS todos_in_progress, | ||
| COALESCE(SUM("todoWrites"), 0)::bigint AS todo_writes, | ||
| COALESCE(SUM("todoReads"), 0)::bigint AS todo_reads, | ||
| ARRAY_AGG(DISTINCT model) FILTER (WHERE model IS NOT NULL) AS models | ||
| FROM base | ||
| WHERE date_trunc(${config.truncUnit}, date) = ANY(${config.periodStarts}) | ||
| GROUP BY period_start, application | ||
| HAVING COUNT(*) > 0 | ||
| ), | ||
| conversation_starts AS ( | ||
| SELECT | ||
| application, | ||
| "conversationHash", | ||
| MIN(date) AS first_message_at | ||
| FROM base | ||
| GROUP BY application, "conversationHash" | ||
| ), |
There was a problem hiding this comment.
The new aggregation query builds base without any date constraint, but conversation_starts groups over the entire base set. When recalculateUserStats is triggered for a small affected window (e.g., after deleting a few days), this will still scan and group all historical message_stats for the user/applications, which can be a major performance regression / timeout risk. Consider restricting the conversation-start computation to only conversation hashes that appear in the affected periods (e.g., a candidate_conversations CTE from the affected window, then compute MIN(date) for just those hashes), or otherwise add a selective filter that avoids a full-history scan per period type.
| onError: (error) => { | ||
| toast.error(error.message || "Failed to create token"); | ||
| }, |
There was a problem hiding this comment.
useMutation callbacks receive error as unknown by default in React Query v5. With strict: true, error.message will be a type error here. Match the pattern used elsewhere (e.g., error instanceof Error ? error.message : ...) or provide a typed TError generic so this compiles safely.
| onError: (error) => { | ||
| toast.error(error.message || "Failed to delete token"); | ||
| }, |
There was a problem hiding this comment.
Same typing issue as above: error is unknown by default for React Query mutations, so error.message is not type-safe under strict mode. Please guard with error instanceof Error (or type the mutation error).
There was a problem hiding this comment.
Actionable comments posted: 19
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/stats-page.tsx (1)
43-57:⚠️ Potential issue | 🟠 MajorHandle stats-query error state explicitly
statsData === undefinedcurrently maps toDashboardSkeleton, so a failed stats request can appear as a perpetual loading state. Please trackisLoading/errorfrom this query and render a proper error fallback.Also applies to: 112-116
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/stats-page.tsx` around lines 43 - 57, The component currently only reads const { data: statsData } = useQuery(...) so failed requests leave statsData undefined and show DashboardSkeleton indefinitely; update the useQuery call to destructure isLoading and error (e.g., const { data: statsData, isLoading, error } = useQuery(...)) and change the render logic where statsData === undefined maps to DashboardSkeleton to explicitly check isLoading to show the skeleton and check error to render a proper error fallback (with a retry button or message); apply the same change to the other query instance referenced in the file (the similar block around lines 112-116) so both useQuery callers handle isLoading and error states explicitly.
🧹 Nitpick comments (14)
src/components/navbar.tsx (1)
33-50: Consider nested route handling and accessibility enhancement.The navigation implementation is clean, but two optional improvements:
Nested routes: The exact match
pathname === hrefwon't highlight parent links for nested routes (e.g., visiting/settings/profilewon't highlight "Settings"). If nested routes are planned, consider usingstartsWithfor non-root paths.Accessibility: Adding
aria-current="page"to active links improves screen reader navigation.♻️ Optional enhancement
<Link key={href} href={href} + aria-current={pathname === href ? "page" : undefined} className={cn( "px-3 py-1.5 rounded-md text-sm font-medium transition-colors", - pathname === href + (href === "/" ? pathname === href : pathname.startsWith(href)) ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted/60" )} >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/navbar.tsx` around lines 33 - 50, The active-link logic using pathname === href doesn't handle nested routes and lacks accessibility attributes: update the render of NAV_LINKS (inside the nav block using Link, pathname, and cn) to compute an isActive boolean that treats root ("/") with exact equality but uses pathname.startsWith(href) for non-root hrefs so parent links are highlighted for nested routes, and pass aria-current="page" on the Link when isActive; keep the existing className branching but base it on isActive instead of pathname === href.src/app/_stats/stats-footer-cells.tsx (1)
35-61: Potential precision loss when converting BigInt to Number.The coding guidelines specify using
BigInt.toString()when calculating totals from stats. UsingNumber()on values that originate from BigInt fields (like token counts) can cause precision loss for values exceedingNumber.MAX_SAFE_INTEGER(~9 quadrillion).Since
formatLargeNumberaccepts strings, consider using.toString()instead ofNumber()to preserve precision:♻️ Suggested refactor to preserve BigInt precision
- <TableCell>{formatLargeNumber(Number(totals.cachedTokens ?? 0))}</TableCell> - <TableCell>{formatLargeNumber(Number(totals.inputTokens ?? 0))}</TableCell> + <TableCell>{formatLargeNumber(String(totals.cachedTokens ?? 0))}</TableCell> + <TableCell>{formatLargeNumber(String(totals.inputTokens ?? 0))}</TableCell>Apply the same pattern to all other cells using
Number()conversion.Based on learnings: "Use BigInt.toString() when calculating totals from stats to handle BigInt correctly".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/stats-footer-cells.tsx` around lines 35 - 61, The cells currently convert BigInt-derived totals using Number(...) which can lose precision; update each TableCell to pass strings to formatLargeNumber by converting via BigInt(...).toString() for single fields (e.g. replace formatLargeNumber(Number(totals.cachedTokens ?? 0)) with formatLargeNumber(BigInt(totals.cachedTokens ?? 0).toString())) and for summed values use BigInt additions then toString() (e.g. formatLargeNumber((BigInt(totals.fileSearches ?? 0) + BigInt(totals.fileContentSearches ?? 0)).toString())); apply this pattern to all uses of Number(...) (references: formatLargeNumber, totals.* fields, and the TableCell entries in stats-footer-cells.tsx).src/app/_stats/source-badges.tsx (1)
7-7: Use alias import forStatsData.Please replace the relative
./typesimport with the@/alias form used across the codebase.Suggested change
-import { type StatsData } from "./types"; +import { type StatsData } from "@/app/_stats/types";As per coding guidelines "
**/*.{ts,tsx,js,jsx}: Use the@/* path alias for all imports, mapping to ./src/*".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/source-badges.tsx` at line 7, The import for the StatsData type uses a relative path; change it to the repository path-alias form used everywhere: replace the current import of "StatsData" from "./types" with the alias import from "@/app/_stats/types" (i.e., import { type StatsData } from "@/app/_stats/types"), updating the import in source-badges.tsx to use the `@/` alias.src/app/api/upload-stats/route.ts (1)
323-323: Make model arrays deterministic before persisting.
ARRAY_AGG(DISTINCT m.model)does not guarantee order, so row updates can churn even when the underlying set is unchanged. Sorting at query time keeps results stable.Suggested change
- ARRAY_AGG(DISTINCT m.model) FILTER (WHERE m.model IS NOT NULL) AS models + ARRAY_AGG(DISTINCT m.model ORDER BY m.model) FILTER (WHERE m.model IS NOT NULL) AS modelsAlso applies to: 375-375
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/upload-stats/route.ts` at line 323, The ARRAY_AGG(DISTINCT m.model) expression is non-deterministic; change both occurrences (the one producing AS models) to include an ORDER BY so the array is stable, e.g. use ARRAY_AGG(DISTINCT m.model ORDER BY m.model) FILTER (WHERE m.model IS NOT NULL) AS models (apply same change to the second occurrence around the code that builds models at line ~375) so persisted rows don't churn when the set is unchanged.src/app/_stats/total-stats-table.tsx (1)
15-15: Use@/alias instead of relative import.Switch
./typesto the project alias path for consistency with repo conventions.Suggested change
-import { type StatsData } from "./types"; +import { type StatsData } from "@/app/_stats/types";As per coding guidelines "
**/*.{ts,tsx,js,jsx}: Use the@/* path alias for all imports, mapping to ./src/*".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/total-stats-table.tsx` at line 15, Replace the relative import of the StatsData type in total-stats-table.tsx with the project alias import; locate the import line that reads import { type StatsData } from "./types" and change it to use the `@/` path alias that maps to src (e.g., import { type StatsData } from "@/app/_stats/types") so the file follows the repository's import conventions and linter rules.src/app/analytics/page.tsx (1)
4-4: Use the@/alias for this import.Replace the relative import with the project alias to stay consistent with repository import rules.
Suggested change
-import AnalyticsPage from "./analytics-page"; +import AnalyticsPage from "@/app/analytics/analytics-page";As per coding guidelines "
**/*.{ts,tsx,js,jsx}: Use the@/* path alias for all imports, mapping to ./src/*".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/analytics/page.tsx` at line 4, Replace the relative import of AnalyticsPage with the project alias: change the import statement that references "./analytics-page" to use the "@/app/analytics/analytics-page" path so AnalyticsPage is imported via the `@/` alias (update the import in the AnalyticsPage reference in page.tsx).src/app/_stats/stats-charts-legends.tsx (1)
15-25: Expose legend toggle state for assistive techThese buttons act as toggles; add
type="button"andaria-pressedso state is announced correctly.Also applies to: 45-57
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/stats-charts-legends.tsx` around lines 15 - 25, These legend buttons are missing button semantics for assistive tech; update the <button> in StatsChartsLegends (the element using s.key, onToggle, hidden, s.normKey, s.color, s.label) to include type="button" and an aria-pressed attribute that reflects the toggle state (set aria-pressed to !hidden.has(s.normKey) so true when visible, false when hidden). Apply the same change to the other identical button instance referenced (lines 45-57) so both toggle buttons expose proper pressed state and won't submit any enclosing form.src/app/_stats/stats-overview.tsx (1)
135-149: Make the Advanced Insights control an accessible disclosure buttonAdd
type="button"andaria-expanded(plusaria-controlsif you give the panel an id) so the collapsed/expanded state is explicit.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/stats-overview.tsx` around lines 135 - 149, The Advanced Insights toggle button currently lacks explicit accessibility attributes; update the button element that calls setShowAdvancedInsights and reads showAdvancedInsights to include type="button" and aria-expanded={showAdvancedInsights}, and add aria-controls="<panel-id>" (and give the corresponding panel element that renders the advanced insights the same id) so screen readers can determine and link the collapsed/expanded state.src/app/_stats/stats-charts.tsx (1)
195-205: Treat period/metric pills as toggle buttons semanticallyAdd
type="button"andaria-pressed={...}on these controls so state is accessible to screen readers.Also applies to: 307-317
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/stats-charts.tsx` around lines 195 - 205, The period/metric pill buttons currently use a plain <button> without semantic toggle attributes; update the button elements rendered in stats-charts (the ones that call setPeriod(opt.value) and the similar metric buttons) to include type="button" and aria-pressed={period === opt.value} (or appropriate metric state for the other group) so screen readers treat them as toggles; keep the existing onClick, classes and key, and apply the same change to the other occurrence (the metric pills around the block that references the metric state on lines 307-317).src/app/_stats/stats-charts-tooltips.tsx (1)
4-5: Switch relative imports to@/aliases.Please replace
./stats-charts-configimports with the@/alias form for consistency with repo standards.As per coding guidelines, "
**/*.{ts,tsx,js,jsx}: Use the@/* path alias for all imports, mapping to ./src/*".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/stats-charts-tooltips.tsx` around lines 4 - 5, Replace the relative imports from "./stats-charts-config" with the project path-alias form "@/app/_stats/stats-charts-config" (or the repo's equivalent alias mapping to src) so the two imported symbols AREA_SERIES and AreaChartPoint use the `@/` alias; update the import lines that currently import AREA_SERIES and type AreaChartPoint to use "@/..." instead of "./..." to conform to the repo's path-alias rule.src/app/_stats/date-helpers.ts (1)
3-3: Use@/alias import forAnalyticsPeriod.Please replace the relative import with the project alias path.
As per coding guidelines, "
**/*.{ts,tsx,js,jsx}: Use the@/* path alias for all imports, mapping to ./src/*".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/date-helpers.ts` at line 3, Replace the relative import of AnalyticsPeriod in date-helpers.ts with the project alias path; change the import that currently reads from "./types" to use the "@/app/_stats/types" alias so the file imports AnalyticsPeriod via import { type AnalyticsPeriod } from "@/app/_stats/types"; this updates the import to follow the project's `@/` mapping while keeping the same symbol name.src/app/_stats/total-daily-stats-table.tsx (1)
11-21: Use@/aliases for local imports in this TSX file.These internal imports should use the project alias instead of
./...paths.As per coding guidelines, "
**/*.{ts,tsx,js,jsx}: Use the@/* path alias for all imports, mapping to ./src/*".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/total-daily-stats-table.tsx` around lines 11 - 21, Update the local imports in this TSX file to use the project path alias instead of relative paths: change the imports for StatsFooterMetricCells, StatsTableShell, the types AnalyticsPeriod and StatsData, and the date helper functions addPeriod, formatDateForDisplay, getDateHoverText, getPeriodCountLabel, getPeriodLabel, and getPeriodStart to use "@/..." module paths (mapping to ./src/*) so all internal imports follow the `@/` alias convention.src/app/_stats/types.ts (1)
28-54: AlignGrandTotalcounter field types with BigInt-safe transport.
DayStatsupports stringified counters, butGrandTotalis hard-typed tonumber. This inconsistency encourages unsafe numeric coercion for large totals.As per coding guidelines, "
**/*.{ts,tsx}: Use BigInt.toString() when calculating totals from stats to handle BigInt correctly".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/types.ts` around lines 28 - 54, GrandTotal currently types many aggregate counter fields as number while DayStat supports stringified (BigInt-safe) counters; change the aggregate counter fields in the GrandTotal type (e.g., inputTokens, outputTokens, cachedTokens, reasoningTokens, cacheCreationTokens, cacheReadTokens, tokens, conversations, toolCalls, terminalCommands, fileSearches, fileContentSearches, filesRead, filesAdded, filesEdited, filesDeleted, linesRead, linesAdded, linesEdited) to string (or string|number if transitional) so they match DayStat's BigInt-safe transport, and update any code that computes totals to call BigInt.toString() per the guideline; reference the GrandTotal type and DayStat when making these updates.src/app/_stats/app-stats-table.tsx (1)
10-19: Use@/aliases for internal imports in this file.Local module imports should follow the repo alias convention instead of relative
./...paths.As per coding guidelines, "
**/*.{ts,tsx,js,jsx}: Use the@/* path alias for all imports, mapping to ./src/*".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/_stats/app-stats-table.tsx` around lines 10 - 19, Replace the relative imports at the top of the file with the repo alias form using "@/": change imports of StatsFooterMetricCells, StatsTableShell, and the types (AnalyticsPeriod, DayStat, StatsData) from "./stats-footer-cells", "./stats-table-shell", and "./types" to "@/app/_stats/stats-footer-cells", "@/app/_stats/stats-table-shell", and "@/app/_stats/types" respectively, and change the date helper import (addPeriod, formatDateForDisplay, getDateHoverText, getPeriodCountLabel, getPeriodStart) from "./date-helpers" to "@/app/_stats/date-helpers" so all internal module imports use the `@/` alias.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@prisma/migrations/20260224154500_add_user_stats_conversations_models/migration.sql`:
- Around line 1-3: Update src/types/index.ts to reflect the migration by adding
the new fields: add "conversations" to the BigIntStatKeys array, and add
conversations: bigint and models: string[] to both the Stats and StatsAsNumbers
interfaces (ensure types match: BigInt in DB maps to bigint in TS for
Stats/StatsAsNumbers and models is string[]). Locate and modify the
BigIntStatKeys constant and the Stats and StatsAsNumbers interface declarations
to include these new symbols so the TypeScript types stay in sync with the
Prisma schema.
In `@src/app/_stats/app-stats-table.tsx`:
- Around line 147-167: DeltaText is rendering currency deltas with
value.toFixed(2), causing mismatched formatting vs the main cost cell; replace
that branch to call the same currency formatter used by the main cost cell
(e.g., formatCurrency/formatMoney) instead of toFixed(2), preserve the positive
"+" prefix logic, and ensure the formatter is imported/available to DeltaText;
also update the other instance mentioned (around the second occurrence) so both
currency delta renderings use the shared formatter and remain consistent with
formatDelta for non-currency values.
In `@src/app/_stats/date-helpers.ts`:
- Around line 28-57: Replace the custom period logic in getPeriodStart and
addPeriod with the shared utilities from src/lib/dateUtils.ts: call
getPeriodStartForDate(date, period) inside getPeriodStart and use
getPeriodEndForDate or the appropriate add-period helper (e.g.,
getPeriodEndForDate(date, period) or a provided addPeriod helper) to compute the
next period in addPeriod so aggregation boundaries match backend; update imports
to bring in getPeriodStartForDate (and getPeriodEndForDate or addPeriod helper)
and remove the local date math in getPeriodStart and addPeriod.
In `@src/app/_stats/stats-charts-legends.tsx`:
- Line 3: Replace the relative import of AREA_SERIES with the repository alias
import style; in stats-charts-legends.tsx update the import that currently reads
from "./stats-charts-config" to use the `@/`* path alias (import AREA_SERIES from
"@/app/_stats/stats-charts-config" or the project's equivalent alias path) so it
follows the project's import convention and resolves to ./src/*.
In `@src/app/_stats/stats-charts-utils.ts`:
- Around line 2-10: Replace the relative imports in this module: change the
import that brings in StatsData and the import that brings in AreaChartPoint,
BarChartPoint, BarMetric, ModelData, Period, and RawDataPoint to use the
repository path-alias (`@/`*) instead of "./types" and "./stats-charts-config";
update the import specifiers so they reference the same symbols via the @ alias
(mapping to src) to comply with project import conventions.
- Around line 20-53: getDateRange reimplements period boundary math; switch it
to use the shared utilities getPeriodStartForDate(...) and
getPeriodEndForDate(...) from src/lib/dateUtils.ts so aggregations stay
consistent. Parse/validate customStart/customEnd into Date objects as before,
but instead of manual UTC arithmetic call getPeriodStartForDate(period,
startDate) and getPeriodEndForDate(period, endDate) (for non-custom cases call
those helpers with the appropriate reference date, e.g. today or firstDataDate
for "lifetime") to compute start and end, and return the resulting date-range
strings; keep the same fallback behavior when parsing fails but replace all
manual setUTCDate/date math with the helper calls and update references inside
getDateRange accordingly.
In `@src/app/_stats/stats-charts.tsx`:
- Around line 19-33: The imports in stats-charts.tsx use relative paths; change
them to the project alias format (`@/`...) so modules like StatsData,
AREA_CHART_CONFIG, AREA_SERIES, BAR_METRIC_OPTIONS, MODEL_PALETTE,
PERIOD_OPTIONS, BarMetric, ModelData, Period, buildAreaData, buildBarData,
getDateRange, AreaTooltip, BarTooltip, AreaLegend and BarLegend are imported via
'@/app/_stats/...' (or the appropriate alias root) instead of relative "./"
paths; update each import statement to use the `@/` alias while preserving the
exact exported symbol names.
In `@src/app/_stats/stats-overview.tsx`:
- Around line 19-21: The local relative imports in stats-overview.tsx should use
the project alias rather than "./" paths; replace "./stat-card" with
"@/app/_stats/stat-card" and "./types" with "@/app/_stats/types" so imports for
StatCard, DayStat, and GrandTotal use the `@/` path alias (leave the existing
Tooltip import as-is).
In `@src/app/_stats/total-daily-stats-table.tsx`:
- Around line 115-118: The current empty-row check only treats a day as
non-empty when Number(s.conversations ?? 0) > 0, which hides rows that have
tokens, cost, or tool activity; update the predicate in the loop that iterates
(Object.keys(dayData) as ApplicationType[]).forEach to push app if any of these
exist (e.g., Number(s.conversations ?? 0) > 0 || Number(s.tokens ?? 0) > 0 ||
Number(s.cost ?? 0) > 0 || (s.tools && Object.keys(s.tools).length > 0)); apply
the same updated logic to the other occurrence referenced (the block around
lines 157-160) so both places treat rows as non-empty when conversations,
tokens, cost, or tools indicate activity.
In `@src/app/analytics/analytics-page.tsx`:
- Around line 25-51: The empty-state currently conflates unauthenticated and
fetch-error cases with "No stats" — add explicit branches before the `if
(!statsData?.stats)` return: check authentication using `status ===
"unauthenticated"` or `!session?.user?.id` and return a clear unauthenticated
message; check the query error using the `isError`/`error` values from the
`useQuery` result (e.g. `statsLoading`, `isError`, `error`) and render an error
message that includes `error?.message`; keep the existing loading branch
(`status === "loading" || statsLoading`) and only fall through to the "No stats
data available yet." UI when authenticated, not loading, and there is no fetch
error.
In `@src/app/api/upload-stats/route.ts`:
- Around line 309-329: The per-bucket db.$queryRaw inside the affectedBuckets
loop (producing conversationAndModelData from message_stats) causes an expensive
round-trip per bucket; replace it with a single grouped query that computes
conversations and ARRAY_AGG(models) for all affected buckets in one shot (GROUP
BY the bucket identifier you use in affectedBuckets), then map those results
back to each bucket instead of issuing db.$queryRaw per iteration; also remove
any server-side BATCH_SIZE constraint in this route (and verify no duplicate
batching logic is present) so uploads rely on upstream CLI chunking.
In `@src/app/api/user/`[userId]/recalculate-stats/route.ts:
- Around line 150-151: The models array is created from a Set via
Array.from(modelSets.get(key) ?? []), which preserves insertion order and can
vary; replace that with a deterministically sorted array so saved output is
stable (e.g., build models from modelSets.get(key) and sort the resulting array)
— update the code that sets models (currently using
Array.from(modelSets.get(key) ?? [])) to produce a sorted array before
persisting.
In `@src/app/api/user/`[userId]/stats/route.ts:
- Around line 10-16: The import block using a local relative path should use the
repository alias; replace the relative import with the `@/` mapping that points to
./src. Update the import that currently brings in createEmptyTotalsAccumulator,
DailyStatsRow, mergeTotals, StatsRecord, and TotalsAccumulator so it uses the
`@/`* alias (e.g. "@/..." path that maps to src) instead of a relative "./types"
path, preserving all named imports exactly as shown.
- Around line 26-41: Validate the timezone before it's used in the SQL query:
attempt to construct an Intl.DateTimeFormat (e.g. new
Intl.DateTimeFormat(undefined, { timeZone: timezone })) or check membership in
Intl.supportedValuesOf('timeZone') and if it throws/doesn't contain the value
return NextResponse.json({ error: "Invalid timezone" }, { status: 400 }); ensure
this validation runs after reading timezone from searchParams and before using
timezone in the db.$queryRaw (the query that references ("periodStart" AT TIME
ZONE ${timezone} ...)).
- Around line 93-143: The code is converting DB BigInt stats to Number via n()
during accumulation, losing precision; instead keep token/count fields as bigint
in TotalsAccumulator and all accumulation steps (initialize fields in
createEmptyTotalsAccumulator to 0n and stop calling n(...) in the appTotals
updates and when building per-row accumulators), only convert BigInt to string
when serializing the API response (e.g., in the response transformer or right
before returning the payload). Update usages of n(), the TotalsAccumulator
shape, createEmptyTotalsAccumulator(), and the appTotals accumulation block so
tokens remain bigint throughout aggregation and are stringified at output.
In `@src/app/stats-page.tsx`:
- Around line 11-16: Replace the relative imports in stats-page.tsx with the
repository alias imports: import the named exports StatsOverview,
SetupInstructions, SourceBadges and SelectedSource, StatsCharts,
TotalStatsTable, and the type StatsData using the `@/`... path alias (e.g.
"@/...") instead of the current relative paths; update the import statements so
each import (StatsOverview, SetupInstructions, SourceBadges, SelectedSource,
StatsCharts, TotalStatsTable, StatsData) references its corresponding module via
the `@/` prefix to map to the src tree and keep names unchanged.
In `@src/components/cli-token-display.tsx`:
- Around line 166-169: The component currently picks the newest token using
tokens[0] which relies on API ordering; update the logic that computes
latestToken (used by setApiTokenCommand) to first sort the tokens array by
createdAt in descending order and then take the first element (e.g., sort tokens
by (a,b) => new Date(b.createdAt)-new Date(a.createdAt) to get the newest
token), so setApiTokenCommand always references the token with the most recent
createdAt timestamp.
- Around line 121-129: The click handlers createNewToken and deleteToken
currently await createTokenMutation.mutateAsync and
deleteTokenMutation.mutateAsync which can produce unhandled promise rejections;
change them to call createTokenMutation.mutate(newTokenName) and
deleteTokenMutation.mutate(tokenId) (or wrap the mutateAsync calls in try/catch)
so errors are handled by React Query's callbacks instead of returning a rejected
Promise; keep the session checks and adjust/remove the async/await on
createNewToken/deleteToken as needed.
In `@src/hooks/use-format-converted-currency.ts`:
- Around line 51-54: The hook currently formats the original USD amount using
the user's non-USD currency code when conversion data is missing; update the
logic in use-format-converted-currency (references: preferences, exchangeRates,
formatCurrency, amount, locale) so that whenever conversion is NOT performed
(e.g., missing exchangeRates.data or eurToUsd or currency === "USD" branch), you
explicitly format using "USD" as the currency code instead of
preferences.currency; ensure the same change is applied to the other similar
branches mentioned (around lines handling the 71-73 and 84-89 logic) so
displayed values are labeled USD unless a conversion was actually applied.
---
Outside diff comments:
In `@src/app/stats-page.tsx`:
- Around line 43-57: The component currently only reads const { data: statsData
} = useQuery(...) so failed requests leave statsData undefined and show
DashboardSkeleton indefinitely; update the useQuery call to destructure
isLoading and error (e.g., const { data: statsData, isLoading, error } =
useQuery(...)) and change the render logic where statsData === undefined maps to
DashboardSkeleton to explicitly check isLoading to show the skeleton and check
error to render a proper error fallback (with a retry button or message); apply
the same change to the other query instance referenced in the file (the similar
block around lines 112-116) so both useQuery callers handle isLoading and error
states explicitly.
---
Nitpick comments:
In `@src/app/_stats/app-stats-table.tsx`:
- Around line 10-19: Replace the relative imports at the top of the file with
the repo alias form using "@/": change imports of StatsFooterMetricCells,
StatsTableShell, and the types (AnalyticsPeriod, DayStat, StatsData) from
"./stats-footer-cells", "./stats-table-shell", and "./types" to
"@/app/_stats/stats-footer-cells", "@/app/_stats/stats-table-shell", and
"@/app/_stats/types" respectively, and change the date helper import (addPeriod,
formatDateForDisplay, getDateHoverText, getPeriodCountLabel, getPeriodStart)
from "./date-helpers" to "@/app/_stats/date-helpers" so all internal module
imports use the `@/` alias.
In `@src/app/_stats/date-helpers.ts`:
- Line 3: Replace the relative import of AnalyticsPeriod in date-helpers.ts with
the project alias path; change the import that currently reads from "./types" to
use the "@/app/_stats/types" alias so the file imports AnalyticsPeriod via
import { type AnalyticsPeriod } from "@/app/_stats/types"; this updates the
import to follow the project's `@/` mapping while keeping the same symbol name.
In `@src/app/_stats/source-badges.tsx`:
- Line 7: The import for the StatsData type uses a relative path; change it to
the repository path-alias form used everywhere: replace the current import of
"StatsData" from "./types" with the alias import from "@/app/_stats/types"
(i.e., import { type StatsData } from "@/app/_stats/types"), updating the import
in source-badges.tsx to use the `@/` alias.
In `@src/app/_stats/stats-charts-legends.tsx`:
- Around line 15-25: These legend buttons are missing button semantics for
assistive tech; update the <button> in StatsChartsLegends (the element using
s.key, onToggle, hidden, s.normKey, s.color, s.label) to include type="button"
and an aria-pressed attribute that reflects the toggle state (set aria-pressed
to !hidden.has(s.normKey) so true when visible, false when hidden). Apply the
same change to the other identical button instance referenced (lines 45-57) so
both toggle buttons expose proper pressed state and won't submit any enclosing
form.
In `@src/app/_stats/stats-charts-tooltips.tsx`:
- Around line 4-5: Replace the relative imports from "./stats-charts-config"
with the project path-alias form "@/app/_stats/stats-charts-config" (or the
repo's equivalent alias mapping to src) so the two imported symbols AREA_SERIES
and AreaChartPoint use the `@/` alias; update the import lines that currently
import AREA_SERIES and type AreaChartPoint to use "@/..." instead of "./..." to
conform to the repo's path-alias rule.
In `@src/app/_stats/stats-charts.tsx`:
- Around line 195-205: The period/metric pill buttons currently use a plain
<button> without semantic toggle attributes; update the button elements rendered
in stats-charts (the ones that call setPeriod(opt.value) and the similar metric
buttons) to include type="button" and aria-pressed={period === opt.value} (or
appropriate metric state for the other group) so screen readers treat them as
toggles; keep the existing onClick, classes and key, and apply the same change
to the other occurrence (the metric pills around the block that references the
metric state on lines 307-317).
In `@src/app/_stats/stats-footer-cells.tsx`:
- Around line 35-61: The cells currently convert BigInt-derived totals using
Number(...) which can lose precision; update each TableCell to pass strings to
formatLargeNumber by converting via BigInt(...).toString() for single fields
(e.g. replace formatLargeNumber(Number(totals.cachedTokens ?? 0)) with
formatLargeNumber(BigInt(totals.cachedTokens ?? 0).toString())) and for summed
values use BigInt additions then toString() (e.g.
formatLargeNumber((BigInt(totals.fileSearches ?? 0) +
BigInt(totals.fileContentSearches ?? 0)).toString())); apply this pattern to all
uses of Number(...) (references: formatLargeNumber, totals.* fields, and the
TableCell entries in stats-footer-cells.tsx).
In `@src/app/_stats/stats-overview.tsx`:
- Around line 135-149: The Advanced Insights toggle button currently lacks
explicit accessibility attributes; update the button element that calls
setShowAdvancedInsights and reads showAdvancedInsights to include type="button"
and aria-expanded={showAdvancedInsights}, and add aria-controls="<panel-id>"
(and give the corresponding panel element that renders the advanced insights the
same id) so screen readers can determine and link the collapsed/expanded state.
In `@src/app/_stats/total-daily-stats-table.tsx`:
- Around line 11-21: Update the local imports in this TSX file to use the
project path alias instead of relative paths: change the imports for
StatsFooterMetricCells, StatsTableShell, the types AnalyticsPeriod and
StatsData, and the date helper functions addPeriod, formatDateForDisplay,
getDateHoverText, getPeriodCountLabel, getPeriodLabel, and getPeriodStart to use
"@/..." module paths (mapping to ./src/*) so all internal imports follow the `@/`
alias convention.
In `@src/app/_stats/total-stats-table.tsx`:
- Line 15: Replace the relative import of the StatsData type in
total-stats-table.tsx with the project alias import; locate the import line that
reads import { type StatsData } from "./types" and change it to use the `@/` path
alias that maps to src (e.g., import { type StatsData } from
"@/app/_stats/types") so the file follows the repository's import conventions
and linter rules.
In `@src/app/_stats/types.ts`:
- Around line 28-54: GrandTotal currently types many aggregate counter fields as
number while DayStat supports stringified (BigInt-safe) counters; change the
aggregate counter fields in the GrandTotal type (e.g., inputTokens,
outputTokens, cachedTokens, reasoningTokens, cacheCreationTokens,
cacheReadTokens, tokens, conversations, toolCalls, terminalCommands,
fileSearches, fileContentSearches, filesRead, filesAdded, filesEdited,
filesDeleted, linesRead, linesAdded, linesEdited) to string (or string|number if
transitional) so they match DayStat's BigInt-safe transport, and update any code
that computes totals to call BigInt.toString() per the guideline; reference the
GrandTotal type and DayStat when making these updates.
In `@src/app/analytics/page.tsx`:
- Line 4: Replace the relative import of AnalyticsPage with the project alias:
change the import statement that references "./analytics-page" to use the
"@/app/analytics/analytics-page" path so AnalyticsPage is imported via the `@/`
alias (update the import in the AnalyticsPage reference in page.tsx).
In `@src/app/api/upload-stats/route.ts`:
- Line 323: The ARRAY_AGG(DISTINCT m.model) expression is non-deterministic;
change both occurrences (the one producing AS models) to include an ORDER BY so
the array is stable, e.g. use ARRAY_AGG(DISTINCT m.model ORDER BY m.model)
FILTER (WHERE m.model IS NOT NULL) AS models (apply same change to the second
occurrence around the code that builds models at line ~375) so persisted rows
don't churn when the set is unchanged.
In `@src/components/navbar.tsx`:
- Around line 33-50: The active-link logic using pathname === href doesn't
handle nested routes and lacks accessibility attributes: update the render of
NAV_LINKS (inside the nav block using Link, pathname, and cn) to compute an
isActive boolean that treats root ("/") with exact equality but uses
pathname.startsWith(href) for non-root hrefs so parent links are highlighted for
nested routes, and pass aria-current="page" on the Link when isActive; keep the
existing className branching but base it on isActive instead of pathname ===
href.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (42)
pnpm-workspace.yamlprisma/migrations/20260224154500_add_user_stats_conversations_models/migration.sqlprisma/schema.prismasrc/app/_stats/app-stats-table.tsxsrc/app/_stats/date-helpers.tssrc/app/_stats/setup-instructions.tsxsrc/app/_stats/source-badges.tsxsrc/app/_stats/stat-card.tsxsrc/app/_stats/stats-charts-config.tssrc/app/_stats/stats-charts-legends.tsxsrc/app/_stats/stats-charts-tooltips.tsxsrc/app/_stats/stats-charts-utils.tssrc/app/_stats/stats-charts.tsxsrc/app/_stats/stats-footer-cells.tsxsrc/app/_stats/stats-overview.tsxsrc/app/_stats/stats-table-shell.tsxsrc/app/_stats/total-daily-stats-table.tsxsrc/app/_stats/total-stats-table.tsxsrc/app/_stats/types.tssrc/app/analytics/analytics-page.tsxsrc/app/analytics/loading.tsxsrc/app/analytics/page.tsxsrc/app/api/leaderboard/route.tssrc/app/api/upload-stats/route.tssrc/app/api/user/[userId]/recalculate-stats/route.tssrc/app/api/user/[userId]/stats/models/route.tssrc/app/api/user/[userId]/stats/route.tssrc/app/api/user/[userId]/stats/types.tssrc/app/layout.tsxsrc/app/leaderboard/loading.tsxsrc/app/leaderboard/page.tsxsrc/app/loading.tsxsrc/app/settings/loading.tsxsrc/app/settings/page.tsxsrc/app/stats-page.tsxsrc/components/auth/session-provider.tsxsrc/components/cli-token-display.tsxsrc/components/main.tsxsrc/components/navbar.tsxsrc/components/ui/page-loading.tsxsrc/hooks/use-format-converted-currency.tssrc/lib/stats-recalculation.ts
| ALTER TABLE "user_stats" | ||
| ADD COLUMN "conversations" BIGINT NOT NULL DEFAULT 0, | ||
| ADD COLUMN "models" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check src/types/index.ts for UserStats type definition including conversations and models fields
echo "=== Checking src/types/index.ts for UserStats ==="
rg -n -B 2 -A 15 'interface UserStats|type UserStats' src/types/index.tsRepository: Piebald-AI/splitrail-cloud
Length of output: 119
🏁 Script executed:
#!/bin/bash
# Check prisma/schema.prisma for user_stats model with conversations and models fields
echo "=== Checking prisma/schema.prisma for user_stats model ==="
rg -n -B 2 -A 20 'model user_stats|model UserStats' prisma/schema.prismaRepository: Piebald-AI/splitrail-cloud
Length of output: 1431
🏁 Script executed:
#!/bin/bash
# List all migrations in the migrations directory to understand the context
echo "=== Recent migrations ==="
ls -lht prisma/migrations/ | head -20Repository: Piebald-AI/splitrail-cloud
Length of output: 437
🏁 Script executed:
#!/bin/bash
# Check the migration file content to confirm syntax
echo "=== Migration file content ==="
cat -n "prisma/migrations/20260224154500_add_user_stats_conversations_models/migration.sql"Repository: Piebald-AI/splitrail-cloud
Length of output: 265
🏁 Script executed:
#!/bin/bash
# Get the full UserStats model definition from prisma/schema.prisma
echo "=== Full UserStats model ==="
sed -n '92,150p' prisma/schema.prismaRepository: Piebald-AI/splitrail-cloud
Length of output: 2852
🏁 Script executed:
#!/bin/bash
# Check if src/types/index.ts exists and what it contains
echo "=== src/types/index.ts existence and structure ==="
if [ -f "src/types/index.ts" ]; then
echo "File exists. File size:"
wc -l src/types/index.ts
echo -e "\n=== First 100 lines ==="
head -100 src/types/index.ts
else
echo "File does not exist at src/types/index.ts"
echo "Searching for index.ts files in src/"
find src -name "index.ts" -o -name "*types*"
fiRepository: Piebald-AI/splitrail-cloud
Length of output: 2190
🏁 Script executed:
#!/bin/bash
# Get the complete src/types/index.ts to see if conversations and models are included
echo "=== Complete src/types/index.ts ==="
cat -n src/types/index.tsRepository: Piebald-AI/splitrail-cloud
Length of output: 7810
Update src/types/index.ts to include the new conversations and models fields.
The prisma/schema.prisma has been correctly updated with conversations (BigInt) and models (String[]) fields, and the migration is correct. However, src/types/index.ts is out of sync.
Add conversations to the BigIntStatKeys array and include both conversations: bigint and models: string[] in the Stats and StatsAsNumbers interfaces to maintain type safety consistency with the database schema.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@prisma/migrations/20260224154500_add_user_stats_conversations_models/migration.sql`
around lines 1 - 3, Update src/types/index.ts to reflect the migration by adding
the new fields: add "conversations" to the BigIntStatKeys array, and add
conversations: bigint and models: string[] to both the Stats and StatsAsNumbers
interfaces (ensure types match: BigInt in DB maps to bigint in TS for
Stats/StatsAsNumbers and models is string[]). Locate and modify the
BigIntStatKeys constant and the Stats and StatsAsNumbers interface declarations
to include these new symbols so the TypeScript types stay in sync with the
Prisma schema.
| function DeltaText({ | ||
| value, | ||
| kind = "number", | ||
| }: { | ||
| value: number; | ||
| kind?: "number" | "currency"; | ||
| }) { | ||
| if (value === 0) return null; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "text-[11px]", | ||
| value > 0 | ||
| ? "text-emerald-600 dark:text-emerald-400" | ||
| : "text-rose-600 dark:text-rose-400" | ||
| )} | ||
| > | ||
| {kind === "currency" ? (value > 0 ? "+" : "") : ""} | ||
| {kind === "currency" ? value.toFixed(2) : formatDelta(value)} | ||
| </div> |
There was a problem hiding this comment.
Currency deltas are rendered as raw decimals instead of using the currency formatter.
kind === "currency" currently uses value.toFixed(2) only, so delta display can drift from the formatted/converted currency used in the main cost cell.
💡 Proposed fix
function DeltaText({
value,
kind = "number",
+ formatCurrency,
}: {
value: number;
kind?: "number" | "currency";
+ formatCurrency?: (amount: number) => string;
}) {
@@
- {kind === "currency" ? (value > 0 ? "+" : "") : ""}
- {kind === "currency" ? value.toFixed(2) : formatDelta(value)}
+ {kind === "currency"
+ ? `${value > 0 ? "+" : ""}${formatCurrency ? formatCurrency(value) : value.toFixed(2)}`
+ : formatDelta(value)}- {showDeltas && <DeltaText value={delta?.cost ?? 0} kind="currency" />}
+ {showDeltas && (
+ <DeltaText
+ value={delta?.cost ?? 0}
+ kind="currency"
+ formatCurrency={formatConvertedCurrency}
+ />
+ )}Also applies to: 221-221
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/_stats/app-stats-table.tsx` around lines 147 - 167, DeltaText is
rendering currency deltas with value.toFixed(2), causing mismatched formatting
vs the main cost cell; replace that branch to call the same currency formatter
used by the main cost cell (e.g., formatCurrency/formatMoney) instead of
toFixed(2), preserve the positive "+" prefix logic, and ensure the formatter is
imported/available to DeltaText; also update the other instance mentioned
(around the second occurrence) so both currency delta renderings use the shared
formatter and remain consistent with formatDelta for non-currency values.
| export function getPeriodStart(date: Date, period: AnalyticsPeriod): Date { | ||
| const normalized = new Date(date); | ||
| normalized.setUTCHours(0, 0, 0, 0); | ||
| if (period === "weekly") { | ||
| const day = normalized.getUTCDay(); | ||
| const diff = normalized.getUTCDate() - day + (day === 0 ? -6 : 1); | ||
| normalized.setUTCDate(diff); | ||
| normalized.setUTCHours(0, 0, 0, 0); | ||
| return normalized; | ||
| } | ||
| if (period === "monthly") { | ||
| normalized.setUTCDate(1); | ||
| return normalized; | ||
| } | ||
| return normalized; | ||
| } | ||
|
|
||
| export function addPeriod(date: Date, period: AnalyticsPeriod): Date { | ||
| const next = new Date(date); | ||
| if (period === "weekly") { | ||
| next.setUTCDate(next.getUTCDate() + 7); | ||
| return next; | ||
| } | ||
| if (period === "monthly") { | ||
| next.setUTCMonth(next.getUTCMonth() + 1, 1); | ||
| return next; | ||
| } | ||
| next.setUTCDate(next.getUTCDate() + 1); | ||
| return next; | ||
| } |
There was a problem hiding this comment.
Period boundary logic should come from src/lib/dateUtils.ts utilities.
This file re-implements period start/add logic instead of using the shared period utilities, which can drift from backend aggregation boundaries.
As per coding guidelines, "**/*.{ts,tsx}: Use getPeriodStartForDate() and getPeriodEndForDate() utilities from src/lib/dateUtils.ts for period calculations to ensure correct stats aggregation".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/_stats/date-helpers.ts` around lines 28 - 57, Replace the custom
period logic in getPeriodStart and addPeriod with the shared utilities from
src/lib/dateUtils.ts: call getPeriodStartForDate(date, period) inside
getPeriodStart and use getPeriodEndForDate or the appropriate add-period helper
(e.g., getPeriodEndForDate(date, period) or a provided addPeriod helper) to
compute the next period in addPeriod so aggregation boundaries match backend;
update imports to bring in getPeriodStartForDate (and getPeriodEndForDate or
addPeriod helper) and remove the local date math in getPeriodStart and
addPeriod.
| @@ -0,0 +1,61 @@ | |||
| "use client"; | |||
|
|
|||
| import { AREA_SERIES } from "./stats-charts-config"; | |||
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Use the repository alias import style here
Please replace the relative import with the @/* alias to match project import conventions.
Proposed change
-import { AREA_SERIES } from "./stats-charts-config";
+import { AREA_SERIES } from "@/app/_stats/stats-charts-config";As per coding guidelines, **/*.{ts,tsx,js,jsx}: Use the @/* path alias for all imports, mapping to ./src/*.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import { AREA_SERIES } from "./stats-charts-config"; | |
| import { AREA_SERIES } from "@/app/_stats/stats-charts-config"; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/_stats/stats-charts-legends.tsx` at line 3, Replace the relative
import of AREA_SERIES with the repository alias import style; in
stats-charts-legends.tsx update the import that currently reads from
"./stats-charts-config" to use the `@/`* path alias (import AREA_SERIES from
"@/app/_stats/stats-charts-config" or the project's equivalent alias path) so it
follows the project's import convention and resolves to ./src/*.
| import type { StatsData } from "./types"; | ||
| import type { | ||
| AreaChartPoint, | ||
| BarChartPoint, | ||
| BarMetric, | ||
| ModelData, | ||
| Period, | ||
| RawDataPoint, | ||
| } from "./stats-charts-config"; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Use @/ imports in this module*
Please replace relative imports with alias-based imports for consistency with repo standards.
As per coding guidelines, **/*.{ts,tsx,js,jsx}: Use the @/* path alias for all imports, mapping to ./src/*.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/_stats/stats-charts-utils.ts` around lines 2 - 10, Replace the
relative imports in this module: change the import that brings in StatsData and
the import that brings in AreaChartPoint, BarChartPoint, BarMetric, ModelData,
Period, and RawDataPoint to use the repository path-alias (`@/`*) instead of
"./types" and "./stats-charts-config"; update the import specifiers so they
reference the same symbols via the @ alias (mapping to src) to comply with
project import conventions.
| cost: row.total_cost ?? 0, | ||
| inputTokens: n(row.input_tokens), | ||
| outputTokens: n(row.output_tokens), | ||
| cachedTokens: n(row.cached_tokens), | ||
| reasoningTokens: n(row.reasoning_tokens), | ||
| cacheCreationTokens: n(row.cache_creation_tokens), | ||
| cacheReadTokens: n(row.cache_read_tokens), | ||
| conversations: n(row.conversations), | ||
| toolCalls: n(row.tool_calls), | ||
| terminalCommands: n(row.terminal_commands), | ||
| fileSearches: n(row.file_searches), | ||
| fileContentSearches: n(row.file_content_searches), | ||
| filesRead: n(row.files_read), | ||
| filesAdded: n(row.files_added), | ||
| filesEdited: n(row.files_edited), | ||
| filesDeleted: n(row.files_deleted), | ||
| linesRead: n(row.lines_read), | ||
| linesAdded: n(row.lines_added), | ||
| linesEdited: n(row.lines_edited), | ||
| models: row.models ?? [], | ||
| }; | ||
|
|
||
| const app = r.application as string; | ||
| // Fix: Ensure date keys are always midnight UTC, avoiding timezone shifts | ||
| const dayKey = r.day | ||
| ? `${r.day.toISOString().split("T")[0]}T00:00:00.000Z` | ||
| : null; | ||
| dayKeys.add(dayKey); | ||
| if (!firstDate || row.day < firstDate) firstDate = row.day; | ||
| if (!lastDate || row.day > lastDate) lastDate = row.day; | ||
|
|
||
| if (dayKey === null) { | ||
| // Application totals | ||
| if (!stats.totals) stats.totals = {}; | ||
| stats.totals[app] = { | ||
| cost: r.total_cost, | ||
| inputTokens: n(r.input_tokens), | ||
| outputTokens: n(r.output_tokens), | ||
| cachedTokens: n(r.cached_tokens), | ||
| reasoningTokens: n(r.reasoning_tokens), | ||
| conversations: n(r.conversations), | ||
| toolCalls: n(r.tool_calls), | ||
| linesRead: n(r.lines_read), | ||
| linesAdded: n(r.lines_added), | ||
| linesEdited: n(r.lines_edited), | ||
| models: r.models || [], | ||
| }; | ||
| } else { | ||
| // Daily stats per application | ||
| if (!stats[dayKey]) stats[dayKey] = {}; | ||
| stats[dayKey][app] = { | ||
| cost: r.total_cost, | ||
| inputTokens: n(r.input_tokens), | ||
| outputTokens: n(r.output_tokens), | ||
| cachedTokens: n(r.cached_tokens), | ||
| reasoningTokens: n(r.reasoning_tokens), | ||
| conversations: n(r.conversations), | ||
| toolCalls: n(r.tool_calls), | ||
| linesRead: n(r.lines_read), | ||
| linesAdded: n(r.lines_added), | ||
| linesEdited: n(r.lines_edited), | ||
| models: r.models || [], | ||
| }; | ||
| } | ||
| }); | ||
| if (!totalsByApp[app]) { | ||
| totalsByApp[app] = createEmptyTotalsAccumulator(); | ||
| modelSetsByApp.set(app, new Set<string>()); | ||
| } | ||
|
|
||
| const appTotals = totalsByApp[app]; | ||
| appTotals.cost += row.total_cost ?? 0; | ||
| appTotals.inputTokens += n(row.input_tokens); | ||
| appTotals.outputTokens += n(row.output_tokens); | ||
| appTotals.cachedTokens += n(row.cached_tokens); | ||
| appTotals.reasoningTokens += n(row.reasoning_tokens); | ||
| appTotals.cacheCreationTokens += n(row.cache_creation_tokens); | ||
| appTotals.cacheReadTokens += n(row.cache_read_tokens); | ||
| appTotals.conversations += n(row.conversations); | ||
| appTotals.toolCalls += n(row.tool_calls); | ||
| appTotals.terminalCommands += n(row.terminal_commands); | ||
| appTotals.fileSearches += n(row.file_searches); | ||
| appTotals.fileContentSearches += n(row.file_content_searches); | ||
| appTotals.filesRead += n(row.files_read); | ||
| appTotals.filesAdded += n(row.files_added); | ||
| appTotals.filesEdited += n(row.files_edited); | ||
| appTotals.filesDeleted += n(row.files_deleted); | ||
| appTotals.linesRead += n(row.lines_read); | ||
| appTotals.linesAdded += n(row.lines_added); | ||
| appTotals.linesEdited += n(row.lines_edited); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# First, let's look at the stats route file
echo "=== Checking stats route file ==="
wc -l src/app/api/user/[userId]/stats/route.ts
# Let's read the section mentioned in the comment (lines 93-143)
echo
echo "=== Lines 93-143 of stats route ==="
sed -n '93,143p' src/app/api/user/\[userId\]/stats/route.ts
# Also check lines 161-197
echo
echo "=== Lines 161-197 of stats route ==="
sed -n '161,197p' src/app/api/user/\[userId\]/stats/route.tsRepository: Piebald-AI/splitrail-cloud
Length of output: 3930
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the n() function definition
echo "=== Looking for n() function ==="
rg -n 'export function n\(|const n\s*=' src/lib/
# Also search in the stats route file itself
echo
echo "=== Searching in stats route ==="
rg -n 'export function n\(|const n\s*=' src/app/api/user/Repository: Piebald-AI/splitrail-cloud
Length of output: 212
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check TotalsAccumulator type definition
echo "=== Looking for TotalsAccumulator ==="
rg -n 'type TotalsAccumulator|interface TotalsAccumulator' src/
# Check createEmptyTotalsAccumulator function
echo
echo "=== Looking for createEmptyTotalsAccumulator ==="
rg -n 'createEmptyTotalsAccumulator|function createEmptyTotalsAccumulator' src/ -A 15Repository: Piebald-AI/splitrail-cloud
Length of output: 4098
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Get the n() function full definition
echo "=== n() function implementation ==="
sed -n '213,225p' src/lib/utils.ts
echo
echo "=== TotalsAccumulator type ==="
sed -n '26,80p' src/app/api/user/\[userId\]/stats/types.ts
echo
echo "=== mergeTotals function ==="
rg -n 'export function mergeTotals|export const mergeTotals' src/app/api/user/\[userId\]/stats/types.ts -A 20Repository: Piebald-AI/splitrail-cloud
Length of output: 2883
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check the API response handling - look at the return statement
echo "=== Response return handling at end of route ==="
tail -n 50 src/app/api/user/\[userId\]/stats/route.ts
echo
echo "=== Check if any serializers/transformers are used ==="
rg -n 'serialize|transform|toString' src/app/api/user/\[userId\]/stats/route.tsRepository: Piebald-AI/splitrail-cloud
Length of output: 1514
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check line 188 onwards for the actual response return
echo "=== Lines 200-313 to see final return ==="
sed -n '200,313p' src/app/api/user/\[userId\]/stats/route.tsRepository: Piebald-AI/splitrail-cloud
Length of output: 3225
🏁 Script executed:
#!/bin/bash
set -euo pipeftp
# Check if other stats routes use the same pattern
echo "=== Check export route for comparison ==="
sed -n '110,141p' src/app/api/stats/export/route.ts
echo
echo "=== Check if there are comments in stats route about Number conversion ==="
sed -n '1,50p' src/app/api/user/\[userId\]/stats/route.ts | grep -i "number\|bigint\|precision\|convert" || echo "No relevant comments found"
echo
echo "=== Check types.ts for any comments about TotalsAccumulator ==="
sed -n '20,85p' src/app/api/user/\[userId\]/stats/types.ts | grep -B 3 -A 3 "TotalsAccumulator\|inputTokens" | head -30Repository: Piebald-AI/splitrail-cloud
Length of output: 122
🏁 Script executed:
#!/bin/bash
set -euo pipeftp
# Look for how DailyStatsRow is typed - what types does the database return?
echo "=== DailyStatsRow type definition ==="
rg -n 'export type DailyStatsRow|type DailyStatsRow' src/app/api/user/\[userId\]/stats/types.ts -A 30Repository: Piebald-AI/splitrail-cloud
Length of output: 122
🏁 Script executed:
#!/bin/bash
set -euo pipeftp
# Check if BigInt values can reach unsafe ranges
echo "=== Check MessageStats schema to understand field ranges ==="
rg -n 'inputTokens|outputTokens|cachedTokens|cacheCreationTokens' prisma/schema.prisma -B 1 -A 1Repository: Piebald-AI/splitrail-cloud
Length of output: 122
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check if other stats routes use the same pattern
echo "=== Check export route for comparison ==="
sed -n '110,141p' src/app/api/stats/export/route.ts
echo
echo "=== Check if there are comments in stats route about Number conversion ==="
sed -n '1,50p' src/app/api/user/\[userId\]/stats/route.ts | grep -i "number\|bigint\|precision\|convert" || echo "No relevant comments found"
echo
echo "=== Check types.ts for any comments about TotalsAccumulator ==="
sed -n '20,85p' src/app/api/user/\[userId\]/stats/types.ts | grep -B 3 -A 3 "TotalsAccumulator\|inputTokens" | head -30Repository: Piebald-AI/splitrail-cloud
Length of output: 1879
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Look for how DailyStatsRow is typed - what types does the database return?
echo "=== DailyStatsRow type definition ==="
rg -n 'export type DailyStatsRow|type DailyStatsRow' src/app/api/user/\[userId\]/stats/types.ts -A 30Repository: Piebald-AI/splitrail-cloud
Length of output: 919
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check if BigInt values can reach unsafe ranges
echo "=== Check MessageStats schema to understand field ranges ==="
rg -n 'inputTokens|outputTokens|cachedTokens|cacheCreationTokens' prisma/schema.prisma -B 1 -A 1Repository: Piebald-AI/splitrail-cloud
Length of output: 730
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check for any CLI compatibility or API contract comments in stats route
echo "=== Full stats route to understand context ==="
head -n 100 src/app/api/user/\[userId\]/stats/route.ts
echo
echo "=== Check if there's any Splitrail CLI reference in stats route ==="
rg -n 'cli\|CLI\|splitrail\|compat' src/app/api/user/\[userId\]/stats/route.ts || echo "No CLI references found"
echo
echo "=== Check stats types for any comments ==="
head -n 25 src/app/api/user/\[userId\]/stats/types.tsRepository: Piebald-AI/splitrail-cloud
Length of output: 4081
Convert BigInt stats to strings during serialization, not during accumulation
The current implementation converts BigInt values from the database to Number immediately using n(), then accumulates as numbers. This contradicts the coding guidelines which require using BigInt.toString() for stats calculations and transformer pattern serialization.
Keep token fields as bigint during accumulation in TotalsAccumulator, then convert to strings only in the API response when serializing. The n() function's safety check only prevents overflow within safe integer range—it doesn't preserve precision compared to maintaining bigint throughout aggregation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/api/user/`[userId]/stats/route.ts around lines 93 - 143, The code is
converting DB BigInt stats to Number via n() during accumulation, losing
precision; instead keep token/count fields as bigint in TotalsAccumulator and
all accumulation steps (initialize fields in createEmptyTotalsAccumulator to 0n
and stop calling n(...) in the appTotals updates and when building per-row
accumulators), only convert BigInt to string when serializing the API response
(e.g., in the response transformer or right before returning the payload).
Update usages of n(), the TotalsAccumulator shape,
createEmptyTotalsAccumulator(), and the appTotals accumulation block so tokens
remain bigint throughout aggregation and are stringified at output.
| import { StatsOverview } from "./_stats/stats-overview"; | ||
| import { SetupInstructions } from "./_stats/setup-instructions"; | ||
| import { SourceBadges, type SelectedSource } from "./_stats/source-badges"; | ||
| import { StatsCharts } from "./_stats/stats-charts"; | ||
| import { TotalStatsTable } from "./_stats/total-stats-table"; | ||
| import { type StatsData } from "./_stats/types"; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Use @/ aliases for these module imports*
Please replace these relative imports with alias imports to match repository standards.
As per coding guidelines, **/*.{ts,tsx,js,jsx}: Use the @/* path alias for all imports, mapping to ./src/*.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/stats-page.tsx` around lines 11 - 16, Replace the relative imports in
stats-page.tsx with the repository alias imports: import the named exports
StatsOverview, SetupInstructions, SourceBadges and SelectedSource, StatsCharts,
TotalStatsTable, and the type StatsData using the `@/`... path alias (e.g.
"@/...") instead of the current relative paths; update the import statements so
each import (StatsOverview, SetupInstructions, SourceBadges, SelectedSource,
StatsCharts, TotalStatsTable, StatsData) references its corresponding module via
the `@/` prefix to map to the src tree and keep names unchanged.
| const createNewToken = async () => { | ||
| if (!session) return; | ||
| await createTokenMutation.mutateAsync(newTokenName); | ||
| }; | ||
|
|
||
| const deleteToken = async (tokenId: string) => { | ||
| if (!session) return; | ||
| await deleteTokenMutation.mutateAsync(tokenId); | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, let's verify the file exists and check the exact code at the specified lines
if [ -f "src/components/cli-token-display.tsx" ]; then
echo "=== File content at lines 115-135 ==="
sed -n '115,135p' src/components/cli-token-display.tsx | cat -n
echo ""
echo "=== Full search for mutateAsync in the file ==="
rg -nP '\bmutateAsync\b' src/components/cli-token-display.tsx -B3 -A3
echo ""
echo "=== Search for createNewToken and deleteToken definitions ==="
rg -nP 'const (createNewToken|deleteToken)' src/components/cli-token-display.tsx -A10
else
echo "File not found"
fiRepository: Piebald-AI/splitrail-cloud
Length of output: 2354
🏁 Script executed:
#!/bin/bash
# Check for try/catch blocks in the event handlers and examine onError callbacks
rg -nP '(onError|onSuccess|useQuery|useMutation)' src/components/cli-token-display.tsx -B2 -A5Repository: Piebald-AI/splitrail-cloud
Length of output: 2610
🏁 Script executed:
#!/bin/bash
# Check what version of react-query is being used and verify imports
rg -nP '@tanstack/react-query|react-query' src/components/cli-token-display.tsx -B1 -A1
fd package.json -x grep -l "react-query\|@tanstack/react-query" {} \;
cat package.json | grep -A2 "@tanstack/react-query\|react-query"Repository: Piebald-AI/splitrail-cloud
Length of output: 354
Avoid unhandled mutateAsync rejections in click handlers
Lines 123 and 128 await mutateAsync without a local try/catch. In React Query, mutateAsync rejects on error even when onError is defined, so these event handlers can emit unhandled promise rejections. Use mutate() instead, which triggers error handling without returning a rejected Promise.
💡 Proposed fix
- const createNewToken = async () => {
+ const createNewToken = () => {
if (!session) return;
- await createTokenMutation.mutateAsync(newTokenName);
+ createTokenMutation.mutate(newTokenName);
};
- const deleteToken = async (tokenId: string) => {
+ const deleteToken = (tokenId: string) => {
if (!session) return;
- await deleteTokenMutation.mutateAsync(tokenId);
+ deleteTokenMutation.mutate(tokenId);
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const createNewToken = async () => { | |
| if (!session) return; | |
| await createTokenMutation.mutateAsync(newTokenName); | |
| }; | |
| const deleteToken = async (tokenId: string) => { | |
| if (!session) return; | |
| await deleteTokenMutation.mutateAsync(tokenId); | |
| }; | |
| const createNewToken = () => { | |
| if (!session) return; | |
| createTokenMutation.mutate(newTokenName); | |
| }; | |
| const deleteToken = (tokenId: string) => { | |
| if (!session) return; | |
| deleteTokenMutation.mutate(tokenId); | |
| }; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/cli-token-display.tsx` around lines 121 - 129, The click
handlers createNewToken and deleteToken currently await
createTokenMutation.mutateAsync and deleteTokenMutation.mutateAsync which can
produce unhandled promise rejections; change them to call
createTokenMutation.mutate(newTokenName) and deleteTokenMutation.mutate(tokenId)
(or wrap the mutateAsync calls in try/catch) so errors are handled by React
Query's callbacks instead of returning a rejected Promise; keep the session
checks and adjust/remove the async/await on createNewToken/deleteToken as
needed.
| const latestToken = tokens[0]?.token; | ||
| const setApiTokenCommand = latestToken | ||
| ? `splitrail config set api-token ${latestToken}` | ||
| : "splitrail config set api-token <your-token>"; |
There was a problem hiding this comment.
“Latest token” derivation currently depends on implicit API ordering
Line 166 assumes tokens[0] is newest. If backend order changes, the setup command can reference an older token. Consider sorting by createdAt in this component (or enforce descending order in API response contract).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/cli-token-display.tsx` around lines 166 - 169, The component
currently picks the newest token using tokens[0] which relies on API ordering;
update the logic that computes latestToken (used by setApiTokenCommand) to first
sort the tokens array by createdAt in descending order and then take the first
element (e.g., sort tokens by (a,b) => new Date(b.createdAt)-new
Date(a.createdAt) to get the newest token), so setApiTokenCommand always
references the token with the most recent createdAt timestamp.
| const currency = preferences?.currency || "USD"; | ||
| if (!exchangeRates?.data || !exchangeRates?.eurToUsd || currency === "USD") { | ||
| return formatCurrency(amount, currency, locale); | ||
| } |
There was a problem hiding this comment.
Incorrect currency labeling when conversion data is unavailable
When rates are unavailable, Line 53 and Line 84 format the original USD amount using the user’s non-USD currency code. That misstates value (e.g., USD amount shown as EUR). Use USD formatting unless conversion is actually performed.
💡 Proposed fix
const formatConvertedCurrency = React.useCallback(
(amount: number) => {
const currency = preferences?.currency || "USD";
- if (!exchangeRates?.data || !exchangeRates?.eurToUsd || currency === "USD") {
- return formatCurrency(amount, currency, locale);
+ const canConvert =
+ currency !== "USD" &&
+ Boolean(exchangeRates?.data) &&
+ Boolean(exchangeRates?.eurToUsd);
+
+ if (!canConvert) {
+ return formatCurrency(amount, "USD", locale);
}
const convertedAmount = convertCurrency(
amount,
currency,
@@
const formatConvertedCurrencyAdaptive = React.useCallback(
(amount: number) => {
const currency = preferences?.currency || "USD";
- const convertedAmount =
- !exchangeRates?.data || !exchangeRates?.eurToUsd || currency === "USD"
- ? amount
- : convertCurrency(
- amount,
- currency,
- exchangeRates.data,
- exchangeRates.eurToUsd
- );
+ const canConvert =
+ currency !== "USD" &&
+ Boolean(exchangeRates?.data) &&
+ Boolean(exchangeRates?.eurToUsd);
+ const outputCurrency = canConvert ? currency : "USD";
+ const convertedAmount = canConvert
+ ? convertCurrency(amount, currency, exchangeRates.data, exchangeRates.eurToUsd)
+ : amount;
@@
return new Intl.NumberFormat(locale, {
style: "currency",
- currency,
+ currency: outputCurrency,
minimumFractionDigits: 2,
maximumFractionDigits,
}).format(convertedAmount);Also applies to: 71-73, 84-89
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/hooks/use-format-converted-currency.ts` around lines 51 - 54, The hook
currently formats the original USD amount using the user's non-USD currency code
when conversion data is missing; update the logic in
use-format-converted-currency (references: preferences, exchangeRates,
formatCurrency, amount, locale) so that whenever conversion is NOT performed
(e.g., missing exchangeRates.data or eurToUsd or currency === "USD" branch), you
explicitly format using "USD" as the currency code instead of
preferences.currency; ensure the same change is applied to the other similar
branches mentioned (around lines handling the 71-73 and 84-89 logic) so
displayed values are labeled USD unless a conversion was actually applied.
New UI proposal built over the last week, related to #34.
I’d love feedback on both the UI direction and the code changes.
Not sure but probably better to create a new branch for the pull request.
Summary by CodeRabbit
Release Notes
New Features
Improvements