Skip to content

Propose for the new dashboard UI#35

Open
saphitv wants to merge 20 commits intoPiebald-AI:mainfrom
saphitv:main
Open

Propose for the new dashboard UI#35
saphitv wants to merge 20 commits intoPiebald-AI:mainfrom
saphitv:main

Conversation

@saphitv
Copy link

@saphitv saphitv commented Feb 27, 2026

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

    • Added comprehensive analytics dashboard with charts and detailed metrics tracking
    • Introduced period-based analytics views (Daily, Weekly, Monthly)
    • Added per-application statistics and source filtering
    • Implemented conversation and model usage tracking
    • Added day-over-day delta visualization for metrics
  • Improvements

    • Enhanced loading states with skeleton UI placeholders
    • Improved currency formatting for cost metrics
    • Better table design with sorting and empty-day toggling
    • Refined user interface with smooth animations

@vercel
Copy link
Contributor

vercel bot commented Feb 27, 2026

@saphitv is attempting to deploy a commit to the Piebald Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Database Schema & Migrations
pnpm-workspace.yaml, prisma/migrations/20260224154500_add_user_stats_conversations_models/migration.sql, prisma/schema.prisma
Added workspace root configuration and new conversations and models fields to UserStats table via Prisma migration and schema updates.
Stats Components
src/app/_stats/app-stats-table.tsx, src/app/_stats/total-daily-stats-table.tsx, src/app/_stats/total-stats-table.tsx, src/app/_stats/stats-overview.tsx
New table and overview components for rendering aggregated statistics with sorting, delta visualization, and detailed per-app metrics.
Stats Charts & Visualization
src/app/_stats/stats-charts.tsx, src/app/_stats/stats-charts-config.ts, src/app/_stats/stats-charts-utils.ts, src/app/_stats/stats-charts-legends.tsx, src/app/_stats/stats-charts-tooltips.tsx
Chart configuration, rendering, and data-building utilities for area and bar charts with model breakdowns and normalized metrics.
Stats UI Primitives
src/app/_stats/stat-card.tsx, src/app/_stats/stats-footer-cells.tsx, src/app/_stats/stats-table-shell.tsx, src/app/_stats/source-badges.tsx, src/app/_stats/setup-instructions.tsx, src/app/_stats/date-helpers.ts, src/app/_stats/types.ts
Reusable components and utilities for stats display: cards, footer cells, generic table shell, source selection badges, date formatting, and type definitions.
Analytics Pages
src/app/analytics/page.tsx, src/app/analytics/analytics-page.tsx, src/app/analytics/loading.tsx
New analytics route with authentication, period selection, and conditional rendering of stats tables and charts.
Stats API Routes
src/app/api/user/[userId]/stats/route.ts, src/app/api/user/[userId]/stats/models/route.ts, src/app/api/user/[userId]/recalculate-stats/route.ts, src/app/api/upload-stats/route.ts
New and modified API endpoints for fetching daily/weekly/monthly stats, per-model metrics, stats recalculation with conversations/models aggregation, and stats upload processing.
Leaderboard API Changes
src/app/api/leaderboard/route.ts
Removed email field from leaderboard query results and aggregation logic.
Stats Library & Types
src/lib/stats-recalculation.ts, src/app/api/user/[userId]/stats/types.ts
Added SQL aggregation for conversations and models; introduced type utilities (TotalsAccumulator, DailyStatsRow, StatsRecord) and helpers for stats accumulation.
Core App Updates
src/app/stats-page.tsx, src/app/layout.tsx, src/app/loading.tsx, src/app/leaderboard/page.tsx, src/app/leaderboard/loading.tsx, src/app/settings/page.tsx, src/app/settings/loading.tsx
Refactored stats page to use new component-based architecture; made RootLayout async to pass session context; added loading skeletons and animations; updated settings page loading UI.
Shared UI & Hooks
src/components/auth/session-provider.tsx, src/components/ui/page-loading.tsx, src/components/main.tsx, src/components/navbar.tsx, src/components/cli-token-display.tsx, src/hooks/use-format-converted-currency.ts
Enhanced session provider to accept session prop; added reusable PageLoading with delay and skeleton variants; updated navbar with session-aware navigation; refactored CLI token display to use React Query; added currency formatting hook with exchange rates.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

  • PR #27: Both modify src/app/api/upload-stats/route.ts — the related PR changes the route to use bulk upsert for message_stats, while this PR adds SQL and field propagation for conversations and models tracking.
  • PR #23: Both touch src/app/api/upload-stats/route.ts to replace incremental accumulation with bucket-based/full recalculation of stats including aggregated per-bucket model and conversation data.
  • PR #32: Both extend stats aggregation to include per-application breakdowns and model-level data (models/conversations) in the stats pipeline and response structures.

Suggested reviewers

  • signadou
  • mike1858

Poem

🐰✨ Hop, hop, analytics bright,
Tables shimmer in the light,
Charts dance with conversations and models so keen,
The finest dashboard ever seen! 💫📊

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.34% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title "Propose for the new dashboard UI" is vague and generic. It describes a general area (dashboard UI) but lacks specificity about the core changes or direction of the proposal. Consider a more descriptive title such as "Refactor stats dashboard with new components and analytics tables" or "Add analytics dashboard with period selection and model breakdown charts" to better convey the main changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@saphitv saphitv marked this pull request as ready for review February 28, 2026 07:51
Copilot AI review requested due to automatic review settings February 28, 2026 07:51
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 conversations and models on user_stats and 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 /analytics route with daily/weekly/monthly grouping.
  • Add reusable page loading skeletons and wire route-level loading.tsx fallbacks 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.

