diff --git a/app/components/historyView.js b/app/components/historyView.js
index 25a6306..4c232e8 100644
--- a/app/components/historyView.js
+++ b/app/components/historyView.js
@@ -30,6 +30,14 @@ const COLOR_SLOT_COUNT = 10;
*/
export const INITIAL_VISIBLE_DAYS = 6;
+/**
+ * Maximum number of x-axis date labels shown on the total play-time line chart.
+ * When there are more data points than this, labels are thinned so they remain readable.
+ *
+ * @type {number}
+ */
+export const MAX_X_LABELS = 10;
+
/**
* Extract all unique YYYY-MM-DD date keys present across all games' dailyTime maps.
*
@@ -163,7 +171,8 @@ export function createDataTable(summaryData, gameIds, manifests) {
* Create a total play-time line chart showing daily totals across all games.
*
* 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.
+ * giving a quick at-a-glance trend of overall activity. X-axis is labeled with
+ * MM-DD dates, thinned to at most {@link MAX_X_LABELS} labels to avoid crowding.
*
* @param {Array<{date: string, total: number}>} summaryData - Per-day totals.
* @returns {HTMLElement} A
element containing the total play-time line chart.
@@ -188,7 +197,7 @@ export function createTotalPlayTimeChart(summaryData) {
const svgW = 600;
const svgH = 120;
const pad = {
- top: 10, right: 20, bottom: 28, left: 10,
+ top: 10, right: 20, bottom: 28, left: 52,
};
const plotW = svgW - pad.left - pad.right;
const plotH = svgH - pad.top - pad.bottom;
@@ -200,6 +209,28 @@ export function createTotalPlayTimeChart(summaryData) {
svg.setAttribute('class', 'history-total-chart__svg');
svg.setAttribute('role', 'img');
+ // Y-axis gridlines and tick labels at 0%, 50%, and 100% of scale.
+ [1, 0.5, 0].forEach((fraction) => {
+ const yPos = pad.top + plotH - Math.round(fraction * plotH);
+ const timeMs = Math.round(fraction * maxMs);
+
+ const gridLine = document.createElementNS(SVG_NS, 'line');
+ gridLine.setAttribute('x1', String(pad.left));
+ gridLine.setAttribute('y1', String(yPos));
+ gridLine.setAttribute('x2', String(svgW - pad.right));
+ gridLine.setAttribute('y2', String(yPos));
+ gridLine.setAttribute('class', 'history-total-chart__grid-line');
+ svg.appendChild(gridLine);
+
+ const yLabel = document.createElementNS(SVG_NS, 'text');
+ yLabel.setAttribute('x', String(pad.left - 4));
+ yLabel.setAttribute('y', String(yPos));
+ yLabel.setAttribute('dominant-baseline', 'middle');
+ yLabel.setAttribute('class', 'history-total-chart__y-label');
+ yLabel.textContent = formatDuration(timeMs);
+ svg.appendChild(yLabel);
+ });
+
// 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);
@@ -216,7 +247,9 @@ export function createTotalPlayTimeChart(summaryData) {
svg.appendChild(polyline);
// Dot and date label for each point.
- points.forEach((p) => {
+ // Thin x-axis labels so they remain readable when there are many data points.
+ const labelStep = Math.max(1, Math.ceil(n / MAX_X_LABELS));
+ points.forEach((p, i) => {
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', p.x);
circle.setAttribute('cy', p.y);
@@ -228,38 +261,75 @@ export function createTotalPlayTimeChart(summaryData) {
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);
+ if (i % labelStep === 0) {
+ 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;
}
+/**
+ * Create a y-axis element for the bar chart showing time-scale labels.
+ *
+ * Renders tick labels at the top (maximum value), middle (half of maximum),
+ * and bottom (zero) of the bar area so readers can interpret bar heights.
+ *
+ * @private
+ * @param {number} maxMs - Maximum milliseconds shown at the top of the scale.
+ * @returns {HTMLElement} A div element serving as the y-axis scale indicator.
+ */
+function createBarChartYAxis(maxMs) {
+ const yAxis = document.createElement('div');
+ yAxis.className = 'history-chart__y-axis';
+
+ [maxMs, Math.round(maxMs / 2), 0].forEach((ms) => {
+ const tick = document.createElement('span');
+ tick.className = 'history-chart__y-tick';
+ tick.textContent = formatDuration(ms);
+ yAxis.appendChild(tick);
+ });
+
+ return yAxis;
+}
+
/**
* Build the DOM for a single day-column in the per-game bar chart.
*
+ * Each day group scales its bars and y-axis to its own total play time, so
+ * the y-axis labels are meaningful for the specific day being displayed.
+ *
* @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) {
+function createDayGroup(dayData, gameIds, manifests) {
+ // Scale this group to its own total so bars fill the available height and
+ // the y-axis tick labels reflect this day's actual values.
+ const dayMaxMs = Math.max(dayData.total, 1);
+
const group = document.createElement('div');
group.className = 'history-chart__group';
+ // Body: y-axis on the left, bars on the right.
+ const groupBody = document.createElement('div');
+ groupBody.className = 'history-chart__group-body';
+ groupBody.appendChild(createBarChartYAxis(dayMaxMs));
+
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 heightPct = Math.round((ms / dayMaxMs) * 100);
const bar = document.createElement('div');
const colorIndex = colIndex % COLOR_SLOT_COUNT;
bar.className = `history-chart__bar history-chart__bar--color-${colorIndex}`;
@@ -268,20 +338,20 @@ function createDayGroup(dayData, gameIds, maxMs, manifests) {
barsWrap.appendChild(bar);
});
- // Total bar (grey).
- const totalMs = dayData.total;
- const totalPct = Math.round((totalMs / maxMs) * 100);
+ // Total bar (grey) — always 100% height since dayMaxMs === dayData.total.
const totalBar = document.createElement('div');
totalBar.className = 'history-chart__bar history-chart__bar--total';
- totalBar.style.height = `${totalPct}%`;
- totalBar.title = `Total: ${formatDuration(totalMs)}`;
+ totalBar.style.height = '100%';
+ totalBar.title = `Total: ${formatDuration(dayData.total)}`;
barsWrap.appendChild(totalBar);
+ groupBody.appendChild(barsWrap);
+
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(groupBody);
group.appendChild(dateLabel);
return group;
}
@@ -298,8 +368,6 @@ function createDayGroup(dayData, gameIds, maxMs, manifests) {
* @returns {HTMLElement}
*/
export function createBarChart(summaryData, gameIds, manifests) {
- const maxMs = Math.max(...summaryData.map((d) => d.total), 1);
-
const chartEl = document.createElement('div');
chartEl.className = 'history-chart';
chartEl.setAttribute('aria-hidden', 'true'); // Table is the accessible version.
@@ -316,7 +384,7 @@ export function createBarChart(summaryData, gameIds, manifests) {
const recentGrid = document.createElement('div');
recentGrid.className = 'history-chart__grid';
recentData.forEach((dayData) => {
- recentGrid.appendChild(createDayGroup(dayData, gameIds, maxMs, manifests));
+ recentGrid.appendChild(createDayGroup(dayData, gameIds, manifests));
});
chartEl.appendChild(recentGrid);
@@ -326,7 +394,7 @@ export function createBarChart(summaryData, gameIds, manifests) {
olderGrid.className = 'history-chart__grid';
olderGrid.hidden = true;
olderData.forEach((dayData) => {
- olderGrid.appendChild(createDayGroup(dayData, gameIds, maxMs, manifests));
+ olderGrid.appendChild(createDayGroup(dayData, gameIds, manifests));
});
chartEl.appendChild(olderGrid);
diff --git a/app/components/tests/historyView.test.js b/app/components/tests/historyView.test.js
index f610c39..c0e5c3d 100644
--- a/app/components/tests/historyView.test.js
+++ b/app/components/tests/historyView.test.js
@@ -19,6 +19,7 @@ import {
createTotalPlayTimeChart,
buildHistoryPanel,
INITIAL_VISIBLE_DAYS,
+ MAX_X_LABELS,
} from '../historyView.js';
// ── Test fixtures ─────────────────────────────────────────────────────────────
@@ -307,9 +308,38 @@ describe('createBarChart()', () => {
const btn = chart.querySelector('.history-chart__show-more-btn');
expect(btn).toBeNull();
});
-});
-// ── createBarChart show-more ──────────────────────────────────────────────────
+ it('includes a y-axis element', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const yAxis = chart.querySelector('.history-chart__y-axis');
+ expect(yAxis).not.toBeNull();
+ });
+
+ it('y-axis contains three tick labels per day group', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const ticks = chart.querySelectorAll('.history-chart__y-tick');
+ // 3 ticks per group × number of days in summaryData
+ expect(ticks.length).toBe(summaryData.length * 3);
+ });
+
+ it('y-axis bottom tick label shows 00:00', () => {
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const ticks = [...chart.querySelectorAll('.history-chart__y-tick')];
+ expect(ticks[ticks.length - 1].textContent).toBe('00:00');
+ });
+
+ it('each day group y-axis top tick reflects that day\'s own total time', () => {
+ // summaryData uses dates ['2024-01-01', '2024-01-02'].
+ // 2024-01-01: game-a=60000 + game-b=30000 = total 90000 ms → '01:30'
+ // 2024-01-02: game-a=120000 + game-b=0 = total 120000 ms → '02:00'
+ // Groups are rendered newest-first: [2024-01-02, 2024-01-01].
+ const chart = createBarChart(summaryData, gameIds, MANIFESTS);
+ const groups = [...chart.querySelectorAll('.history-chart__group')];
+ const topTickOf = (group) => group.querySelector('.history-chart__y-tick').textContent;
+ expect(topTickOf(groups[0])).toBe('02:00'); // 2024-01-02 total = 120000 ms
+ expect(topTickOf(groups[1])).toBe('01:30'); // 2024-01-01 total = 90000 ms
+ });
+});
describe('createBarChart() show-more behaviour', () => {
const dates = getAllDates(PROGRESS_MANY_DAYS);
@@ -429,6 +459,50 @@ describe('createTotalPlayTimeChart()', () => {
expect(labels[0].textContent).toBe('01-01');
});
+ it('shows all x-axis labels when data points are within MAX_X_LABELS', () => {
+ // summaryData has 3 points, well under MAX_X_LABELS (10)
+ const chart = createTotalPlayTimeChart(summaryData);
+ const labels = chart.querySelectorAll('.history-total-chart__x-label');
+ expect(labels.length).toBe(dates.length);
+ });
+
+ it('thins x-axis labels to at most MAX_X_LABELS when there are many data points', () => {
+ // Build summaryData with MAX_X_LABELS + 5 data points to trigger thinning.
+ const manyDates = Array.from({ length: MAX_X_LABELS + 5 }, (_, i) => {
+ const d = new Date(2024, 0, i + 1);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ });
+ const manyData = manyDates.map((date) => ({ date, total: 60000 }));
+ const chart = createTotalPlayTimeChart(manyData);
+ const labels = chart.querySelectorAll('.history-total-chart__x-label');
+ expect(labels.length).toBeLessThanOrEqual(MAX_X_LABELS);
+ });
+
+ it('SVG contains three y-axis labels at 0%, 50%, and 100% of scale', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const yLabels = chart.querySelectorAll('.history-total-chart__y-label');
+ expect(yLabels.length).toBe(3);
+ });
+
+ it('y-axis top label shows the maximum total time', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const yLabels = [...chart.querySelectorAll('.history-total-chart__y-label')];
+ // summaryData max total: 2024-01-02 has 120000 ms for game-a alone → "02:00"
+ expect(yLabels[0].textContent).toBe('02:00');
+ });
+
+ it('y-axis bottom label shows 00:00', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const yLabels = [...chart.querySelectorAll('.history-total-chart__y-label')];
+ expect(yLabels[yLabels.length - 1].textContent).toBe('00:00');
+ });
+
+ it('SVG contains three horizontal grid lines', () => {
+ const chart = createTotalPlayTimeChart(summaryData);
+ const gridLines = chart.querySelectorAll('.history-total-chart__grid-line');
+ expect(gridLines.length).toBe(3);
+ });
+
it('includes a title paragraph', () => {
const chart = createTotalPlayTimeChart(summaryData);
const title = chart.querySelector('.history-total-chart__title');
diff --git a/app/styles/history.css b/app/styles/history.css
index af377d4..1fcb668 100644
--- a/app/styles/history.css
+++ b/app/styles/history.css
@@ -205,6 +205,18 @@
fill: var(--text-subtle);
}
+.history-total-chart__grid-line {
+ stroke: var(--border-color);
+ stroke-width: 1;
+ stroke-dasharray: 3 3;
+}
+
+.history-total-chart__y-label {
+ font-size: 10px;
+ fill: var(--text-subtle);
+ text-anchor: end;
+}
+
/* ── History bar chart ───────────────────────────────────────────────────── */
.history-chart {
@@ -253,7 +265,33 @@
flex-direction: column;
align-items: center;
gap: 0.25rem;
- min-width: 60px;
+}
+
+/* Flex row: per-group y-axis scale on the left, bars on the right. */
+.history-chart__group-body {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+}
+
+/* Per-group y-axis scale indicator (one per day cell). */
+.history-chart__y-axis {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ align-items: flex-end;
+ height: 160px; /* Must match .history-chart__bars height. */
+ padding-right: 0.25rem;
+ border-right: 1px solid var(--border-color);
+ flex-shrink: 0;
+ min-width: 1.75rem;
+}
+
+.history-chart__y-tick {
+ font-size: 0.6rem;
+ color: var(--text-subtle);
+ white-space: nowrap;
+ line-height: 1;
}
.history-chart__bar {
diff --git a/package-lock.json b/package-lock.json
index 287fef3..38d62f9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "brain-speed-exercises",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "brain-speed-exercises",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "MIT",
"dependencies": {
"electron-debug": "^4",