Skip to content

Commit 971d321

Browse files
committed
feat(activity): add streak/calendar UI to statistics page and navbar
Add frontend components for the activity feature (issue #227): - Calendar heatmap: GitHub-style SVG grid on the statistics page with per-day tooltips showing the breakdown (created, reviewed, read) - Streak display: current/best streak boxes and today's summary - Navbar: flame icon with streak count replaces the old Statistics link, links to /admin/statistics - Activity components load with the admin module (statistics page) Also updates the changelog with the activity tracking feature.
1 parent b65a8bf commit 971d321

12 files changed

Lines changed: 440 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ ones are marked like "v1.0.0-fork".
99

1010
### Added
1111

12+
* **Activity tracking with streaks and calendar heatmap** (#227): New
13+
`activity_log` table tracks daily terms created, terms reviewed, and texts
14+
read. A backfill migration populates historical data from existing word
15+
timestamps. The home page now shows current streak, best streak, today's
16+
activity summary, and a GitHub-style calendar heatmap of the last 12 months.
17+
New API endpoints at `/api/v1/activity/{dashboard,streak,calendar,today}`.
1218
* **PHP 8.5 support**: All dependencies now support PHP 8.5. Added PHP 8.5 to
1319
the CI test matrix.
1420

src/Modules/Activity/Application/ActivityFacade.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ public function getStreakStatistics(): StreakResult
9292
/**
9393
* Get calendar heatmap data for the last 365 days.
9494
*
95-
* @return array<string, array{total: int}>
95+
* @return array<string, array{total: int, created: int, reviewed: int, read: int}>
9696
*/
9797
public function getCalendarHeatmapData(): array
9898
{

src/Modules/Activity/Application/UseCases/GetCalendarHeatmapData.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ public function __construct(ActivityRepositoryInterface $repository)
4343
/**
4444
* Execute the use case.
4545
*
46-
* @return array<string, array{total: int}> Date (Y-m-d) => total activity
46+
* @return array<string, array{total: int, created: int, reviewed: int, read: int}>
47+
* Date (Y-m-d) => activity breakdown
4748
*/
4849
public function execute(): array
4950
{
@@ -57,7 +58,12 @@ public function execute(): array
5758
foreach ($rows as $row) {
5859
$total = $row['terms_created'] + $row['terms_reviewed'] + $row['texts_read'];
5960
if ($total > 0) {
60-
$result[$row['date']] = ['total' => $total];
61+
$result[$row['date']] = [
62+
'total' => $total,
63+
'created' => $row['terms_created'],
64+
'reviewed' => $row['terms_reviewed'],
65+
'read' => $row['texts_read'],
66+
];
6167
}
6268
}
6369

src/Modules/Admin/Views/statistics.php

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,59 @@
8686
];
8787

8888
?>
89-
<div class="container" x-data="statisticsApp()">
89+
<div class="container">
90+
91+
<!-- Activity & Streak Section -->
92+
<section class="box mb-4" x-data="streakDisplay">
93+
<h2 class="title is-4 mb-4">
94+
<span class="icon-text">
95+
<span class="icon has-text-warning">
96+
<?php echo IconHelper::render('flame', ['class' => 'icon']); ?>
97+
</span>
98+
<span>Your Activity</span>
99+
</span>
100+
</h2>
101+
102+
<!-- Streak stats row -->
103+
<div class="columns is-mobile is-multiline mb-3">
104+
<div class="column is-4-desktop is-6-mobile">
105+
<div class="box has-background-light has-text-centered py-3">
106+
<p class="heading">Current Streak</p>
107+
<p class="title is-5" x-text="streakLabel(currentStreak)">--</p>
108+
</div>
109+
</div>
110+
<div class="column is-4-desktop is-6-mobile">
111+
<div class="box has-background-light has-text-centered py-3">
112+
<p class="heading">Best Streak</p>
113+
<p class="title is-5" x-text="streakLabel(bestStreak)">--</p>
114+
</div>
115+
</div>
116+
<div class="column is-4-desktop is-12-mobile">
117+
<div class="box has-background-light has-text-centered py-3">
118+
<p class="heading">Today</p>
119+
<p class="is-size-6">
120+
<span class="has-text-weight-semibold" x-text="todayCreated"></span> created,
121+
<span class="has-text-weight-semibold" x-text="todayReviewed"></span> reviewed,
122+
<span class="has-text-weight-semibold" x-text="todayTextsRead"></span> read
123+
</p>
124+
</div>
125+
</div>
126+
</div>
127+
128+
<!-- Calendar heatmap -->
129+
<div x-data="calendarHeatmap">
130+
<div style="overflow-x: auto;">
131+
<p class="heading mb-2">Activity (Last 12 Months)</p>
132+
<div x-show="loading" class="has-text-centered py-3">
133+
<span class="has-text-grey-light is-size-7">Loading activity...</span>
134+
</div>
135+
<div x-show="error" class="has-text-danger is-size-7" x-text="error"></div>
136+
<div x-ref="svgContainer" style="min-width: 720px;"></div>
137+
</div>
138+
</div>
139+
</section>
140+
141+
<div x-data="statisticsApp()">
90142
<!-- Hidden data elements for Chart.js initialization -->
91143
<script type="application/json" id="statistics-intensity-data">
92144
<?php echo json_encode(['languages' => $intensityChartData]); ?>
@@ -155,6 +207,7 @@
155207
</div>
156208
</div>
157209
</div>
210+
</div>
158211

159212
<style>
160213
/* Collapsible section styles */

src/Shared/UI/Helpers/PageLayoutHelper.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ public static function buildNavbar(string $currentPage = ''): string
114114
$textsIcon = IconHelper::render('book-text', ['alt' => 'Texts']);
115115
$termsIcon = IconHelper::render('spell-check', ['alt' => 'Terms']);
116116
$languagesIcon = IconHelper::render('languages', ['alt' => 'Languages']);
117-
$statsIcon = IconHelper::render('bar-chart-2', ['alt' => 'Statistics']);
118117
$settingsIcon = IconHelper::render('settings', ['alt' => 'Settings']);
119118

120119
$isTexts = in_array($currentPage, ['texts', 'archived', 'text-tags', 'text-check', 'long-import', 'feeds']);
@@ -270,9 +269,10 @@ public static function buildNavbar(string $currentPage = ''): string
270269
271270
{$langSelectorHtml}
272271
273-
<a class="navbar-item" href="{$base}/admin/statistics">
274-
{$statsIcon}
275-
<span class="ml-1">Statistics</span>
272+
<a class="navbar-item" href="{$base}/admin/statistics" title="Statistics"
273+
x-data="navbarStreak">
274+
<span class="icon has-text-warning"><i data-lucide="flame"></i></span>
275+
<span class="is-size-7 has-text-weight-semibold" x-show="streak > 0" x-text="streak" x-cloak></span>
276276
</a>
277277
</div>
278278

src/frontend/js/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import '@shared/api/client';
5050
// Shared components (used on every page)
5151
import '@shared/components/modal';
5252
import '@shared/components/navbar';
53+
import '@shared/components/navbar_streak';
5354
import '@shared/components/theme_toggle';
5455
import '@shared/components/footer';
5556

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* Calendar Heatmap Component - GitHub-style contribution grid.
3+
*
4+
* Renders an SVG calendar heatmap for the last 365 days.
5+
* Built via DOM API (createElementNS) for CSP compatibility.
6+
*
7+
* @license Unlicense <http://unlicense.org/>
8+
* @since 3.0.0
9+
*/
10+
11+
import Alpine from 'alpinejs';
12+
13+
const SVG_NS = 'http://www.w3.org/2000/svg';
14+
15+
/** Cell size and gap for the grid. */
16+
const CELL_SIZE = 11;
17+
const CELL_GAP = 2;
18+
const CELL_STEP = CELL_SIZE + CELL_GAP;
19+
20+
/** Color scale: 0 = empty, 1-4 = increasing intensity. */
21+
const COLORS = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'];
22+
23+
/** Short month labels for the top axis. */
24+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
25+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
26+
27+
/** Short day-of-week labels for the left axis. */
28+
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
29+
30+
interface CalendarDay {
31+
date: string;
32+
total: number;
33+
created: number;
34+
reviewed: number;
35+
read: number;
36+
col: number;
37+
row: number;
38+
}
39+
40+
interface DayActivity {
41+
total: number;
42+
created: number;
43+
reviewed: number;
44+
read: number;
45+
}
46+
47+
interface HeatmapData {
48+
[date: string]: DayActivity;
49+
}
50+
51+
interface CalendarHeatmapState {
52+
loading: boolean;
53+
error: string;
54+
init(this: CalendarHeatmapState & { $refs: Record<string, HTMLElement> }): void;
55+
buildCalendar(container: HTMLElement, data: HeatmapData): void;
56+
}
57+
58+
/**
59+
* Map a raw activity count to a color index (0-4).
60+
*/
61+
function colorLevel(value: number, max: number): number {
62+
if (value === 0) return 0;
63+
if (max <= 0) return 1;
64+
const ratio = value / max;
65+
if (ratio <= 0.25) return 1;
66+
if (ratio <= 0.50) return 2;
67+
if (ratio <= 0.75) return 3;
68+
return 4;
69+
}
70+
71+
/**
72+
* Format a date string (YYYY-MM-DD) for tooltip display.
73+
*/
74+
function formatDate(dateStr: string): string {
75+
const d = new Date(dateStr + 'T00:00:00');
76+
return d.toLocaleDateString(undefined, {
77+
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric'
78+
});
79+
}
80+
81+
/**
82+
* Build a tooltip string showing the activity breakdown for a day.
83+
*/
84+
function formatTooltip(day: CalendarDay): string {
85+
const date = formatDate(day.date);
86+
if (day.total === 0) return `No activity on ${date}`;
87+
const parts: string[] = [];
88+
if (day.created > 0) parts.push(`${day.created} created`);
89+
if (day.reviewed > 0) parts.push(`${day.reviewed} reviewed`);
90+
if (day.read > 0) parts.push(`${day.read} read`);
91+
return `${parts.join(', ')}${date}`;
92+
}
93+
94+
/**
95+
* Build a grid of CalendarDay objects for the last 365 days.
96+
*/
97+
function buildDayGrid(data: HeatmapData): { days: CalendarDay[]; weeks: number } {
98+
const today = new Date();
99+
today.setHours(0, 0, 0, 0);
100+
const days: CalendarDay[] = [];
101+
102+
// Go back 364 days so we cover ~52 full weeks + today
103+
const start = new Date(today);
104+
start.setDate(start.getDate() - 364);
105+
// Align start to the previous Sunday
106+
start.setDate(start.getDate() - start.getDay());
107+
108+
let col = 0;
109+
const cursor = new Date(start);
110+
while (cursor <= today) {
111+
const dateStr = cursor.toISOString().slice(0, 10);
112+
const row = cursor.getDay(); // 0=Sun, 6=Sat
113+
const entry = data[dateStr];
114+
days.push({
115+
date: dateStr,
116+
total: entry?.total ?? 0,
117+
created: entry?.created ?? 0,
118+
reviewed: entry?.reviewed ?? 0,
119+
read: entry?.read ?? 0,
120+
col,
121+
row,
122+
});
123+
cursor.setDate(cursor.getDate() + 1);
124+
if (cursor.getDay() === 0) col++;
125+
}
126+
return { days, weeks: col + 1 };
127+
}
128+
129+
/**
130+
* Alpine.js component for the calendar heatmap.
131+
*/
132+
export function calendarHeatmap(): CalendarHeatmapState {
133+
return {
134+
loading: true,
135+
error: '',
136+
137+
init() {
138+
const container = this.$refs.svgContainer;
139+
if (!container) return;
140+
141+
fetch('/api/v1/activity/calendar')
142+
.then(r => r.json())
143+
.then((data: HeatmapData) => {
144+
this.loading = false;
145+
this.buildCalendar(container, data);
146+
})
147+
.catch(() => {
148+
this.loading = false;
149+
this.error = 'Failed to load activity data';
150+
});
151+
},
152+
153+
buildCalendar(container: HTMLElement, data: HeatmapData) {
154+
const { days, weeks } = buildDayGrid(data);
155+
const maxVal = Math.max(...days.map(d => d.total), 1);
156+
157+
const leftPad = 30; // space for day-of-week labels
158+
const topPad = 16; // space for month labels
159+
const svgWidth = leftPad + weeks * CELL_STEP;
160+
const svgHeight = topPad + 7 * CELL_STEP;
161+
162+
const svg = document.createElementNS(SVG_NS, 'svg');
163+
svg.setAttribute('width', String(svgWidth));
164+
svg.setAttribute('height', String(svgHeight));
165+
svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
166+
svg.style.display = 'block';
167+
168+
// Day-of-week labels
169+
for (let row = 0; row < 7; row++) {
170+
if (DAY_LABELS[row]) {
171+
const text = document.createElementNS(SVG_NS, 'text');
172+
text.setAttribute('x', '0');
173+
text.setAttribute('y', String(topPad + row * CELL_STEP + CELL_SIZE));
174+
text.setAttribute('font-size', '10');
175+
text.setAttribute('fill', '#767676');
176+
text.textContent = DAY_LABELS[row];
177+
svg.appendChild(text);
178+
}
179+
}
180+
181+
// Month labels — place at first Sunday of each month
182+
const monthsPlaced = new Set<number>();
183+
for (const day of days) {
184+
const d = new Date(day.date + 'T00:00:00');
185+
const m = d.getMonth();
186+
if (day.row === 0 && !monthsPlaced.has(m)) {
187+
monthsPlaced.add(m);
188+
const text = document.createElementNS(SVG_NS, 'text');
189+
text.setAttribute('x', String(leftPad + day.col * CELL_STEP));
190+
text.setAttribute('y', '10');
191+
text.setAttribute('font-size', '10');
192+
text.setAttribute('fill', '#767676');
193+
text.textContent = MONTHS[m];
194+
svg.appendChild(text);
195+
}
196+
}
197+
198+
// Day cells
199+
const tooltip = document.createElement('div');
200+
tooltip.style.cssText =
201+
'position:fixed;padding:4px 8px;background:#24292f;color:#fff;' +
202+
'border-radius:4px;font-size:11px;pointer-events:none;display:none;z-index:100;';
203+
document.body.appendChild(tooltip);
204+
205+
for (const day of days) {
206+
const rect = document.createElementNS(SVG_NS, 'rect');
207+
const x = leftPad + day.col * CELL_STEP;
208+
const y = topPad + day.row * CELL_STEP;
209+
rect.setAttribute('x', String(x));
210+
rect.setAttribute('y', String(y));
211+
rect.setAttribute('width', String(CELL_SIZE));
212+
rect.setAttribute('height', String(CELL_SIZE));
213+
rect.setAttribute('rx', '2');
214+
rect.setAttribute('fill', COLORS[colorLevel(day.total, maxVal)]);
215+
216+
const label = formatTooltip(day);
217+
rect.addEventListener('mouseenter', (e: MouseEvent) => {
218+
tooltip.textContent = label;
219+
tooltip.style.display = 'block';
220+
tooltip.style.left = e.clientX + 8 + 'px';
221+
tooltip.style.top = e.clientY - 28 + 'px';
222+
});
223+
rect.addEventListener('mouseleave', () => {
224+
tooltip.style.display = 'none';
225+
});
226+
227+
svg.appendChild(rect);
228+
}
229+
230+
container.appendChild(svg);
231+
},
232+
};
233+
}
234+
235+
Alpine.data('calendarHeatmap', calendarHeatmap);

0 commit comments

Comments
 (0)