diff --git a/app/components/historyView.js b/app/components/historyView.js
index 3efafaf..2d0d17f 100644
--- a/app/components/historyView.js
+++ b/app/components/historyView.js
@@ -6,7 +6,7 @@
* so it can be unit-tested without a real browser environment.
*
* Bar colors are defined in `style.css` as CSS custom properties
- * (`--chart-color-0` through `--chart-color-5`) and applied via
+ * (`--chart-color-0` through `--chart-color-9`) and applied via
* `history-chart__bar--color-N` / `history-chart__legend-swatch--color-N` classes.
*
* @file Play history visualization component.
@@ -20,7 +20,15 @@ import { formatDuration } from './timerService.js';
*
* @type {number}
*/
-const COLOR_SLOT_COUNT = 6;
+const COLOR_SLOT_COUNT = 10;
+
+/**
+ * Number of most-recent days shown in the per-game bar chart before the
+ * "show older days" toggle button is displayed.
+ *
+ * @type {number}
+ */
+export const INITIAL_VISIBLE_DAYS = 6;
/**
* Extract all unique YYYY-MM-DD date keys present across all games' dailyTime maps.
@@ -152,12 +160,139 @@ export function createDataTable(summaryData, gameIds, manifests) {
}
/**
- * Create a visual CSS bar-chart section for the history data.
+ * Create a total play-time line chart showing daily totals across all games.
*
- * Each day gets a group of colored bars (one per game) sized proportionally
- * to the maximum total play time across all days.
+ * 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 line chart.
+ */
+export function createTotalPlayTimeChart(summaryData) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'history-total-chart';
+ // 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 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,
+ };
+ });
+
+ // 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(svg);
+ return wrapper;
+}
+
+/**
+ * Build the DOM for a single day-column in the per-game bar chart.
+ *
+ * @param {object} dayData - Summary entry for one day.
+ * @param {string[]} gameIds - Game IDs to render bars for.
+ * @param {number} maxMs - Maximum total ms across all days (for scaling).
+ * @param {Array<{id: string, name: string}>} [manifests] - Manifest list for names.
+ * @returns {HTMLElement} A `.history-chart__group` element.
+ */
+function createDayGroup(dayData, gameIds, maxMs, manifests) {
+ const group = document.createElement('div');
+ group.className = 'history-chart__group';
+
+ const barsWrap = document.createElement('div');
+ barsWrap.className = 'history-chart__bars';
+
+ gameIds.forEach((gameId, colIndex) => {
+ const ms = dayData[gameId] || 0;
+ const heightPct = Math.round((ms / maxMs) * 100);
+ const bar = document.createElement('div');
+ const colorIndex = colIndex % COLOR_SLOT_COUNT;
+ bar.className = `history-chart__bar history-chart__bar--color-${colorIndex}`;
+ bar.style.height = `${heightPct}%`;
+ bar.title = `${getGameName(gameId, manifests)}: ${formatDuration(ms)}`;
+ barsWrap.appendChild(bar);
+ });
+
+ // Total bar (grey).
+ const totalMs = dayData.total;
+ const totalPct = Math.round((totalMs / maxMs) * 100);
+ const totalBar = document.createElement('div');
+ totalBar.className = 'history-chart__bar history-chart__bar--total';
+ totalBar.style.height = `${totalPct}%`;
+ totalBar.title = `Total: ${formatDuration(totalMs)}`;
+ barsWrap.appendChild(totalBar);
+
+ const dateLabel = document.createElement('span');
+ dateLabel.className = 'history-chart__label';
+ dateLabel.textContent = dayData.date.slice(5); // Display as MM-DD.
+
+ group.appendChild(barsWrap);
+ group.appendChild(dateLabel);
+ return group;
+}
+
+/**
+ * Create a visual CSS bar-chart section for the history data arranged in a grid.
+ *
+ * The most recent {@link INITIAL_VISIBLE_DAYS} days are shown in a 2-column
+ * grid. If there are more days, a toggle button reveals the older entries.
+ *
+ * @param {Array<{date: string, total: number}>} summaryData - Per-day totals (ascending).
* @param {string[]} gameIds - Game IDs to chart.
* @param {Array<{id: string, name: string}>} [manifests] - Manifest list for names.
* @returns {HTMLElement}
@@ -169,42 +304,42 @@ export function createBarChart(summaryData, gameIds, manifests) {
chartEl.className = 'history-chart';
chartEl.setAttribute('aria-hidden', 'true'); // Table is the accessible version.
- summaryData.forEach((dayData, dayIndex) => {
- const group = document.createElement('div');
- group.className = 'history-chart__group';
-
- const barsWrap = document.createElement('div');
- barsWrap.className = 'history-chart__bars';
-
- gameIds.forEach((gameId, colIndex) => {
- const ms = dayData[gameId] || 0;
- const heightPct = Math.round((ms / maxMs) * 100);
- const bar = document.createElement('div');
- const colorIndex = colIndex % COLOR_SLOT_COUNT;
- bar.className = `history-chart__bar history-chart__bar--color-${colorIndex}`;
- bar.style.height = `${heightPct}%`;
- bar.title = `${getGameName(gameId, manifests)}: ${formatDuration(ms)}`;
- barsWrap.appendChild(bar);
+ // Split: the most-recent INITIAL_VISIBLE_DAYS are visible; older days are hidden.
+ const hasMore = summaryData.length > INITIAL_VISIBLE_DAYS;
+ const olderData = hasMore ? summaryData.slice(0, -INITIAL_VISIBLE_DAYS) : [];
+ const recentData = hasMore ? summaryData.slice(-INITIAL_VISIBLE_DAYS) : summaryData;
+
+ // Grid for older (initially hidden) days.
+ if (hasMore) {
+ const olderGrid = document.createElement('div');
+ olderGrid.className = 'history-chart__grid';
+ olderGrid.hidden = true;
+ olderData.forEach((dayData) => {
+ olderGrid.appendChild(createDayGroup(dayData, gameIds, maxMs, manifests));
});
+ chartEl.appendChild(olderGrid);
+
+ const olderCount = olderData.length;
+ const showMoreBtn = document.createElement('button');
+ showMoreBtn.className = 'history-chart__show-more-btn';
+ showMoreBtn.textContent = `Show ${olderCount} older day${olderCount !== 1 ? 's' : ''}`;
+ showMoreBtn.addEventListener('click', () => {
+ const isHidden = olderGrid.hidden;
+ olderGrid.hidden = !isHidden;
+ showMoreBtn.textContent = isHidden
+ ? 'Show fewer days'
+ : `Show ${olderCount} older day${olderCount !== 1 ? 's' : ''}`;
+ });
+ chartEl.appendChild(showMoreBtn);
+ }
- // Total bar (grey).
- const totalMs = dayData.total;
- const totalPct = Math.round((totalMs / maxMs) * 100);
- const totalBar = document.createElement('div');
- totalBar.className = 'history-chart__bar history-chart__bar--total';
- totalBar.style.height = `${totalPct}%`;
- totalBar.title = `Total: ${formatDuration(totalMs)}`;
- barsWrap.appendChild(totalBar);
-
- const dateLabel = document.createElement('span');
- dateLabel.className = 'history-chart__label';
- // Display as MM-DD for compactness.
- dateLabel.textContent = summaryData[dayIndex].date.slice(5);
-
- group.appendChild(barsWrap);
- group.appendChild(dateLabel);
- chartEl.appendChild(group);
+ // Grid for the most-recent days (always visible).
+ const recentGrid = document.createElement('div');
+ recentGrid.className = 'history-chart__grid';
+ recentData.forEach((dayData) => {
+ recentGrid.appendChild(createDayGroup(dayData, gameIds, maxMs, manifests));
});
+ chartEl.appendChild(recentGrid);
// Legend.
const legend = document.createElement('div');
@@ -241,7 +376,7 @@ export function createBarChart(summaryData, gameIds, manifests) {
*
* Returns a `` containing either:
* - An "empty state" message (no history recorded yet), or
- * - A bar chart followed by an accessible data table.
+ * - A total play-time chart, a per-game bar chart, and an accessible data table.
*
* @param {object} progress - Player progress object (may be null/undefined).
* @param {Array<{id: string, name: string}>} [manifests] - Game manifests for display names.
@@ -264,9 +399,11 @@ export function buildHistoryPanel(progress, manifests) {
}
const summaryData = buildSummaryData(progress, dates, gameIds);
+ const totalChart = createTotalPlayTimeChart(summaryData);
const chart = createBarChart(summaryData, gameIds, manifests);
const table = createDataTable(summaryData, gameIds, manifests);
+ section.appendChild(totalChart);
section.appendChild(chart);
section.appendChild(table);
return section;
diff --git a/app/components/tests/historyView.test.js b/app/components/tests/historyView.test.js
index 9ddd1da..64a011a 100644
--- a/app/components/tests/historyView.test.js
+++ b/app/components/tests/historyView.test.js
@@ -16,7 +16,9 @@ import {
getGameName,
createDataTable,
createBarChart,
+ createTotalPlayTimeChart,
buildHistoryPanel,
+ INITIAL_VISIBLE_DAYS,
} from '../historyView.js';
// ── Test fixtures ─────────────────────────────────────────────────────────────
@@ -46,6 +48,29 @@ const PROGRESS_WITH_DATA = {
},
};
+/**
+ * Progress fixture with more than INITIAL_VISIBLE_DAYS of data (8 days) to
+ * verify the "show more" toggle in createBarChart.
+ */
+const PROGRESS_MANY_DAYS = {
+ playerId: 'default',
+ games: {
+ 'game-a': {
+ highScore: 10,
+ dailyTime: {
+ '2024-01-01': 10000,
+ '2024-01-02': 20000,
+ '2024-01-03': 30000,
+ '2024-01-04': 40000,
+ '2024-01-05': 50000,
+ '2024-01-06': 60000,
+ '2024-01-07': 70000,
+ '2024-01-08': 80000,
+ },
+ },
+ },
+};
+
const PROGRESS_EMPTY = {
playerId: 'default',
games: {},
@@ -276,6 +301,128 @@ describe('createBarChart()', () => {
expect(swatches[0].classList.contains('history-chart__legend-swatch--color-0')).toBe(true);
expect(swatches[1].classList.contains('history-chart__legend-swatch--color-1')).toBe(true);
});
+
+ it('does not show a show-more button when days <= INITIAL_VISIBLE_DAYS', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const btn = chart.querySelector('.history-chart__show-more-btn');
+ expect(btn).toBeNull();
+ });
+});
+
+// ── createBarChart show-more ──────────────────────────────────────────────────
+
+describe('createBarChart() show-more behaviour', () => {
+ const dates = getAllDates(PROGRESS_MANY_DAYS);
+ const gameIds = getGamesWithData(PROGRESS_MANY_DAYS);
+ const summaryData = buildSummaryData(PROGRESS_MANY_DAYS, dates, gameIds);
+
+ it('shows a show-more button when days exceed INITIAL_VISIBLE_DAYS', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const btn = chart.querySelector('.history-chart__show-more-btn');
+ expect(btn).not.toBeNull();
+ });
+
+ it('older days grid is hidden by default', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const grids = chart.querySelectorAll('.history-chart__grid');
+ // First grid (older days) must be hidden; second grid (recent days) must not.
+ expect(grids[0].hidden).toBe(true);
+ expect(grids[1].hidden).toBe(false);
+ });
+
+ it('show-more button reveals the older days grid when clicked', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const btn = chart.querySelector('.history-chart__show-more-btn');
+ const olderGrid = chart.querySelector('.history-chart__grid');
+ btn.click();
+ expect(olderGrid.hidden).toBe(false);
+ });
+
+ it('show-more button label changes after click and reverts on second click', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const btn = chart.querySelector('.history-chart__show-more-btn');
+ const originalLabel = btn.textContent;
+ btn.click();
+ expect(btn.textContent).toBe('Show fewer days');
+ btn.click();
+ expect(btn.textContent).toBe(originalLabel);
+ });
+
+ it('recent grid always contains at most INITIAL_VISIBLE_DAYS groups', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const grids = chart.querySelectorAll('.history-chart__grid');
+ const recentGrid = grids[grids.length - 1];
+ const groups = recentGrid.querySelectorAll('.history-chart__group');
+ expect(groups.length).toBeLessThanOrEqual(INITIAL_VISIBLE_DAYS);
+ });
+});
+
+// ── createTotalPlayTimeChart ──────────────────────────────────────────────────
+
+describe('createTotalPlayTimeChart()', () => {
+ const dates = ['2024-01-01', '2024-01-02', '2024-01-03'];
+ const gameIds = ['game-a', 'game-b'];
+ const summaryData = buildSummaryData(PROGRESS_WITH_DATA, dates, gameIds);
+
+ it('returns a div with class history-total-chart', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ expect(chart.tagName).toBe('DIV');
+ expect(chart.classList.contains('history-total-chart')).toBe(true);
+ });
+
+ it('contains an SVG element', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const svg = chart.querySelector('svg');
+ expect(svg).not.toBeNull();
+ });
+
+ it('SVG contains a polyline connecting all data points', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ 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('SVG contains one dot (circle) per date', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ 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('x-axis labels use MM-DD format', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const labels = [...chart.querySelectorAll('.history-total-chart__x-label')];
+ expect(labels[0].textContent).toBe('01-01');
+ });
+
+ 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.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);
+ });
});
// ── buildHistoryPanel ─────────────────────────────────────────────────────────
@@ -305,6 +452,12 @@ describe('buildHistoryPanel()', () => {
expect(msg).not.toBeNull();
});
+ it('includes a total play-time chart when history exists', () => {
+ const panel = buildHistoryPanel(PROGRESS_WITH_DATA, MANIFESTS);
+ const totalChart = panel.querySelector('.history-total-chart');
+ expect(totalChart).not.toBeNull();
+ });
+
it('includes a bar chart when history exists', () => {
const panel = buildHistoryPanel(PROGRESS_WITH_DATA, MANIFESTS);
const chart = panel.querySelector('.history-chart');
diff --git a/app/styles/history.css b/app/styles/history.css
index 3facf3c..af377d4 100644
--- a/app/styles/history.css
+++ b/app/styles/history.css
@@ -167,13 +167,77 @@
padding: 2rem 0;
}
+/* ── Total play-time summary chart (SVG line chart) ──────────────────────── */
+
+.history-total-chart {
+ margin-bottom: 1.5rem;
+}
+
+.history-total-chart__title {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ margin: 0 0 0.25rem;
+}
+
+.history-total-chart__svg {
+ width: 100%;
+ height: 120px;
+ display: block;
+}
+
+.history-total-chart__line {
+ fill: none;
+ stroke: var(--chart-color-0);
+ stroke-width: 2;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+
+.history-total-chart__dot {
+ fill: var(--chart-color-0);
+ stroke: var(--bg-card);
+ stroke-width: 1.5;
+}
+
+.history-total-chart__x-label {
+ font-size: 10px;
+ fill: var(--text-subtle);
+}
+
/* ── History bar chart ───────────────────────────────────────────────────── */
.history-chart {
- overflow-x: auto;
margin-bottom: 1.5rem;
}
+/* Grid container for day groups — 2 columns. */
+.history-chart__grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+ margin-bottom: 0.75rem;
+}
+
+/* Show-more / show-fewer toggle button. */
+.history-chart__show-more-btn {
+ display: block;
+ margin: 0 auto 0.75rem;
+ background: none;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ padding: 0.25rem 1rem;
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: background-color var(--transition-quick), color var(--transition-quick);
+}
+
+.history-chart__show-more-btn:hover {
+ background-color: var(--bg-subtle);
+ color: var(--text-base);
+}
+
.history-chart__bars {
display: flex;
gap: 0.5rem;
@@ -204,13 +268,17 @@
width: 10px;
}
-/* Per-slot bar colours (indices cycle for > 6 games). */
+/* Per-slot bar colours (indices cycle for > 10 games). */
.history-chart__bar--color-0 { background-color: var(--chart-color-0); }
.history-chart__bar--color-1 { background-color: var(--chart-color-1); }
.history-chart__bar--color-2 { background-color: var(--chart-color-2); }
.history-chart__bar--color-3 { background-color: var(--chart-color-3); }
.history-chart__bar--color-4 { background-color: var(--chart-color-4); }
.history-chart__bar--color-5 { background-color: var(--chart-color-5); }
+.history-chart__bar--color-6 { background-color: var(--chart-color-6); }
+.history-chart__bar--color-7 { background-color: var(--chart-color-7); }
+.history-chart__bar--color-8 { background-color: var(--chart-color-8); }
+.history-chart__bar--color-9 { background-color: var(--chart-color-9); }
/* Legend swatch colours matching the bars above. */
.history-chart__legend-swatch--color-0 { background-color: var(--chart-color-0); }
@@ -219,6 +287,10 @@
.history-chart__legend-swatch--color-3 { background-color: var(--chart-color-3); }
.history-chart__legend-swatch--color-4 { background-color: var(--chart-color-4); }
.history-chart__legend-swatch--color-5 { background-color: var(--chart-color-5); }
+.history-chart__legend-swatch--color-6 { background-color: var(--chart-color-6); }
+.history-chart__legend-swatch--color-7 { background-color: var(--chart-color-7); }
+.history-chart__legend-swatch--color-8 { background-color: var(--chart-color-8); }
+.history-chart__legend-swatch--color-9 { background-color: var(--chart-color-9); }
.history-chart__label {
font-size: 0.75rem;
diff --git a/app/styles/variables.css b/app/styles/variables.css
index fb9e91a..9d44681 100644
--- a/app/styles/variables.css
+++ b/app/styles/variables.css
@@ -71,12 +71,16 @@
--transition-fast: 0.15s ease;
--transition-quick: 0.1s ease;
- /* ── Chart bar colour palette (6 accessible colours + total-bar grey) ───── */
+ /* ── Chart bar colour palette (10 accessible colours + total-bar grey) ──── */
--chart-color-0: #005fcc;
--chart-color-1: #c9510c;
--chart-color-2: #238636;
--chart-color-3: #8250df;
--chart-color-4: #d1242f;
--chart-color-5: #0969da;
+ --chart-color-6: #bf8700;
+ --chart-color-7: #1a7f74;
+ --chart-color-8: #e85aad;
+ --chart-color-9: #4d6b1e;
--chart-color-total: #adb5bd;
}
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 -