Skip to content

feat: year-calendar heatmap (GitHub-style 365-day grid)#29

Open
jakduch wants to merge 1 commit into
josepe98:mainfrom
jakduch:feat/year-calendar-heatmap
Open

feat: year-calendar heatmap (GitHub-style 365-day grid)#29
jakduch wants to merge 1 commit into
josepe98:mainfrom
jakduch:feat/year-calendar-heatmap

Conversation

@jakduch
Copy link
Copy Markdown
Collaborator

@jakduch jakduch commented May 23, 2026

What does this add and why do you believe it belongs in this dashboard?

Adds a GitHub-style 53x7 year-calendar heatmap covering the trailing 365 days of Claude Code activity, colored by daily cost intensity. The point of a personal usage dashboard is to give you a feel for your own habits at a glance; the existing daily bar chart answers "how much did I spend this week," but not "when did I actually use this over the past year?" A year heatmap is the canonical idiom for that — every developer already reads it on GitHub, so zero learning curve. The trailing-365-day window deliberately ignores the Range/Models filter (the value of the year view is the year view itself; filters would defeat it). Cells are bucketed into four intensity steps using the existing --accent theme color, so it adapts to every theme. Tooltips show date, cost, and turns. Cost is calculated server-side from the canonical pricing.py table, so unbillable models contribute turns but zero cost — matching the JS calcCost helper. Click-to-drill (filtering the rest of the dashboard to the clicked day) is a natural next step but intentionally out of scope here to keep this a single concern.

Checklist

Code correctness

  • All calcCost() calls pass 6 arguments: (model, inp, out, cache_read, cache_creation, cache_1h)
  • JavaScript template literals use bare backticks (`), not escaped ones (\`)
  • No JS variables referenced before they are defined
  • No new third-party dependencies introduced

Tests

  • python3 -m unittest discover -s tests -v — all passing
  • python3 -m unittest tests.test_browser -v — all passing
  • New behaviour is covered by at least one test

Scope

  • This is a single concern — one feature or fix per PR
  • Only touches existing files (dashboard.py, scanner.py, cli.py, pricing.py, cowork.py, tests/) — or I've explained below why a new file is needed

@jakduch
Copy link
Copy Markdown
Collaborator Author

jakduch commented May 24, 2026

Force-pushed: cleanly rebased on current main (a7d4e2d, the setRange fix), so the branch is now MERGEABLE with zero conflicts and is purely additive.

What lands:

  • dashboard.py (+158): new _year_calendar(conn, today=None) helper that aggregates turns + cost per day server-side over the trailing 365 days using the canonical PRICING table (non-billable models contribute turns but $0). The API payload gains a year_calendar key; HTML grows a .yc-wrap/.yc-grid/.yc-legend card (id="year-calendar", 53x7 column-major) and a renderYearCalendar(rows) JS function with 4 discrete intensity buckets derived from the active theme's --accent. The hook in applyFilter() deliberately passes the unfiltered rawData.year_calendar so the card stays a true year view immune to the Models/Range filter.
  • tests/test_year_calendar.py (+233): 10 new tests covering the empty-DB 365-zero case, window boundaries, cost from PRICING, unbillable-model handling, out-of-window exclusion, API payload shape, and HTML/JS wiring.

Coexists cleanly with everything currently in main (incl. the setRange fix). python3 -m unittest discover tests -> 200 passed, 6 skipped (unchanged from baseline).

@jakduch
Copy link
Copy Markdown
Collaborator Author

jakduch commented May 24, 2026

Fixed a merge artifact in the embedded JS template: the bootstrapPrefs IIFE (which restores localStorage prefs at startup) was accidentally inlined inside triggerRescan() after a stray await token. Because triggerRescan was async, this happened to parse — await (function bootstrapPrefs(){...})() is valid syntax — but it had two consequences:

  1. bootstrapPrefs only ran when the user clicked Rescan, never at page load, so saved model-filter prefs from localStorage were silently ignored.
  2. Every Rescan re-ran the bootstrap and called loadData() outside the proper await chain.

Moved the IIFE back to module top-level (right before the final loadData(); scheduleAutoRefresh();) and restored triggerRescan to call await loadData() cleanly.

@josepe98 josepe98 closed this May 25, 2026
@josepe98 josepe98 reopened this May 25, 2026
@josepe98 josepe98 closed this May 25, 2026
@josepe98 josepe98 reopened this May 25, 2026
@josepe98 josepe98 closed this May 25, 2026
@josepe98 josepe98 reopened this May 25, 2026
@jakduch jakduch force-pushed the feat/year-calendar-heatmap branch 5 times, most recently from e34d42d to 8e760d5 Compare May 27, 2026 06:49
Adds a contribution-graph-style 'Activity (last 365 days)' card to the
dashboard. The grid is 53 cols x 7 rows (Sun-top), with 4 discrete
intensity buckets based on daily cost relative to the year's peak day.

- dashboard.py: new _year_calendar(conn, today=None) helper aggregates
  turns + cost server-side per day from the canonical PRICING table;
  non-billable models still count toward the day's turn total.
- API payload exposes the 365-day series under 'year_calendar' so the
  front-end can render it regardless of the active Models/Range filter.
- HTML/CSS card + renderYearCalendar() JS renderer with hover tooltips
  and a Less/More legend that derives its colour from --accent.
- tests/test_year_calendar.py: 10 tests covering empty-DB shape, window
  boundaries, seeded cost/turn values, unbillable-model handling, the
  out-of-window cutoff, the API key presence, and the HTML/JS wiring.
@jakduch jakduch force-pushed the feat/year-calendar-heatmap branch from 8e760d5 to dc1bdc7 Compare May 27, 2026 10:38
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