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 };