feat: GitHub-style contribution calendar heatmap#144
Conversation
Add a year-long activity heatmap to the Overview page showing daily token consumption or cost data. Includes switchable Tokens/Costs modes, hover tooltips, dark/light theme support, and responsive layout. Backend: add cost fields (InputCost, OutputCost, TotalCost) to the DailyUsage struct and update SQLite, PostgreSQL, and MongoDB readers to aggregate and return cost data alongside token counts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds a yearly contribution calendar UI and client module to the admin dashboard, extends DailyUsage and UsageLogEntry to include cost fields, and updates MongoDB/PostgreSQL/SQLite readers to aggregate/return cost totals. Also inserts the contribution-calendar script tag twice in layout.html. Changes
Sequence DiagramsequenceDiagram
participant Dashboard as Dashboard Init
participant CalendarModule as Calendar Module
participant Backend as Backend API
participant Database as Database
Dashboard->>CalendarModule: init / request calendarModuleFactory
CalendarModule->>Backend: fetchCalendarData()
Backend->>Database: Query daily usage with cost+token aggregates
Database-->>Backend: Return usage records (dates, tokens, costs)
Backend-->>CalendarModule: JSON response
CalendarModule->>CalendarModule: buildCalendarGrid() / compute levels
CalendarModule->>Dashboard: render calendar cells + tooltips
User->>Dashboard: toggle tokens/costs
Dashboard->>CalendarModule: toggleCalendarMode()
CalendarModule->>CalendarModule: rebuild grid & re-render
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/usage/reader.go (1)
73-76: 🧹 Nitpick | 🔵 TrivialMinor formatting inconsistency.
Line 75 has inconsistent alignment compared to lines 73-74. Consider running
gofmtto normalize spacing.🔧 Proposed formatting fix
- InputCost *float64 `json:"input_cost"` - OutputCost *float64 `json:"output_cost"` - TotalCost *float64 `json:"total_cost"` + InputCost *float64 `json:"input_cost"` + OutputCost *float64 `json:"output_cost"` + TotalCost *float64 `json:"total_cost"`🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@internal/usage/reader.go` around lines 73 - 76, The struct field alignment is inconsistent for TotalCost compared to InputCost and OutputCost; run gofmt or adjust spacing so the field declarations and struct tags align consistently (e.g., make TotalCost use the same spacing/tab alignment as InputCost/OutputCost) in the struct that contains fields InputCost, OutputCost, TotalCost, and RawData in reader.go to normalize formatting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@internal/admin/dashboard/static/js/dashboard.js`:
- Line 109: Init currently calls fetchCalendarData() unconditionally which can
throw if the optional calendar module wasn't registered; remove that direct call
from init() and instead guard all calendar calls by checking the module exists
(e.g., calendarModule or whatever identifier you use when registering the
calendar) before invoking fetchCalendarData(), and add a call to
fetchCalendarData() from the shared refresh path (fetchAll()) so auth retries
and other refreshes also update calendar data only when the module is present;
update any registration logic (the optional registerModule call) to ensure the
presence check matches the registration identifier.
In `@internal/admin/dashboard/static/js/modules/contribution-calendar.js`:
- Around line 131-145: calendarSummaryText currently calls buildCalendarGrid
(expensive) to compute a total; change it to iterate over the raw calendarData
instead to avoid rebuilding the grid: inside calendarSummaryText, sum values
directly from this.calendarData (skip empty or falsy entries) and format the
result based on this.calendarMode ('costs' => dollar with toFixed(2), else use
toLocaleString for tokens). Keep calendarSummaryText signature and return
strings unchanged and remove the buildCalendarGrid call from this method so the
expensive grid construction is not triggered on every reactive render.
In `@internal/admin/dashboard/templates/index.html`:
- Around line 60-61: Add explicit type="button" attributes to the calendar mode
toggle buttons to prevent implicit form submission; update the two elements
using the usage-mode-btn class (the buttons that call
toggleCalendarMode('tokens') and toggleCalendarMode('costs')) to include
type="button" while preserving their class bindings and click handlers.
In `@internal/usage/reader_postgresql.go`:
- Around line 190-191: The cost SUM aggregations are wrapped with COALESCE(...,
0) which forces NULL -> 0 and breaks the nullable *float64 semantics; in
GetDailyUsage (see variable costCols and the surrounding query),
GetUsageByModel, and GetSummary remove the COALESCE around SUM(input_cost),
SUM(output_cost), and SUM(total_cost) so the SQL returns NULL when costs are
unknown, letting the Go *float64 fields remain nil; update the SELECT fragments
that currently use COALESCE(SUM(...), 0) to use SUM(...) directly in those three
functions.
In `@internal/usage/reader_sqlite.go`:
- Around line 198-199: The cost aggregation uses COALESCE(SUM(...),0) which
forces missing costs to 0.0 and prevents DailyUsage's *float64 cost pointers
from being nil; remove the COALESCE wrappers in the costCols variable and any
corresponding SELECT fragments so SUM(...) can return NULL, then ensure
GetDailyUsage and GetSummary continue scanning into the DailyUsage cost pointer
fields (so they stay nil when no cost rows exist). Apply the same change for the
analogous query fragments in reader_postgresql.go and update any related query
construction logic that references costCols, query, GetDailyUsage, GetSummary,
and DailyUsage so NULL aggregates map to nil pointers.
---
Outside diff comments:
In `@internal/usage/reader.go`:
- Around line 73-76: The struct field alignment is inconsistent for TotalCost
compared to InputCost and OutputCost; run gofmt or adjust spacing so the field
declarations and struct tags align consistently (e.g., make TotalCost use the
same spacing/tab alignment as InputCost/OutputCost) in the struct that contains
fields InputCost, OutputCost, TotalCost, and RawData in reader.go to normalize
formatting.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 79528bea-cf91-4f41-9524-c1483ef513e9
📒 Files selected for processing (10)
internal/admin/dashboard/static/css/dashboard.cssinternal/admin/dashboard/static/js/dashboard.jsinternal/admin/dashboard/static/js/modules/charts.jsinternal/admin/dashboard/static/js/modules/contribution-calendar.jsinternal/admin/dashboard/templates/index.htmlinternal/admin/dashboard/templates/layout.htmlinternal/usage/reader.gointernal/usage/reader_mongodb.gointernal/usage/reader_postgresql.gointernal/usage/reader_sqlite.go
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@internal/admin/dashboard/templates/index.html`:
- Around line 84-87: The calendar cells (div.contribution-calendar-cell) are
only hoverable via `@mouseenter/`@mouseleave so keyboard and screen-reader users
can't access day-level info; make each cell keyboard-accessible by adding
tabindex="0" and an appropriate ARIA role (e.g., role="button" or
role="gridcell") and an aria-label that includes the day and level (derived from
day and day.level), wire focus/blur handlers to call showCalendarTooltip and
hideCalendarTooltip (or reuse existing methods) and add a `@keydown` handler that
triggers showCalendarTooltip on Enter/Space and hideCalendarTooltip on Escape to
mirror mouse behavior; update handlers referenced (showCalendarTooltip,
hideCalendarTooltip) to accept keyboard events if needed and ensure empty days
still convey emptiness in the aria-label (e.g., "no contributions") for screen
readers.
- Around line 56-110: The calendar HTML unconditionally calls calendar helpers
(calendarMonthLabels(), buildCalendarGrid(), calendarSummaryText(),
calendarTooltip, toggleCalendarMode, showCalendarTooltip, hideCalendarTooltip)
which breaks when the optional calendar module isn't registered; wrap the entire
calendar block (the contribution-calendar-section and the
contribution-calendar-tooltip) with a guard that only renders/binds when the
module is available (e.g., add a top-level x-if/x-show like
calendarModuleAvailable or a runtime check such as typeof calendarMonthLabels
=== 'function'), and update any inline bindings to short-circuit against that
same guard so events like `@click`="toggleCalendarMode('tokens')" and tooltip
mouse handlers are only attached when those functions/objects exist.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 465f803b-f1ba-400c-87fc-68dcf8e06b6b
📒 Files selected for processing (6)
internal/admin/dashboard/static/js/dashboard.jsinternal/admin/dashboard/static/js/modules/contribution-calendar.jsinternal/admin/dashboard/templates/index.htmlinternal/usage/reader.gointernal/usage/reader_postgresql.gointernal/usage/reader_sqlite.go
| <div class="contribution-calendar-section"> | ||
| <div class="contribution-calendar-header"> | ||
| <h3>Activity</h3> | ||
| <div class="usage-mode-toggle"> | ||
| <button type="button" class="usage-mode-btn" :class="{active: calendarMode==='tokens'}" @click="toggleCalendarMode('tokens')">Tokens</button> | ||
| <button type="button" class="usage-mode-btn" :class="{active: calendarMode==='costs'}" @click="toggleCalendarMode('costs')">Costs</button> | ||
| </div> | ||
| </div> | ||
| <div class="contribution-calendar-grid-wrapper"> | ||
| <div class="contribution-calendar-day-labels"> | ||
| <span></span> | ||
| <span>Mon</span> | ||
| <span></span> | ||
| <span>Wed</span> | ||
| <span></span> | ||
| <span>Fri</span> | ||
| <span></span> | ||
| </div> | ||
| <div class="contribution-calendar-scroll"> | ||
| <div class="contribution-calendar-months"> | ||
| <template x-for="ml in calendarMonthLabels()" :key="ml.key"> | ||
| <span class="contribution-calendar-month-label" :style="'grid-column: ' + (ml.col + 1)" x-text="ml.label"></span> | ||
| </template> | ||
| </div> | ||
| <div class="contribution-calendar-grid"> | ||
| <template x-for="(week, wi) in buildCalendarGrid()" :key="wi"> | ||
| <div class="contribution-calendar-week"> | ||
| <template x-for="(day, di) in week" :key="wi + '-' + di"> | ||
| <div class="contribution-calendar-cell" | ||
| :class="[day.empty ? 'empty' : 'level-' + day.level]" | ||
| @mouseenter="showCalendarTooltip($event, day)" | ||
| @mouseleave="hideCalendarTooltip()"></div> | ||
| </template> | ||
| </div> | ||
| </template> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div class="contribution-calendar-footer"> | ||
| <span class="contribution-calendar-summary" x-text="calendarSummaryText()"></span> | ||
| <div class="contribution-calendar-legend"> | ||
| <span>Less</span> | ||
| <div class="contribution-calendar-cell level-0"></div> | ||
| <div class="contribution-calendar-cell level-1"></div> | ||
| <div class="contribution-calendar-cell level-2"></div> | ||
| <div class="contribution-calendar-cell level-3"></div> | ||
| <div class="contribution-calendar-cell level-4"></div> | ||
| <span>More</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div class="contribution-calendar-tooltip" | ||
| x-show="calendarTooltip.show" | ||
| x-text="calendarTooltip.text" | ||
| :style="'left: ' + calendarTooltip.x + 'px; top: ' + (calendarTooltip.y - 40) + 'px'"></div> |
There was a problem hiding this comment.
Guard calendar bindings when the module is unavailable.
This block unconditionally evaluates calendar methods/properties, but dashboard.js treats the module as optional. If it is not registered, Alpine can fail at render time (e.g., calling undefined methods).
💡 Proposed fix
- <div class="contribution-calendar-section">
+ <div class="contribution-calendar-section" x-show="hasCalendarModule">
@@
- <div class="contribution-calendar-tooltip"
- x-show="calendarTooltip.show"
+ <div class="contribution-calendar-tooltip"
+ x-show="hasCalendarModule && calendarTooltip.show"
x-text="calendarTooltip.text"
:style="'left: ' + calendarTooltip.x + 'px; top: ' + (calendarTooltip.y - 40) + 'px'"></div>
+ <p class="empty-state" x-show="!hasCalendarModule">Activity calendar unavailable.</p>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/admin/dashboard/templates/index.html` around lines 56 - 110, The
calendar HTML unconditionally calls calendar helpers (calendarMonthLabels(),
buildCalendarGrid(), calendarSummaryText(), calendarTooltip, toggleCalendarMode,
showCalendarTooltip, hideCalendarTooltip) which breaks when the optional
calendar module isn't registered; wrap the entire calendar block (the
contribution-calendar-section and the contribution-calendar-tooltip) with a
guard that only renders/binds when the module is available (e.g., add a
top-level x-if/x-show like calendarModuleAvailable or a runtime check such as
typeof calendarMonthLabels === 'function'), and update any inline bindings to
short-circuit against that same guard so events like
`@click`="toggleCalendarMode('tokens')" and tooltip mouse handlers are only
attached when those functions/objects exist.
| <div class="contribution-calendar-cell" | ||
| :class="[day.empty ? 'empty' : 'level-' + day.level]" | ||
| @mouseenter="showCalendarTooltip($event, day)" | ||
| @mouseleave="hideCalendarTooltip()"></div> |
There was a problem hiding this comment.
Add keyboard-accessible semantics for calendar cells.
The cells are hover-only (mouseenter/mouseleave) and not keyboard focusable, so day-level values are inaccessible for keyboard/screen-reader users.
💡 Proposed fix
- <div class="contribution-calendar-cell"
+ <div class="contribution-calendar-cell"
:class="[day.empty ? 'empty' : 'level-' + day.level]"
+ :tabindex="day.empty ? -1 : 0"
+ role="img"
+ :aria-label="day.empty ? '' : (calendarMode === 'costs'
+ ? ('$' + (day.value || 0).toFixed(4) + ' on ' + day.dateStr)
+ : ((day.value || 0).toLocaleString() + ' tokens on ' + day.dateStr))"
`@mouseenter`="showCalendarTooltip($event, day)"
`@mouseleave`="hideCalendarTooltip()"></div>📝 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.
| <div class="contribution-calendar-cell" | |
| :class="[day.empty ? 'empty' : 'level-' + day.level]" | |
| @mouseenter="showCalendarTooltip($event, day)" | |
| @mouseleave="hideCalendarTooltip()"></div> | |
| <div class="contribution-calendar-cell" | |
| :class="[day.empty ? 'empty' : 'level-' + day.level]" | |
| :tabindex="day.empty ? -1 : 0" | |
| role="img" | |
| :aria-label="day.empty ? '' : (calendarMode === 'costs' | |
| ? ('$' + (day.value || 0).toFixed(4) + ' on ' + day.dateStr) | |
| : ((day.value || 0).toLocaleString() + ' tokens on ' + day.dateStr))" | |
| `@mouseenter`="showCalendarTooltip($event, day)" | |
| `@mouseleave`="hideCalendarTooltip()"></div> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@internal/admin/dashboard/templates/index.html` around lines 84 - 87, The
calendar cells (div.contribution-calendar-cell) are only hoverable via
`@mouseenter/`@mouseleave so keyboard and screen-reader users can't access
day-level info; make each cell keyboard-accessible by adding tabindex="0" and an
appropriate ARIA role (e.g., role="button" or role="gridcell") and an aria-label
that includes the day and level (derived from day and day.level), wire
focus/blur handlers to call showCalendarTooltip and hideCalendarTooltip (or
reuse existing methods) and add a `@keydown` handler that triggers
showCalendarTooltip on Enter/Space and hideCalendarTooltip on Escape to mirror
mouse behavior; update handlers referenced (showCalendarTooltip,
hideCalendarTooltip) to accept keyboard events if needed and ensure empty days
still convey emptiness in the aria-label (e.g., "no contributions") for screen
readers.
Summary
DailyUsagestruct withInputCost,OutputCost,TotalCostfields and update all three database readers (SQLite, PostgreSQL, MongoDB) to aggregate cost datacontribution-calendar.jsmodule with independent data fetching (/admin/api/v1/usage/daily?days=365), switchable Tokens/Costs modes, quartile-based intensity levels, hover tooltips, and month labelsTest plan
make test— all 23 packages pass, all pre-commit hooks passmake run— verify calendar renders on/admin/dashboard/overview🤖 Generated with Claude Code
Summary by CodeRabbit