diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 00000000..f464d754
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,70 @@
+# Learning Streak — Feature Summary
+
+## What it does
+
+Adds a learning streak to the PyBe dashboard. The dashboard now shows how
+many consecutive days a learner has completed at least one scenario
+session, plus their best (longest) streak ever — a small 🔥 badge sitting
+alongside the existing Scenarios / Sessions / Prompt score stats.
+
+## Why
+
+PyBe has no login or persistent user accounts — progress lives entirely
+in local session history. A streak is a lightweight way to encourage
+regular practice without needing to add authentication or a database.
+
+## How it works
+
+- **`computeStreak(sessionDates, todayDateString)`** — a pure function in
+ `server/src/services/streak.js` that takes the list of session
+ timestamps already stored in `db.json` and derives:
+ - `current` — the active streak right now
+ - `longest` — the best streak ever recorded
+ - `lastActiveDate` — the most recent day with a session
+- No new fields were added to `db.json`. The streak is calculated on the
+ fly from existing session history every time `/api/analytics` is
+ called, so there's nothing new to keep in sync.
+- The `/api/analytics` endpoint now includes a `streak` object in its
+ response.
+- The dashboard (`client/src/main.jsx`) displays it as a fourth stat card
+ next to the existing three.
+
+## Testing
+
+- 6 unit tests in `server/src/services/__tests__/streak.test.js`,
+ run via `npm test` (Node's built-in test runner, no new dependency):
+ - no sessions yet
+ - first-ever session
+ - multiple sessions on the same day (should not double-count)
+ - consecutive days (streak increments)
+ - a gap day (current streak resets, longest is preserved)
+ - last active yesterday (streak still considered "alive")
+- Manually verified end-to-end: submitted a real scenario session in the
+ running app and confirmed the dashboard correctly showed a 1-day streak.
+
+## Files changed
+
+- `server/src/services/streak.js` — new
+- `server/src/services/__tests__/streak.test.js` — new
+- `server/src/routes/analytics.js` — modified (added streak to response)
+- `client/src/main.jsx` — modified (added streak badge)
+- `server/package.json` — modified (added `test` script)
+
+
+
+
+## Engagement Summary (streak + time + last active)
+
+Extends the streak feature with two more signals on the same dashboard card:
+
+- **Time learning** — total time spent across all sessions, tracked from
+ when a learner opens a scenario to when they submit their reasoning.
+ Stored per-session as `durationSeconds` and summed on the backend
+ (`server/src/services/engagement.js`).
+- **Last active** — shows "Today", "Yesterday", or "X days ago" based on
+ the learner's most recent session, reusing the streak service's
+ `lastActiveDate`.
+
+Together these give a learner a quick, at-a-glance sense of how
+consistently and how long they've been practicing — without needing
+login or persistent accounts, staying true to PyBe's local-first design.
\ No newline at end of file
diff --git a/client/src/main.jsx b/client/src/main.jsx
index ef3da3a9..3094a8b5 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
Brain,
@@ -24,8 +24,26 @@ async function api(path, options) {
});
if (!response.ok) throw new Error(await response.text());
return response.json();
+ }
+ function formatDuration(totalSeconds = 0) {
+ const seconds = Math.max(0, Number(totalSeconds) || 0);
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ if (minutes > 0) return `${minutes}m`;
+ return '<1m';
+}
+
+function formatLastActive(dateString) {
+ if (!dateString) return 'Never';
+ const today = new Date().toISOString().slice(0, 10);
+ const diffDays = Math.round((new Date(today) - new Date(dateString)) / (24 * 60 * 60 * 1000));
+ if (diffDays <= 0) return 'Today';
+ if (diffDays === 1) return 'Yesterday';
+ return `${diffDays} days ago`;
}
+
function App() {
const [scenarios, setScenarios] = useState([]);
const [selected, setSelected] = useState(null);
@@ -37,9 +55,14 @@ function App() {
const [activeResult, setActiveResult] = useState(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
+ const sessionStartRef = useRef(Date.now());
const concepts = useMemo(() => [...new Set(scenarios.flatMap((scenario) => scenario.concepts || []))].sort(), [scenarios]);
+ useEffect(() => {
+ sessionStartRef.current = Date.now();
+ }, [selected?._id]);
+
async function refresh() {
const params = new URLSearchParams(Object.entries(filters).filter(([, value]) => value));
const [scenarioData, sessionData, analyticsData, roadmapData] = await Promise.all([
@@ -60,14 +83,16 @@ function App() {
refresh().catch(console.error);
}, [filters.q, filters.difficulty, filters.concept]);
- async function submitSession(event) {
+ const durationSeconds = Math.round((Date.now() - sessionStartRef.current) / 1000);
+ async function submitSession(event) {
event.preventDefault();
if (!selected || !form.reasoning.trim()) return;
setSubmitting(true);
try {
+ const durationSeconds = Math.round((Date.now() - sessionStartRef.current) / 1000);
const result = await api('/sessions', {
method: 'POST',
- body: JSON.stringify({ ...form, scenarioId: selected._id })
+ body: JSON.stringify({ ...form, scenarioId: selected._id, durationSeconds })
});
setActiveResult(result);
setForm({ ...form, reasoning: '', promptText: '', reflection: '' });
@@ -139,6 +164,9 @@ function App() {
{analytics?.scenarioCount || 0}Scenarios
{analytics?.sessionCount || 0}Sessions
{analytics?.averagePromptScore || 0}Prompt score
+ {analytics?.streak?.current || 0}🔥 Day streak
+ {formatDuration(analytics?.totalTimeSpentSeconds)}⏱️ Time learning
+ {formatLastActive(analytics?.streak?.lastActiveDate)}Last active
diff --git a/server/package.json b/server/package.json
index 67b91719..4776f536 100644
--- a/server/package.json
+++ b/server/package.json
@@ -5,7 +5,8 @@
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js",
- "seed": "node src/seed.js"
+ "seed": "node src/seed.js",
+ "test": "node --test src/**/__tests__/*.test.js"
},
"dependencies": {
"cors": "^2.8.5",
diff --git a/server/src/data/db.json b/server/src/data/db.json
index 6547d2e3..915ff676 100644
--- a/server/src/data/db.json
+++ b/server/src/data/db.json
@@ -596,5 +596,38 @@
"updatedAt": "2026-06-19T11:54:44.733Z"
}
],
- "sessions": []
+ "sessions": [
+ {
+ "_id": "81c94d38-6b7a-4a50-85d7-49fb279a224c",
+ "learnerName": "Guest learner",
+ "scenario": "8cf644f0-1402-4763-9542-02bd2ca0d1dc",
+ "reasoning": "I would store the bag weight in a ",
+ "promptText": "",
+ "abstractionMap": [
+ {
+ "pattern": "Sequential thinking",
+ "pythonConcept": "statements and variables",
+ "explanation": "You described a step-by-step solution. Python starts by representing those steps as statements."
+ }
+ ],
+ "generatedCode": "scenario = \"Bag Weight Label\"\nreasoning = \"Break the situation into clear steps\"\nprint(scenario)\nprint(reasoning)",
+ "codeExplanation": "The code starts from your natural reasoning and turns it into Python structure: Sequential thinking becomes statements and variables.",
+ "promptScore": 35,
+ "promptFeedback": [
+ "Add more context about the situation and expected output.",
+ "Ask the AI to explain its reasoning, not just produce code.",
+ "Include an example input or output to make the prompt testable.",
+ "Name the Python concept you think may apply."
+ ],
+ "reflection": "",
+ "misconceptions": [
+ "Reasoning is brief. Try naming the inputs, decision rule, and expected result."
+ ],
+ "masterySignals": [
+ "Recognized sequential thinking"
+ ],
+ "createdAt": "2026-08-23T05:28:43.060Z",
+ "updatedAt": "2026-08-23T05:28:43.060Z"
+ }
+ ]
}
diff --git a/server/src/routes/analytics.js b/server/src/routes/analytics.js
index 8d4c57f0..0efb9d11 100644
--- a/server/src/routes/analytics.js
+++ b/server/src/routes/analytics.js
@@ -1,5 +1,7 @@
const express = require('express');
const store = require('../data/store');
+const { computeStreak } = require('../services/streak');
+const { computeTotalTimeSpent } = require('../services/engagement');
const router = express.Router();
@@ -20,6 +22,8 @@ router.get('/', async (_req, res, next) => {
misconceptionCounts[item] = (misconceptionCounts[item] || 0) + 1;
});
});
+ const streak = computeStreak(sessions.map((session) => session.createdAt));
+ const totalTimeSpentSeconds = computeTotalTimeSpent(sessions);
res.json({
scenarioCount,
@@ -27,8 +31,11 @@ router.get('/', async (_req, res, next) => {
averagePromptScore: sessions.length ? Math.round(promptTotal / sessions.length) : 0,
conceptCounts,
misconceptionCounts,
- recentSessions: sessions.slice(0, 5)
+ recentSessions: sessions.slice(0, 5),
+ streak,
+ totalTimeSpentSeconds
});
+
} catch (error) {
next(error);
}
diff --git a/server/src/routes/sessions.js b/server/src/routes/sessions.js
index 86d942a7..3c65b11a 100644
--- a/server/src/routes/sessions.js
+++ b/server/src/routes/sessions.js
@@ -21,7 +21,7 @@ router.post('/', async (req, res, next) => {
const abstractionMap = engine.mapReasoning(req.body.reasoning);
const generatedCode = engine.generateCode(scenario, abstractionMap);
const prompt = engine.evaluatePrompt(req.body.promptText);
- const session = await store.addSession({
+ const session = await store.addSession({
learnerName: req.body.learnerName || 'Guest learner',
scenario: scenario._id,
reasoning: req.body.reasoning,
@@ -33,7 +33,8 @@ router.post('/', async (req, res, next) => {
promptFeedback: prompt.feedback,
reflection: req.body.reflection || '',
misconceptions: engine.detectMisconceptions(req.body.reasoning),
- masterySignals: engine.masterySignals(abstractionMap, prompt.score)
+ masterySignals: engine.masterySignals(abstractionMap, prompt.score),
+ durationSeconds: Math.max(0, Math.round(Number(req.body.durationSeconds) || 0))
});
res.status(201).json(session);
} catch (error) {
diff --git a/server/src/services/__tests__/engagement.test.js b/server/src/services/__tests__/engagement.test.js
new file mode 100644
index 00000000..197511d7
--- /dev/null
+++ b/server/src/services/__tests__/engagement.test.js
@@ -0,0 +1,40 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { computeTotalTimeSpent } = require('../engagement');
+
+test('no sessions -> zero total time', () => {
+ const result = computeTotalTimeSpent([]);
+ assert.equal(result, 0);
+});
+
+test('single session -> returns its duration', () => {
+ const result = computeTotalTimeSpent([{ durationSeconds: 120 }]);
+ assert.equal(result, 120);
+});
+
+test('multiple sessions -> sums durations', () => {
+ const result = computeTotalTimeSpent([
+ { durationSeconds: 60 },
+ { durationSeconds: 90 },
+ { durationSeconds: 30 }
+ ]);
+ assert.equal(result, 180);
+});
+
+test('missing durationSeconds on old sessions -> treated as zero', () => {
+ const result = computeTotalTimeSpent([
+ { durationSeconds: 100 },
+ {}, // legacy session with no duration field
+ { durationSeconds: 50 }
+ ]);
+ assert.equal(result, 150);
+});
+
+test('negative or garbage values are clamped to zero', () => {
+ const result = computeTotalTimeSpent([
+ { durationSeconds: -50 },
+ { durationSeconds: 'not a number' },
+ { durationSeconds: 20 }
+ ]);
+ assert.equal(result, 20);
+});
\ No newline at end of file
diff --git a/server/src/services/__tests__/streak.test.js b/server/src/services/__tests__/streak.test.js
new file mode 100644
index 00000000..626081c6
--- /dev/null
+++ b/server/src/services/__tests__/streak.test.js
@@ -0,0 +1,63 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { computeStreak } = require('../streak');
+
+test('no sessions -> zeroed streak', () => {
+ const result = computeStreak([]);
+ assert.deepEqual(result, { current: 0, longest: 0, lastActiveDate: null });
+});
+
+test('first-ever session today -> current streak of 1', () => {
+ const today = '2026-08-22';
+ const result = computeStreak([`${today}T10:00:00.000Z`], today);
+ assert.equal(result.current, 1);
+ assert.equal(result.longest, 1);
+ assert.equal(result.lastActiveDate, today);
+});
+
+test('same-day, multiple sessions -> still counts as 1 day', () => {
+ const today = '2026-08-22';
+ const result = computeStreak(
+ [`${today}T09:00:00.000Z`, `${today}T18:00:00.000Z`],
+ today
+ );
+ assert.equal(result.current, 1);
+ assert.equal(result.longest, 1);
+});
+
+test('consecutive days -> streak increments', () => {
+ const today = '2026-08-22';
+ const result = computeStreak(
+ ['2026-08-20T10:00:00.000Z', '2026-08-21T10:00:00.000Z', '2026-08-22T10:00:00.000Z'],
+ today
+ );
+ assert.equal(result.current, 3);
+ assert.equal(result.longest, 3);
+ assert.equal(result.lastActiveDate, today);
+});
+
+test('gap day breaks the current streak but preserves longest', () => {
+ const today = '2026-08-22';
+ const result = computeStreak(
+ [
+ '2026-08-10T10:00:00.000Z',
+ '2026-08-11T10:00:00.000Z',
+ '2026-08-12T10:00:00.000Z',
+ '2026-08-20T10:00:00.000Z'
+ ],
+ today
+ );
+ assert.equal(result.current, 0);
+ assert.equal(result.longest, 3);
+ assert.equal(result.lastActiveDate, '2026-08-20');
+});
+
+test('last active yesterday still counts as an alive streak', () => {
+ const today = '2026-08-22';
+ const result = computeStreak(
+ ['2026-08-20T10:00:00.000Z', '2026-08-21T10:00:00.000Z'],
+ today
+ );
+ assert.equal(result.current, 2);
+ assert.equal(result.longest, 2);
+});
diff --git a/server/src/services/engagement.js b/server/src/services/engagement.js
new file mode 100644
index 00000000..bdefb275
--- /dev/null
+++ b/server/src/services/engagement.js
@@ -0,0 +1,15 @@
+/**
+ * Computes total learning time from a list of sessions.
+ * Pure function: no I/O, no side effects, easy to unit test.
+ *
+ * @param {{durationSeconds?: number}[]} sessions
+ * @returns {number} total seconds spent across all sessions
+ */
+function computeTotalTimeSpent(sessions = []) {
+ return sessions.reduce((total, session) => {
+ const seconds = Number(session.durationSeconds) || 0;
+ return total + Math.max(0, seconds);
+ }, 0);
+}
+
+module.exports = { computeTotalTimeSpent };
\ No newline at end of file
diff --git a/server/src/services/streak.js b/server/src/services/streak.js
new file mode 100644
index 00000000..7937497c
--- /dev/null
+++ b/server/src/services/streak.js
@@ -0,0 +1,54 @@
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function toDateOnly(isoString) {
+ return new Date(isoString).toISOString().slice(0, 10);
+}
+
+function daysBetween(dateA, dateB) {
+ return Math.round((new Date(dateB) - new Date(dateA)) / DAY_MS);
+}
+
+/**
+ * Computes a learning streak from a list of session timestamps.
+ * Pure function: no I/O, no side effects, easy to unit test.
+ *
+ * @param {string[]} sessionDates - array of ISO timestamps (session.createdAt)
+ * @param {string} [todayDateString] - override "today" for deterministic tests, YYYY-MM-DD
+ * @returns {{ current: number, longest: number, lastActiveDate: string|null }}
+ */
+function computeStreak(sessionDates = [], todayDateString) {
+ if (!sessionDates.length) {
+ return { current: 0, longest: 0, lastActiveDate: null };
+ }
+
+ const activeDays = [...new Set(sessionDates.map(toDateOnly))].sort();
+
+ let longest = 1;
+ let run = 1;
+ for (let i = 1; i < activeDays.length; i += 1) {
+ const gap = daysBetween(activeDays[i - 1], activeDays[i]);
+ run = gap === 1 ? run + 1 : 1;
+ longest = Math.max(longest, run);
+ }
+
+ const lastActiveDate = activeDays[activeDays.length - 1];
+ const today = todayDateString || toDateOnly(new Date().toISOString());
+ const gapFromToday = daysBetween(lastActiveDate, today);
+
+ let current = 0;
+ if (gapFromToday <= 1) {
+ current = 1;
+ for (let i = activeDays.length - 1; i > 0; i -= 1) {
+ const gap = daysBetween(activeDays[i - 1], activeDays[i]);
+ if (gap === 1) {
+ current += 1;
+ } else {
+ break;
+ }
+ }
+ }
+
+ return { current, longest: Math.max(longest, current), lastActiveDate };
+}
+
+module.exports = { computeStreak };