Comment on lines 131 to +135
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");
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 43 to 57
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,
});
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +36
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");
},
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +67
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
`;

Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
`;
}

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +340
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"
),
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +91
onError: (error) => {
toast.error(error.message || "Failed to create token");
},
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +118
onError: (error) => {
toast.error(error.message || "Failed to delete token");
},
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Handle stats-query error state explicitly

statsData === undefined currently maps to DashboardSkeleton, so a failed stats request can appear as a perpetual loading state. Please track isLoading/error from 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:

  1. Nested routes: The exact match pathname === href won't highlight parent links for nested routes (e.g., visiting /settings/profile won't highlight "Settings"). If nested routes are planned, consider using startsWith for non-root paths.

  2. 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. Using Number() on values that originate from BigInt fields (like token counts) can cause precision loss for values exceeding Number.MAX_SAFE_INTEGER (~9 quadrillion).

Since formatLargeNumber accepts strings, consider using .toString() instead of Number() 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 for StatsData.

Please replace the relative ./types import 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 models

Also 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 ./types to 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 tech

These buttons act as toggles; add type="button" and aria-pressed so 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 button

Add type="button" and aria-expanded (plus aria-controls if 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 semantically

Add type="button" and aria-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-config imports 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 for AnalyticsPeriod.

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: Align GrandTotal counter field types with BigInt-safe transport.

DayStat supports stringified counters, but GrandTotal is hard-typed to number. 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

📥 Commits

Reviewing files that changed from the base of the PR and between 68ff7d6 and 3254fea.

📒 Files selected for processing (42)
  • pnpm-workspace.yaml
  • prisma/migrations/20260224154500_add_user_stats_conversations_models/migration.sql
  • prisma/schema.prisma
  • src/app/_stats/app-stats-table.tsx
  • src/app/_stats/date-helpers.ts
  • src/app/_stats/setup-instructions.tsx
  • src/app/_stats/source-badges.tsx
  • src/app/_stats/stat-card.tsx
  • src/app/_stats/stats-charts-config.ts
  • src/app/_stats/stats-charts-legends.tsx
  • src/app/_stats/stats-charts-tooltips.tsx
  • src/app/_stats/stats-charts-utils.ts
  • src/app/_stats/stats-charts.tsx
  • src/app/_stats/stats-footer-cells.tsx
  • src/app/_stats/stats-overview.tsx
  • src/app/_stats/stats-table-shell.tsx
  • src/app/_stats/total-daily-stats-table.tsx
  • src/app/_stats/total-stats-table.tsx
  • src/app/_stats/types.ts
  • src/app/analytics/analytics-page.tsx
  • src/app/analytics/loading.tsx
  • src/app/analytics/page.tsx
  • src/app/api/leaderboard/route.ts
  • src/app/api/upload-stats/route.ts
  • src/app/api/user/[userId]/recalculate-stats/route.ts
  • src/app/api/user/[userId]/stats/models/route.ts
  • src/app/api/user/[userId]/stats/route.ts
  • src/app/api/user/[userId]/stats/types.ts
  • src/app/layout.tsx
  • src/app/leaderboard/loading.tsx
  • src/app/leaderboard/page.tsx
  • src/app/loading.tsx
  • src/app/settings/loading.tsx
  • src/app/settings/page.tsx
  • src/app/stats-page.tsx
  • src/components/auth/session-provider.tsx
  • src/components/cli-token-display.tsx
  • src/components/main.tsx
  • src/components/navbar.tsx
  • src/components/ui/page-loading.tsx
  • src/hooks/use-format-converted-currency.ts
  • src/lib/stats-recalculation.ts

Comment on lines +1 to +3
ALTER TABLE "user_stats"
ADD COLUMN "conversations" BIGINT NOT NULL DEFAULT 0,
ADD COLUMN "models" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.ts

Repository: 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.prisma

Repository: 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 -20

Repository: 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.prisma

Repository: 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*"
fi

Repository: 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.ts

Repository: 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.

Comment on lines +147 to +167
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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +28 to +57
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Suggested change
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/*.

Comment on lines +2 to +10
import type { StatsData } from "./types";
import type {
AreaChartPoint,
BarChartPoint,
BarMetric,
ModelData,
Period,
RawDataPoint,
} from "./stats-charts-config";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +93 to +143
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.ts

Repository: 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 15

Repository: 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 20

Repository: 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.ts

Repository: 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.ts

Repository: 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 -30

Repository: 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 30

Repository: 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 1

Repository: 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 -30

Repository: 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 30

Repository: 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 1

Repository: 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.ts

Repository: 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.

Comment on lines +11 to +16
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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment on lines +121 to 129
const createNewToken = async () => {
if (!session) return;
await createTokenMutation.mutateAsync(newTokenName);
};

const deleteToken = async (tokenId: string) => {
if (!session) return;
await deleteTokenMutation.mutateAsync(tokenId);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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"
fi

Repository: 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 -A5

Repository: 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.

Suggested change
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.

Comment on lines +166 to +169
const latestToken = tokens[0]?.token;
const setApiTokenCommand = latestToken
? `splitrail config set api-token ${latestToken}`
: "splitrail config set api-token <your-token>";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

“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.

Comment on lines +51 to +54
const currency = preferences?.currency || "USD";
if (!exchangeRates?.data || !exchangeRates?.eurToUsd || currency === "USD") {
return formatCurrency(amount, currency, locale);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants