Skip to content

feat(costs): cost analytics page (#557)#590

Merged
frankbria merged 4 commits into
mainfrom
feat/557-cost-analytics-page
May 13, 2026
Merged

feat(costs): cost analytics page (#557)#590
frankbria merged 4 commits into
mainfrom
feat/557-cost-analytics-page

Conversation

@frankbria
Copy link
Copy Markdown
Owner

@frankbria frankbria commented May 13, 2026

Closes #557.

Summary

  • Adds /costs page showing total AI spend, tasks run, and avg cost / task from the workspace's token_usage table.
  • 7/30/90-day time-range selector wires through to a pure-SVG daily bar chart (no new chart dependency).
  • New GET /api/v2/costs/summary?days=30 endpoint backed by TokenRepository.get_costs_summary with zero-filled daily buckets.

Implementation notes

  • The router opens the workspace SQLite DB directly rather than via Database(...).initialize(). The current global SchemaManager schema and the v2 workspace schema (codeframe/core/workspace.py) define blockers differently, so Database.initialize() fails on an existing workspace DB. The handler tolerates the token_usage table being absent and returns an empty-state payload in that case, matching what users will see in workspaces that haven't run an agent yet.
  • TokenRepository.get_costs_summary is independent of that conflict and runs from any synchronous repo connection — covered by its own unit tests.

Test plan

  • uv run pytest tests/persistence tests/ui — 426 passed
  • uv run ruff check on touched files — clean
  • npm test — 826 passed (11 new in __tests__/components/costs)
  • npm run build — clean, /costs listed as a static route
  • Manual: navigate to /costs, confirm summary cards + chart render, switch time range to 7 and 90 days, confirm empty state on a workspace with no token_usage data.

Acceptance criteria from #557

  • /costs renders with summary cards and chart
  • Time range selector updates the chart
  • Empty state shown when no data
  • npm test and npm run build pass

Summary by CodeRabbit

  • New Features

    • Added a Costs page (new sidebar link) with workspace selection.
    • View KPIs: total spend (USD), tasks run, average cost per task.
    • Choose time ranges (7 / 30 / 90 days) and see an interactive daily spend bar chart.
    • New backend summary API powering the page; returns a zero-filled empty state when no data exists.
  • Tests

    • New unit and integration tests covering aggregation, API behavior, UI rendering, and date/window validation.

Review Change Stack

Add a /costs page that surfaces total AI spend from the workspace's
token_usage table. Includes summary cards (total spend, tasks run,
average cost per task), a 7/30/90-day time-range selector, and a pure
SVG bar chart for daily spend.

Backend
- TokenRepository.get_costs_summary(days) aggregates token_usage into
  daily buckets with zero-fill for missing days. Excludes NULL task_ids
  from the task count.
- New v2 router GET /api/v2/costs/summary?days=30 returns the summary.
  Opens the workspace DB directly to avoid the pre-existing schema
  conflict between the v2 workspace schema and SchemaManager; returns
  an empty-state payload when token_usage doesn't yet exist.

Frontend
- /costs page mirrors the workspace page pattern (useSWR, workspace
  selector guard, loading skeleton, error banner).
- SpendBarChart is a pure-SVG/CSS chart (no Recharts) — empty state,
  proportional bar heights, hover tooltips, date-fns labels.
- Sidebar entry between Proof and Settings.

Tests: 11 backend + 11 frontend.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b8d80943-6db4-4437-b53e-9ec5b0b6b89d

📥 Commits

Reviewing files that changed from the base of the PR and between 594368a and 6d0ece8.

📒 Files selected for processing (3)
  • codeframe/persistence/repositories/token_repository.py
  • codeframe/ui/routers/costs_v2.py
  • tests/persistence/test_token_repository_costs.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • codeframe/persistence/repositories/token_repository.py
  • tests/persistence/test_token_repository_costs.py
  • codeframe/ui/routers/costs_v2.py

Walkthrough

Adds TokenRepository.get_costs_summary(days), a v2 FastAPI endpoint (/api/v2/costs/summary) that reads workspace SQLite and returns totals plus daily zero-filled buckets, wires the router into the server, and implements a typed frontend Costs page, SpendBarChart, API client, mocks, and tests.

Changes

Cost Analytics Implementation

Layer / File(s) Summary
Shared frontend types and icon mocks
web-ui/src/types/index.ts, web-ui/__mocks__/@hugeicons/react.js
Adds DailyCostPoint and CostSummaryResponse types and three icon mocks used by the Costs UI tests.
Repository cost summary method and persistence tests
codeframe/persistence/repositories/token_repository.py, tests/persistence/test_token_repository_costs.py
Adds TokenRepository.get_costs_summary(days) with UTC day-window aggregation, distinct non-NULL task counting, daily zero-fill, and tests for empty state, aggregation, timestamp formats, windowing, and ordering.
FastAPI v2 costs endpoint with DB access and API tests
codeframe/ui/routers/costs_v2.py, tests/ui/test_costs_v2.py
Adds GET /api/v2/costs/summary with 7–90 days validation, direct workspace SQLite access, graceful empty-state fallback, delegation to TokenRepository(sync_conn=conn), and API tests for empty-state, aggregation, and parameter validation.
Server router registration
codeframe/ui/server.py
Mounts the new costs_v2 router into the FastAPI app under the v2 namespace.
Frontend API client and types
web-ui/src/lib/api.ts, web-ui/src/types/index.ts
Adds costsApi.getSummary(workspacePath, days) and imports the cost-summary types for typed frontend usage.
Costs page, SpendBarChart, and sidebar link
web-ui/src/app/costs/page.tsx, web-ui/src/components/costs/SpendBarChart.tsx, web-ui/src/components/layout/AppSidebar.tsx
Implements the Costs page with workspace selection and 7/30/90 presets, a SpendBarChart that adapts labels and shows an empty state when no positive spend exists, and a Costs entry in the sidebar.
Frontend component tests
web-ui/src/__tests__/components/costs/*
Adds Jest/RTL tests for CostsPage and SpendBarChart validating UI flows, KPI rendering, time-range behavior, loading/error states, empty states, and bar rendering/formatting.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant CostsPage
  participant SWR
  participant CostsAPI
  participant WorkspaceDB
  User->>CostsPage: Select workspace + time range
  CostsPage->>SWR: Fetch summary (workspace_path, days)
  SWR->>CostsAPI: GET /api/v2/costs/summary?workspace_path=...&days=30
  CostsAPI->>WorkspaceDB: Open DB, verify token_usage table
  alt DB opened and table present
    CostsAPI->>WorkspaceDB: TokenRepository.get_costs_summary(days) via sync_conn
    WorkspaceDB-->>CostsAPI: totals + daily rows
    CostsAPI->>CostsAPI: backfill missing days, compute avg
    CostsAPI-->>SWR: CostSummaryResponse
  else DB open fails or table missing
    CostsAPI-->>SWR: empty CostSummaryResponse (zero-filled)
  end
  SWR-->>CostsPage: data ready
  CostsPage->>CostsPage: Render KPI cards + SpendBarChart
  CostsPage-->>User: Display totals and daily bars
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through rows of token logs,

I summed the costs and filled the gaps,
Bars and cards now show the flux,
Seven, thirty, ninety — choose your map,
A little rabbit charts your spend with taps.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a costs analytics page with cost tracking and time-range selection features.
Linked Issues check ✅ Passed All core coding objectives from issue #557 are implemented: /costs page with summary cards, 7/30/90-day selector, spend-over-time chart, and GET /api/v2/costs/summary backend endpoint.
Out of Scope Changes check ✅ Passed All changes align with issue #557 scope; out-of-scope items (per-task breakdown, budget alerts) are not included.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/557-cost-analytics-page

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

@claude
Copy link
Copy Markdown

claude Bot commented May 13, 2026

PR #590 Review — Cost Analytics Page

Overall: Good implementation, ready with one medium-severity fix.

This is a clean, self-contained feature that follows existing project patterns — rate limiting, SWR, direct SQLite access for the schema-conflict workaround, and solid test coverage across all three layers. The empty-state handling and zero-filled daily buckets are exactly right for a chart consumer.


Medium: Timestamp format mismatch in SQL boundary

Both TokenRepository.get_costs_summary and _query_costs in costs_v2.py build the window start with:

start_iso = datetime(
    start_date.year, start_date.month, start_date.day, tzinfo=timezone.utc
).isoformat()
# → "2026-05-13T00:00:00+00:00"   (T-separator, +00:00 suffix)

SQLite's CURRENT_TIMESTAMP produces "YYYY-MM-DD HH:MM:SS" (space separator, no offset). Lexicographic comparison:

"2026-05-13 12:34:56"   <   "2026-05-13T00:00:00+00:00"

…because ASCII space (0x20) < T (0x54). Any row inserted via the schema default (DEFAULT CURRENT_TIMESTAMP) or raw SQL with a space-separated timestamp will sort before the T-form boundary — silently excluding the entire first day of the window. Records inserted by Python code via .isoformat() (T-form) are fine, but future inserts or historic data from the schema default will be dropped.

Fix: use the format SQLite's own functions understand —

start_iso = start_date.strftime("%Y-%m-%d %H:%M:%S")  # "2026-05-13 00:00:00"

or keep the T-form but strip the offset:

start_iso = f"{start_date.isoformat()}T00:00:00"

The same fix is needed in both token_repository.py and costs_v2.py.


Low: Duplicated SQL logic

_query_costs() in costs_v2.py and TokenRepository.get_costs_summary() contain the same two SQL queries. The PR notes explain why the router can't call Database.initialize(), but TokenRepository itself could be instantiated directly with a raw connection:

from codeframe.persistence.repositories.token_repository import TokenRepository
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
repo = TokenRepository(conn)
summary = repo.get_costs_summary(days)

This eliminates the duplication without needing SchemaManager. Worth doing before the schema conflict is resolved — it removes the drift risk.


Low: Missing index for time-range queries

WHERE timestamp >= ? on token_usage will do a full table scan as the table grows. An index on timestamp would be a cheap win. Not a blocker for this PR, but worth capturing as a follow-up before workspaces accumulate months of runs.


Low: Task01Icon not in the icon mock additions

page.tsx imports Task01Icon from @hugeicons/react, but only MoneyBag02Icon, Analytics01Icon, and ChartLineData01Icon are added to the mock file in this PR. If Task01Icon wasn't already mocked in the file's existing set, CostsPage.test.tsx would render undefined as a component. Since the test suite reports passing, it's presumably already there — but worth a quick verify to avoid a future regression when the mock file is audited.


What's solid

  • _empty_summary() / zero-filled daily series: exactly right; the chart never needs to handle missing dates.
  • conn.close() in the finally block: no connection leaks.
  • SWR key ['/api/v2/costs/summary', workspacePath, days] correctly invalidates cache on workspace or range change.
  • refreshInterval: 60_000 is a reasonable default for a spend dashboard.
  • Test coverage is thorough: boundary validation (days < 7, days > 90), null task_id exclusion from task count, outside-window exclusion, ordering guarantee, empty-state render, error banner, loading skeleton.
  • No new chart library dependency — the flexbox bar chart is a good call at this stage.

Recommended action before merge: Fix the start_iso timestamp format in both files. The SQL duplication cleanup is a nice-to-have but can be a follow-up.

…y in router

Address claude-review feedback on #590:

- get_costs_summary built its window-start with `.isoformat()`, producing
  "YYYY-MM-DDTHH:MM:SS+00:00". SQLite's `DEFAULT CURRENT_TIMESTAMP` writes
  "YYYY-MM-DD HH:MM:SS" (space, no offset). Lexicographic comparison
  then drops every row inserted via the schema default ("space" < "T").
  Switch the boundary to `strftime("%Y-%m-%d %H:%M:%S")`, which sorts
  correctly against both formats, and add a regression test that inserts
  a space-separated timestamp directly.

- Refactor `costs_v2._query_costs` to instantiate TokenRepository with a
  raw sqlite3 connection instead of duplicating the two aggregate queries.
  Eliminates drift risk between the router and the repository.
Copy link
Copy Markdown
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: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@codeframe/persistence/repositories/token_repository.py`:
- Around line 307-314: The aggregate SQL in the cursor.execute block computing
total_spend and task_count currently only filters with "WHERE timestamp >= ?"
and therefore can include future-dated rows; update that query to constrain
timestamp to the same daily window as the `daily` list by adding an upper bound
(e.g., "AND timestamp < ?" or "AND timestamp <= ?") using the same
end_date/end_date+1 value you use for the daily window so the aggregates over
`token_usage` (fields estimated_cost_usd, task_id, timestamp) match the per-day
breakdown for total_spend and task_count.

In `@codeframe/ui/routers/costs_v2.py`:
- Around line 63-126: The _query_costs function contains DB aggregation/business
logic that belongs in core/repository; extract its SQL and aggregation into a
repository function (e.g., create get_costs_summary(db_path: str, days: int) in
the core/repository module) that returns the same dict structure
(total_spend_usd, total_tasks, avg_cost_per_task, daily) and keeps the
token_usage queries there, then replace _query_costs implementation in the
router with a thin adapter that calls get_costs_summary(db_path, days) and
returns its result (handle errors by returning _empty_summary(days) if the repo
call fails).
- Around line 72-77: The current code only catches sqlite3.Error during
sqlite3.connect() and returns _empty_summary(days), but query-time exceptions
(e.g., during cursor.execute/fetch) are not caught; update the function that
opens the DB (around sqlite3.connect and conn.row_factory) to also wrap the
query execution block in a try/except sqlite3.Error that returns
_empty_summary(days) on any DB error, and ensure the connection is closed in a
finally block (or use a context manager) so resources are released; reference
the sqlite3.connect call, conn.row_factory assignment, the query execution block
(cursor.execute / cursor.fetchall), and the _empty_summary(days) return to
locate where to add the additional except/finally handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3a9a8ee9-f70a-45ea-817a-f6b8a33e8408

📥 Commits

Reviewing files that changed from the base of the PR and between 9c19e0d and a8e7aa1.

📒 Files selected for processing (13)
  • codeframe/persistence/repositories/token_repository.py
  • codeframe/ui/routers/costs_v2.py
  • codeframe/ui/server.py
  • tests/persistence/test_token_repository_costs.py
  • tests/ui/test_costs_v2.py
  • web-ui/__mocks__/@hugeicons/react.js
  • web-ui/src/__tests__/components/costs/CostsPage.test.tsx
  • web-ui/src/__tests__/components/costs/SpendBarChart.test.tsx
  • web-ui/src/app/costs/page.tsx
  • web-ui/src/components/costs/SpendBarChart.tsx
  • web-ui/src/components/layout/AppSidebar.tsx
  • web-ui/src/lib/api.ts
  • web-ui/src/types/index.ts

Comment thread codeframe/persistence/repositories/token_repository.py
Comment thread codeframe/ui/routers/costs_v2.py Outdated
Comment thread codeframe/ui/routers/costs_v2.py
@claude
Copy link
Copy Markdown

claude Bot commented May 13, 2026

Follow-up to Prior Review. Good progress on the two issues I flagged previously. Timestamp format (Medium): Fixed - start_iso now uses strftime format in token_repository.py. SQL duplication (Low): Fixed - _query_costs() now delegates to repo.get_costs_summary(days). One remaining item: _query_costs only catches sqlite3.Error during connect() - if the query itself fails the exception propagates as a 500. Add an except sqlite3.Error clause around the query block before finally: conn.close(). Low severity but consistent with the endpoint goal of never erroring for missing workspaces. Good to merge once this is addressed.

Follow-up to claude-review on #590: the existing try/except only covered
the `sqlite3.connect()` call, so a query-time failure (locked DB, corrupt
schema) would 500 the dashboard. Wrap the cursor/repo block in its own
except so the endpoint stays as graceful as the empty-state path.
Copy link
Copy Markdown
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: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@codeframe/ui/routers/costs_v2.py`:
- Around line 92-104: The async handler get_costs_summary is calling the
blocking _query_costs (which uses TokenRepository.get_costs_summary) on the
event loop; offload that DB work to a threadpool to avoid blocking. Replace the
direct call to _query_costs with an awaited call using
asyncio.to_thread(_query_costs, str(workspace.db_path), days) (or make the route
synchronous) so sqlite3 operations run off the event loop; update any
callers/returns accordingly to keep the same response shape.
- Around line 70-74: The current code calls sqlite3.connect(db_path) which will
create the DB file on a GET; instead either short-circuit if the file is missing
or open the DB read-only to avoid mutation. Modify the block around
sqlite3.connect/db_path to first check for existence (e.g.,
os.path.exists(db_path) and return _empty_summary(days) if absent) or open with
sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) and handle
sqlite3.OperationalError to return _empty_summary(days); keep the existing
conn.row_factory = sqlite3.Row and callers that use conn unchanged.

In `@tests/persistence/test_token_repository_costs.py`:
- Around line 69-71: The test test_default_days_is_30 currently passes an
explicit days=30 so it doesn't verify the default behavior; update it to call
db.token_usage.get_costs_summary() without the days argument and assert
len(summary["daily"]) == 30 to validate the default window (or alternatively
rename the test to test_fixed_window_of_30_and keep the explicit days=30 if you
intend to test fixed-size behavior rather than the default). Ensure you modify
the test function test_default_days_is_30 and its call to get_costs_summary to
reflect the chosen intent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0e11d7d0-83a6-4766-8294-0a298cba1309

📥 Commits

Reviewing files that changed from the base of the PR and between a8e7aa1 and be3e895.

📒 Files selected for processing (3)
  • codeframe/persistence/repositories/token_repository.py
  • codeframe/ui/routers/costs_v2.py
  • tests/persistence/test_token_repository_costs.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • codeframe/persistence/repositories/token_repository.py

Comment on lines +70 to +74
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
except sqlite3.Error:
return _empty_summary(days)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid creating a database file on a read-only GET.

sqlite3.connect(db_path) will create the SQLite file when it is missing, so this endpoint can mutate workspace state and silently mask a bad workspace.db_path. Open read-only (or at least short-circuit when the file is absent) before falling back to _empty_summary(days).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@codeframe/ui/routers/costs_v2.py` around lines 70 - 74, The current code
calls sqlite3.connect(db_path) which will create the DB file on a GET; instead
either short-circuit if the file is missing or open the DB read-only to avoid
mutation. Modify the block around sqlite3.connect/db_path to first check for
existence (e.g., os.path.exists(db_path) and return _empty_summary(days) if
absent) or open with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) and
handle sqlite3.OperationalError to return _empty_summary(days); keep the
existing conn.row_factory = sqlite3.Row and callers that use conn unchanged.

Comment on lines +92 to +104
async def get_costs_summary(
request: Request,
workspace: Workspace = Depends(get_v2_workspace),
days: int = Query(30, ge=7, le=90, description="Window size in days (7-90)"),
):
"""Return total spend, task count, average cost, and a daily series.

Reads from the workspace's `token_usage` table. Returns zero-filled
daily buckets so the client can render a chart without conditionals.
If the table doesn't exist (no agent has run in this workspace yet),
returns an empty-state response rather than an error.
"""
summary = _query_costs(str(workspace.db_path), days)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "costs_v2.py" | head -20

Repository: frankbria/codeframe

Length of output: 98


🏁 Script executed:

cat -n codeframe/ui/routers/costs_v2.py | head -120

Repository: frankbria/codeframe

Length of output: 4611


🏁 Script executed:

# Check the _query_costs function definition
rg "_query_costs" -B 3 -A 15

Repository: frankbria/codeframe

Length of output: 2233


🏁 Script executed:

find . -type f -name "token_repository.py" -o -name "*repository.py" | grep -E "token_repository|repositories"

Repository: frankbria/codeframe

Length of output: 1208


🏁 Script executed:

# Check TokenRepository.get_costs_summary to see if it's blocking
rg "get_costs_summary" -B 2 -A 20

Repository: frankbria/codeframe

Length of output: 16330


🏁 Script executed:

# Check how other async routers handle blocking operations
rg "async def" codeframe/ui/routers/ -A 10 | head -100

Repository: frankbria/codeframe

Length of output: 6740


🏁 Script executed:

# Search for threadpool/to_thread usage in the codebase to see the pattern
rg "to_thread|run_in_threadpool|ThreadPoolExecutor" -B 2 -A 2

Repository: frankbria/codeframe

Length of output: 14276


🏁 Script executed:

# Check TokenRepository.get_costs_summary implementation
cat -n codeframe/persistence/repositories/token_repository.py | grep -A 60 "def get_costs_summary"

Repository: frankbria/codeframe

Length of output: 3096


🏁 Script executed:

# Check if there's any pattern preference - look at which is more common
rg "async def.*\(.*Depends|Query" codeframe/ui/routers/*.py | wc -l
rg "def .*\(.*Depends|Query" codeframe/ui/routers/*.py | wc -l

Repository: frankbria/codeframe

Length of output: 69


Do not run synchronous SQLite work on the event loop.

This async handler calls _query_costs(), which performs blocking sqlite3 operations (via TokenRepository.get_costs_summary()). These blocking calls will run on the event loop and can stall unrelated requests under lock contention or in larger workspaces. Make the route synchronous or wrap _query_costs() with asyncio.to_thread() to offload to a threadpool.

The codebase has established a consistent pattern for this — see settings_v2.py (uses run_in_threadpool), discovery.py, and chat.py (use asyncio.to_thread) for examples.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@codeframe/ui/routers/costs_v2.py` around lines 92 - 104, The async handler
get_costs_summary is calling the blocking _query_costs (which uses
TokenRepository.get_costs_summary) on the event loop; offload that DB work to a
threadpool to avoid blocking. Replace the direct call to _query_costs with an
awaited call using asyncio.to_thread(_query_costs, str(workspace.db_path), days)
(or make the route synchronous) so sqlite3 operations run off the event loop;
update any callers/returns accordingly to keep the same response shape.

Comment on lines +69 to +71
def test_default_days_is_30(self, db):
summary = db.token_usage.get_costs_summary(days=30)
assert len(summary["daily"]) == 30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This test does not verify the default window.

Passing days=30 explicitly means this still passes if the default changes or only exists at the API layer. Either rename it to reflect fixed-size behavior or move the default assertion to the router test that omits the query parameter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/persistence/test_token_repository_costs.py` around lines 69 - 71, The
test test_default_days_is_30 currently passes an explicit days=30 so it doesn't
verify the default behavior; update it to call
db.token_usage.get_costs_summary() without the days argument and assert
len(summary["daily"]) == 30 to validate the default window (or alternatively
rename the test to test_fixed_window_of_30_and keep the explicit days=30 if you
intend to test fixed-size behavior rather than the default). Ensure you modify
the test function test_default_days_is_30 and its call to get_costs_summary to
reflect the chosen intent.

@claude
Copy link
Copy Markdown

claude Bot commented May 13, 2026

Code Review — PR #590: Cost Analytics Page

This is a clean, well-scoped implementation of the /costs feature from #557. Full-stack coverage, good empty-state handling, and the schema-conflict workaround is the right pragmatic call given the current state of SchemaManager. A few things worth addressing:


Bugs / Correctness

SQL queries lack an upper timestamp bound
Both queries in get_costs_summary filter WHERE timestamp >= ? but have no AND timestamp < ? ceiling. The daily list is correctly bounded to the window, but total_spend_usd and total_tasks will silently include any rows with timestamps beyond today (clock skew, bad seed data, etc.). Those rows would show in the KPI cards but not in the bar chart, creating an inconsistency.

Suggested fix in both cursor.execute calls in token_repository.py:

WHERE timestamp >= ? AND timestamp < ?
# params: (start_iso, (end_date + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"))

Design / Tech Debt

Direct DB connection in the router is a workaround, not a fix
The PR description correctly explains the SchemaManager conflict. The router opens SQLite raw to dodge Database.initialize(). This is safe for now, but the root cause (divergent blockers schema between workspace.py and schema_manager.py) will affect any future router that needs both schemas. Worth tracking explicitly as a follow-up issue, or at minimum as a # TODO(schema-conflict): comment near _query_costs so the next engineer understands why the pattern differs from every other router.

formatCurrency is duplicated
Identical implementation exists in both web-ui/src/app/costs/page.tsx and web-ui/src/components/costs/SpendBarChart.tsx. Extracting to web-ui/src/lib/format.ts (or wherever the project keeps shared utilities) would prevent future drift.


Minor

Silent exception swallowing in _query_costs
The except sqlite3.Error block returns an empty summary which is the right user-facing behavior. But there is no logging, so a corrupted DB or permission error would be invisible. Even a single logger.warning("%s: %s", db_path, e) here would save future debugging time.

_empty_summary date-range logic is duplicated with TokenRepository
_empty_summary(days) in the router replicates the same end_date - timedelta(days=days-1) loop that get_costs_summary already handles for the table-is-absent case. No bug here — the router returns early before calling the repo — but there are two places to update if the response shape changes.


What's solid

  • The zero-fill loop (oldest to newest) is clean and the sort-order test verifies it.
  • The timestamp-format test (space-separated vs. ISO-8601) is a nice edge-case catch that directly documents the SQLite quirk.
  • SWR key includes [url, workspacePath, days] — changing the time range correctly triggers a refetch.
  • role="img" aria-label on the chart container is the right accessibility pattern for a data visualization.
  • 60 s auto-refresh on a cost dashboard is a sensible interval.
  • days <= 0 guard in the repository prevents silent truncation.

The main ask is the upper-bound SQL fix — the rest is low-stakes cleanup.

Copy link
Copy Markdown
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.

♻️ Duplicate comments (2)
codeframe/ui/routers/costs_v2.py (2)

97-109: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Offload synchronous DB work from the async request handler.

Line 109 calls _query_costs(...) directly from an async route. This runs blocking SQLite I/O on the event loop and can stall concurrent requests under lock contention or slow storage.

Suggested patch
+import asyncio
 import sqlite3
 from datetime import datetime, timedelta, timezone
 from typing import Dict, List
@@
-    summary = _query_costs(str(workspace.db_path), days)
+    summary = await asyncio.to_thread(_query_costs, str(workspace.db_path), days)
#!/bin/bash
# Verify this async route currently executes sync DB work directly.
rg -n -C3 'async def get_costs_summary|summary = _query_costs\(' codeframe/ui/routers/costs_v2.py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@codeframe/ui/routers/costs_v2.py` around lines 97 - 109, The async route
get_costs_summary is calling the synchronous _query_costs directly (blocking
SQLite I/O) which can stall the event loop; change get_costs_summary to offload
that call to a threadpool (e.g., using starlette.concurrency.run_in_threadpool
or asyncio.get_event_loop().run_in_executor) so _query_costs executes off the
loop and await the result, ensuring the rest of the handler remains
async-compatible and returns the same summary object. Make sure you only wrap
the _query_costs(...) invocation and preserve existing parameters and return
handling.

70-74: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use read-only SQLite connection for this GET path.

Line 71 (sqlite3.connect(db_path)) can create a database file when missing, which mutates state on a read request and can mask an invalid workspace DB path. Prefer read-only URI mode and keep the empty-state fallback on open failure.

Suggested patch
-    try:
-        conn = sqlite3.connect(db_path)
+    try:
+        conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
         conn.row_factory = sqlite3.Row
     except sqlite3.Error:
         return _empty_summary(days)
In Python sqlite3, does sqlite3.connect("path.db") create the file if it does not exist, and is sqlite3.connect("file:path.db?mode=ro", uri=True) the recommended read-only pattern?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@codeframe/ui/routers/costs_v2.py` around lines 70 - 74, The GET handler
currently opens SQLite with sqlite3.connect(db_path) which can create the DB
file; change it to open in read-only URI mode by using sqlite3.connect with a
"file:...?...mode=ro" URI and uri=True (so the open fails instead of creating a
file), keep setting conn.row_factory = sqlite3.Row and preserve the except
sqlite3.Error fallback that returns _empty_summary(days); update the code around
the conn creation (the sqlite3.connect call that sets conn) to use the read-only
URI form referencing db_path, and ensure all subsequent logic using conn remains
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@codeframe/ui/routers/costs_v2.py`:
- Around line 97-109: The async route get_costs_summary is calling the
synchronous _query_costs directly (blocking SQLite I/O) which can stall the
event loop; change get_costs_summary to offload that call to a threadpool (e.g.,
using starlette.concurrency.run_in_threadpool or
asyncio.get_event_loop().run_in_executor) so _query_costs executes off the loop
and await the result, ensuring the rest of the handler remains async-compatible
and returns the same summary object. Make sure you only wrap the
_query_costs(...) invocation and preserve existing parameters and return
handling.
- Around line 70-74: The GET handler currently opens SQLite with
sqlite3.connect(db_path) which can create the DB file; change it to open in
read-only URI mode by using sqlite3.connect with a "file:...?...mode=ro" URI and
uri=True (so the open fails instead of creating a file), keep setting
conn.row_factory = sqlite3.Row and preserve the except sqlite3.Error fallback
that returns _empty_summary(days); update the code around the conn creation (the
sqlite3.connect call that sets conn) to use the read-only URI form referencing
db_path, and ensure all subsequent logic using conn remains unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 39e78e83-e882-49ba-8c6d-3a1274afadf1

📥 Commits

Reviewing files that changed from the base of the PR and between be3e895 and 594368a.

📒 Files selected for processing (1)
  • codeframe/ui/routers/costs_v2.py

…t TODO

Address claude-review on #590 (commit 594368a):

- get_costs_summary used `WHERE timestamp >= ?` with no upper bound, so
  rows dated past today would inflate the KPI cards but not appear in
  the daily chart (which is built from a fixed list of in-window dates).
  Add an exclusive `< end_of_today` bound to both queries so KPIs and
  chart always cover the same set of rows. Regression test included.

- Log a warning when _query_costs falls back to the empty summary (DB
  open or query failure) so corrupted/locked DBs surface in logs.

- Document the schema-conflict workaround inline with TODO(schema-conflict)
  so the next engineer doesn't propagate the raw-sqlite pattern blindly.
@claude
Copy link
Copy Markdown

claude Bot commented May 13, 2026

Follow-up Review -- PR 590

Both issues I flagged in my first review are resolved in the latest commits.

FIXED: Timestamp format -- start_iso and end_iso now use strftime("%Y-%m-%d %H:%M:%S") throughout, so lexicographic comparisons work correctly against SQLites CURRENT_TIMESTAMP default (YYYY-MM-DD HH:MM:SS). The mismatch I described is gone.

FIXED: SQL duplication -- _query_costs() already delegates to TokenRepository(sync_conn=conn).get_costs_summary(days). No duplicated SQL in the router.

Remaining minor items (none blocking):

Task01Icon mock -- page.tsx imports Task01Icon but only MoneyBag02Icon, Analytics01Icon, and ChartLineData01Icon were added to the mock file in this PR. Since 11 new tests pass, Task01Icon is presumably already mocked in the pre-existing set. Quick verification before merge: confirm it appears in web-ui/mocks/@hugeicons/react.js.

timestamp index -- Still no migration adds an index on token_usage.timestamp. Full table scan grows linearly with agent runs. Fine for now; worth a follow-up ticket before workspaces accumulate months of history.

formatCurrency duplication -- The same function is defined identically in both page.tsx and SpendBarChart.tsx. Could be extracted to a lib/format.ts utility. Minor polish; not a blocker.

Overall: the fixes land correctly. Ready to merge once the Task01Icon mock is confirmed.

@frankbria frankbria merged commit a4f7e66 into main May 13, 2026
11 checks passed
@frankbria frankbria deleted the feat/557-cost-analytics-page branch May 13, 2026 04:09
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.

[Phase 5.2] Cost analytics page: total spend and time range

1 participant