From 96f8f7e08ea0bc2453439ab5254357b3cfbe4b24 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 13 Apr 2026 23:41:13 +0000
Subject: [PATCH 1/4] Initial plan
From 32d304bb7631363520c70fa28a8ac369140189e8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 14 Apr 2026 00:09:16 +0000
Subject: [PATCH 2/4] Add speed history tracking and trend chart to all games;
add getSpeedHistory tests
Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/60497adb-33a4-4f98-9fc7-52414d0e292d
Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com>
---
.../tests/trendChartService.test.js | 156 ++++++++++++++++++
app/components/trendChartService.js | 84 ++++++++++
app/games/directional-processing/game.js | 20 +++
app/games/directional-processing/index.js | 23 +++
.../directional-processing/interface.html | 13 ++
.../directional-processing/tests/game.test.js | 33 ++++
.../tests/index.test.js | 1 +
app/games/fast-piggie/game.js | 19 +++
app/games/fast-piggie/index.js | 24 +++
app/games/fast-piggie/interface.html | 13 ++
app/games/fast-piggie/tests/game.test.js | 43 +++++
app/games/fast-piggie/tests/index.test.js | 1 +
app/games/field-of-view/interface.html | 8 +-
app/games/field-of-view/render.js | 45 ++---
app/games/high-speed-memory/game.js | 19 +++
app/games/high-speed-memory/index.js | 25 +++
app/games/high-speed-memory/interface.html | 13 ++
.../high-speed-memory/tests/game.test.js | 38 +++++
.../high-speed-memory/tests/index.test.js | 1 +
app/games/object-track/game.js | 16 ++
app/games/object-track/index.js | 25 +++
app/games/object-track/interface.html | 13 ++
app/games/object-track/tests/game.test.js | 32 ++++
app/games/object-track/tests/index.test.js | 1 +
app/games/orbit-sprite-memory/game.js | 19 +++
app/games/orbit-sprite-memory/index.js | 24 +++
app/games/orbit-sprite-memory/interface.html | 13 ++
.../orbit-sprite-memory/tests/game.test.js | 38 +++++
.../orbit-sprite-memory/tests/index.test.js | 1 +
app/games/otter-stop/game.js | 19 +++
app/games/otter-stop/index.js | 23 +++
app/games/otter-stop/interface.html | 13 ++
app/games/otter-stop/tests/game.test.js | 32 ++++
app/games/otter-stop/tests/index.test.js | 1 +
app/games/sound-sweep/game.js | 20 +++
app/games/sound-sweep/index.js | 23 +++
app/games/sound-sweep/interface.html | 13 ++
app/games/sound-sweep/tests/game.test.js | 33 ++++
app/games/sound-sweep/tests/index.test.js | 1 +
app/style.css | 38 +++++
40 files changed, 940 insertions(+), 37 deletions(-)
create mode 100644 app/components/tests/trendChartService.test.js
create mode 100644 app/components/trendChartService.js
diff --git a/app/components/tests/trendChartService.test.js b/app/components/tests/trendChartService.test.js
new file mode 100644
index 0000000..33d3f12
--- /dev/null
+++ b/app/components/tests/trendChartService.test.js
@@ -0,0 +1,156 @@
+/** @jest-environment jsdom */
+/**
+ * trendChartService.test.js — Unit tests for the centralized trend chart service.
+ *
+ * Exercises buildPolylinePoints and renderTrendChart against simple inputs,
+ * edge cases, and null-element guards.
+ *
+ * @file Tests for app/components/trendChartService.js
+ */
+
+import { describe, test, expect } from '@jest/globals';
+import { buildPolylinePoints, renderTrendChart } from '../trendChartService.js';
+
+// ── buildPolylinePoints ───────────────────────────────────────────────────────
+
+describe('buildPolylinePoints', () => {
+ test('returns empty string for an empty array', () => {
+ expect(buildPolylinePoints([])).toBe('');
+ });
+
+ test('returns empty string for null input', () => {
+ expect(buildPolylinePoints(null)).toBe('');
+ });
+
+ test('returns empty string for undefined input', () => {
+ expect(buildPolylinePoints(undefined)).toBe('');
+ });
+
+ test('returns a single point string for a one-element array', () => {
+ const result = buildPolylinePoints([300]);
+ expect(result).not.toBe('');
+ expect(result.split(' ').length).toBe(1);
+ });
+
+ test('returns three coordinate pairs for a three-element array', () => {
+ const result = buildPolylinePoints([200, 150, 100]);
+ expect(result.split(' ').length).toBe(3);
+ });
+
+ test('each coordinate pair contains an x and y value separated by a comma', () => {
+ const result = buildPolylinePoints([100, 200]);
+ result.split(' ').forEach((pair) => {
+ expect(pair).toMatch(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/);
+ });
+ });
+
+ test('uses the full chart width between first and last point', () => {
+ const result = buildPolylinePoints([100, 200]);
+ const pairs = result.split(' ').map((p) => p.split(',').map(Number));
+ // With CHART_PAD=10 and CHART_WIDTH=300, first x should be ~10, last x ~290
+ expect(pairs[0][0]).toBeCloseTo(10, 1);
+ expect(pairs[1][0]).toBeCloseTo(290, 1);
+ });
+
+ test('places a single entry at leftmost x position', () => {
+ const result = buildPolylinePoints([500]);
+ const [x] = result.split(',').map(Number);
+ expect(x).toBeCloseTo(10, 1);
+ });
+
+ test('handles all-identical values without division errors', () => {
+ expect(() => buildPolylinePoints([100, 100, 100])).not.toThrow();
+ const result = buildPolylinePoints([100, 100, 100]);
+ expect(result.split(' ').length).toBe(3);
+ });
+
+ test('larger values produce higher y coordinates (lower on SVG = larger value)', () => {
+ // With values [100, 200]: value 100 (min) → lower y, value 200 (max) → higher y
+ const result = buildPolylinePoints([100, 200]);
+ const pairs = result.split(' ').map((p) => p.split(',').map(Number));
+ // value 100 is at min: y should be near bottom (large y number)
+ // value 200 is at max: y should be near top (small y number)
+ expect(pairs[0][1]).toBeGreaterThan(pairs[1][1]);
+ });
+});
+
+// ── renderTrendChart ──────────────────────────────────────────────────────────
+
+describe('renderTrendChart', () => {
+ test('updates lineEl points attribute for a non-empty values array', () => {
+ const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+ const emptyEl = document.createElement('p');
+ const latestEl = document.createElement('strong');
+
+ renderTrendChart({ lineEl, emptyEl, latestEl }, [200, 150, 100], 500);
+
+ expect(lineEl.getAttribute('points')).not.toBe('');
+ expect(emptyEl.hidden).toBe(true);
+ expect(latestEl.textContent).toBe('100');
+ });
+
+ test('clears the lineEl and shows emptyEl when values array is empty', () => {
+ const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+ lineEl.setAttribute('points', '10,10 20,20');
+ const emptyEl = document.createElement('p');
+ const latestEl = document.createElement('strong');
+
+ renderTrendChart({ lineEl, emptyEl, latestEl }, [], 400);
+
+ expect(lineEl.getAttribute('points')).toBe('');
+ expect(emptyEl.hidden).toBe(false);
+ expect(latestEl.textContent).toBe('400');
+ });
+
+ test('uses currentValue for latestEl when values array is empty', () => {
+ const latestEl = document.createElement('strong');
+ renderTrendChart(
+ { lineEl: null, emptyEl: null, latestEl },
+ [],
+ '350',
+ );
+ expect(latestEl.textContent).toBe('350');
+ });
+
+ test('uses the last value in the array as the latest value', () => {
+ const latestEl = document.createElement('strong');
+ renderTrendChart(
+ { lineEl: null, emptyEl: null, latestEl },
+ [500, 450, 380],
+ 500,
+ );
+ expect(latestEl.textContent).toBe('380');
+ });
+
+ test('tolerates null lineEl without throwing', () => {
+ expect(() => renderTrendChart(
+ { lineEl: null, emptyEl: null, latestEl: null },
+ [100, 200],
+ 300,
+ )).not.toThrow();
+ });
+
+ test('tolerates null emptyEl without throwing', () => {
+ const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+ expect(() => renderTrendChart(
+ { lineEl, emptyEl: null, latestEl: null },
+ [100, 200],
+ 300,
+ )).not.toThrow();
+ });
+
+ test('tolerates null latestEl without throwing', () => {
+ const lineEl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+ expect(() => renderTrendChart(
+ { lineEl, emptyEl: null, latestEl: null },
+ [],
+ 300,
+ )).not.toThrow();
+ });
+
+ test('renders empty string for latestEl when currentValue is null and values is empty', () => {
+ const latestEl = document.createElement('strong');
+ renderTrendChart({ lineEl: null, emptyEl: null, latestEl }, [], null);
+ expect(latestEl.textContent).toBe('');
+ });
+});
diff --git a/app/components/trendChartService.js b/app/components/trendChartService.js
new file mode 100644
index 0000000..eaac0cb
--- /dev/null
+++ b/app/components/trendChartService.js
@@ -0,0 +1,84 @@
+/**
+ * trendChartService.js — Centralized trend chart service for BrainSpeedExercises.
+ *
+ * Provides shared SVG polyline chart generation and rendering utilities used by
+ * all game plugins to display a speed/difficulty metric history during gameplay.
+ * Each game tracks its own history array and passes it here for rendering.
+ *
+ * @file Centralized trend chart rendering service.
+ */
+
+// ── Chart geometry constants ───────────────────────────────────────────────────
+
+/** SVG viewBox width in user units. */
+const CHART_WIDTH = 300;
+
+/** SVG viewBox height in user units. */
+const CHART_HEIGHT = 120;
+
+/** Padding from each edge of the SVG in user units. */
+const CHART_PAD = 10;
+
+// ── Core chart helpers ────────────────────────────────────────────────────────
+
+/**
+ * Build an SVG polyline `points` attribute string from an array of numeric values.
+ *
+ * Maps each value onto an (x, y) coordinate within the padded SVG viewBox.
+ * The x axis represents progression over time (left = first, right = last);
+ * the y axis is scaled so the minimum value sits near the bottom and the
+ * maximum near the top (lower values appear lower on the chart).
+ *
+ * @param {number[]} values - Ordered array of numeric metric values to plot.
+ * @returns {string} Space-separated `"x,y"` coordinate pairs, or `""` when
+ * the array is empty or falsy.
+ */
+export function buildPolylinePoints(values) {
+ if (!values || values.length === 0) {
+ return '';
+ }
+
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = Math.max(max - min, 1);
+ const denominator = Math.max(values.length - 1, 1);
+
+ return values.map((value, index) => {
+ const x = CHART_PAD + ((CHART_WIDTH - CHART_PAD * 2) * index) / denominator;
+ const normalized = (value - min) / span;
+ const y = CHART_HEIGHT - CHART_PAD - normalized * (CHART_HEIGHT - CHART_PAD * 2);
+ return `${x.toFixed(2)},${y.toFixed(2)}`;
+ }).join(' ');
+}
+
+/**
+ * Render (or update) a speed/difficulty trend chart with the supplied history.
+ *
+ * Accepts a plain object of nullable element references so callers can pass
+ * whatever subset of the chart elements they have wired up without crashing.
+ *
+ * @param {{
+ * lineEl: SVGPolylineElement|null,
+ * emptyEl: HTMLElement|null,
+ * latestEl: HTMLElement|null,
+ * }} els - References to the chart's key DOM nodes.
+ * @param {number[]} values - Ordered array of numeric metric values to plot.
+ * @param {number|string} currentValue - The value to display when `values` is
+ * empty (typically the metric's starting/default value).
+ */
+export function renderTrendChart(els, values, currentValue) {
+ const latest = values.length > 0 ? values[values.length - 1] : currentValue;
+
+ if (els.latestEl) {
+ els.latestEl.textContent = latest != null ? String(latest) : '';
+ }
+
+ if (!els.lineEl) return;
+
+ const points = buildPolylinePoints(values);
+ els.lineEl.setAttribute('points', points);
+
+ if (els.emptyEl) {
+ els.emptyEl.hidden = points.length > 0;
+ }
+}
diff --git a/app/games/directional-processing/game.js b/app/games/directional-processing/game.js
index 6faa04d..66e79d4 100644
--- a/app/games/directional-processing/game.js
+++ b/app/games/directional-processing/game.js
@@ -68,6 +68,13 @@ let consecutiveCorrect = 0;
/** @type {number} */
let consecutiveWrong = 0;
+/**
+ * Session history of display durations in ms, one entry per completed trial.
+ * Used to render the in-game speed trend chart.
+ * @type {number[]}
+ */
+let speedHistory = [];
+
// ── Exported functions ────────────────────────────────────────────────────────
/**
@@ -82,6 +89,7 @@ export function initGame() {
trialsCompleted = 0;
consecutiveCorrect = 0;
consecutiveWrong = 0;
+ speedHistory = [];
}
/**
@@ -157,6 +165,8 @@ export function recordTrial({ success }) {
}
}
+ speedHistory.push(LEVELS[currentLevel].displayDurationMs);
+
return { level: currentLevel, consecutiveCorrect, consecutiveWrong };
}
@@ -222,3 +232,13 @@ export function getConsecutiveWrong() {
export function isRunning() {
return running;
}
+
+/**
+ * Get the session speed history as an array of display durations in ms.
+ * One entry is appended per completed trial after any staircase adjustment.
+ *
+ * @returns {number[]}
+ */
+export function getSpeedHistory() {
+ return [...speedHistory];
+}
diff --git a/app/games/directional-processing/index.js b/app/games/directional-processing/index.js
index ad023d7..dfec98a 100644
--- a/app/games/directional-processing/index.js
+++ b/app/games/directional-processing/index.js
@@ -16,6 +16,7 @@ import { playFeedbackSound } from '../../components/audioService.js';
import { returnToMainMenu } from '../../components/gameUtils.js';
import { saveScore } from '../../components/scoreService.js';
import * as timerService from '../../components/timerService.js';
+import { renderTrendChart } from '../../components/trendChartService.js';
/** Game identifier used for progress persistence (must match manifest.json id). */
const GAME_ID = 'directional-processing';
@@ -57,6 +58,12 @@ let _trialsEl = null;
let _streakEl = null;
/** @type {HTMLElement|null} */
let _sessionTimerEl = null;
+/** @type {SVGPolylineElement|null} */
+let _trendLineEl = null;
+/** @type {HTMLElement|null} */
+let _trendEmptyEl = null;
+/** @type {HTMLElement|null} */
+let _trendLatestEl = null;
/** @type {HTMLElement|null} */
let _finalLevelEl = null;
/** @type {HTMLElement|null} */
@@ -178,6 +185,17 @@ export function updateStats() {
if (_streakEl) _streakEl.textContent = String(streak);
}
+/**
+ * Render the speed trend chart with the latest display-duration history.
+ */
+export function updateTrendChart() {
+ renderTrendChart(
+ { lineEl: _trendLineEl, emptyEl: _trendEmptyEl, latestEl: _trendLatestEl },
+ game.getSpeedHistory(),
+ game.getCurrentLevelConfig().displayDurationMs,
+ );
+}
+
/**
* Apply a brief colored flash to the stage to indicate correct/incorrect.
*
@@ -328,6 +346,7 @@ export function handleDirectionResponse(direction) {
game.recordTrial({ success });
updateStats();
+ updateTrendChart();
playFeedbackSound(success);
flashStageFeedback(success);
@@ -419,6 +438,9 @@ function init(gameContainer) {
_trialsEl = _container.querySelector('#dp-trials');
_streakEl = _container.querySelector('#dp-streak');
_sessionTimerEl = _container.querySelector('#dp-session-timer');
+ _trendLineEl = _container.querySelector('#dp-trend-line');
+ _trendEmptyEl = _container.querySelector('#dp-trend-empty');
+ _trendLatestEl = _container.querySelector('#dp-trend-latest');
_finalLevelEl = _container.querySelector('#dp-final-level');
_finalScoreEl = _container.querySelector('#dp-final-score');
_finalTrialsEl = _container.querySelector('#dp-final-trials');
@@ -530,6 +552,7 @@ function reset() {
clearDirectionHighlights();
setDirectionButtonsEnabled(false);
updateStats();
+ updateTrendChart();
}
export default {
diff --git a/app/games/directional-processing/interface.html b/app/games/directional-processing/interface.html
index b39956c..71c7a8b 100644
--- a/app/games/directional-processing/interface.html
+++ b/app/games/directional-processing/interface.html
@@ -69,6 +69,19 @@
How to Play
+
+
+ Speed Trend (ms)
+
+ Complete a trial to populate chart.
+
+ Latest: -- ms
+
+
+