Statistics and Tracking
Overview
Racines's statistics system is entirely anonymous — no personal user data is collected. Events are aggregated by day and by language.
Two tables store the data:
| Table | Content |
|---|---|
statistics |
Usage events (card views, audio plays, unlocks) |
quiz_sessions |
MCQ revision sessions (scores, categories, abandonments) |
Table statistics
Schema
CREATE TABLE statistics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
language_id UUID REFERENCES languages(id) ON DELETE CASCADE,
card_id UUID REFERENCES cards(id) ON DELETE CASCADE,
item_id UUID REFERENCES items(id) ON DELETE CASCADE,
event_type VARCHAR(50) NOT NULL,
count INTEGER DEFAULT 1,
event_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(language_id, card_id, item_id, event_type, event_date)
);
Key behavior: Events are aggregated by day via an upsert:
INSERT INTO statistics (language_id, card_id, item_id, event_type, count, event_date)
VALUES ($1, $2, $3, $4, 1, CURRENT_DATE)
ON CONFLICT (language_id, card_id, item_id, event_type, event_date)
DO UPDATE SET count = statistics.count + 1;
No row per click — 1 row per (language, card, item, type, day), incremented.
Event types
event_type |
Trigger | card_id |
item_id |
|---|---|---|---|
unlock_language |
Player unlocks a language | null |
null |
view_card |
Player displays a card (word or phrase) | required | null |
play_audio |
Player plays audio | required | required |
Tracking API
POST /api/statistics/track
Public endpoint (no authentication). Called automatically by the frontend.
// Triggers in the code:
// unlock_language → LanguageContext.unlockLanguage()
await fetch('/api/statistics/track', {
method: 'POST',
body: JSON.stringify({ eventType: 'unlock_language', languageId }),
});
// view_card → pages /[language]/words/[n] and /[language]/phrases/[n]
trackEvent({ eventType: 'view_card', languageId, cardId });
// play_audio → AudioPlayer.handlePlay()
trackEvent({ eventType: 'play_audio', languageId, cardId, itemId });
Body:
{
"eventType": "play_audio",
"languageId": "uuid",
"cardId": "uuid",
"itemId": "uuid"
}
Response:
{ "success": true, "count": 42 }
count = cumulative number of occurrences for that day.
Offline statistics management
IndexedDB queue
When the network is unavailable, events are queued in IndexedDB:
// Store: statistics-queue (racines-db v3)
{
id: autoIncrement,
timestamp: Date.now(),
eventType: "play_audio",
languageId: "uuid",
cardId: "uuid",
itemId: "uuid"
}
Automatic synchronization
// src/components/ServiceWorkerRegistration.tsx
// Attempt on component mount (at app load)
flushStatisticsQueue();
// Also triggered on network reconnection
window.addEventListener("online", () => {
flushStatisticsQueue();
});
flushStatisticsQueue():
1. Reads all events from the IndexedDB queue
2. Sends them one by one to /api/statistics/track
3. Deletes successfully sent events
4. Keeps failures for the next attempt
Admin dashboard
Endpoint GET /api/statistics/by-language
Aggregates events by language, with support for comparison mode:
# Primary period
GET /api/statistics/by-language?from=2026-01-01&to=2026-01-31
# With comparison period
GET /api/statistics/by-language?from=2026-01-01&to=2026-01-31&compareFrom=2025-12-01&compareTo=2025-12-31
Response with comparison:
{
"success": true,
"stats": [
{
"language_id": "uuid",
"language_name": "Pular",
"language_code": "pul",
"unlock_count": 245,
"view_count": 1830,
"audio_count": 956
}
],
"compareStats": [
{
"language_name": "Pular",
"unlock_count": 180,
"view_count": 1420,
"audio_count": 720
}
]
}
Endpoint GET /api/statistics/top-cards
Most viewed cards over a period:
GET /api/statistics/top-cards?languageId=uuid&limit=10&from=2026-01-01&to=2026-01-31
Useful SQL queries
Global view by language (all periods)
SELECT
l.name AS language,
SUM(CASE WHEN s.event_type = 'unlock_language' THEN s.count ELSE 0 END) AS unlocks,
SUM(CASE WHEN s.event_type = 'view_card' THEN s.count ELSE 0 END) AS card_views,
SUM(CASE WHEN s.event_type = 'play_audio' THEN s.count ELSE 0 END) AS audio_plays
FROM statistics s
JOIN languages l ON s.language_id = l.id
GROUP BY l.id, l.name
ORDER BY unlocks DESC;
Daily activity (last 30 days)
SELECT
event_date,
SUM(count) AS total_events
FROM statistics
WHERE event_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY event_date
ORDER BY event_date;
Top 10 most viewed cards
SELECT
c.card_number,
c.type,
c.theme,
l.name AS language,
SUM(s.count) AS views
FROM statistics s
JOIN cards c ON s.card_id = c.id
JOIN languages l ON s.language_id = l.id
WHERE s.event_type = 'view_card'
AND s.event_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY c.id, c.card_number, c.type, c.theme, l.name
ORDER BY views DESC
LIMIT 10;
Audio engagement rate (audio played vs viewed)
SELECT
l.name AS language,
SUM(CASE WHEN s.event_type = 'view_card' THEN s.count ELSE 0 END) AS views,
SUM(CASE WHEN s.event_type = 'play_audio' THEN s.count ELSE 0 END) AS audio_plays,
ROUND(
SUM(CASE WHEN s.event_type = 'play_audio' THEN s.count ELSE 0 END)::numeric /
NULLIF(SUM(CASE WHEN s.event_type = 'view_card' THEN s.count ELSE 0 END), 0) * 100, 1
) AS audio_engagement_pct
FROM statistics s
JOIN languages l ON s.language_id = l.id
GROUP BY l.id, l.name
ORDER BY audio_engagement_pct DESC NULLS LAST;
Quiz statistics
Table quiz_sessions
CREATE TABLE quiz_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
language_id UUID REFERENCES languages(id) ON DELETE CASCADE,
category_slug VARCHAR(255) NOT NULL,
category_name VARCHAR(255),
total_questions INTEGER NOT NULL,
score INTEGER, -- null if abandoned
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ, -- null if abandoned
created_at TIMESTAMPTZ DEFAULT NOW()
);
Endpoint GET /api/admin/quiz/stats
# Quiz stats with comparison mode
GET /api/admin/quiz/stats?languageId=uuid&from=2026-01-01&compareFrom=2025-12-01&compareTo=2025-12-31
Global quiz SQL query
SELECT
l.name AS language,
COUNT(*) AS total_sessions,
COUNT(qs.completed_at) AS completed_sessions,
COUNT(*) - COUNT(qs.completed_at) AS abandoned_sessions,
ROUND(COUNT(qs.completed_at)::numeric / COUNT(*) * 100, 1) AS completion_rate_pct,
ROUND(AVG(CASE WHEN qs.completed_at IS NOT NULL
THEN qs.score::numeric / qs.total_questions * 100 END), 1) AS avg_score_pct
FROM quiz_sessions qs
JOIN languages l ON qs.language_id = l.id
WHERE qs.started_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY l.id, l.name
ORDER BY total_sessions DESC;
Top categories by language
SELECT
qs.category_name,
COUNT(*) AS sessions,
COUNT(qs.completed_at) AS completed,
ROUND(AVG(CASE WHEN qs.completed_at IS NOT NULL
THEN qs.score::numeric / qs.total_questions * 100 END), 1) AS avg_score
FROM quiz_sessions qs
WHERE qs.language_id = $1
GROUP BY qs.category_name
ORDER BY sessions DESC
LIMIT 10;
Purge and maintenance
GET /api/admin/statistics
Count rows before purge:
curl "http://localhost:3000/api/admin/statistics?languageId=uuid&from=2025-01-01&to=2025-12-31" \
-b cookies.txt
# → {"count": 45230}
DELETE /api/admin/statistics
Filtered purge:
# Purge a language over a period
curl -X DELETE \
"http://localhost:3000/api/admin/statistics?languageId=uuid&from=2025-01-01&to=2025-12-31" \
-b cookies.txt
# → {"success": true, "deleted": 45230}
Full purge:
# Required header to prevent accidental deletions
curl -X DELETE http://localhost:3000/api/admin/statistics \
-H "x-delete-all-confirmed: true" \
-b cookies.txt
# → {"success": true, "deleted": 123456}
⚠️ Irreversible action. Deleted statistics cannot be recovered.
Data export
The statistics data does not have a built-in CSV export endpoint. To export:
# CSV export via psql
psql -h $PG_HOST -U $PG_USER -d $PG_DATABASE -c \
"COPY (
SELECT
event_date, l.name AS language, event_type,
c.card_number, c.theme, SUM(s.count) AS total
FROM statistics s
JOIN languages l ON s.language_id = l.id
LEFT JOIN cards c ON s.card_id = c.id
GROUP BY event_date, l.name, event_type, c.card_number, c.theme
ORDER BY event_date DESC
) TO STDOUT WITH CSV HEADER" > stats_export.csv
# JSON export via admin API
curl "http://localhost:3000/api/statistics/by-language?from=2026-01-01&to=2026-12-31" \
-b cookies.txt > stats_2026.json