Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 91 additions & 23 deletions app/components/historyView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 <div> element containing the total play-time line chart.
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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}`;
Expand All @@ -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;
}
Expand All @@ -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.
Expand All @@ -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);

Expand All @@ -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);

Expand Down
78 changes: 76 additions & 2 deletions app/components/tests/historyView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
createTotalPlayTimeChart,
buildHistoryPanel,
INITIAL_VISIBLE_DAYS,
MAX_X_LABELS,
} from '../historyView.js';

// ── Test fixtures ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
40 changes: 39 additions & 1 deletion app/styles/history.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading