Skip to content

Commit b9736f6

Browse files
Merge pull request #63 from ContextLab/010-analytics-data-collection
Add analytics tracking and response data collection
2 parents b237c33 + d9a3d12 commit b9736f6

15 files changed

Lines changed: 1210 additions & 19 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Auto-generated from all feature plans. Last updated: 2026-02-27
1111
- localStorage (user progress), file-based JSON (question banks) (007-fix-mobile-mode)
1212
- JavaScript ES2022+ (ES modules), HTML5, CSS3 + nanostores 1.1, Vite 7.3, deck.gl 9.2, KaTeX (CDN), pako (new — for deflate compression) (008-shareable-map-links)
1313
- localStorage (user progress), URL query parameter (shared state) (008-shareable-map-links)
14+
- JavaScript ES2022+ (ES modules), HTML5, CSS3 + nanostores 1.1, Vite 7.3, pako (existing — for token deflate), GoatCounter (external CDN script) (010-analytics-data-collection)
15+
- localStorage (opt-out preference), Google Sheets (collection records via GAS) (010-analytics-data-collection)
1416

1517
- JavaScript ES2022+ (ES modules), HTML5, CSS3 + nanostores 1.1.0, Vite 7.3.1, Canvas 2D API, KaTeX (CDN) (003-ux-bugfix-cleanup)
1618

@@ -31,9 +33,9 @@ npm test && npm run lint
3133
JavaScript ES2022+ (ES modules), HTML5, CSS3: Follow standard conventions
3234

3335
## Recent Changes
36+
- 010-analytics-data-collection: Added JavaScript ES2022+ (ES modules), HTML5, CSS3 + nanostores 1.1, Vite 7.3, pako (existing — for token deflate), GoatCounter (external CDN script)
3437
- 008-shareable-map-links: Added JavaScript ES2022+ (ES modules), HTML5, CSS3 + nanostores 1.1, Vite 7.3, deck.gl 9.2, KaTeX (CDN), pako (new — for deflate compression)
3538
- 007-fix-mobile-mode: Added JavaScript ES2022+ (ES modules), HTML5, CSS3 + nanostores 1.1, Vite 7.3, deck.gl 9.2, KaTeX (CDN)
36-
- 006-performance-and-ux-refinement: Added JavaScript ES2022+ (ES modules), HTML5, CSS3 + nanostores 1.1, Vite 7.3, Canvas 2D API, KaTeX (CDN), deck.gl 9.2
3739

3840

3941
<!-- MANUAL ADDITIONS START -->

index.html

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -994,7 +994,7 @@ <h2>Map out (an approximation of) everything you know!</h2>
994994
</p>
995995
<p>
996996
<i class="fa-solid fa-graduation-cap" style="margin-right: 8px;"></i>
997-
Click <a href="#" id="landing-info-link" style="white-space:nowrap;"><i class="fa-solid fa-circle-info" style="margin: 0 2px;"></i> info</a> in the upper right to learn more, or read our <a href="https://osf.io/preprints/psyarxiv/dh3q2" target="_blank" rel="noopener">research paper</a>.
997+
Click <a href="#" id="landing-info-link" style="white-space:nowrap;"><i class="fa-solid fa-circle-info" style="margin: 0 2px;"></i> info</a> in the upper right to learn more, or read our <a href="https://www.doi.org/10.1038/s41467-026-69746-w" target="_blank" rel="noopener">research paper</a>.
998998
</p>
999999

