Skip to content

Commit 5ad86e6

Browse files
feat: add GitHub-style contribution calendar heatmap to dashboard
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>
1 parent 4ab6a04 commit 5ad86e6

10 files changed

Lines changed: 414 additions & 17 deletions

File tree

internal/admin/dashboard/static/css/dashboard.css

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
--chart-tooltip-bg: #1e1d1c;
2323
--chart-tooltip-border: #2a2826;
2424
--chart-tooltip-text: #e8e0d6;
25+
--cal-level-0: #161b22;
26+
--cal-level-1: #0e4429;
27+
--cal-level-2: #006d32;
28+
--cal-level-3: #26a641;
29+
--cal-level-4: #39d353;
2530
color-scheme: dark;
2631
}
2732

@@ -43,6 +48,11 @@
4348
--chart-tooltip-bg: #ffffff;
4449
--chart-tooltip-border: #e8e0d6;
4550
--chart-tooltip-text: #2d2519;
51+
--cal-level-0: #ebedf0;
52+
--cal-level-1: #9be9a8;
53+
--cal-level-2: #40c463;
54+
--cal-level-3: #30a14e;
55+
--cal-level-4: #216e39;
4656
color-scheme: light;
4757
}
4858

@@ -65,6 +75,11 @@
6575
--chart-tooltip-bg: #ffffff;
6676
--chart-tooltip-border: #e8e0d6;
6777
--chart-tooltip-text: #2d2519;
78+
--cal-level-0: #ebedf0;
79+
--cal-level-1: #9be9a8;
80+
--cal-level-2: #40c463;
81+
--cal-level-3: #30a14e;
82+
--cal-level-4: #216e39;
6883
color-scheme: light;
6984
}
7085
}
@@ -1547,6 +1562,135 @@ body.conversation-drawer-open {
15471562
cursor: default;
15481563
}
15491564

1565+
/* Contribution Calendar */
1566+
.contribution-calendar-section {
1567+
background: var(--bg-surface);
1568+
border: 1px solid var(--border);
1569+
border-radius: var(--radius);
1570+
padding: 24px;
1571+
margin-top: 24px;
1572+
}
1573+
1574+
.contribution-calendar-header {
1575+
display: flex;
1576+
align-items: center;
1577+
justify-content: space-between;
1578+
margin-bottom: 16px;
1579+
}
1580+
1581+
.contribution-calendar-header h3 {
1582+
font-size: 16px;
1583+
font-weight: 600;
1584+
}
1585+
1586+
.contribution-calendar-grid-wrapper {
1587+
display: flex;
1588+
gap: 8px;
1589+
}
1590+
1591+
.contribution-calendar-day-labels {
1592+
display: flex;
1593+
flex-direction: column;
1594+
gap: 2px;
1595+
padding-top: 22px;
1596+
}
1597+
1598+
.contribution-calendar-day-labels span {
1599+
height: 13px;
1600+
font-size: 10px;
1601+
line-height: 13px;
1602+
color: var(--text-muted);
1603+
text-align: right;
1604+
min-width: 28px;
1605+
}
1606+
1607+
.contribution-calendar-scroll {
1608+
flex: 1;
1609+
overflow-x: auto;
1610+
min-width: 0;
1611+
}
1612+
1613+
.contribution-calendar-months {
1614+
display: grid;
1615+
grid-auto-columns: 15px;
1616+
grid-auto-flow: column;
1617+
gap: 2px;
1618+
margin-bottom: 6px;
1619+
height: 16px;
1620+
}
1621+
1622+
.contribution-calendar-month-label {
1623+
font-size: 10px;
1624+
color: var(--text-muted);
1625+
white-space: nowrap;
1626+
}
1627+
1628+
.contribution-calendar-grid {
1629+
display: flex;
1630+
gap: 2px;
1631+
}
1632+
1633+
.contribution-calendar-week {
1634+
display: flex;
1635+
flex-direction: column;
1636+
gap: 2px;
1637+
}
1638+
1639+
.contribution-calendar-cell {
1640+
width: 13px;
1641+
height: 13px;
1642+
border-radius: 2px;
1643+
background: var(--cal-level-0);
1644+
}
1645+
1646+
.contribution-calendar-cell.level-1 { background: var(--cal-level-1); }
1647+
.contribution-calendar-cell.level-2 { background: var(--cal-level-2); }
1648+
.contribution-calendar-cell.level-3 { background: var(--cal-level-3); }
1649+
.contribution-calendar-cell.level-4 { background: var(--cal-level-4); }
1650+
.contribution-calendar-cell.empty { background: transparent; }
1651+
1652+
.contribution-calendar-footer {
1653+
display: flex;
1654+
align-items: center;
1655+
justify-content: space-between;
1656+
margin-top: 12px;
1657+
}
1658+
1659+
.contribution-calendar-summary {
1660+
font-size: 12px;
1661+
color: var(--text-muted);
1662+
}
1663+
1664+
.contribution-calendar-legend {
1665+
display: flex;
1666+
align-items: center;
1667+
gap: 4px;
1668+
font-size: 11px;
1669+
color: var(--text-muted);
1670+
}
1671+
1672+
.contribution-calendar-legend .contribution-calendar-cell {
1673+
width: 11px;
1674+
height: 11px;
1675+
cursor: default;
1676+
}
1677+
1678+
.contribution-calendar-tooltip {
1679+
position: fixed;
1680+
z-index: 200;
1681+
background: var(--bg-surface);
1682+
color: var(--text);
1683+
border: 1px solid var(--border);
1684+
border-radius: 4px;
1685+
padding: 4px 8px;
1686+
font-size: 12px;
1687+
font-family: 'SF Mono', Menlo, Consolas, monospace;
1688+
white-space: nowrap;
1689+
pointer-events: none;
1690+
transform: translateX(-50%);
1691+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
1692+
}
1693+
15501694
/* Responsive */
15511695
@media (max-width: 768px) {
15521696
.sidebar { width: 60px; }
@@ -1638,6 +1782,10 @@ body.conversation-drawer-open {
16381782
max-width: 100%;
16391783
}
16401784

1785+
/* Contribution calendar mobile */
1786+
.contribution-calendar-day-labels { display: none; }
1787+
.contribution-calendar-section { padding: 16px; }
1788+
16411789
/* Date picker mobile */
16421790
.date-picker-dropdown {
16431791
flex-direction: column;

internal/admin/dashboard/static/js/dashboard.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ function dashboard() {
106106
});
107107

108108
this.fetchAll();
109+
this.fetchCalendarData();
109110
},
110111

