feat(player-stats): local listening history tab with heatmap and summaries (#849)

* feat(player-stats): local listening history tab with heatmap and summaries

Record play sessions in library.sqlite when the library index is enabled,
add Rust read APIs and Tauri commands for year/day aggregates, and ship the
Player stats UI with session clustering, event-driven live refresh, and a
notice when some servers are excluded from indexing.

* test(player-stats): split play_session repo and expand test coverage

Move the repository into play_session/ (completion, cluster, integration tests),
add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh
hooks on the frontend per spec v0.3.

* docs(release): CHANGELOG and credits for player stats (PR #849)

* fix(player-stats): satisfy tsc and clippy CI gates

Use InternetRadioStation field names in the radio skip test and replace
manual month/day range checks with RangeInclusive::contains.
This commit is contained in:
cucadmuh
2026-05-22 14:07:38 +03:00
committed by GitHub
parent 7afddf7b84
commit 23f7ba02d6
49 changed files with 3059 additions and 19 deletions
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { formatPlayerStatsDayLabel } from './formatPlayerStatsDay';
const t = (key: string) => {
if (key === 'statistics.playerDayToday') return 'Today';
if (key === 'statistics.playerDayYesterday') return 'Yesterday';
return key;
};
describe('formatPlayerStatsDayLabel', () => {
it('labels today and yesterday', () => {
const today = new Date();
const y = today.getFullYear();
const m = String(today.getMonth() + 1).padStart(2, '0');
const d = String(today.getDate()).padStart(2, '0');
expect(formatPlayerStatsDayLabel(`${y}-${m}-${d}`, t, 'en')).toBe('Today');
const yest = new Date(today);
yest.setDate(yest.getDate() - 1);
const ym = String(yest.getMonth() + 1).padStart(2, '0');
const yd = String(yest.getDate()).padStart(2, '0');
expect(formatPlayerStatsDayLabel(`${yest.getFullYear()}-${ym}-${yd}`, t, 'en')).toBe('Yesterday');
});
});
@@ -0,0 +1,41 @@
export const PLAYER_STATS_RECENT_DAYS_LIMIT = 30;
export function localTodayIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function parseLocalDate(dateIso: string): Date {
const [y, m, d] = dateIso.split('-').map(Number);
return new Date(y, m - 1, d);
}
function startOfLocalDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
/** Human-readable day header for recent-days accordion. */
export function formatPlayerStatsDayLabel(
dateIso: string,
t: (key: string, opts?: Record<string, unknown>) => string,
locale?: string,
): string {
const date = parseLocalDate(dateIso);
const today = startOfLocalDay(new Date());
const day = startOfLocalDay(date);
const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000);
if (diffDays === 0) return t('statistics.playerDayToday');
if (diffDays === 1) return t('statistics.playerDayYesterday');
const fmt = new Intl.DateTimeFormat(locale, {
weekday: 'long',
month: 'short',
day: 'numeric',
year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined,
});
return fmt.format(date);
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { heatmapCellMetrics } from './heatmapLevels';
describe('heatmapCellMetrics', () => {
it('shrinks cells to fit narrow containers', () => {
const m = heatmapCellMetrics(400, 53);
expect(m.cell).toBeLessThan(14);
expect(m.cell).toBeGreaterThanOrEqual(4);
const total = m.labelW + m.bodyGap + 53 * m.cell + 52 * m.gap;
expect(total).toBeLessThanOrEqual(400 + 1);
});
it('caps at 14px on wide containers', () => {
const m = heatmapCellMetrics(1200, 53);
expect(m.cell).toBe(14);
});
});
+110
View File
@@ -0,0 +1,110 @@
/** GitHub-style intensity bucket from track play count vs year max. */
export function heatmapLevel(count: number, maxCount: number): 0 | 1 | 2 | 3 | 4 {
if (count <= 0 || maxCount <= 0) return 0;
const ratio = count / maxCount;
if (ratio >= 0.75) return 4;
if (ratio >= 0.5) return 3;
if (ratio >= 0.25) return 2;
return 1;
}
export const HEATMAP_LEVEL_COUNT = 5;
export type HeatmapCell = { date: string; count: number };
export function yearDayKeys(year: number): string[] {
const out: string[] = [];
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
out.push(`${y}-${m}-${day}`);
}
return out;
}
/** Week columns (Sun→Sat rows) with leading/trailing padding cells. */
export function heatmapWeekColumns(year: number, dayCounts: Map<string, number>): {
weeks: HeatmapCell[][];
maxCount: number;
} {
const days = yearDayKeys(year);
const maxCount = Math.max(0, ...days.map(d => dayCounts.get(d) ?? 0));
const firstDow = new Date(year, 0, 1).getDay();
const cells: HeatmapCell[] = [];
for (let i = 0; i < firstDow; i++) {
cells.push({ date: '', count: 0 });
}
for (const date of days) {
cells.push({ date, count: dayCounts.get(date) ?? 0 });
}
while (cells.length % 7 !== 0) {
cells.push({ date: '', count: 0 });
}
const weeks: HeatmapCell[][] = [];
for (let i = 0; i < cells.length; i += 7) {
weeks.push(cells.slice(i, i + 7));
}
return { weeks, maxCount };
}
/** Month labels aligned to week columns (first day of each month). */
export function heatmapMonthLabels(
year: number,
locale?: string,
): { columnIndex: number; label: string }[] {
const fmt = new Intl.DateTimeFormat(locale, { month: 'short' });
const firstDow = new Date(year, 0, 1).getDay();
const labels: { columnIndex: number; label: string }[] = [];
let lastMonth = -1;
const yearStart = new Date(year, 0, 1).getTime();
const end = new Date(year, 11, 31);
for (let d = new Date(year, 0, 1); d <= end; d.setDate(d.getDate() + 1)) {
const month = d.getMonth();
if (month === lastMonth) continue;
const dayOfYear = Math.floor((d.getTime() - yearStart) / 86_400_000);
labels.push({
columnIndex: Math.floor((firstDow + dayOfYear) / 7),
label: fmt.format(new Date(year, month, 1)),
});
lastMonth = month;
}
return labels;
}
/** Weekday row labels (SunSat); empty string hides a row label like GitHub. */
export function heatmapWeekdayLabels(locale?: string): string[] {
const fmt = new Intl.DateTimeFormat(locale, { weekday: 'narrow' });
// Jan 4 2026 is Sunday — anchor week for stable weekday order.
const anchor = new Date(2026, 0, 4);
return Array.from({ length: 7 }, (_, i) => {
if (i % 2 === 0) return '';
const d = new Date(anchor);
d.setDate(anchor.getDate() + i);
return fmt.format(d);
});
}
const HEATMAP_LABEL_W = 24;
const HEATMAP_BODY_GAP = 8;
const HEATMAP_CELL_MIN = 4;
const HEATMAP_CELL_MAX = 14;
/** Fit square cells to the heatmap container width (week columns only). */
export function heatmapCellMetrics(
containerWidth: number,
weekCount: number,
): { cell: number; gap: number; labelW: number; bodyGap: number } {
const gap = containerWidth > 640 ? 3 : 2;
const available = containerWidth - HEATMAP_LABEL_W - HEATMAP_BODY_GAP;
if (available <= 0 || weekCount <= 0) {
return { cell: HEATMAP_CELL_MIN, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
}
const cell = Math.min(
HEATMAP_CELL_MAX,
Math.max(HEATMAP_CELL_MIN, (available - (weekCount - 1) * gap) / weekCount),
);
return { cell, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
}