10001000
<button id="landing-start-btn" class="landing-start-btn">Map my knowledge!</button>
@@ -1079,9 +1079,7 @@ <h3 style="margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--color-primary)
10791079
</p>
10801080
<p style="line-height: 1.7; margin-bottom: 1rem; font-size: 0.9rem;">
10811081
<strong>Research paper:</strong>
1082-
<a href="https://osf.io/preprints/psyarxiv/dh3q2" target="_blank" rel="noopener">
1083-
Text embedding models yield detailed conceptual knowledge maps derived from short multiple-choice quizzes
1084-
</a>
1082+
Fitzpatrick PC, Heusser AC, Manning JR (2026). Text embedding models yield detailed conceptual knowledge maps derived from short multiple-choice quizzes. <em>Nature Communications</em>, 17(2055): <a href="https://www.doi.org/10.1038/s41467-026-69746-w" target="_blank" rel="noopener">10.1038/s41467-026-69746-w</a>.
10851083
</p>
10861084
<p style="line-height: 1.7; margin-bottom: 1rem; font-size: 0.9rem;">
10871085
<strong>Source code:</strong>
@@ -1116,9 +1114,21 @@ <h3 style="margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--color-primary)
11161114
Click on the correct response to answer each question. Use the number keys 1&ndash;4 for quick answers.
11171115
</p>
11181116
<p style="font-size: 0.8rem; color: var(--color-text-muted);">
1119-
All computation runs in your browser. No data is sent to any server.
1117+
All computation runs in your browser.
11201118
Your progress is saved locally and can be exported or reset at any time.
11211119
</p>
1120+
<h3 style="margin-top: 1rem; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--color-primary);">Data Collection</h3>
1121+
<p style="line-height: 1.7; margin-bottom: 0.5rem; font-size: 0.85rem;">
1122+
We collect anonymized quiz responses (answers only) to help improve our system.
1123+
No personal information is stored. You can opt out at any time using the toggle below.
1124+
</p>
1125+
<div id="collect-toggle-wrap" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem;">
1126+
<div id="collect-toggle-track" role="switch" aria-checked="false" aria-label="Share anonymized responses" tabindex="0"
1127+
style="position:relative;width:36px;height:20px;background:var(--color-text-muted,#94a3b8);border-radius:10px;cursor:pointer;transition:background 0.2s;flex-shrink:0;">
1128+
<div id="collect-toggle-thumb" style="position:absolute;top:2px;left:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);"></div>
1129+
</div>
1130+
<span style="font-size:0.8rem;color:var(--color-text-muted);">Share anonymized responses</span>
1131+
</div>
11221132
<p style="font-size: 0.75rem; color: var(--color-text-muted); opacity: 0.8; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border);">
11231133
<strong>Note:</strong> The knowledge estimates assume that your responses reflect genuine effort.
11241134
Randomly clicking through questions without thinking will generate estimates
@@ -1220,5 +1230,9 @@ <h1>JavaScript Required</h1>
12201230

12211231
<!-- Vite Entry Point -->
12221232
<script type="module" src="/src/app.js"></script>
1233+
1234+
<!-- GoatCounter analytics (cookie-free, privacy-respecting) -->
1235+
<script data-goatcounter="https://context-lab.goatcounter.com/count"
1236+
async src="//gc.zgo.at/count.js"></script>
12231237
</body>
12241238
</html>

