element containing the total play-time chart.
*/
export function createTotalPlayTimeChart(summaryData) {
- const maxMs = Math.max(...summaryData.map((d) => d.total), 1);
-
const wrapper = document.createElement('div');
wrapper.className = 'history-total-chart';
- wrapper.setAttribute('aria-hidden', 'true'); // Accessible data is in the table below.
+ // Daily totals are also present in the accessible data table below,
+ // so this visual-only chart is safely hidden from assistive technology.
+ wrapper.setAttribute('aria-hidden', 'true');
const title = document.createElement('p');
title.className = 'history-total-chart__title';
title.textContent = 'Total Play Time';
wrapper.appendChild(title);
+ if (summaryData.length === 0) {
+ return wrapper;
+ }
+
+ const maxMs = Math.max(...summaryData.map((d) => d.total), 1);
+
const barsEl = document.createElement('div');
barsEl.className = 'history-total-chart__bars';
diff --git a/app/components/tests/historyView.test.js b/app/components/tests/historyView.test.js
index 66c7fbe..1c32098 100644
--- a/app/components/tests/historyView.test.js
+++ b/app/components/tests/historyView.test.js
@@ -395,6 +395,12 @@ describe('createTotalPlayTimeChart()', () => {
const labels = [...chart.querySelectorAll('.history-total-chart__label')];
expect(labels[0].textContent).toBe('01-01');
});
+
+ it('returns a wrapper with title but no bars for empty summaryData', () => {
+ const chart = createTotalPlayTimeChart([]);
+ expect(chart.querySelector('.history-total-chart__title')).not.toBeNull();
+ expect(chart.querySelectorAll('.history-total-chart__group').length).toBe(0);
+ });
});
// ── buildHistoryPanel ─────────────────────────────────────────────────────────
From 374cde11574ea559594ef6587400e73449e3d488 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 01:41:35 +0000
Subject: [PATCH 5/6] Convert total play-time chart from bars to SVG line chart
Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/f67a2631-5083-4350-a9d1-2a389b6457ee
Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com>
---
app/components/historyView.js | 79 ++++++++++++++++--------
app/components/tests/historyView.test.js | 52 +++++++++++-----
app/styles/history.css | 45 ++++++--------
3 files changed, 110 insertions(+), 66 deletions(-)
diff --git a/app/components/historyView.js b/app/components/historyView.js
index 3885128..2d0d17f 100644
--- a/app/components/historyView.js
+++ b/app/components/historyView.js
@@ -160,13 +160,13 @@ export function createDataTable(summaryData, gameIds, manifests) {
}
/**
- * Create a total play-time bar chart showing daily totals across all games.
+ * Create a total play-time line chart showing daily totals across all games.
*
- * Renders one bar per day proportional to the maximum daily total, giving
- * a quick at-a-glance overview of overall activity. Labeled with MM-DD dates.
+ * Renders an SVG line chart with one data point per day connected by a line,
+ * giving a quick at-a-glance trend of overall activity. Labeled with MM-DD dates.
*
* @param {Array<{date: string, total: number}>} summaryData - Per-day totals.
- * @returns {HTMLElement} A
element containing the total play-time chart.
+ * @returns {HTMLElement} A
element containing the total play-time line chart.
*/
export function createTotalPlayTimeChart(summaryData) {
const wrapper = document.createElement('div');
@@ -184,31 +184,60 @@ export function createTotalPlayTimeChart(summaryData) {
return wrapper;
}
+ const SVG_NS = 'http://www.w3.org/2000/svg';
+ const svgW = 600;
+ const svgH = 120;
+ const pad = {
+ top: 10, right: 20, bottom: 28, left: 10,
+ };
+ const plotW = svgW - pad.left - pad.right;
+ const plotH = svgH - pad.top - pad.bottom;
const maxMs = Math.max(...summaryData.map((d) => d.total), 1);
+ const n = summaryData.length;
+
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`);
+ svg.setAttribute('class', 'history-total-chart__svg');
+ svg.setAttribute('role', 'img');
+
+ // Calculate pixel coordinates for each data point.
+ const points = summaryData.map((d, i) => {
+ const x = pad.left + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
+ const y = pad.top + plotH - Math.round((d.total / maxMs) * plotH);
+ return {
+ x, y, date: d.date, total: d.total,
+ };
+ });
- const barsEl = document.createElement('div');
- barsEl.className = 'history-total-chart__bars';
-
- summaryData.forEach((dayData) => {
- const group = document.createElement('div');
- group.className = 'history-total-chart__group';
-
- const bar = document.createElement('div');
- bar.className = 'history-total-chart__bar';
- const heightPct = Math.round((dayData.total / maxMs) * 100);
- bar.style.height = `${heightPct}%`;
- bar.title = `${dayData.date}: ${formatDuration(dayData.total)}`;
-
- const label = document.createElement('span');
- label.className = 'history-total-chart__label';
- label.textContent = dayData.date.slice(5); // Display as MM-DD for compactness.
-
- group.appendChild(bar);
- group.appendChild(label);
- barsEl.appendChild(group);
+ // Polyline connecting all data points.
+ const polyline = document.createElementNS(SVG_NS, 'polyline');
+ polyline.setAttribute('points', points.map((p) => `${p.x},${p.y}`).join(' '));
+ polyline.setAttribute('class', 'history-total-chart__line');
+ svg.appendChild(polyline);
+
+ // Dot and date label for each point.
+ points.forEach((p) => {
+ const circle = document.createElementNS(SVG_NS, 'circle');
+ circle.setAttribute('cx', p.x);
+ circle.setAttribute('cy', p.y);
+ circle.setAttribute('r', '4');
+ circle.setAttribute('class', 'history-total-chart__dot');
+
+ const tooltipTitle = document.createElementNS(SVG_NS, 'title');
+ tooltipTitle.textContent = `${p.date}: ${formatDuration(p.total)}`;
+ circle.appendChild(tooltipTitle);
+ svg.appendChild(circle);
+
+ const label = document.createElementNS(SVG_NS, 'text');
+ label.setAttribute('x', p.x);
+ label.setAttribute('y', svgH - 4);
+ label.setAttribute('text-anchor', 'middle');
+ label.setAttribute('class', 'history-total-chart__x-label');
+ label.textContent = p.date.slice(5); // Display as MM-DD.
+ svg.appendChild(label);
});
- wrapper.appendChild(barsEl);
+ wrapper.appendChild(svg);
return wrapper;
}
diff --git a/app/components/tests/historyView.test.js b/app/components/tests/historyView.test.js
index 1c32098..64a011a 100644
--- a/app/components/tests/historyView.test.js
+++ b/app/components/tests/historyView.test.js
@@ -370,36 +370,58 @@ describe('createTotalPlayTimeChart()', () => {
expect(chart.classList.contains('history-total-chart')).toBe(true);
});
- it('creates one group per date', () => {
+ it('contains an SVG element', () => {
const chart = createTotalPlayTimeChart(summaryData);
- const groups = chart.querySelectorAll('.history-total-chart__group');
- expect(groups.length).toBe(dates.length);
+ const svg = chart.querySelector('svg');
+ expect(svg).not.toBeNull();
});
- it('each bar has a non-zero height for days with play time', () => {
+ it('SVG contains a polyline connecting all data points', () => {
const chart = createTotalPlayTimeChart(summaryData);
- const bars = chart.querySelectorAll('.history-total-chart__bar');
- const hasNonZero = [...bars].some((b) => b.style.height !== '0%');
- expect(hasNonZero).toBe(true);
+ const polyline = chart.querySelector('polyline');
+ expect(polyline).not.toBeNull();
+ // One x,y pair per date entry.
+ const pairs = polyline.getAttribute('points').trim().split(' ');
+ expect(pairs.length).toBe(dates.length);
});
- it('includes a title paragraph', () => {
+ it('SVG contains one dot (circle) per date', () => {
const chart = createTotalPlayTimeChart(summaryData);
- const title = chart.querySelector('.history-total-chart__title');
- expect(title).not.toBeNull();
- expect(title.textContent).toBeTruthy();
+ const circles = chart.querySelectorAll('circle');
+ expect(circles.length).toBe(dates.length);
+ });
+
+ it('SVG dots carry a tooltip with date and duration', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const firstDot = chart.querySelector('circle');
+ const tooltip = firstDot.querySelector('title');
+ expect(tooltip).not.toBeNull();
+ expect(tooltip.textContent).toContain('2024-01-01');
});
- it('labels use MM-DD format', () => {
+ it('x-axis labels use MM-DD format', () => {
const chart = createTotalPlayTimeChart(summaryData);
- const labels = [...chart.querySelectorAll('.history-total-chart__label')];
+ const labels = [...chart.querySelectorAll('.history-total-chart__x-label')];
expect(labels[0].textContent).toBe('01-01');
});
- it('returns a wrapper with title but no bars for empty summaryData', () => {
+ it('includes a title paragraph', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const title = chart.querySelector('.history-total-chart__title');
+ expect(title).not.toBeNull();
+ expect(title.textContent).toBeTruthy();
+ });
+
+ it('returns a wrapper with title but no SVG for empty summaryData', () => {
const chart = createTotalPlayTimeChart([]);
expect(chart.querySelector('.history-total-chart__title')).not.toBeNull();
- expect(chart.querySelectorAll('.history-total-chart__group').length).toBe(0);
+ expect(chart.querySelector('svg')).toBeNull();
+ });
+
+ it('handles a single data point without error', () => {
+ const single = [{ date: '2024-01-01', total: 60000 }];
+ const chart = createTotalPlayTimeChart(single);
+ expect(chart.querySelectorAll('circle').length).toBe(1);
});
});
diff --git a/app/styles/history.css b/app/styles/history.css
index 5d3569c..af377d4 100644
--- a/app/styles/history.css
+++ b/app/styles/history.css
@@ -167,7 +167,7 @@
padding: 2rem 0;
}
-/* ── Total play-time summary chart ───────────────────────────────────────── */
+/* ── Total play-time summary chart (SVG line chart) ──────────────────────── */
.history-total-chart {
margin-bottom: 1.5rem;
@@ -177,39 +177,32 @@
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
- margin: 0 0 0.5rem;
+ margin: 0 0 0.25rem;
}
-.history-total-chart__bars {
- display: flex;
- gap: 0.35rem;
- align-items: flex-end;
- height: 100px;
- border-bottom: 2px solid var(--border-color);
- overflow-x: auto;
+.history-total-chart__svg {
+ width: 100%;
+ height: 120px;
+ display: block;
}
-.history-total-chart__group {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0.2rem;
- flex: 1;
- min-width: 32px;
+.history-total-chart__line {
+ fill: none;
+ stroke: var(--chart-color-0);
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
}
-.history-total-chart__bar {
- width: 100%;
- min-height: 2px;
- border-radius: 2px 2px 0 0;
- background-color: var(--chart-color-total);
- transition: height 0.2s ease;
+.history-total-chart__dot {
+ fill: var(--chart-color-0);
+ stroke: var(--bg-card);
+ stroke-width: 1.5;
}
-.history-total-chart__label {
- font-size: 0.7rem;
- color: var(--text-subtle);
- white-space: nowrap;
+.history-total-chart__x-label {
+ font-size: 10px;
+ fill: var(--text-subtle);
}
/* ── History bar chart ───────────────────────────────────────────────────── */
From c502d23b18ce276e07030cd355866253e712a298 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 01:44:54 +0000
Subject: [PATCH 6/6] Update history layout: total chart, grid view, show-more
pagination, 10 colors
Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/f67a2631-5083-4350-a9d1-2a389b6457ee
Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com>
---
nohup.out | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 nohup.out
diff --git a/nohup.out b/nohup.out
new file mode 100644
index 0000000..0b3a192
--- /dev/null
+++ b/nohup.out
@@ -0,0 +1,2 @@
+127.0.0.1 - - [15/Apr/2026 01:43:26] "GET /preview.html HTTP/1.1" 200 -
+127.0.0.1 - - [15/Apr/2026 01:43:31] "GET /preview.html HTTP/1.1" 200 -