From b5460008803336448fcf5b28db91b03276b07a31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:15:18 +0000 Subject: [PATCH 1/6] Initial plan From f7faba9d3530d61b4c42e572b82c3101a300123d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:21:38 +0000 Subject: [PATCH 2/6] Update history layout: grid, total chart, show-more button, 10 colors Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/42ce514b-68cb-4ff0-979e-61a06c44ac02 Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- app/components/historyView.js | 182 ++++++++++++++++++----- app/components/tests/historyView.test.js | 125 ++++++++++++++++ app/styles/history.css | 83 ++++++++++- app/styles/variables.css | 6 +- 4 files changed, 354 insertions(+), 42 deletions(-) diff --git a/app/components/historyView.js b/app/components/historyView.js index 3efafaf..6c409a4 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,106 @@ export function createDataTable(summaryData, gameIds, manifests) { } /** - * Create a visual CSS bar-chart section for the history data. + * Create a total play-time bar 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 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. * * @param {Array<{date: string, total: number}>} summaryData - Per-day totals. + * @returns {HTMLElement} A
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. + + const title = document.createElement('p'); + title.className = 'history-total-chart__title'; + title.textContent = 'Total Play Time'; + wrapper.appendChild(title); + + 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); + }); + + wrapper.appendChild(barsEl); + 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 {number} dayIndex - Index within summaryData (used for label). + * @param {string[]} gameIds - Game IDs to render bars for. + * @param {number} maxMs - Maximum total ms across all days (for scaling). + * @param {Array<{date: string, total: number}>} summaryData - Full summary array. + * @param {Array<{id: string, name: string}>} [manifests] - Manifest list for names. + * @returns {HTMLElement} A `.history-chart__group` element. + */ +function createDayGroup(dayData, dayIndex, gameIds, maxMs, summaryData, 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 = summaryData[dayIndex].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 +271,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, idx) => { + olderGrid.appendChild(createDayGroup(dayData, idx, gameIds, maxMs, olderData, 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, idx) => { + recentGrid.appendChild(createDayGroup(dayData, idx, gameIds, maxMs, recentData, manifests)); }); + chartEl.appendChild(recentGrid); // Legend. const legend = document.createElement('div'); @@ -241,7 +343,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 +366,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..66c7fbe 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,100 @@ 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('creates one group per date', () => { + const chart = createTotalPlayTimeChart(summaryData); + const groups = chart.querySelectorAll('.history-total-chart__group'); + expect(groups.length).toBe(dates.length); + }); + + it('each bar has a non-zero height for days with play time', () => { + 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); + }); + + 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('labels use MM-DD format', () => { + const chart = createTotalPlayTimeChart(summaryData); + const labels = [...chart.querySelectorAll('.history-total-chart__label')]; + expect(labels[0].textContent).toBe('01-01'); + }); }); // ── buildHistoryPanel ───────────────────────────────────────────────────────── @@ -305,6 +424,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..5d3569c 100644 --- a/app/styles/history.css +++ b/app/styles/history.css @@ -167,13 +167,84 @@ padding: 2rem 0; } +/* ── Total play-time summary 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.5rem; +} + +.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__group { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + flex: 1; + min-width: 32px; +} + +.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__label { + font-size: 0.7rem; + color: var(--text-subtle); + white-space: nowrap; +} + /* ── 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 +275,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 +294,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; } From e40a72af0bdce0ec4eb5a8a02f29b4b7a4fe883b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:23:34 +0000 Subject: [PATCH 3/6] Fix createDayGroup: use dayData.date directly, drop unused params Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/42ce514b-68cb-4ff0-979e-61a06c44ac02 Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- app/components/historyView.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/components/historyView.js b/app/components/historyView.js index 6c409a4..35d1edb 100644 --- a/app/components/historyView.js +++ b/app/components/historyView.js @@ -210,14 +210,12 @@ export function createTotalPlayTimeChart(summaryData) { * Build the DOM for a single day-column in the per-game bar chart. * * @param {object} dayData - Summary entry for one day. - * @param {number} dayIndex - Index within summaryData (used for label). * @param {string[]} gameIds - Game IDs to render bars for. * @param {number} maxMs - Maximum total ms across all days (for scaling). - * @param {Array<{date: string, total: number}>} summaryData - Full summary array. * @param {Array<{id: string, name: string}>} [manifests] - Manifest list for names. * @returns {HTMLElement} A `.history-chart__group` element. */ -function createDayGroup(dayData, dayIndex, gameIds, maxMs, summaryData, manifests) { +function createDayGroup(dayData, gameIds, maxMs, manifests) { const group = document.createElement('div'); group.className = 'history-chart__group'; @@ -246,7 +244,7 @@ function createDayGroup(dayData, dayIndex, gameIds, maxMs, summaryData, manifest const dateLabel = document.createElement('span'); dateLabel.className = 'history-chart__label'; - dateLabel.textContent = summaryData[dayIndex].date.slice(5); // Display as MM-DD. + dateLabel.textContent = dayData.date.slice(5); // Display as MM-DD. group.appendChild(barsWrap); group.appendChild(dateLabel); @@ -281,8 +279,8 @@ export function createBarChart(summaryData, gameIds, manifests) { const olderGrid = document.createElement('div'); olderGrid.className = 'history-chart__grid'; olderGrid.hidden = true; - olderData.forEach((dayData, idx) => { - olderGrid.appendChild(createDayGroup(dayData, idx, gameIds, maxMs, olderData, manifests)); + olderData.forEach((dayData) => { + olderGrid.appendChild(createDayGroup(dayData, gameIds, maxMs, manifests)); }); chartEl.appendChild(olderGrid); @@ -303,8 +301,8 @@ export function createBarChart(summaryData, gameIds, manifests) { // Grid for the most-recent days (always visible). const recentGrid = document.createElement('div'); recentGrid.className = 'history-chart__grid'; - recentData.forEach((dayData, idx) => { - recentGrid.appendChild(createDayGroup(dayData, idx, gameIds, maxMs, recentData, manifests)); + recentData.forEach((dayData) => { + recentGrid.appendChild(createDayGroup(dayData, gameIds, maxMs, manifests)); }); chartEl.appendChild(recentGrid); From cbdd2102065c5bf666939614a5354dcefb65b087 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:25:21 +0000 Subject: [PATCH 4/6] Address review: guard empty summaryData, improve aria comment, add test Agent-Logs-Url: https://github.com/acrosman/BrainSpeedExercises/sessions/42ce514b-68cb-4ff0-979e-61a06c44ac02 Co-authored-by: acrosman <2972053+acrosman@users.noreply.github.com> --- app/components/historyView.js | 12 +++++++++--- app/components/tests/historyView.test.js | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/components/historyView.js b/app/components/historyView.js index 35d1edb..3885128 100644 --- a/app/components/historyView.js +++ b/app/components/historyView.js @@ -169,17 +169,23 @@ export function createDataTable(summaryData, gameIds, manifests) { * @returns {HTMLElement} A
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 -