111112
toggleSidebar() {
@@ -315,6 +316,7 @@ function dashboard() {
315316
typeof dashboardUsageModule === 'function' ? dashboardUsageModule : null,
316317
typeof dashboardAuditListModule === 'function' ? dashboardAuditListModule : null,
317318
typeof dashboardConversationDrawerModule === 'function' ? dashboardConversationDrawerModule : null,
319+
typeof dashboardContributionCalendarModule === 'function' ? dashboardContributionCalendarModule : null,
318320
typeof dashboardChartsModule === 'function' ? dashboardChartsModule : null
319321
];
320322

internal/admin/dashboard/static/js/modules/charts.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
const result = [];
1818
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
1919
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
20-
result.push(byDate[key] || { date: key, input_tokens: 0, output_tokens: 0, total_tokens: 0, requests: 0 });
20+
result.push(byDate[key] || { date: key, input_tokens: 0, output_tokens: 0, total_tokens: 0, requests: 0, input_cost: null, output_cost: null, total_cost: null });
2121
}
2222
return result;
2323
},
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
(function(global) {
2+
function dashboardContributionCalendarModule() {
3+
return {
4+
calendarData: [],
5+
calendarMode: 'tokens',
6+
calendarLoading: false,
7+
calendarTooltip: { show: false, x: 0, y: 0, text: '' },
8+
9+
async fetchCalendarData() {
10+
this.calendarLoading = true;
11+
try {
12+
const res = await fetch('/admin/api/v1/usage/daily?days=365&interval=daily', { headers: this.headers() });
13+
if (!this.handleFetchResponse(res, 'calendar')) {
14+
this.calendarData = [];
15+
return;
16+
}
17+
this.calendarData = await res.json();
18+
} catch (e) {
19+
console.error('Failed to fetch calendar data:', e);
20+
this.calendarData = [];
21+
} finally {
22+
this.calendarLoading = false;
23+
}
24+
},
25+
26+
buildCalendarGrid() {
27+
var byDate = {};
28+
(this.calendarData || []).forEach(function(d) { byDate[d.date] = d; });
29+
30+
var today = new Date();
31+
today.setHours(0, 0, 0, 0);
32+
33+
var start = new Date(today);
34+
start.setDate(start.getDate() - 364);
35+
36+
// Align start to Monday (ISO week start)
37+
var dayOfWeek = start.getDay();
38+
var diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
39+
start.setDate(start.getDate() + diff);
40+
41+
var mode = this.calendarMode;
42+
var days = [];
43+
for (var d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) {
44+
var key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
45+
var entry = byDate[key];
46+
var value = 0;
47+
if (entry) {
48+
if (mode === 'costs') {
49+
value = entry.total_cost != null ? entry.total_cost : 0;
50+
} else {
51+
value = entry.total_tokens || 0;
52+
}
53+
}
54+
days.push({ dateStr: key, value: value, level: 0, empty: false });
55+
}
56+
57+
// Calculate levels based on non-zero values
58+
var nonZero = days.filter(function(d) { return d.value > 0; }).map(function(d) { return d.value; });
59+
nonZero.sort(function(a, b) { return a - b; });
60+
var max = nonZero.length > 0 ? nonZero[nonZero.length - 1] : 0;
61+
62+
for (var i = 0; i < days.length; i++) {
63+
days[i].level = this.calendarLevel(days[i].value, max, nonZero);
64+
}
65+
66+
// Build weeks (columns)
67+
var weeks = [];
68+
var week = [];
69+
for (var j = 0; j < days.length; j++) {
70+
week.push(days[j]);
71+
if (week.length === 7) {
72+
weeks.push(week);
73+
week = [];
74+
}
75+
}
76+
if (week.length > 0) {
77+
// Pad remaining week with empty slots
78+
while (week.length < 7) {
79+
week.push({ dateStr: '', value: 0, level: 0, empty: true });
80+
}
81+
weeks.push(week);
82+
}
83+
84+
return weeks;
85+
},
86+
87+
calendarLevel(value, max, sortedNonZero) {
88+
if (value === 0 || max === 0) return 0;
89+
if (!sortedNonZero || sortedNonZero.length === 0) return 0;
90+
var len = sortedNonZero.length;
91+
var q1 = sortedNonZero[Math.floor(len * 0.25)];
92+
var q2 = sortedNonZero[Math.floor(len * 0.5)];
93+
var q3 = sortedNonZero[Math.floor(len * 0.75)];
94+
if (value <= q1) return 1;
95+
if (value <= q2) return 2;
96+
if (value <= q3) return 3;
97+
return 4;
98+
},
99+
100+
toggleCalendarMode(mode) {
101+
this.calendarMode = mode;
102+
},
103+
104+
calendarMonthLabels() {
105+
var today = new Date();
106+
today.setHours(0, 0, 0, 0);
107+
var start = new Date(today);
108+
start.setDate(start.getDate() - 364);
109+
var dayOfWeek = start.getDay();
110+
var diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
111+
start.setDate(start.getDate() + diff);
112+
113+
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
114+
var labels = [];
115+
var lastMonth = -1;
116+
var weekIdx = 0;
117+
118+
for (var d = new Date(start); d <= today; ) {
119+
var m = d.getMonth();
120+
if (m !== lastMonth) {
121+
labels.push({ label: months[m], col: weekIdx, key: m + '-' + d.getFullYear() });
122+
lastMonth = m;
123+
}
124+
d.setDate(d.getDate() + 7);
125+
weekIdx++;
126+
}
127+
128+
return labels;
129+
},
130+
131+
calendarSummaryText() {
132+
var weeks = this.buildCalendarGrid();
133+
var total = 0;
134+
for (var i = 0; i < weeks.length; i++) {
135+
for (var j = 0; j < weeks[i].length; j++) {
136+
if (!weeks[i][j].empty) {
137+
total += weeks[i][j].value;
138+
}
139+
}
140+
}
141+
if (this.calendarMode === 'costs') {
142+
return '$' + total.toFixed(2) + ' in the last year';
143+
}
144+
return total.toLocaleString() + ' tokens in the last year';
145+
},
146+
147+
showCalendarTooltip(event, day) {
148+
if (day.empty) return;
149+
var label = '';
150+
if (this.calendarMode === 'costs') {
151+
label = '$' + (day.value || 0).toFixed(4) + ' on ' + day.dateStr;
152+
} else {
153+
label = (day.value || 0).toLocaleString() + ' tokens on ' + day.dateStr;
154+
}
155+
this.calendarTooltip = {
156+
show: true,
157+
x: event.clientX,
158+
y: event.clientY,
159+
text: label
160+
};
161+
},
162+
163+
hideCalendarTooltip() {
164+
this.calendarTooltip = { show: false, x: 0, y: 0, text: '' };
165+
}
166+
};
167+
}
168+
169+
global.dashboardContributionCalendarModule = dashboardContributionCalendarModule;
170+
})(window);

0 commit comments

Comments
 (0)