Stores every answer from a quiz, flashcard or study app in the browser and shows how much the learner has improved per week, per month and per topic.
- Zero dependencies, no build step, no server. Three files you can load with
<script>and<link>. - Nothing gets lost. Each answer is saved the moment it is recorded, whether the learner finishes the session, stops halfway or closes the tab. Two open tabs don't overwrite each other.
- Content-agnostic. The library only needs a question id, a topic and whether the answer was correct.
- Several apps on one domain. Each app uses its own
appId, so apps hosted on the same origin (for exampleusername.github.io) keep their data separate. - Users own their data. Progress can be exported and imported as JSON for backups or to move to another device.
- Ready-made progress view (optional): stat tiles, per-topic table, weekly/monthly charts, session history and export/import buttons. Light and dark mode.
npm install @fabianimv/study-tracker// ES modules / bundlers
import StudyTracker from '@fabianimv/study-tracker';
import StudyTrackerView from '@fabianimv/study-tracker/view'; // optional UI
import '@fabianimv/study-tracker/style.css'; // optional UI styles
// CommonJS
const StudyTracker = require('@fabianimv/study-tracker');Always pin an exact version so an update never changes your app without you noticing.
<!-- jsDelivr -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fabianimv/study-tracker@0.1.0/study-tracker.css">
<script src="https://cdn.jsdelivr.net/npm/@fabianimv/study-tracker@0.1.0/study-tracker.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@fabianimv/study-tracker@0.1.0/study-tracker-view.js"></script>
<!-- or unpkg -->
<script src="https://unpkg.com/@fabianimv/study-tracker@0.1.0/study-tracker.js"></script>Loaded with <script>, the library exposes window.StudyTracker and window.StudyTrackerView.
<div id="progress"></div>
<script>
var tracker = StudyTracker.create({ appId: 'my-quiz' });
// When a session (quiz, practice round…) starts
var session = tracker.startSession({ planned: 20 });
// Every time the learner answers (saved immediately)
tracker.recordAnswer(session, {
question: 'q-017', // stable question id
topic: 'networking', // topic or domain
correct: false,
response: [2] // optional: what was picked, to review later
});
// When the session ends (or the learner stops early)
tracker.endSession(session, 'completed'); // or 'stopped'
// Optional ready-made progress screen
StudyTrackerView.render(document.getElementById('progress'), tracker, {
topics: [
{ id: 'networking', label: 'Networking', total: 40 },
{ id: 'storage', label: 'Storage', total: 35 }
],
getQuestion: function (id) { /* optional: to review each past session */ }
});
</script>All data lives in localStorage under the key study-tracker:<appId>. localStorage
is shared by every page on the same origin, so give each app its own appId:
StudyTracker.create({ appId: 'spanish-vocab' });
StudyTracker.create({ appId: 'chemistry-101' });- Allowed characters: letters, digits,
.,-and_. - Once an app is live, don't change its
appId: learners would lose access to their saved history (it stays under the old key). importDatarefuses files exported from a differentappId.
Progress is computed per question id. Once an app is published, never change or reuse the id of an existing question:
- Renaming an id makes the question look brand new: its history is orphaned and mastery/coverage drop.
- Reusing an id for a different question mixes the history of two questions.
Use ids that don't depend on order (for example net-017 rather than the array index),
fix typos in the question text freely, and give a new id only to genuinely new questions.
The same applies to topic ids.
| Metric | Meaning |
|---|---|
| Estimated mastery | Of the questions seen, the share answered correctly the last time each was answered. Repeating an easy question doesn't inflate it; fixing a mistake raises it. Its change compares today's value with the value at the start of the week or month. |
| Period accuracy | Share of correct answers in the week or month, compared with the previous period. |
| Questions seen | How many distinct questions have been answered out of the whole bank (coverage). |
Weeks run Monday to Sunday in the learner's local time.
appId(required): app identifier, see Isolating data between apps.storage: any object withgetItem,setItemandremoveItem. Defaults tolocalStorage, falling back to memory when it is unavailable (e.g. some private modes).now: function returning the current time in ms. Useful in tests.
| Method | What it does |
|---|---|
startSession({ planned?, meta? }) |
Opens a session and returns its id. meta accepts anything (filters used, mode…). |
recordAnswer(sessionId, { question, topic?, correct, response? }) |
Saves an answer immediately. topic defaults to ''. |
endSession(sessionId, reason?) |
Closes the session ('completed', 'stopped' or any text). Unclosed sessions keep their answers. |
importData(jsonOrObject, 'merge' | 'replace') |
Imports a backup. merge (default) never duplicates answers. Returns how many answers were added. |
clear() |
Deletes all data for this app. |
| Method | Returns |
|---|---|
getSessions() |
Sessions with at least one answer, newest first, with answered, correct, pct and byTopic. |
getAnswers({ session?, topic?, question?, from?, to? }) |
Filtered answers. |
periodStats('day' | 'week' | 'month', { limit? }) |
Accuracy per period, including empty periods (pct: null) so gaps are visible. |
mastery({ topics?, until? }) |
Estimated mastery overall and per topic. topics is { topicId: totalQuestions } to compute coverage. |
progress('week' | 'month', { topics? }) |
Everything needed to answer "am I improving?": current mastery and accuracy and their change, overall and per topic. |
questionStats() |
Per question: attempts, correct count and whether the last attempt was correct. Handy for "only missed" or "only new" sessions. |
exportData() |
Full copy of the data. |
onChange(fn) |
Calls fn whenever the data changes, including from another tab when using the default localStorage. Returns an unsubscribe function. |
isPersistent() |
false when the browser can't store data (it will be lost on close). |
Renders the progress screen. Returns { refresh, destroy }; call destroy() before reusing the container.
| Option | Use |
|---|---|
topics |
[{ id, label, total }], in display order. |
getQuestion(id) |
Optional. Returns { text, options, correct } to show the details of each past session. |
period |
'week' (default) or 'month'. |
locale |
Locale for dates. Defaults to 'en'. |
texts |
Overrides any string in StudyTrackerView.texts. |
fileName |
File name used when exporting. |
The built-in strings are in English. Pass locale (used to format dates) and override
any string through texts; the full list of keys is in StudyTrackerView.texts.
Keys you leave out keep their English default. Error messages thrown by the library
are always in English.
Spanish example:
var ES = {
title: 'Tu progreso',
week: 'Semana', month: 'Mes',
thisWeek: 'esta semana', thisMonth: 'este mes',
prevWeek: 'la semana anterior', prevMonth: 'el mes anterior',
empty: 'Aún no hay respuestas guardadas. Haz una prueba y tu avance aparecerá aquí.',
notPersistent: 'Este navegador no permite guardar datos (por ejemplo, en modo privado). Tu progreso se perderá al cerrar la página.',
mastery: 'Dominio estimado',
masteryHelp: 'De las preguntas que has visto, cuántas acertaste la última vez que las respondiste.',
accuracy: 'Acierto',
seen: 'Preguntas vistas',
answered: 'Respondidas',
noChange: 'sin cambios',
noPrev: 'sin datos previos',
byTopic: 'Por tema',
topic: 'Tema', change: 'Cambio', seenShort: 'Vistas',
trend: 'Acierto por', overall: 'General',
history: 'Historial de pruebas',
completed: 'Completada', stopped: 'Terminada antes', open: 'Sin cerrar',
of: 'de', answeredOf: 'respondidas',
yourAnswer: 'Tu respuesta', correctAnswer: 'Correcta',
data: 'Tus datos',
dataHelp: 'Todo se guarda solo en este navegador. Exporta una copia para no perderla o para pasarla a otro dispositivo.',
exportBtn: 'Exportar copia', importBtn: 'Importar copia', clearBtn: 'Borrar todo',
confirmClear: '¿Borrar todo tu historial? No se puede deshacer.',
imported: 'Importadas {n} respuestas nuevas.',
importError: 'No se pudo importar: ',
pts: 'pts', points: 'respuestas', weekOf: 'Semana del', inProgress: 'en curso',
liveNote: 'El punto hueco es el periodo en curso: aún puede cambiar.'
};
StudyTrackerView.render(el, tracker, {
locale: 'es',
texts: ES,
fileName: 'mi-progreso.json'
});Colors can be themed with CSS variables: --st-surface-custom, --st-text-custom,
--st-muted-custom, --st-line-custom, --st-grid-custom and --st-accent-custom.
The stored data is plain, versioned JSON. Files from exportData() have the same shape plus an exportedAt timestamp:
{
"schema": 1,
"appId": "my-quiz",
"sessions": [
{ "id": "m1x2…", "startedAt": 1760000000000, "endedAt": 1760000600000,
"reason": "completed", "planned": 20, "meta": { "mode": "practice" } }
],
"answers": [
{ "session": "m1x2…", "question": "net-007", "topic": "networking",
"correct": true, "response": [0], "at": 1760000030000 }
]
}schemais the format version. If the format ever changes, the library migrates older data automatically when it loads or imports it, so existing history keeps working after an upgrade.- Answers are stored as a flat list so they are easy to analyze elsewhere (spreadsheets, pandas…).
- Timestamps are milliseconds since the Unix epoch.
localStorageallows about 5 MB per origin. Each answer takes roughly 120 bytes, so there is room for around 40,000 answers.
Everything stays in the learner's browser, so offer a way to back it up. The progress view already includes Export, Import and Clear buttons; to do it yourself:
// Export: download a JSON backup
var blob = new Blob([JSON.stringify(tracker.exportData(), null, 2)], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'my-quiz-progress.json';
a.click();
// Import: from an <input type="file">
input.addEventListener('change', function () {
input.files[0].text().then(function (text) {
var added = tracker.importData(text, 'merge'); // or 'replace'
console.log(added + ' new answers imported');
});
});merge adds only the sessions and answers that are missing, so importing the same file
twice is safe. replace discards the current data first.
npm test # uses Node's built-in test runner (Node 18+), nothing to installSee CONTRIBUTING.md and CHANGELOG.md.
MIT © Fabian Munoz