Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 31 additions & 3 deletions client/src/main.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand All @@ -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([
Expand 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: '' });
Expand Down Expand Up @@ -139,6 +164,9 @@ function App() {
<span>{analytics?.scenarioCount || 0}<small>Scenarios</small></span>
<span>{analytics?.sessionCount || 0}<small>Sessions</small></span>
<span>{analytics?.averagePromptScore || 0}<small>Prompt score</small></span>
<span>{analytics?.streak?.current || 0}<small>🔥 Day streak</small></span>
<span>{formatDuration(analytics?.totalTimeSpentSeconds)}<small>⏱️ Time learning</small></span>
<span>{formatLastActive(analytics?.streak?.lastActiveDate)}<small>Last active</small></span>
</div>
</header>

Expand Down
3 changes: 2 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 34 additions & 1 deletion server/src/data/db.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
9 changes: 8 additions & 1 deletion server/src/routes/analytics.js
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -20,15 +22,20 @@ 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,
sessionCount: sessions.length,
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);
}
Expand Down
5 changes: 3 additions & 2 deletions server/src/routes/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions server/src/services/__tests__/engagement.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
63 changes: 63 additions & 0 deletions server/src/services/__tests__/streak.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
15 changes: 15 additions & 0 deletions server/src/services/engagement.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading