mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -5,10 +5,12 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
vi.mock('../../i18n', () => ({
|
||||
default: {
|
||||
t: (key: string, params: Record<string, unknown>) => `${key}|${JSON.stringify(params)}`,
|
||||
resolvedLanguage: 'en',
|
||||
language: 'en',
|
||||
},
|
||||
}));
|
||||
|
||||
import { formatHumanHoursMinutes } from './formatHumanDuration';
|
||||
import { formatHumanHoursMinutes, formatPlayerStatsListeningTotal, formatPlayerStatsListenedSec } from './formatHumanDuration';
|
||||
|
||||
describe('formatHumanHoursMinutes', () => {
|
||||
it('rounds to the nearest minute instead of truncating', () => {
|
||||
@@ -33,3 +35,34 @@ describe('formatHumanHoursMinutes', () => {
|
||||
expect(formatHumanHoursMinutes(-5)).toBe('common.durationMinutesOnly|{"minutes":0}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPlayerStatsListeningTotal', () => {
|
||||
it('formats 25.5 hours as compact day hour minute parts', () => {
|
||||
expect(formatPlayerStatsListeningTotal(25.5 * 3600)).toBe(
|
||||
'statistics.playerListeningDayShort|{"count":1} statistics.playerListeningHourShort|{"count":1} statistics.playerListeningMinuteShort|{"count":30}',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows minutes only for sub-hour totals', () => {
|
||||
expect(formatPlayerStatsListeningTotal(45 * 60)).toBe(
|
||||
'statistics.playerListeningMinuteShort|{"count":45}',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits zero day and hour parts', () => {
|
||||
expect(formatPlayerStatsListeningTotal(2 * 24 * 3600)).toBe(
|
||||
'statistics.playerListeningDayShort|{"count":2}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPlayerStatsListenedSec', () => {
|
||||
it('uses seconds below one minute', () => {
|
||||
expect(formatPlayerStatsListenedSec(45.6)).toBe('statistics.playerListenedSecShort|{"seconds":46}');
|
||||
});
|
||||
|
||||
it('uses decimal minutes from one minute upward', () => {
|
||||
expect(formatPlayerStatsListenedSec(90)).toBe('statistics.playerListenedMinDecimal|{"minutes":"1.5"}');
|
||||
expect(formatPlayerStatsListenedSec(125)).toBe('statistics.playerListenedMinDecimal|{"minutes":"2.1"}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,3 +16,36 @@ export function formatHumanHoursMinutes(seconds: number): string {
|
||||
}
|
||||
return i18n.t('common.durationMinutesOnly', { minutes: totalMin });
|
||||
}
|
||||
|
||||
/** Player stats totals: compact days, hours, minutes (omit zero parts). */
|
||||
export function formatPlayerStatsListeningTotal(seconds: number): string {
|
||||
const totalMin = Math.max(0, Math.round(seconds / 60));
|
||||
const days = Math.floor(totalMin / 1440);
|
||||
const hours = Math.floor((totalMin % 1440) / 60);
|
||||
const minutes = totalMin % 60;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(i18n.t('statistics.playerListeningDayShort', { count: days }));
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(i18n.t('statistics.playerListeningHourShort', { count: hours }));
|
||||
}
|
||||
if (minutes > 0 || parts.length === 0) {
|
||||
parts.push(i18n.t('statistics.playerListeningMinuteShort', { count: minutes }));
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Per-track listened time in player stats drill-down. */
|
||||
export function formatPlayerStatsListenedSec(seconds: number): string {
|
||||
const sec = Math.max(0, seconds);
|
||||
if (sec >= 60) {
|
||||
const minutes = (sec / 60).toLocaleString(i18n.resolvedLanguage ?? i18n.language, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return i18n.t('statistics.playerListenedMinDecimal', { minutes });
|
||||
}
|
||||
return i18n.t('statistics.playerListenedSecShort', { seconds: Math.round(sec) });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (Sun–Sat); 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user