scripts/decode-tokens.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Offline token decoder for Knowledge Mapper response collection.
5+
*
6+
* Reads tokens from a CSV or JSON file (exported from the Google Sheet)
7+
* and decodes each into structured response data.
8+
*
9+
* Usage:
10+
* node scripts/decode-tokens.js --input tokens.csv --format csv > decoded.csv
11+
* node scripts/decode-tokens.js --input tokens.json --format json > decoded.json
12+
*
13+
* Input CSV format (from Google Sheet):
14+
* Timestamp, Session ID, Token, Response Count, Domain
15+
*
16+
* Output includes: session_id, timestamp, question_id, is_correct, is_skipped
17+
*/
18+
19+
import { readFileSync } from 'fs';
20+
import { resolve, dirname } from 'path';
21+
import { fileURLToPath } from 'url';
22+
import { inflate } from 'pako';
23+
24+
const __dirname = dirname(fileURLToPath(import.meta.url));
25+
26+
// ── Inline token decoder (avoids importing browser-only modules) ───────
27+
28+
function base64urlToBytes(str) {
29+
const b64 = str.replace(/-/g, '+').replace(/_/g, '/');
30+
const pad = (4 - (b64.length % 4)) % 4;
31+
const padded = b64 + '='.repeat(pad);
32+
const binary = atob(padded);
33+
const bytes = new Uint8Array(binary.length);
34+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
35+
return bytes;
36+
}
37+
38+
function decodeTokenRaw(base64urlString) {
39+
try {
40+
const compressed = base64urlToBytes(base64urlString);
41+
const bytes = inflate(compressed, { raw: true });
42+
if (bytes.length < 3) return null;
43+
44+
const version = bytes[0];
45+
const count = (bytes[1] << 8) | bytes[2];
46+
const entries = [];
47+
48+
for (let i = 0; i < count; i++) {
49+
const offset = 3 + i * 3;
50+
if (offset + 2 >= bytes.length) break;
51+
const index = (bytes[offset] << 8) | bytes[offset + 1];
52+
const value = bytes[offset + 2];
53+
entries.push({
54+
index,
55+
is_correct: value === 2,
56+
is_skipped: value === 1,
57+
});
58+
}
59+
60+
return { version, entries };
61+
} catch (err) {
62+
console.error('[decoder] Failed to decode token:', err.message);
63+
return null;
64+
}
65+
}
66+
67+
// ── Question index builder ─────────────────────────────────────────────
68+
69+
async function loadQuestionIndex() {
70+
// Load all domain bundles and merge questions (matching browser boot flow)
71+
const dataDir = resolve(__dirname, '..', 'data', 'domains');
72+
const { readdirSync } = await import('fs');
73+
const files = readdirSync(dataDir).filter(f => f.endsWith('.json') && f !== 'all.json');
74+
75+
const allQuestions = new Map();
76+
77+
// Load all.json first (boot bundle with 50 questions)
78+
const allBundle = JSON.parse(readFileSync(resolve(dataDir, 'all.json'), 'utf-8'));
79+
for (const q of allBundle.questions) allQuestions.set(q.id, q);
80+
81+
// Load all domain bundles to get the full 2500 questions
82+
for (const file of files) {
83+
try {
84+
const bundle = JSON.parse(readFileSync(resolve(dataDir, file), 'utf-8'));
85+
if (bundle.questions) {
86+
for (const q of bundle.questions) allQuestions.set(q.id, q);
87+
}
88+
} catch { /* skip malformed files */ }
89+
}
90+
91+
// Sort deterministically — must match buildIndex() in question-index.js exactly
92+
// (uses < / > comparison, NOT localeCompare)
93+
const sorted = [...allQuestions.values()].sort((a, b) => {
94+
const da = (a.domain_ids?.[0] || '');
95+
const db = (b.domain_ids?.[0] || '');
96+
if (da < db) return -1;
97+
if (da > db) return 1;
98+
if (a.id < b.id) return -1;
99+
if (a.id > b.id) return 1;
100+
return 0;
101+
});
102+
103+
const indexToQuestion = new Map();
104+
sorted.forEach((q, i) => indexToQuestion.set(i, q));
105+
106+
return indexToQuestion;
107+
}
108+
109+
// ── Input parsing ──────────────────────────────────────────────────────
110+
111+
function parseCSVInput(content) {
112+
const lines = content.trim().split('\n');
113+
// Skip header row
114+
const records = [];
115+
for (let i = 1; i < lines.length; i++) {
116+
const parts = lines[i].split(',').map(s => s.trim());
117+
if (parts.length < 3) continue;
118+
records.push({
119+
timestamp: parts[0],
120+
session_id: parts[1],
121+
token: parts[2],
122+
response_count: parseInt(parts[3], 10) || 0,
123+
});
124+
}
125+
return records;
126+
}
127+
128+
function parseJSONInput(content) {
129+
const data = JSON.parse(content);
130+
return Array.isArray(data) ? data : [data];
131+
}
132+
133+
// ── Main ───────────────────────────────────────────────────────────────
134+
135+
async function main() {
136+
const args = process.argv.slice(2);
137+
let inputFile = null;
138+
let outputFormat = 'csv';
139+
140+
for (let i = 0; i < args.length; i++) {
141+
if (args[i] === '--input' && args[i + 1]) inputFile = args[++i];
142+
else if (args[i] === '--format' && args[i + 1]) outputFormat = args[++i];
143+
else if (args[i] === '--help') {
144+
console.log('Usage: node scripts/decode-tokens.js --input <file> --format <csv|json>');
145+
process.exit(0);
146+
}
147+
}
148+
149+
if (!inputFile) {
150+
console.error('Error: --input <file> is required');
151+
process.exit(1);
152+
}
153+
154+
const content = readFileSync(resolve(inputFile), 'utf-8');
155+
const isJSON = inputFile.endsWith('.json');
156+
const records = isJSON ? parseJSONInput(content) : parseCSVInput(content);
157+
158+
console.error(`[decoder] Loading question index...`);
159+
const indexToQuestion = await loadQuestionIndex();
160+
console.error(`[decoder] Loaded ${indexToQuestion.size} questions`);
161+
console.error(`[decoder] Decoding ${records.length} tokens...`);
162+
163+
const decoded = [];
164+
165+
for (const record of records) {
166+
const result = decodeTokenRaw(record.token);
167+
if (!result) {
168+
console.error(`[decoder] Failed to decode token from session ${record.session_id}`);
169+
continue;
170+
}
171+
172+
for (const entry of result.entries) {
173+
const q = indexToQuestion.get(entry.index);
174+
decoded.push({
175+
session_id: record.session_id,
176+
timestamp: record.timestamp,
177+
question_index: entry.index,
178+
question_id: q?.id || `unknown_${entry.index}`,
179+
domain: q?.domain_ids?.[0] || 'unknown',
180+
question_text: q?.question_text || '',
181+
correct_answer: q ? q.options?.[q.correct_answer] || '' : '',
182+
is_correct: entry.is_correct,
183+
is_skipped: entry.is_skipped,
184+
});
185+
}
186+
}
187+
188+
// Output
189+
if (outputFormat === 'json') {
190+
console.log(JSON.stringify(decoded, null, 2));
191+
} else {
192+
// CSV
193+
console.log('session_id,timestamp,domain,question_index,question_id,question_text,correct_answer,is_correct,is_skipped');
194+
for (const row of decoded) {
195+
const text = `"${(row.question_text || '').replace(/"/g, '""')}"`;
196+
const answer = `"${(row.correct_answer || '').replace(/"/g, '""')}"`;
197+
console.log(`${row.session_id},${row.timestamp},${row.domain},${row.question_index},${row.question_id},${text},${answer},${row.is_correct},${row.is_skipped}`);
198+
}
199+
}
200+
201+
console.error(`[decoder] Decoded ${decoded.length} responses from ${records.length} tokens`);
202+
}
203+
204+
main().catch(err => {
205+
console.error('Fatal:', err);
206+
process.exit(1);
207+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Specification Quality Checklist: Analytics & Response Data Collection
2+
3+
**Purpose**: Validate specification completeness and quality before proceeding to planning
4+
**Created**: 2026-03-20
5+
**Feature**: [spec.md](../spec.md)
6+
7+
## Content Quality
8+
9+
- [X] No implementation details (languages, frameworks, APIs)
10+
- [X] Focused on user value and business needs
11+
- [X] Written for non-technical stakeholders
12+
- [X] All mandatory sections completed
13+
14+
## Requirement Completeness
15+
16+
- [X] No [NEEDS CLARIFICATION] markers remain
17+
- [X] Requirements are testable and unambiguous
18+
- [X] Success criteria are measurable
19+
- [X] Success criteria are technology-agnostic (no implementation details)
20+
- [X] All acceptance scenarios are defined
21+
- [X] Edge cases are identified
22+
- [X] Scope is clearly bounded
23+
- [X] Dependencies and assumptions identified
24+
25+
## Feature Readiness
26+
27+
- [X] All functional requirements have clear acceptance criteria
28+
- [X] User scenarios cover primary flows
29+
- [X] Feature meets measurable outcomes defined in Success Criteria
30+
- [X] No implementation details leak into specification
31+
32+
## Notes
33+
34+
- All items pass. Spec is ready for planning.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Contract: Google Apps Script Collection Endpoint
2+
3+
## Request
4+
5+
**Method**: POST
6+
**URL**: `https://script.google.com/macros/s/{DEPLOYMENT_ID}/exec`
7+
**Mode**: `no-cors` (fire-and-forget — no response body expected)
8+
**Content-Type**: `application/json`
9+
10+
### Body
11+
12+
```json
13+
{
14+
"session_id": "a1b2c3d4",
15+
"token": "O8LAwTDnP-NPJiap_8xO_...",
16+
"response_count": 10,
17+
"domain": "all"
18+
}
19+
```
20+
21+
| Field | Type | Required | Description |
22+
|-|-|-|-|
23+
| session_id | string | yes | 8-char hex, generated per page load |
24+
| token | string | yes | Base64url-encoded response token from `encodeToken()` |
25+
| response_count | integer | yes | Total responses in the token |
26+
| domain | string | yes | Active domain ID at time of send |
27+
28+
## Response
29+
30+
Not inspected (no-cors mode). The client treats all sends as fire-and-forget.
31+
32+
## Google Apps Script Handler
33+
34+
The `doPost(e)` function:
35+
1. Parses `e.postData.contents` as JSON
36+
2. Appends a row to the configured sheet: `[new Date(), session_id, token, response_count, domain]`
37+
3. Returns `ContentService.createTextOutput('ok')`
38+
39+
## Error Handling
40+
41+
- Network failure: silently caught, logged to console
42+
- Invalid JSON: GAS returns error, client ignores (no-cors)
43+
- Quota exceeded: GAS returns 429, client ignores (no-cors)

0 commit comments

Comments
 (0)