From 23f7ba02d6e3346950bca46f8c67eaaf570d61a7 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 22 May 2026 14:07:38 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 11 + .../migrations/006_play_session.sql | 22 + .../crates/psysonic-library/src/commands.rs | 56 ++- src-tauri/crates/psysonic-library/src/dto.rs | 88 ++++ .../crates/psysonic-library/src/repos/mod.rs | 2 + .../src/repos/play_session/cluster.rs | 52 +++ .../src/repos/play_session/completion.rs | 47 ++ .../src/repos/play_session/mod.rs | 349 ++++++++++++++ .../src/repos/play_session/tests.rs | 426 ++++++++++++++++++ .../psysonic-library/src/repos/track.rs | 1 + .../crates/psysonic-library/src/store.rs | 4 +- src-tauri/src/lib.rs | 6 + src/api/library.ts | 89 ++++ src/app/AppRoutes.tsx | 1 + .../statistics/PlayerStatisticsPanel.tsx | 163 +++++++ .../statistics/PlayerStatsDayTracks.tsx | 30 ++ .../statistics/PlayerStatsHeatmap.tsx | 141 ++++++ .../PlayerStatsPartialIndexNotice.tsx | 40 ++ .../statistics/PlayerStatsRecentDays.tsx | 197 ++++++++ .../statistics/StatisticsTabBar.tsx | 38 ++ src/config/settingsCredits.ts | 1 + .../tauriBridge/useMediaAndWindowBridge.ts | 5 + src/hooks/usePlayerStatsLiveRefresh.test.ts | 39 ++ src/hooks/usePlayerStatsLiveRefresh.ts | 32 ++ src/locales/de/statistics.ts | 29 ++ src/locales/en/statistics.ts | 29 ++ src/locales/es/statistics.ts | 29 ++ src/locales/fr/statistics.ts | 29 ++ src/locales/nb/statistics.ts | 29 ++ src/locales/nl/statistics.ts | 29 ++ src/locales/ro/statistics.ts | 29 ++ src/locales/ru/statistics.ts | 29 ++ src/locales/zh/statistics.ts | 29 ++ src/pages/Statistics.tsx | 14 +- src/store/audioEventHandlers.ts | 23 +- src/store/playListenSession.test.ts | 187 ++++++++ src/store/playListenSession.ts | 170 +++++++ src/store/playSessionRecorded.test.ts | 16 + src/store/playSessionRecorded.ts | 25 + src/store/playTrackAction.ts | 3 + src/store/queueMutationActions.ts | 2 + src/store/transportLightActions.ts | 2 + src/styles/components/statistics-page.css | 275 ++++++++++- src/utils/format/formatHumanDuration.test.ts | 35 +- src/utils/format/formatHumanDuration.ts | 33 ++ .../playerStats/formatPlayerStatsDay.test.ts | 24 + src/utils/playerStats/formatPlayerStatsDay.ts | 41 ++ src/utils/playerStats/heatmapLevels.test.ts | 17 + src/utils/playerStats/heatmapLevels.ts | 110 +++++ 49 files changed, 3059 insertions(+), 19 deletions(-) create mode 100644 src-tauri/crates/psysonic-library/migrations/006_play_session.sql create mode 100644 src-tauri/crates/psysonic-library/src/repos/play_session/cluster.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/play_session/completion.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs create mode 100644 src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs create mode 100644 src/components/statistics/PlayerStatisticsPanel.tsx create mode 100644 src/components/statistics/PlayerStatsDayTracks.tsx create mode 100644 src/components/statistics/PlayerStatsHeatmap.tsx create mode 100644 src/components/statistics/PlayerStatsPartialIndexNotice.tsx create mode 100644 src/components/statistics/PlayerStatsRecentDays.tsx create mode 100644 src/components/statistics/StatisticsTabBar.tsx create mode 100644 src/hooks/usePlayerStatsLiveRefresh.test.ts create mode 100644 src/hooks/usePlayerStatsLiveRefresh.ts create mode 100644 src/store/playListenSession.test.ts create mode 100644 src/store/playListenSession.ts create mode 100644 src/store/playSessionRecorded.test.ts create mode 100644 src/store/playSessionRecorded.ts create mode 100644 src/utils/playerStats/formatPlayerStatsDay.test.ts create mode 100644 src/utils/playerStats/formatPlayerStatsDay.ts create mode 100644 src/utils/playerStats/heatmapLevels.test.ts create mode 100644 src/utils/playerStats/heatmapLevels.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b472e1..af4266ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Added +### Player stats — local listening history + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#849](https://github.com/Psychotoxical/psysonic/pull/849)** + +* **Statistics → Player stats** tab (`/player-stats`): year summary (listening time, clustered sessions, track plays, unique tracks, listening days, full/partial counts), GitHub-style heatmap by track plays per day, recent-days accordion, and day drill-down with per-track completion. +* Records finalized listens to `play_session` in `library.sqlite` when the playback server's local index is enabled and ready (not preview/radio; `listenedSec > 10`). +* Live UI refresh after a persisted listen; partial-index notice when only some servers are indexed. +* i18n across 9 locales. + + + ### Servers — edit existing profiles **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)** diff --git a/src-tauri/crates/psysonic-library/migrations/006_play_session.sql b/src-tauri/crates/psysonic-library/migrations/006_play_session.sql new file mode 100644 index 00000000..e676851b --- /dev/null +++ b/src-tauri/crates/psysonic-library/migrations/006_play_session.sql @@ -0,0 +1,22 @@ +-- Player listening history — see workdocs player-stats spec §3.1 +CREATE TABLE play_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id TEXT NOT NULL, + track_id TEXT NOT NULL, + started_at_ms INTEGER NOT NULL, + listened_sec REAL NOT NULL, + position_max_sec REAL NOT NULL, + completion TEXT NOT NULL, + end_reason TEXT NOT NULL, + FOREIGN KEY (server_id, track_id) REFERENCES track(server_id, id), + CHECK (completion IN ('partial', 'full')) +); + +CREATE INDEX idx_play_session_server_time + ON play_session(server_id, started_at_ms DESC); + +CREATE INDEX idx_play_session_track + ON play_session(server_id, track_id, started_at_ms DESC); + +CREATE INDEX idx_play_session_started + ON play_session(started_at_ms DESC); diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 7e10307b..7766af63 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -20,12 +20,13 @@ use crate::dto::{ count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto, FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse, LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto, - LibraryTracksEnvelope, OfflinePathDto, PurgeReportDto, SyncJobDto, SyncStateDto, + LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto, + PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto, TrackArtifactDto, TrackFactDto, TrackRefDto, }; use crate::live_search; use crate::payload::LibrarySyncProgressPayload; -use crate::repos::{SyncStateRepository, TrackRepository}; +use crate::repos::{PlaySessionRepository, SyncStateRepository, TrackRepository}; use crate::runtime::{CurrentJob, LibraryRuntime, SyncSession}; use crate::search::search_tracks; use crate::store::LibraryStore; @@ -955,6 +956,53 @@ pub fn library_put_fact( crate::repos::FactRepository::new(&runtime.store).put(&server_id, &track_id, &fact, now_unix_ms()) } +#[tauri::command] +pub fn library_record_play_session( + runtime: State<'_, LibraryRuntime>, + input: PlaySessionInputDto, +) -> Result<(), String> { + PlaySessionRepository::new(&runtime.store).insert(&input) +} + +#[tauri::command] +pub fn library_get_player_stats_year_summary( + runtime: State<'_, LibraryRuntime>, + year: i32, +) -> Result { + PlaySessionRepository::new(&runtime.store).year_summary(year) +} + +#[tauri::command] +pub fn library_get_player_stats_heatmap( + runtime: State<'_, LibraryRuntime>, + year: i32, +) -> Result, String> { + PlaySessionRepository::new(&runtime.store).heatmap(year) +} + +#[tauri::command] +pub fn library_get_player_stats_day_detail( + runtime: State<'_, LibraryRuntime>, + date_iso: String, +) -> Result { + PlaySessionRepository::new(&runtime.store).day_detail(&date_iso) +} + +#[tauri::command] +pub fn library_get_player_stats_year_bounds( + runtime: State<'_, LibraryRuntime>, +) -> Result { + PlaySessionRepository::new(&runtime.store).year_bounds() +} + +#[tauri::command] +pub fn library_get_player_stats_recent_days( + runtime: State<'_, LibraryRuntime>, + limit: Option, +) -> Result, String> { + PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30)) +} + #[tauri::command] pub fn library_purge_server( runtime: State<'_, LibraryRuntime>, @@ -1013,6 +1061,10 @@ pub fn library_purge_server( "DELETE FROM track_id_history WHERE server_id = ?1", params![server_id], )?; + tx.execute( + "DELETE FROM play_session WHERE server_id = ?1", + params![server_id], + )?; tx.execute( "DELETE FROM track WHERE server_id = ?1", params![server_id], diff --git a/src-tauri/crates/psysonic-library/src/dto.rs b/src-tauri/crates/psysonic-library/src/dto.rs index 377672bc..f7041a54 100644 --- a/src-tauri/crates/psysonic-library/src/dto.rs +++ b/src-tauri/crates/psysonic-library/src/dto.rs @@ -281,6 +281,94 @@ fn default_confidence() -> f64 { 1.0 } +/// Input to `library_record_play_session`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionInputDto { + pub server_id: String, + pub track_id: String, + pub started_at_ms: i64, + pub listened_sec: f64, + pub position_max_sec: f64, + pub end_reason: String, + /// Player-known duration when `track.duration_sec` in the index is missing/zero. + #[serde(default)] + pub duration_sec_hint: Option, +} + +/// Cross-server year summary for the Player stats tab. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionYearSummaryDto { + pub total_listened_sec: f64, + /// Listening sessions (plays clustered by idle gap). + pub session_count: u32, + /// Individual track plays (`COUNT(*)`). + pub track_play_count: u32, + /// Distinct tracks heard at least once in the year. + pub unique_track_count: u32, + /// Calendar days with at least one recorded play. + pub listening_day_count: u32, + pub full_count: u32, + pub partial_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionHeatmapDayDto { + pub date: String, + pub track_play_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionDayTotalsDto { + pub total_listened_sec: f64, + pub session_count: u32, + pub track_play_count: u32, + pub full_count: u32, + pub partial_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionDayTrackDto { + pub server_id: String, + pub track_id: String, + pub title: String, + pub artist: Option, + pub listened_sec: f64, + pub completion: String, + pub started_at_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionDayDetailDto { + pub totals: PlaySessionDayTotalsDto, + pub tracks: Vec, +} + +/// Summary for one day in the recent-days list (no track rows). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionRecentDayDto { + pub date: String, + pub total_listened_sec: f64, + pub session_count: u32, + pub track_play_count: u32, + pub full_count: u32, + pub partial_count: u32, +} + +/// Earliest/latest calendar years with at least one session (local TZ). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PlaySessionYearBoundsDto { + pub min_year: Option, + pub max_year: Option, +} + /// `library_purge_server` outcome. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/crates/psysonic-library/src/repos/mod.rs b/src-tauri/crates/psysonic-library/src/repos/mod.rs index 383edded..6da4ab7f 100644 --- a/src-tauri/crates/psysonic-library/src/repos/mod.rs +++ b/src-tauri/crates/psysonic-library/src/repos/mod.rs @@ -1,11 +1,13 @@ pub mod artifact; pub mod fact; +pub mod play_session; pub mod sync_state; pub mod track; pub mod track_id_history; pub use artifact::ArtifactRepository; pub use fact::FactRepository; +pub use play_session::PlaySessionRepository; pub use sync_state::SyncStateRepository; pub use track::{RemapEntry, RemapStats, TrackRepository, TrackRow}; pub use track_id_history::TrackIdHistoryRepository; diff --git a/src-tauri/crates/psysonic-library/src/repos/play_session/cluster.rs b/src-tauri/crates/psysonic-library/src/repos/play_session/cluster.rs new file mode 100644 index 00000000..e2621cb6 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/play_session/cluster.rs @@ -0,0 +1,52 @@ +/// Idle gap after which the next play starts a new listening session. +pub(crate) const LISTENING_SESSION_GAP_MS: i64 = 30 * 60 * 1000; + +#[derive(Clone, Copy)] +pub(crate) struct PlaySpan { + pub started_at_ms: i64, + pub listened_sec: f64, +} + +fn play_end_ms(span: PlaySpan) -> i64 { + span.started_at_ms + (span.listened_sec * 1000.0) as i64 +} + +pub(crate) fn count_listening_sessions(plays: &[PlaySpan]) -> u32 { + if plays.is_empty() { + return 0; + } + let mut sorted = plays.to_vec(); + sorted.sort_by_key(|p| p.started_at_ms); + let mut sessions = 1u32; + let mut prev_end = play_end_ms(sorted[0]); + for span in sorted.iter().skip(1) { + if span.started_at_ms - prev_end > LISTENING_SESSION_GAP_MS { + sessions += 1; + } + prev_end = prev_end.max(play_end_ms(*span)); + } + sessions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clusters_by_thirty_minute_gap() { + let plays = vec![ + PlaySpan { started_at_ms: 0, listened_sec: 120.0 }, + PlaySpan { started_at_ms: 5 * 60 * 1000, listened_sec: 120.0 }, + PlaySpan { + started_at_ms: 45 * 60 * 1000, + listened_sec: 120.0, + }, + ]; + assert_eq!(count_listening_sessions(&plays), 2); + } + + #[test] + fn empty_plays_is_zero_sessions() { + assert_eq!(count_listening_sessions(&[]), 0); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/play_session/completion.rs b/src-tauri/crates/psysonic-library/src/repos/play_session/completion.rs new file mode 100644 index 00000000..dc3988b6 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/play_session/completion.rs @@ -0,0 +1,47 @@ +pub(crate) const MIN_LISTENED_SEC: f64 = 10.0; +pub(crate) const FULL_COMPLETION_RATIO: f64 = 0.70; + +pub(crate) fn effective_duration_sec(db_duration_sec: i64, hint: Option) -> i64 { + let hint = hint.filter(|d| *d > 0).unwrap_or(0); + if db_duration_sec > 0 && hint > 0 { + return db_duration_sec.max(hint); + } + if db_duration_sec > 0 { + return db_duration_sec; + } + hint +} + +pub(crate) fn completion_from_position(position_max_sec: f64, duration_sec: i64) -> &'static str { + if duration_sec <= 0 { + return "partial"; + } + if position_max_sec / duration_sec as f64 >= FULL_COMPLETION_RATIO { + "full" + } else { + "partial" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_threshold_at_seventy_percent() { + assert_eq!(completion_from_position(70.0, 100), "full"); + assert_eq!(completion_from_position(69.0, 100), "partial"); + } + + #[test] + fn zero_duration_is_always_partial() { + assert_eq!(completion_from_position(100.0, 0), "partial"); + } + + #[test] + fn effective_duration_prefers_max_of_db_and_hint() { + assert_eq!(effective_duration_sec(1, Some(300)), 300); + assert_eq!(effective_duration_sec(200, Some(100)), 200); + assert_eq!(effective_duration_sec(0, Some(240)), 240); + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs b/src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs new file mode 100644 index 00000000..e3765485 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/play_session/mod.rs @@ -0,0 +1,349 @@ +//! Append-only player listening sessions (`play_session`). + +mod cluster; +mod completion; + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; + +use rusqlite::{params, OptionalExtension}; + +use crate::dto::{ + PlaySessionDayDetailDto, PlaySessionDayTrackDto, PlaySessionDayTotalsDto, + PlaySessionHeatmapDayDto, PlaySessionInputDto, PlaySessionRecentDayDto, + PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, +}; +use crate::store::LibraryStore; + +use cluster::{count_listening_sessions, PlaySpan}; +use completion::{ + completion_from_position, effective_duration_sec, MIN_LISTENED_SEC, +}; + +struct DayAgg { + total_listened_sec: f64, + track_play_count: u32, + full_count: u32, + partial_count: u32, + plays: Vec, +} + +fn validate_date_iso(date_iso: &str) -> Result<(), String> { + if date_iso.len() != 10 || date_iso.as_bytes()[4] != b'-' || date_iso.as_bytes()[7] != b'-' { + return Err("dateIso must be YYYY-MM-DD".into()); + } + let year: i32 = date_iso[0..4] + .parse() + .map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?; + let month: u32 = date_iso[5..7] + .parse() + .map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?; + let day: u32 = date_iso[8..10] + .parse() + .map_err(|_| "dateIso must be YYYY-MM-DD".to_string())?; + if year < 1970 || !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return Err("dateIso must be YYYY-MM-DD".into()); + } + Ok(()) +} + +pub struct PlaySessionRepository<'a> { + store: &'a LibraryStore, +} + +impl<'a> PlaySessionRepository<'a> { + pub fn new(store: &'a LibraryStore) -> Self { + Self { store } + } + + /// Insert one finalized session. Rejects `listened_sec <= 10` and missing tracks. + pub fn insert(&self, input: &PlaySessionInputDto) -> Result<(), String> { + if !input.listened_sec.is_finite() || input.listened_sec <= MIN_LISTENED_SEC { + return Err(format!( + "listened_sec must be > {} (got {})", + MIN_LISTENED_SEC, input.listened_sec + )); + } + if !input.position_max_sec.is_finite() || input.position_max_sec < 0.0 { + return Err("position_max_sec must be finite and >= 0".into()); + } + if input.server_id.is_empty() || input.track_id.is_empty() { + return Err("server_id and track_id are required".into()); + } + + self.store + .with_conn("play_session.insert", |conn| { + let duration_sec: i64 = conn + .query_row( + "SELECT duration_sec FROM track \ + WHERE server_id = ?1 AND id = ?2 AND deleted = 0", + params![input.server_id, input.track_id], + |row| row.get(0), + ) + .optional()? + .ok_or(rusqlite::Error::QueryReturnedNoRows)?; + + let duration_for_completion = + effective_duration_sec(duration_sec, input.duration_sec_hint); + let completion = + completion_from_position(input.position_max_sec, duration_for_completion); + conn.execute( + "INSERT INTO play_session \ + (server_id, track_id, started_at_ms, listened_sec, position_max_sec, \ + completion, end_reason) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + input.server_id, + input.track_id, + input.started_at_ms, + input.listened_sec, + input.position_max_sec, + completion, + input.end_reason, + ], + )?; + Ok(()) + }) + .map_err(|e| e.to_string()) + } + + pub fn year_summary(&self, year: i32) -> Result { + let year_str = year.to_string(); + self.store + .with_read_conn(|conn| { + let totals = conn.query_row( + "SELECT \ + COALESCE(SUM(listened_sec), 0.0), \ + COUNT(*), \ + COUNT(DISTINCT server_id || ':' || track_id), \ + COUNT(DISTINCT date(started_at_ms / 1000, 'unixepoch', 'localtime')), \ + COALESCE(SUM(CASE WHEN completion = 'full' THEN 1 ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN completion = 'partial' THEN 1 ELSE 0 END), 0) \ + FROM play_session \ + WHERE strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') = ?1", + params![year_str], + |row| { + Ok(( + row.get::<_, f64>(0)?, + row.get::<_, i64>(1)? as u32, + row.get::<_, i64>(2)? as u32, + row.get::<_, i64>(3)? as u32, + row.get::<_, i64>(4)? as u32, + row.get::<_, i64>(5)? as u32, + )) + }, + )?; + + let mut stmt = conn.prepare( + "SELECT started_at_ms, listened_sec \ + FROM play_session \ + WHERE strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \ + ORDER BY started_at_ms ASC", + )?; + let plays = stmt + .query_map(params![year_str], |row| { + Ok(PlaySpan { + started_at_ms: row.get(0)?, + listened_sec: row.get(1)?, + }) + })? + .collect::>>()?; + + let ( + total_listened_sec, + track_play_count, + unique_track_count, + listening_day_count, + full_count, + partial_count, + ) = totals; + Ok(PlaySessionYearSummaryDto { + total_listened_sec, + session_count: count_listening_sessions(&plays), + track_play_count, + unique_track_count, + listening_day_count, + full_count, + partial_count, + }) + }) + .map_err(|e| e.to_string()) + } + + pub fn heatmap(&self, year: i32) -> Result, String> { + let year_str = year.to_string(); + self.store + .with_read_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT \ + date(started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \ + COUNT(*) AS n \ + FROM play_session \ + WHERE strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \ + GROUP BY d \ + ORDER BY d ASC", + )?; + let rows = stmt + .query_map(params![year_str], |row| { + Ok(PlaySessionHeatmapDayDto { + date: row.get(0)?, + track_play_count: row.get::<_, i64>(1)? as u32, + }) + })? + .collect::>>()?; + Ok(rows) + }) + .map_err(|e| e.to_string()) + } + + pub fn day_detail(&self, date_iso: &str) -> Result { + validate_date_iso(date_iso)?; + self.store + .with_read_conn(|conn| { + let totals_row = conn.query_row( + "SELECT \ + COALESCE(SUM(listened_sec), 0.0), \ + COUNT(*), \ + COALESCE(SUM(CASE WHEN completion = 'full' THEN 1 ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN completion = 'partial' THEN 1 ELSE 0 END), 0) \ + FROM play_session \ + WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = ?1", + params![date_iso], + |row| { + Ok(( + row.get::<_, f64>(0)?, + row.get::<_, i64>(1)? as u32, + row.get::<_, i64>(2)? as u32, + row.get::<_, i64>(3)? as u32, + )) + }, + )?; + + let mut stmt = conn.prepare( + "SELECT ps.server_id, ps.track_id, t.title, t.artist, \ + ps.listened_sec, ps.completion, ps.started_at_ms \ + FROM play_session ps \ + JOIN track t ON t.server_id = ps.server_id AND t.id = ps.track_id \ + WHERE date(ps.started_at_ms / 1000, 'unixepoch', 'localtime') = ?1 \ + ORDER BY ps.started_at_ms DESC", + )?; + let tracks = stmt + .query_map(params![date_iso], |row| { + Ok(PlaySessionDayTrackDto { + server_id: row.get(0)?, + track_id: row.get(1)?, + title: row.get(2)?, + artist: row.get(3)?, + listened_sec: row.get(4)?, + completion: row.get(5)?, + started_at_ms: row.get(6)?, + }) + })? + .collect::>>()?; + + let plays: Vec = tracks + .iter() + .map(|t| PlaySpan { + started_at_ms: t.started_at_ms, + listened_sec: t.listened_sec, + }) + .collect(); + let (total_listened_sec, track_play_count, full_count, partial_count) = totals_row; + let totals = PlaySessionDayTotalsDto { + total_listened_sec, + session_count: count_listening_sessions(&plays), + track_play_count, + full_count, + partial_count, + }; + + Ok(PlaySessionDayDetailDto { totals, tracks }) + }) + .map_err(|e| e.to_string()) + } + + /// Calendar years that contain at least one session (local TZ). + pub fn year_bounds(&self) -> Result { + self.store + .with_read_conn(|conn| { + conn.query_row( + "SELECT \ + MIN(CAST(strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER)), \ + MAX(CAST(strftime('%Y', started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER)) \ + FROM play_session", + [], + |row| { + Ok(PlaySessionYearBoundsDto { + min_year: row.get::<_, Option>(0)?.map(|y| y as i32), + max_year: row.get::<_, Option>(1)?.map(|y| y as i32), + }) + }, + ) + }) + .map_err(|e| e.to_string()) + } + + /// Most recent calendar days with sessions (newest first). + pub fn recent_days(&self, limit: u32) -> Result, String> { + let limit = limit.clamp(1, 90); + self.store + .with_read_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT \ + date(started_at_ms / 1000, 'unixepoch', 'localtime') AS d, \ + started_at_ms, listened_sec, completion \ + FROM play_session \ + ORDER BY d DESC, started_at_ms ASC", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, String>(3)?, + )) + })?; + + let mut by_day: HashMap = HashMap::new(); + for row in rows { + let (date, started_at_ms, listened_sec, completion) = row?; + let agg = by_day.entry(date).or_insert_with(|| DayAgg { + total_listened_sec: 0.0, + track_play_count: 0, + full_count: 0, + partial_count: 0, + plays: Vec::new(), + }); + agg.total_listened_sec += listened_sec; + agg.track_play_count += 1; + if completion == "full" { + agg.full_count += 1; + } else { + agg.partial_count += 1; + } + agg.plays.push(PlaySpan { + started_at_ms, + listened_sec, + }); + } + + let mut out: Vec = by_day + .into_iter() + .map(|(date, agg)| PlaySessionRecentDayDto { + date, + total_listened_sec: agg.total_listened_sec, + session_count: count_listening_sessions(&agg.plays), + track_play_count: agg.track_play_count, + full_count: agg.full_count, + partial_count: agg.partial_count, + }) + .collect(); + out.sort_by(|a, b| b.date.cmp(&a.date)); + out.truncate(limit as usize); + Ok(out) + }) + .map_err(|e| e.to_string()) + } +} diff --git a/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs b/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs new file mode 100644 index 00000000..b8c1f814 --- /dev/null +++ b/src-tauri/crates/psysonic-library/src/repos/play_session/tests.rs @@ -0,0 +1,426 @@ +use super::*; +use crate::dto::PlaySessionInputDto; +use crate::repos::{TrackRepository, TrackRow}; + +fn seed_track(store: &LibraryStore, server_id: &str, track_id: &str, duration_sec: i64) { + TrackRepository::new(store) + .upsert_batch(&[TrackRow { + server_id: server_id.into(), + id: track_id.into(), + title: "Test".into(), + title_sort: None, + artist: Some("Artist".into()), + artist_id: None, + album: "Album".into(), + album_id: None, + album_artist: None, + duration_sec, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: None, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: None, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + }]) + .expect("seed track"); +} + +fn row_with_id_hash(server: &str, id: &str, hash: &str, path: &str) -> TrackRow { + TrackRow { + server_id: server.into(), + id: id.into(), + title: "Title".into(), + title_sort: None, + artist: None, + artist_id: None, + album: "Album".into(), + album_id: None, + album_artist: None, + duration_sec: 200, + track_number: None, + disc_number: None, + year: None, + genre: None, + suffix: None, + bit_rate: None, + size_bytes: None, + cover_art_id: None, + starred_at: None, + user_rating: None, + play_count: None, + played_at: None, + server_path: if path.is_empty() { + None + } else { + Some(path.into()) + }, + library_id: None, + isrc: None, + mbid_recording: None, + bpm: None, + replay_gain_track_db: None, + replay_gain_album_db: None, + content_hash: if hash.is_empty() { + None + } else { + Some(hash.into()) + }, + server_updated_at: None, + server_created_at: None, + deleted: false, + synced_at: 1, + raw_json: "{}".into(), + } +} + +fn sample_input(server_id: &str, track_id: &str) -> PlaySessionInputDto { + PlaySessionInputDto { + server_id: server_id.into(), + track_id: track_id.into(), + started_at_ms: 1_000, + listened_sec: 20.0, + position_max_sec: 15.0, + end_reason: "ended".into(), + duration_sec_hint: None, + } +} + +fn purge_play_sessions_for_server(store: &LibraryStore, server_id: &str) { + store + .with_conn_mut("test.purge_play_session", |conn| { + conn.execute( + "DELETE FROM play_session WHERE server_id = ?1", + rusqlite::params![server_id], + )?; + Ok(()) + }) + .expect("purge play_session"); +} + +#[test] +fn insert_rejects_short_sessions() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 200); + let repo = PlaySessionRepository::new(&store); + let input = PlaySessionInputDto { + server_id: "s1".into(), + track_id: "t1".into(), + started_at_ms: 1_000, + listened_sec: 10.0, + position_max_sec: 50.0, + end_reason: "ended".into(), + duration_sec_hint: None, + }; + assert!(repo.insert(&input).is_err()); +} + +#[test] +fn insert_fails_when_track_missing() { + let store = LibraryStore::open_in_memory(); + let repo = PlaySessionRepository::new(&store); + assert!(repo.insert(&sample_input("s1", "missing")).is_err()); +} + +#[test] +fn insert_full_vs_partial_completion() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 100); + let repo = PlaySessionRepository::new(&store); + + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: "t1".into(), + started_at_ms: 1_000, + listened_sec: 80.0, + position_max_sec: 75.0, + end_reason: "ended".into(), + duration_sec_hint: None, + }) + .expect("insert full"); + + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: "t1".into(), + started_at_ms: 2_000, + listened_sec: 30.0, + position_max_sec: 40.0, + end_reason: "skip".into(), + duration_sec_hint: None, + }) + .expect("insert partial"); + + let summary = repo.year_summary(1970).expect("summary"); + assert_eq!(summary.track_play_count, 2); + assert_eq!(summary.session_count, 1); + assert_eq!(summary.unique_track_count, 1); + assert_eq!(summary.listening_day_count, 1); + assert_eq!(summary.full_count, 1); + assert_eq!(summary.partial_count, 1); +} + +#[test] +fn listening_sessions_cluster_by_idle_gap() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 200); + seed_track(&store, "s1", "t2", 200); + seed_track(&store, "s1", "t3", 200); + let repo = PlaySessionRepository::new(&store); + let base = 1_700_000_000_000_i64; + let insert = |offset_ms: i64, track_id: &str| { + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: track_id.into(), + started_at_ms: base + offset_ms, + listened_sec: 120.0, + position_max_sec: 100.0, + end_reason: "ended".into(), + duration_sec_hint: None, + }) + .expect("insert"); + }; + insert(0, "t1"); + insert(5 * 60 * 1000, "t2"); + insert(10 * 60 * 1000, "t3"); + insert(45 * 60 * 1000, "t1"); + + let year = repo + .year_bounds() + .expect("bounds") + .max_year + .expect("year with data"); + let summary = repo.year_summary(year).expect("summary"); + assert_eq!(summary.track_play_count, 4); + assert_eq!(summary.session_count, 2); + assert_eq!(summary.unique_track_count, 3); + assert_eq!(summary.listening_day_count, 1); + + let heat = repo.heatmap(year).expect("heatmap"); + assert_eq!(heat.len(), 1); + assert_eq!(heat[0].track_play_count, 4); + + let days = repo.recent_days(10).expect("recent"); + assert_eq!(days[0].track_play_count, 4); + assert_eq!(days[0].session_count, 2); +} + +#[test] +fn year_bounds_empty_and_populated() { + let store = LibraryStore::open_in_memory(); + let repo = PlaySessionRepository::new(&store); + let empty = repo.year_bounds().expect("empty bounds"); + assert_eq!(empty.min_year, None); + assert_eq!(empty.max_year, None); + + seed_track(&store, "s1", "t1", 200); + seed_track(&store, "s1", "t2", 200); + let insert = |started_at_ms: i64, track_id: &str| { + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: track_id.into(), + started_at_ms, + listened_sec: 20.0, + position_max_sec: 15.0, + end_reason: "ended".into(), + duration_sec_hint: None, + }) + .expect("insert"); + }; + insert(1_577_836_800_000, "t1"); + insert(1_609_459_200_000, "t2"); + + let bounds = repo.year_bounds().expect("bounds"); + assert_eq!(bounds.min_year, Some(2020)); + assert_eq!(bounds.max_year, Some(2021)); +} + +#[test] +fn recent_days_newest_first_with_limit() { + let store = LibraryStore::open_in_memory(); + let repo = PlaySessionRepository::new(&store); + seed_track(&store, "s1", "t1", 200); + seed_track(&store, "s1", "t2", 200); + let insert = |started_at_ms: i64, track_id: &str| { + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: track_id.into(), + started_at_ms, + listened_sec: 20.0, + position_max_sec: 15.0, + end_reason: "ended".into(), + duration_sec_hint: None, + }) + .expect("insert"); + }; + insert(1_577_836_800_000, "t1"); + insert(1_609_459_200_000, "t2"); + + let days = repo.recent_days(30).expect("recent"); + assert_eq!(days.len(), 2); + assert_eq!(days[0].date, "2021-01-01"); + assert_eq!(days[1].date, "2020-01-01"); + assert_eq!(days[0].session_count, 1); + assert_eq!(days[0].track_play_count, 1); +} + +#[test] +fn day_detail_rejects_invalid_date() { + let store = LibraryStore::open_in_memory(); + let repo = PlaySessionRepository::new(&store); + assert!(repo.day_detail("2025-13-40").is_err()); + assert!(repo.day_detail("not-a-date").is_err()); +} + +#[test] +fn zero_index_duration_uses_hint_and_stays_partial() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 0); + let repo = PlaySessionRepository::new(&store); + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: "t1".into(), + started_at_ms: 1_000, + listened_sec: 45.0, + position_max_sec: 40.0, + end_reason: "skip".into(), + duration_sec_hint: Some(300), + }) + .expect("insert"); + + let detail = repo.day_detail("1970-01-01").expect("detail"); + assert_eq!(detail.tracks[0].completion, "partial"); +} + +#[test] +fn zero_duration_without_hint_is_partial_not_full() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 0); + let repo = PlaySessionRepository::new(&store); + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: "t1".into(), + started_at_ms: 1_000, + listened_sec: 45.0, + position_max_sec: 40.0, + end_reason: "skip".into(), + duration_sec_hint: None, + }) + .expect("insert"); + + let detail = repo.day_detail("1970-01-01").expect("detail"); + assert_eq!(detail.tracks[0].completion, "partial"); +} + +#[test] +fn corrupt_short_db_duration_prefers_player_hint() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 1); + let repo = PlaySessionRepository::new(&store); + repo.insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: "t1".into(), + started_at_ms: 1_000, + listened_sec: 45.0, + position_max_sec: 40.0, + end_reason: "skip".into(), + duration_sec_hint: Some(300), + }) + .expect("insert"); + + let detail = repo.day_detail("1970-01-01").expect("detail"); + assert_eq!(detail.tracks[0].completion, "partial"); +} + +#[test] +fn remap_updates_play_session_track_id() { + let store = LibraryStore::open_in_memory(); + let track_repo = TrackRepository::new(&store); + track_repo + .upsert_batch(&[row_with_id_hash("s1", "tr_old", "deadbeef", "/music/a.flac")]) + .expect("seed old"); + + let play_repo = PlaySessionRepository::new(&store); + play_repo + .insert(&PlaySessionInputDto { + server_id: "s1".into(), + track_id: "tr_old".into(), + started_at_ms: 1_000, + listened_sec: 30.0, + position_max_sec: 20.0, + end_reason: "ended".into(), + duration_sec_hint: None, + }) + .expect("insert play"); + + let stats = track_repo + .upsert_batch_with_remap( + &[row_with_id_hash("s1", "tr_new", "deadbeef", "/music/a.flac")], + true, + ) + .expect("remap"); + assert_eq!(stats.remapped.len(), 1); + assert_eq!(stats.remapped[0].old_id, "tr_old"); + assert_eq!(stats.remapped[0].new_id, "tr_new"); + + let track_id: String = store + .with_conn("test.read_play_session", |conn| { + conn.query_row( + "SELECT track_id FROM play_session WHERE server_id = ?1", + rusqlite::params!["s1"], + |row| row.get(0), + ) + }) + .expect("read play_session"); + assert_eq!(track_id, "tr_new"); +} + +#[test] +fn purge_deletes_play_session_rows_for_server() { + let store = LibraryStore::open_in_memory(); + seed_track(&store, "s1", "t1", 200); + seed_track(&store, "s2", "t2", 200); + let repo = PlaySessionRepository::new(&store); + repo.insert(&sample_input("s1", "t1")).expect("s1 play"); + repo.insert(&sample_input("s2", "t2")).expect("s2 play"); + + purge_play_sessions_for_server(&store, "s1"); + + let s1_count: i64 = store + .with_conn("test.count_s1", |conn| { + conn.query_row( + "SELECT COUNT(*) FROM play_session WHERE server_id = ?1", + rusqlite::params!["s1"], + |row| row.get(0), + ) + }) + .expect("count s1"); + let s2_count: i64 = store + .with_conn("test.count_s2", |conn| { + conn.query_row( + "SELECT COUNT(*) FROM play_session WHERE server_id = ?1", + rusqlite::params!["s2"], + |row| row.get(0), + ) + }) + .expect("count s2"); + assert_eq!(s1_count, 0); + assert_eq!(s2_count, 1); +} diff --git a/src-tauri/crates/psysonic-library/src/repos/track.rs b/src-tauri/crates/psysonic-library/src/repos/track.rs index b1889b07..65709168 100644 --- a/src-tauri/crates/psysonic-library/src/repos/track.rs +++ b/src-tauri/crates/psysonic-library/src/repos/track.rs @@ -365,6 +365,7 @@ fn remap_existing_to_new( "track_fact", "track_artifact", "track_canonical_link", + "play_session", ] { tx.execute( &format!( diff --git a/src-tauri/crates/psysonic-library/src/store.rs b/src-tauri/crates/psysonic-library/src/store.rs index d6938b1c..b5aa7b4f 100644 --- a/src-tauri/crates/psysonic-library/src/store.rs +++ b/src-tauri/crates/psysonic-library/src/store.rs @@ -8,7 +8,7 @@ use tauri::Manager; /// Current head of the embedded migrations. Bump each time a new /// `migrations/NNN_*.sql` is added. -pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 5; +pub const LIBRARY_DB_SCHEMA_VERSION: i64 = 6; /// Lowest applied schema version the current code can advance from purely /// additively. If a DB carries a version below this, the breaking-bump hook @@ -29,6 +29,7 @@ const MIGRATION_004_TRACK_TITLE_INDEX: &str = include_str!("../migrations/004_track_title_index.sql"); const MIGRATION_005_TRACK_GENRE_YEAR_INDEXES: &str = include_str!("../migrations/005_track_genre_year_indexes.sql"); +const MIGRATION_006_PLAY_SESSION: &str = include_str!("../migrations/006_play_session.sql"); /// Embedded migrations. Ordered ascending by `version`; the runner sorts /// defensively before applying so the source order can stay readable. @@ -38,6 +39,7 @@ const MIGRATIONS: &[(i64, &str)] = &[ (3, MIGRATION_003_TRACK_REMAP_INDEXES), (4, MIGRATION_004_TRACK_TITLE_INDEX), (5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES), + (6, MIGRATION_006_PLAY_SESSION), ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e92746e8..4c496a69 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -594,6 +594,12 @@ pub fn run() { psysonic_library::commands::library_patch_track, psysonic_library::commands::library_put_artifact, psysonic_library::commands::library_put_fact, + psysonic_library::commands::library_record_play_session, + psysonic_library::commands::library_get_player_stats_year_summary, + psysonic_library::commands::library_get_player_stats_heatmap, + psysonic_library::commands::library_get_player_stats_day_detail, + psysonic_library::commands::library_get_player_stats_year_bounds, + psysonic_library::commands::library_get_player_stats_recent_days, psysonic_library::commands::library_purge_server, psysonic_library::commands::library_delete_server_data, psysonic_syncfs::cache::offline::download_track_offline, diff --git a/src/api/library.ts b/src/api/library.ts index f0b79bf8..cf6e8bcf 100644 --- a/src/api/library.ts +++ b/src/api/library.ts @@ -464,6 +464,95 @@ export function libraryDeleteServerData(serverId: string): Promise { return invoke('library_delete_server_data', { serverId }); } +// ── Player stats (local listening history) ──────────────────────────── + +export type PlaySessionEndReason = 'ended' | 'skip' | 'stop' | 'switch' | 'close'; + +export type PlaySessionInput = { + serverId: string; + trackId: string; + startedAtMs: number; + listenedSec: number; + positionMaxSec: number; + endReason: PlaySessionEndReason; + /** Player-known track duration when the library index has none. */ + durationSecHint?: number; +}; + +export type PlaySessionYearSummary = { + totalListenedSec: number; + sessionCount: number; + trackPlayCount: number; + uniqueTrackCount: number; + listeningDayCount: number; + fullCount: number; + partialCount: number; +}; + +export type PlaySessionHeatmapDay = { + date: string; + trackPlayCount: number; +}; + +export type PlaySessionDayTrack = { + serverId: string; + trackId: string; + title: string; + artist: string | null; + listenedSec: number; + completion: 'partial' | 'full' | string; + startedAtMs: number; +}; + +export type PlaySessionDayDetail = { + totals: { + totalListenedSec: number; + sessionCount: number; + trackPlayCount: number; + fullCount: number; + partialCount: number; + }; + tracks: PlaySessionDayTrack[]; +}; + +export type PlaySessionYearBounds = { + minYear: number | null; + maxYear: number | null; +}; + +export type PlaySessionRecentDay = { + date: string; + totalListenedSec: number; + sessionCount: number; + trackPlayCount: number; + fullCount: number; + partialCount: number; +}; + +export function libraryRecordPlaySession(input: PlaySessionInput): Promise { + return invoke('library_record_play_session', { input }); +} + +export function libraryGetPlayerStatsYearSummary(year: number): Promise { + return invoke('library_get_player_stats_year_summary', { year }); +} + +export function libraryGetPlayerStatsHeatmap(year: number): Promise { + return invoke('library_get_player_stats_heatmap', { year }); +} + +export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise { + return invoke('library_get_player_stats_day_detail', { dateIso }); +} + +export function libraryGetPlayerStatsYearBounds(): Promise { + return invoke('library_get_player_stats_year_bounds'); +} + +export function libraryGetPlayerStatsRecentDays(limit = 30): Promise { + return invoke('library_get_player_stats_recent_days', { limit }); +} + // ── Event subscriptions ─────────────────────────────────────────────── export interface LibrarySyncProgressPayload { diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 074267de..eba782b8 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -66,6 +66,7 @@ export default function AppRoutes() { } /> } /> } /> + } /> } /> } /> : } /> diff --git a/src/components/statistics/PlayerStatisticsPanel.tsx b/src/components/statistics/PlayerStatisticsPanel.tsx new file mode 100644 index 00000000..6d19502f --- /dev/null +++ b/src/components/statistics/PlayerStatisticsPanel.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + libraryGetPlayerStatsHeatmap, + libraryGetPlayerStatsYearBounds, + libraryGetPlayerStatsYearSummary, + type PlaySessionYearBounds, + type PlaySessionYearSummary, +} from '../../api/library'; +import { usePlayerStatsLiveRefresh } from '../../hooks/usePlayerStatsLiveRefresh'; +import PlayerStatsHeatmap from './PlayerStatsHeatmap'; +import PlayerStatsPartialIndexNotice from './PlayerStatsPartialIndexNotice'; +import PlayerStatsRecentDays from './PlayerStatsRecentDays'; +import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration'; + +const currentCalendarYear = () => new Date().getFullYear(); + +export default function PlayerStatisticsPanel() { + const { t } = useTranslation(); + const [year, setYear] = useState(currentCalendarYear); + const [yearBounds, setYearBounds] = useState(null); + const [summary, setSummary] = useState(null); + const [dayCounts, setDayCounts] = useState>(new Map()); + const [selectedDate, setSelectedDate] = useState(null); + const [loading, setLoading] = useState(true); + const [recentRefreshKey, setRecentRefreshKey] = useState(0); + const [liveRefreshKey, setLiveRefreshKey] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setSelectedDate(null); + Promise.all([ + libraryGetPlayerStatsYearSummary(year), + libraryGetPlayerStatsHeatmap(year), + libraryGetPlayerStatsYearBounds(), + ]) + .then(([s, heat, bounds]) => { + if (cancelled) return; + setSummary(s); + setDayCounts(new Map(heat.map(h => [h.date, h.trackPlayCount]))); + setYearBounds(bounds); + setLoading(false); + setRecentRefreshKey(k => k + 1); + }) + .catch(() => { + if (!cancelled) { + setSummary(null); + setDayCounts(new Map()); + setLoading(false); + } + }); + return () => { cancelled = true; }; + }, [year]); + + const refreshLive = useCallback(async () => { + try { + const [s, heat] = await Promise.all([ + libraryGetPlayerStatsYearSummary(year), + libraryGetPlayerStatsHeatmap(year), + ]); + setSummary(s); + setDayCounts(new Map(heat.map(h => [h.date, h.trackPlayCount]))); + setLiveRefreshKey(k => k + 1); + } catch { + /* ignore transient read errors during live refresh */ + } + }, [year]); + + usePlayerStatsLiveRefresh(refreshLive); + + const empty = !loading && (summary?.trackPlayCount ?? 0) === 0; + const calYear = currentCalendarYear(); + const maxNavYear = yearBounds?.maxYear != null + ? Math.min(calYear, yearBounds.maxYear) + : calYear; + const canGoPrev = !loading && yearBounds?.minYear != null && year > yearBounds.minYear; + const canGoNext = !loading && year < maxNavYear; + + return ( +
+ +
+ + {year} + +
+ + {loading ? ( +
+ ) : ( + <> + {!empty && summary && ( +
+
+ {formatPlayerStatsListeningTotal(summary.totalListenedSec)} + {t('statistics.playerSummaryTime')} +
+
+ {summary.sessionCount.toLocaleString()} + {t('statistics.playerSummarySessions')} +
+
+ {summary.trackPlayCount.toLocaleString()} + {t('statistics.playerSummaryTracks')} +
+
+ {summary.uniqueTrackCount.toLocaleString()} + {t('statistics.playerSummaryUniqueTracks')} +
+
+ {summary.listeningDayCount.toLocaleString()} + {t('statistics.playerSummaryDays')} +
+
+ {summary.fullCount} / {summary.partialCount} + {t('statistics.playerSummaryCompletion')} +
+
+ )} + + {empty && ( +

{t('statistics.playerEmpty')}

+ )} + + + + )} + + {!loading && ( + + )} +
+ ); +} diff --git a/src/components/statistics/PlayerStatsDayTracks.tsx b/src/components/statistics/PlayerStatsDayTracks.tsx new file mode 100644 index 00000000..207cd9d9 --- /dev/null +++ b/src/components/statistics/PlayerStatsDayTracks.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from 'react-i18next'; +import type { PlaySessionDayDetail } from '../../api/library'; +import { formatPlayerStatsListenedSec } from '../../utils/format/formatHumanDuration'; + +type Props = { + detail: PlaySessionDayDetail; +}; + +export default function PlayerStatsDayTracks({ detail }: Props) { + const { t } = useTranslation(); + + return ( +
    + {detail.tracks.map(tr => ( +
  • +
    {tr.title}
    +
    + {tr.artist ?? '—'} + {' · '} + {formatPlayerStatsListenedSec(tr.listenedSec)} + {' · '} + {tr.completion === 'full' + ? t('statistics.completionFull') + : t('statistics.completionPartial')} +
    +
  • + ))} +
+ ); +} diff --git a/src/components/statistics/PlayerStatsHeatmap.tsx b/src/components/statistics/PlayerStatsHeatmap.tsx new file mode 100644 index 00000000..52bc15b1 --- /dev/null +++ b/src/components/statistics/PlayerStatsHeatmap.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + HEATMAP_LEVEL_COUNT, + heatmapCellMetrics, + heatmapLevel, + heatmapMonthLabels, + heatmapWeekColumns, + heatmapWeekdayLabels, + type HeatmapCell, +} from '../../utils/playerStats/heatmapLevels'; + +const LEVEL_OPACITY = [0.14, 0.32, 0.52, 0.72, 1] as const; + +type Props = { + year: number; + dayCounts: Map; + selectedDate: string | null; + onSelectDate: (date: string) => void; +}; + +function cellClass(cell: HeatmapCell, level: number, active: boolean): string { + const parts = ['player-heatmap-cell']; + if (!cell.date) parts.push('player-heatmap-cell--pad'); + else if (cell.count <= 0) parts.push('player-heatmap-cell--empty'); + else parts.push(`player-heatmap-cell--l${level}`); + if (active) parts.push('player-heatmap-cell--selected'); + return parts.join(' '); +} + +function cellStyle(cell: HeatmapCell, level: number): CSSProperties | undefined { + if (!cell.date || cell.count <= 0) return undefined; + return { + background: `color-mix(in srgb, var(--accent) ${Math.round(LEVEL_OPACITY[level] * 100)}%, transparent)`, + }; +} + +export default function PlayerStatsHeatmap({ year, dayCounts, selectedDate, onSelectDate }: Props) { + const { t, i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? i18n.language; + const wrapRef = useRef(null); + const [layoutWidth, setLayoutWidth] = useState(0); + + const { weeks, maxCount } = useMemo( + () => heatmapWeekColumns(year, dayCounts), + [year, dayCounts], + ); + const monthLabels = useMemo(() => heatmapMonthLabels(year, locale), [year, locale]); + const weekdayLabels = useMemo(() => heatmapWeekdayLabels(locale), [locale]); + + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + const measure = () => setLayoutWidth(el.clientWidth); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [weeks.length]); + + const { cell, gap, labelW, bodyGap } = useMemo( + () => heatmapCellMetrics(layoutWidth, weeks.length), + [layoutWidth, weeks.length], + ); + + const heatmapVars = { + '--hm-weeks': weeks.length, + '--hm-cell': `${cell}px`, + '--hm-gap': `${gap}px`, + '--hm-label-w': `${labelW}px`, + '--hm-body-gap': `${bodyGap}px`, + '--hm-pitch': `${cell + gap}px`, + '--hm-grid-w': `${weeks.length * (cell + gap) - gap}px`, + } as CSSProperties; + + return ( +
+
+ + +
+ + +
+ {weeks.map((week, wi) => ( +
+ {week.map((cellItem, di) => { + if (!cellItem.date) { + return ; + } + const level = heatmapLevel(cellItem.count, maxCount); + const active = selectedDate === cellItem.date; + return ( +
+ ))} +
+
+ + +
+
+ ); +} diff --git a/src/components/statistics/PlayerStatsPartialIndexNotice.tsx b/src/components/statistics/PlayerStatsPartialIndexNotice.tsx new file mode 100644 index 00000000..ebfca3ee --- /dev/null +++ b/src/components/statistics/PlayerStatsPartialIndexNotice.tsx @@ -0,0 +1,40 @@ +import { Info } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; + +export default function PlayerStatsPartialIndexNotice() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const servers = useAuthStore(s => s.servers); + const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer); + + const excludedCount = useMemo( + () => servers.filter(s => syncExcludedByServer[s.id] === true).length, + [servers, syncExcludedByServer], + ); + + if (!masterEnabled || excludedCount === 0 || servers.length <= 1) { + return null; + } + + return ( +
+ + + {t('statistics.playerPartialIndexNotice')} + {' '} + + +
+ ); +} diff --git a/src/components/statistics/PlayerStatsRecentDays.tsx b/src/components/statistics/PlayerStatsRecentDays.tsx new file mode 100644 index 00000000..deb5af38 --- /dev/null +++ b/src/components/statistics/PlayerStatsRecentDays.tsx @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { + libraryGetPlayerStatsDayDetail, + libraryGetPlayerStatsRecentDays, + type PlaySessionDayDetail, + type PlaySessionRecentDay, +} from '../../api/library'; +import { formatPlayerStatsListeningTotal } from '../../utils/format/formatHumanDuration'; +import { + formatPlayerStatsDayLabel, + PLAYER_STATS_RECENT_DAYS_LIMIT, +} from '../../utils/playerStats/formatPlayerStatsDay'; +import PlayerStatsDayTracks from './PlayerStatsDayTracks'; + +type Props = { + /** Day selected on the heatmap — auto-expand when present. */ + heatmapSelectedDate: string | null; + /** Full reload (year change) — shows section spinner. */ + refreshKey: number; + /** Silent poll while listening — updates list and expanded day details. */ + liveRefreshKey: number; +}; + +function formatDayMeta( + summary: PlaySessionRecentDay, + t: (key: string, opts?: Record) => string, +): string { + return [ + formatPlayerStatsListeningTotal(summary.totalListenedSec), + t('statistics.playerDaySessions', { count: summary.sessionCount }), + t('statistics.playerDayTrackPlays', { count: summary.trackPlayCount }), + ].join(' · '); +} + +export default function PlayerStatsRecentDays({ + heatmapSelectedDate, + refreshKey, + liveRefreshKey, +}: Props) { + const { t, i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? i18n.language; + const [days, setDays] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedDates, setExpandedDates] = useState>(() => new Set()); + const [details, setDetails] = useState>(() => new Map()); + const [loadingDates, setLoadingDates] = useState>(() => new Set()); + const detailsRef = useRef(details); + detailsRef.current = details; + const expandedRef = useRef(expandedDates); + expandedRef.current = expandedDates; + + const loadDetail = useCallback(async (date: string) => { + setLoadingDates(prev => new Set(prev).add(date)); + try { + const detail = await libraryGetPlayerStatsDayDetail(date); + setDetails(prev => new Map(prev).set(date, detail)); + } catch { + /* ignore */ + } finally { + setLoadingDates(prev => { + const next = new Set(prev); + next.delete(date); + return next; + }); + } + }, []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT) + .then(rows => { + if (!cancelled) { + setDays(rows); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setDays([]); + setLoading(false); + } + }); + return () => { cancelled = true; }; + }, [refreshKey]); + + useEffect(() => { + if (liveRefreshKey === 0) return; + let cancelled = false; + libraryGetPlayerStatsRecentDays(PLAYER_STATS_RECENT_DAYS_LIMIT) + .then(rows => { + if (cancelled) return; + setDays(rows); + const refreshDates = new Set(expandedRef.current); + if (heatmapSelectedDate && expandedRef.current.has(heatmapSelectedDate)) { + refreshDates.add(heatmapSelectedDate); + } + setDetails(prev => { + const next = new Map(prev); + for (const date of refreshDates) next.delete(date); + return next; + }); + for (const date of refreshDates) { + void loadDetail(date); + } + }) + .catch(() => { /* ignore */ }); + return () => { cancelled = true; }; + }, [liveRefreshKey, heatmapSelectedDate, loadDetail]); + + const daySet = useMemo(() => new Set(days.map(d => d.date)), [days]); + + const ensureDetail = useCallback(async (date: string) => { + if (detailsRef.current.has(date)) return; + await loadDetail(date); + }, [loadDetail]); + + const toggleDate = useCallback((date: string) => { + setExpandedDates(prev => { + const next = new Set(prev); + if (next.has(date)) next.delete(date); + else next.add(date); + return next; + }); + void ensureDetail(date); + }, [ensureDetail]); + + useEffect(() => { + if (!heatmapSelectedDate) return; + setExpandedDates(prev => new Set(prev).add(heatmapSelectedDate)); + void ensureDetail(heatmapSelectedDate); + }, [heatmapSelectedDate, ensureDetail]); + + const extraHeatmapDay = heatmapSelectedDate && !daySet.has(heatmapSelectedDate) + ? heatmapSelectedDate + : null; + + if (loading) { + return ( +
+

{t('statistics.playerRecentDaysTitle')}

+
+
+
+
+ ); + } + + if (days.length === 0 && !extraHeatmapDay) return null; + + const renderRow = (date: string, summary: PlaySessionRecentDay | null) => { + const open = expandedDates.has(date); + const detail = details.get(date); + const pending = loadingDates.has(date); + const label = formatPlayerStatsDayLabel(date, t, locale); + const meta = summary ? formatDayMeta(summary, t) : null; + + return ( +
+ + {open && ( +
+ {pending && !detail && ( +
+
+
+ )} + {detail && } +
+ )} +
+ ); + }; + + return ( +
+

{t('statistics.playerRecentDaysTitle')}

+
+ {extraHeatmapDay && renderRow(extraHeatmapDay, null)} + {days.map(day => renderRow(day.date, day))} +
+
+ ); +} diff --git a/src/components/statistics/StatisticsTabBar.tsx b/src/components/statistics/StatisticsTabBar.tsx new file mode 100644 index 00000000..0670ef90 --- /dev/null +++ b/src/components/statistics/StatisticsTabBar.tsx @@ -0,0 +1,38 @@ +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; + +export default function StatisticsTabBar() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const servers = useAuthStore(s => s.servers); + const masterEnabled = useLibraryIndexStore(s => s.masterEnabled); + const syncExcludedByServer = useLibraryIndexStore(s => s.syncExcludedByServer); + + const showPlayerTab = + masterEnabled && servers.some(s => syncExcludedByServer[s.id] !== true); + if (!showPlayerTab) return null; + + const isPlayer = location.pathname === '/player-stats'; + + return ( +
+ + +
+ ); +} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 1669bb9b..aa3d1855 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -124,6 +124,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Home album rails: stable play/enqueue hover on WebKitGTK/Wayland (PR #787)', 'Local library index: multi-server settings UI, serial sync queue, music-library-scoped local search, parallel initial ingest, i18n across 9 locales (PR #846)', 'Library browse: local-vs-network text search race, All Albums/Artists catalog from index, DevTools browse-race logging (PR #847)', + 'Player stats: local listening history tab with heatmap, year summary, recent days, and day drill-down (PR #849)', ], }, { diff --git a/src/hooks/tauriBridge/useMediaAndWindowBridge.ts b/src/hooks/tauriBridge/useMediaAndWindowBridge.ts index e99b4995..be8cb5af 100644 --- a/src/hooks/tauriBridge/useMediaAndWindowBridge.ts +++ b/src/hooks/tauriBridge/useMediaAndWindowBridge.ts @@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; import type { NavigateFunction } from 'react-router-dom'; import { flushPlayQueuePosition } from '../../store/queueSync'; +import { playListenSessionFinalize } from '../../store/playListenSession'; import { getPlaybackProgressSnapshot } from '../../store/playbackProgress'; import { usePlayerStore } from '../../store/playerStore'; import { useAuthStore } from '../../store/authStore'; @@ -107,6 +108,10 @@ export function useMediaAndWindowBridge(navigate: NavigateFunction) { // server can't keep the app hanging on quit; the playback heartbeat // is the safety net for anything that didn't make it out in time. const performExit = async () => { + await Promise.race([ + playListenSessionFinalize('close'), + new Promise(r => setTimeout(r, 1500)), + ]); await Promise.race([ flushPlayQueuePosition(), new Promise(r => setTimeout(r, 1500)), diff --git a/src/hooks/usePlayerStatsLiveRefresh.test.ts b/src/hooks/usePlayerStatsLiveRefresh.test.ts new file mode 100644 index 00000000..7476d39f --- /dev/null +++ b/src/hooks/usePlayerStatsLiveRefresh.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { usePlayerStatsLiveRefresh } from './usePlayerStatsLiveRefresh'; +import { emitPlaySessionRecorded } from '../store/playSessionRecorded'; + +describe('usePlayerStatsLiveRefresh', () => { + it('refreshes when a play session is recorded and the tab is visible', () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); + const onRefresh = vi.fn(); + renderHook(() => usePlayerStatsLiveRefresh(onRefresh)); + emitPlaySessionRecorded({ serverId: 's1', trackId: 't1', startedAtMs: 1 }); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not refresh on record while the tab is hidden', () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'hidden', + }); + const onRefresh = vi.fn(); + renderHook(() => usePlayerStatsLiveRefresh(onRefresh)); + emitPlaySessionRecorded({ serverId: 's1', trackId: 't1', startedAtMs: 1 }); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('refreshes when the tab becomes visible again', () => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => 'visible', + }); + const onRefresh = vi.fn(); + renderHook(() => usePlayerStatsLiveRefresh(onRefresh)); + document.dispatchEvent(new Event('visibilitychange')); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/usePlayerStatsLiveRefresh.ts b/src/hooks/usePlayerStatsLiveRefresh.ts new file mode 100644 index 00000000..8cef2823 --- /dev/null +++ b/src/hooks/usePlayerStatsLiveRefresh.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; +import { onPlaySessionRecorded } from '../store/playSessionRecorded'; + +/** Refresh player stats when a listen is persisted or the tab becomes visible again. */ +export function usePlayerStatsLiveRefresh(onRefresh: () => void) { + const onRefreshRef = useRef(onRefresh); + onRefreshRef.current = onRefresh; + + useEffect(() => { + const refreshIfVisible = () => { + if (document.visibilityState === 'visible') { + onRefreshRef.current(); + } + }; + + const unsubRecorded = onPlaySessionRecorded(() => { + refreshIfVisible(); + }); + + const onVisibility = () => { + if (document.visibilityState === 'visible') { + onRefreshRef.current(); + } + }; + document.addEventListener('visibilitychange', onVisibility); + + return () => { + unsubRecorded(); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, []); +} diff --git a/src/locales/de/statistics.ts b/src/locales/de/statistics.ts index 8aa3c064..674f16ce 100644 --- a/src/locales/de/statistics.ts +++ b/src/locales/de/statistics.ts @@ -62,4 +62,33 @@ export const statistics = { exportSaving: 'Speichere…', exportSaved: 'Gespeichert.', exportSaveFailed: 'Bild konnte nicht gespeichert werden.', + tabServer: 'Server-Statistik', + tabPlayer: 'Player-Statistik', + playerEmpty: 'Hör zu — dein lokaler Verlauf erscheint hier, sobald der Library-Index aktiv ist.', + playerSummaryTime: 'Hörzeit', + playerListeningDayShort: '{{count}}d', + playerListeningHourShort: '{{count}}h', + playerListeningMinuteShort: '{{count}}min', + playerSummarySessions: 'Sitzungen', + playerSummaryTracks: 'Titel', + playerSummaryUniqueTracks: 'Einzigartige Titel', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Einige Server sind vom lokalen Bibliotheksindex ausgeschlossen. Wiedergaben auf diesen Servern werden nicht in der Player-Statistik erfasst.', + playerPartialIndexSettings: 'Bibliothekseinstellungen', + playerSummaryCompletion: 'Voll / teilweise', + playerYearPrev: 'Vorheriges Jahr', + playerYearNext: 'Nächstes Jahr', + playerHeatmapLegend: 'Dunkler = mehr gehörte Titel an diesem Tag.', + playerHeatmapLess: 'Weniger', + playerHeatmapMore: 'Mehr', + playerDaySessions: '{{count}} Sitzungen', + playerDayTrackPlays: '{{count}} Titel', + playerDayFullPartial: '{{full}} voll · {{partial}} teilweise', + playerRecentDaysTitle: 'Letzte Tage', + playerDayToday: 'Heute', + playerDayYesterday: 'Gestern', + playerListenedSecShort: '{{seconds}} s', + playerListenedMinDecimal: '{{minutes}} Min.', + completionFull: 'Vollständig', + completionPartial: 'Teilweise', }; diff --git a/src/locales/en/statistics.ts b/src/locales/en/statistics.ts index 8e4e928b..f0eca0ec 100644 --- a/src/locales/en/statistics.ts +++ b/src/locales/en/statistics.ts @@ -62,4 +62,33 @@ export const statistics = { exportSaving: 'Saving…', exportSaved: 'Saved.', exportSaveFailed: 'Could not save the image.', + tabServer: 'Server stats', + tabPlayer: 'Player stats', + playerEmpty: 'Start listening — your local play history will appear here once the library index is enabled.', + playerSummaryTime: 'Listening time', + playerListeningDayShort: '{{count}}d', + playerListeningHourShort: '{{count}}h', + playerListeningMinuteShort: '{{count}}m', + playerSummarySessions: 'Sessions', + playerSummaryTracks: 'Track plays', + playerSummaryUniqueTracks: 'Unique tracks', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Some servers are excluded from the local library index. Listening on those servers is not recorded in player statistics.', + playerPartialIndexSettings: 'Library settings', + playerSummaryCompletion: 'Full / partial', + playerYearPrev: 'Previous year', + playerYearNext: 'Next year', + playerHeatmapLegend: 'Darker cells = more tracks played that day.', + playerHeatmapLess: 'Less', + playerHeatmapMore: 'More', + playerDaySessions: '{{count}} sessions', + playerDayTrackPlays: '{{count}} tracks', + playerDayFullPartial: '{{full}} full · {{partial}} partial', + playerRecentDaysTitle: 'Recent days', + playerDayToday: 'Today', + playerDayYesterday: 'Yesterday', + playerListenedSecShort: '{{seconds}}s', + playerListenedMinDecimal: '{{minutes}} min', + completionFull: 'Full listen', + completionPartial: 'Partial', }; diff --git a/src/locales/es/statistics.ts b/src/locales/es/statistics.ts index ef6562af..7a16c9f5 100644 --- a/src/locales/es/statistics.ts +++ b/src/locales/es/statistics.ts @@ -44,4 +44,33 @@ export const statistics = { topRatedArtists: 'Artistas Mejor Calificados', noRatedSongs: 'Aún no hay canciones calificadas. Califica canciones en la vista de álbum o playlist.', noRatedArtists: 'Aún no hay artistas calificados.', + tabServer: 'Estadísticas del servidor', + tabPlayer: 'Estadísticas del reproductor', + playerEmpty: 'Empieza a escuchar — tu historial local aparecerá aquí con el índice de biblioteca activo.', + playerSummaryTime: 'Tiempo de escucha', + playerListeningDayShort: '{{count}}d', + playerListeningHourShort: '{{count}}h', + playerListeningMinuteShort: '{{count}}m', + playerSummarySessions: 'Sesiones', + playerSummaryTracks: 'Pistas', + playerSummaryUniqueTracks: 'Pistas únicas', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Algunos servidores están excluidos del índice local de biblioteca. La escucha en esos servidores no se registra en las estadísticas del reproductor.', + playerPartialIndexSettings: 'Ajustes de biblioteca', + playerSummaryCompletion: 'Completas / parciales', + playerYearPrev: 'Año anterior', + playerYearNext: 'Año siguiente', + playerHeatmapLegend: 'Más oscuro = más pistas escuchadas ese día.', + playerHeatmapLess: 'Menos', + playerHeatmapMore: 'Más', + playerDaySessions: '{{count}} sesiones', + playerDayTrackPlays: '{{count}} pistas', + playerDayFullPartial: '{{full}} completas · {{partial}} parciales', + playerRecentDaysTitle: 'Días recientes', + playerDayToday: 'Hoy', + playerDayYesterday: 'Ayer', + playerListenedSecShort: '{{seconds}} s', + playerListenedMinDecimal: '{{minutes}} min', + completionFull: 'Escucha completa', + completionPartial: 'Parcial', }; diff --git a/src/locales/fr/statistics.ts b/src/locales/fr/statistics.ts index f733b02d..ceec04a4 100644 --- a/src/locales/fr/statistics.ts +++ b/src/locales/fr/statistics.ts @@ -44,4 +44,33 @@ export const statistics = { topRatedArtists: 'Artistes les mieux notés', noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.', noRatedArtists: 'Aucun artiste noté.', + tabServer: 'Stats serveur', + tabPlayer: 'Stats lecteur', + playerEmpty: 'Commencez à écouter — l’historique local apparaîtra ici avec l’index bibliothèque activé.', + playerSummaryTime: 'Temps d’écoute', + playerListeningDayShort: '{{count}}j', + playerListeningHourShort: '{{count}}h', + playerListeningMinuteShort: '{{count}}m', + playerSummarySessions: 'Sessions', + playerSummaryTracks: 'Morceaux', + playerSummaryUniqueTracks: 'Morceaux uniques', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Certains serveurs sont exclus de l’index local de bibliothèque. L’écoute sur ces serveurs n’est pas enregistrée dans les statistiques du lecteur.', + playerPartialIndexSettings: 'Paramètres bibliothèque', + playerSummaryCompletion: 'Complets / partiels', + playerYearPrev: 'Année précédente', + playerYearNext: 'Année suivante', + playerHeatmapLegend: 'Plus foncé = plus de morceaux écoutés ce jour-là.', + playerHeatmapLess: 'Moins', + playerHeatmapMore: 'Plus', + playerDaySessions: '{{count}} sessions', + playerDayTrackPlays: '{{count}} morceaux', + playerDayFullPartial: '{{full}} complets · {{partial}} partiels', + playerRecentDaysTitle: 'Jours récents', + playerDayToday: "Aujourd'hui", + playerDayYesterday: 'Hier', + playerListenedSecShort: '{{seconds}} s', + playerListenedMinDecimal: '{{minutes}} min', + completionFull: 'Écoute complète', + completionPartial: 'Partiel', }; diff --git a/src/locales/nb/statistics.ts b/src/locales/nb/statistics.ts index a032dcd9..5e82ed23 100644 --- a/src/locales/nb/statistics.ts +++ b/src/locales/nb/statistics.ts @@ -44,4 +44,33 @@ export const statistics = { topRatedArtists: 'Høyest vurderte artister', noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.', noRatedArtists: 'Ingen vurderte artister ennå.', + tabServer: 'Serverstatistikk', + tabPlayer: 'Spillerstatistikk', + playerEmpty: 'Begynn å lytte — lokal historikk vises her når bibliotekindeks er aktivert.', + playerSummaryTime: 'Lyttetid', + playerListeningDayShort: '{{count}}d', + playerListeningHourShort: '{{count}}t', + playerListeningMinuteShort: '{{count}}m', + playerSummarySessions: 'Økter', + playerSummaryTracks: 'Spor', + playerSummaryUniqueTracks: 'Unike spor', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Noen servere er ekskludert fra det lokale biblioteksindeks. Lytting på disse serverne registreres ikke i spillerstatistikken.', + playerPartialIndexSettings: 'Bibliotekinnstillinger', + playerSummaryCompletion: 'Full / delvis', + playerYearPrev: 'Forrige år', + playerYearNext: 'Neste år', + playerHeatmapLegend: 'Mørkere = flere spor den dagen.', + playerHeatmapLess: 'Mindre', + playerHeatmapMore: 'Mer', + playerDaySessions: '{{count}} økter', + playerDayTrackPlays: '{{count}} spor', + playerDayFullPartial: '{{full}} full · {{partial}} delvis', + playerRecentDaysTitle: 'Siste dager', + playerDayToday: 'I dag', + playerDayYesterday: 'I går', + playerListenedSecShort: '{{seconds}} s', + playerListenedMinDecimal: '{{minutes}} min', + completionFull: 'Full lytting', + completionPartial: 'Delvis', }; diff --git a/src/locales/nl/statistics.ts b/src/locales/nl/statistics.ts index ce5ce1c4..79003803 100644 --- a/src/locales/nl/statistics.ts +++ b/src/locales/nl/statistics.ts @@ -44,4 +44,33 @@ export const statistics = { topRatedArtists: 'Best beoordeelde artiesten', noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.', noRatedArtists: 'Nog geen beoordeelde artiesten.', + tabServer: 'Serverstatistieken', + tabPlayer: 'Spelerstatistieken', + playerEmpty: 'Begin te luisteren — je lokale geschiedenis verschijnt hier zodra de bibliotheekindex aan staat.', + playerSummaryTime: 'Luistertijd', + playerListeningDayShort: '{{count}}d', + playerListeningHourShort: '{{count}}u', + playerListeningMinuteShort: '{{count}}m', + playerSummarySessions: 'Sessies', + playerSummaryTracks: 'Nummers', + playerSummaryUniqueTracks: 'Unieke nummers', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Sommige servers zijn uitgesloten van de lokale bibliotheekindex. Luisteren op die servers wordt niet vastgelegd in de playerstatistieken.', + playerPartialIndexSettings: 'Bibliotheekinstellingen', + playerSummaryCompletion: 'Volledig / gedeeltelijk', + playerYearPrev: 'Vorig jaar', + playerYearNext: 'Volgend jaar', + playerHeatmapLegend: 'Donkerder = meer nummers geluisterd op die dag.', + playerHeatmapLess: 'Minder', + playerHeatmapMore: 'Meer', + playerDaySessions: '{{count}} sessies', + playerDayTrackPlays: '{{count}} nummers', + playerDayFullPartial: '{{full}} volledig · {{partial}} gedeeltelijk', + playerRecentDaysTitle: 'Recente dagen', + playerDayToday: 'Vandaag', + playerDayYesterday: 'Gisteren', + playerListenedSecShort: '{{seconds}} s', + playerListenedMinDecimal: '{{minutes}} min', + completionFull: 'Volledig geluisterd', + completionPartial: 'Gedeeltelijk', }; diff --git a/src/locales/ro/statistics.ts b/src/locales/ro/statistics.ts index 890a6e65..c344a1d1 100644 --- a/src/locales/ro/statistics.ts +++ b/src/locales/ro/statistics.ts @@ -62,4 +62,33 @@ export const statistics = { exportSaving: 'Se salvează…', exportSaved: 'Salvat.', exportSaveFailed: 'Nu s-a putut salva imaginea.', + tabServer: 'Statistici server', + tabPlayer: 'Statistici player', + playerEmpty: 'Începe să asculți — istoricul local va apărea aici când indexul bibliotecii este activ.', + playerSummaryTime: 'Timp de ascultare', + playerListeningDayShort: '{{count}}z', + playerListeningHourShort: '{{count}}h', + playerListeningMinuteShort: '{{count}}m', + playerSummarySessions: 'Sesiuni', + playerSummaryTracks: 'Piese', + playerSummaryUniqueTracks: 'Piese unice', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Unele servere sunt excluse din indexul local al bibliotecii. Ascultarea pe aceste servere nu este înregistrată în statisticile playerului.', + playerPartialIndexSettings: 'Setări bibliotecă', + playerSummaryCompletion: 'Complete / parțiale', + playerYearPrev: 'Anul anterior', + playerYearNext: 'Anul următor', + playerHeatmapLegend: 'Mai închis = mai multe piese ascultate în acea zi.', + playerHeatmapLess: 'Mai puțin', + playerHeatmapMore: 'Mai mult', + playerDaySessions: '{{count}} sesiuni', + playerDayTrackPlays: '{{count}} piese', + playerDayFullPartial: '{{full}} complete · {{partial}} parțiale', + playerRecentDaysTitle: 'Zile recente', + playerDayToday: 'Astăzi', + playerDayYesterday: 'Ieri', + playerListenedSecShort: '{{seconds}} s', + playerListenedMinDecimal: '{{minutes}} min', + completionFull: 'Ascultare completă', + completionPartial: 'Parțial', }; diff --git a/src/locales/ru/statistics.ts b/src/locales/ru/statistics.ts index d063f6ea..4855488d 100644 --- a/src/locales/ru/statistics.ts +++ b/src/locales/ru/statistics.ts @@ -55,4 +55,33 @@ export const statistics = { topRatedArtists: 'Лучшие по рейтингу исполнители', noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.', noRatedArtists: 'Нет оценённых исполнителей.', + tabServer: 'Статистика сервера', + tabPlayer: 'Статистика плеера', + playerEmpty: 'Начните слушать — локальная история появится здесь при включённом индексе библиотеки.', + playerSummaryTime: 'Время прослушивания', + playerListeningDayShort: '{{count}}д', + playerListeningHourShort: '{{count}}ч', + playerListeningMinuteShort: '{{count}}мин', + playerSummarySessions: 'Сессии', + playerSummaryTracks: 'Треки', + playerSummaryUniqueTracks: 'Уникальные треки', + playerSummaryDays: 'Days', + playerPartialIndexNotice: 'Не все серверы включены в локальный индекс библиотеки. Прослушивание с выключенных серверов не попадает в эту статистику.', + playerPartialIndexSettings: 'Настройки библиотеки', + playerSummaryCompletion: 'Полные / частичные', + playerYearPrev: 'Предыдущий год', + playerYearNext: 'Следующий год', + playerHeatmapLegend: 'Темнее — больше прослушанных треков за день.', + playerHeatmapLess: 'Меньше', + playerHeatmapMore: 'Больше', + playerDaySessions: '{{count}} сессий', + playerDayTrackPlays: '{{count}} треков', + playerDayFullPartial: '{{full}} полных · {{partial}} частичных', + playerRecentDaysTitle: 'Недавние дни', + playerDayToday: 'Сегодня', + playerDayYesterday: 'Вчера', + playerListenedSecShort: '{{seconds}} с', + playerListenedMinDecimal: '{{minutes}} мин', + completionFull: 'Прослушано', + completionPartial: 'Частично', }; diff --git a/src/locales/zh/statistics.ts b/src/locales/zh/statistics.ts index b05c288f..170aa207 100644 --- a/src/locales/zh/statistics.ts +++ b/src/locales/zh/statistics.ts @@ -44,4 +44,33 @@ export const statistics = { topRatedArtists: '最高评分艺人', noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。', noRatedArtists: '暂无已评分艺人。', + tabServer: '服务器统计', + tabPlayer: '播放器统计', + playerEmpty: '开始收听 — 启用媒体库索引后,本地播放历史将显示在这里。', + playerSummaryTime: '收听时长', + playerListeningDayShort: '{{count}}天', + playerListeningHourShort: '{{count}}小时', + playerListeningMinuteShort: '{{count}}分钟', + playerSummarySessions: '会话数', + playerSummaryTracks: '曲目', + playerSummaryUniqueTracks: '独立曲目', + playerSummaryDays: 'Days', + playerPartialIndexNotice: '部分服务器未纳入本地库索引,在这些服务器上的收听不会计入播放器统计。', + playerPartialIndexSettings: '库设置', + playerSummaryCompletion: '完整 / 部分', + playerYearPrev: '上一年', + playerYearNext: '下一年', + playerHeatmapLegend: '颜色越深表示当天收听的曲目越多。', + playerHeatmapLess: '少', + playerHeatmapMore: '多', + playerDaySessions: '{{count}} 次会话', + playerDayTrackPlays: '{{count}} 首曲目', + playerDayFullPartial: '{{full}} 完整 · {{partial}} 部分', + playerRecentDaysTitle: '最近几天', + playerDayToday: '今天', + playerDayYesterday: '昨天', + playerListenedSecShort: '{{seconds}} 秒', + playerListenedMinDecimal: '{{minutes}} 分钟', + completionFull: '完整收听', + completionPartial: '部分', }; diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index 3b2a96d8..c964f63c 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -6,9 +6,11 @@ import { Share2 } from 'lucide-react'; import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration'; import AlbumRow from '../components/AlbumRow'; import StatsExportModal from '../components/StatsExportModal'; +import PlayerStatisticsPanel from '../components/statistics/PlayerStatisticsPanel'; +import StatisticsTabBar from '../components/statistics/StatisticsTabBar'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; -import { useNavigate } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -31,7 +33,8 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [ export default function Statistics() { const { t } = useTranslation(); - const navigate = useNavigate(); + const location = useLocation(); + const isPlayerStats = location.pathname === '/player-stats'; const { lastfmSessionKey, lastfmUsername } = useAuthStore(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const [recent, setRecent] = useState([]); @@ -172,8 +175,11 @@ export default function Statistics() { return (

{t('statistics.title')}

+ - {loading ? ( + {isPlayerStats ? ( + + ) : loading ? (
) : (
@@ -394,11 +400,13 @@ export default function Statistics() {
)} + {!isPlayerStats && ( setExportOpen(false)} /> + )}
); } diff --git a/src/store/audioEventHandlers.ts b/src/store/audioEventHandlers.ts index 93507996..b6fe75d3 100644 --- a/src/store/audioEventHandlers.ts +++ b/src/store/audioEventHandlers.ts @@ -4,6 +4,12 @@ import { invoke } from '@tauri-apps/api/core'; import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm'; import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate'; import { notifyLibraryPlaybackHint } from './libraryPlaybackHint'; +import { + playListenSessionFinalize, + playListenSessionOnProgress, + playListenSessionOnTrackSwitched, + playListenSessionOpen, +} from './playListenSession'; import { getPerfProbeFlags } from '../utils/perf/perfFlags'; import { bumpPerfCounter } from '../utils/perf/perfTelemetry'; import { getPlaybackServerId } from '../utils/playback/playbackServer'; @@ -75,13 +81,15 @@ export type NormalizationStatePayload = { targetLufs: number; }; -export function handleAudioPlaying(_duration: number): void { +export function handleAudioPlaying(duration: number): void { setDeferHotCachePrefetch(false); resetProgressEmitThrottles(); usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false }); - // Tell the library scheduler to throttle bulk crawl while a stream - // is active (spec §6.2.4). No-op unless the index is enabled. notifyLibraryPlaybackHint('playing'); + const track = usePlayerStore.getState().currentTrack; + if (track) { + void playListenSessionOpen(track, getPlaybackServerId(), duration); + } } export function handleAudioProgress( @@ -142,6 +150,7 @@ export function handleAudioProgress( const dur = duration > 0 ? duration : track.duration; if (dur <= 0) return; const progress = displayTime / dur; + playListenSessionOnProgress(current_time, buffering, dur).catch(() => {}); if (!progressUiDisabled) { const nowLive = Date.now(); const live = getPlaybackProgressSnapshot(); @@ -311,16 +320,14 @@ export function handleAudioProgress( } export function handleAudioEnded(): void { - // Playback stopped — let the library scheduler resume normal crawl - // parallelism (spec §6.2.4). No-op unless the index is enabled. notifyLibraryPlaybackHint('idle'); - // If a gapless switch happened recently, this ended event is stale — the - // progress task fired it for the OLD source before seeing the chained one. if (Date.now() - getLastGaplessSwitchTime() < 600) { return; } + void playListenSessionFinalize('ended'); + // Radio stream disconnected — just stop; don't advance queue. if (usePlayerStore.getState().currentRadio) { setIsAudioPaused(false); @@ -392,6 +399,8 @@ export function handleAudioTrackSwitched(_duration: number): void { if (!nextTrack) return; + void playListenSessionOnTrackSwitched(nextTrack); + const switchServerId = getPlaybackServerId(); const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId); const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl); diff --git a/src/store/playListenSession.test.ts b/src/store/playListenSession.test.ts new file mode 100644 index 00000000..a3917a93 --- /dev/null +++ b/src/store/playListenSession.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { onInvoke } from '../test/mocks/tauri'; +import { useLibraryIndexStore } from './libraryIndexStore'; +import { + _resetPlayListenSessionForTest, + playListenSessionFinalize, + playListenSessionOnProgress, + playListenSessionOpen, + resolveDurationSecHint, +} from './playListenSession'; +import { onPlaySessionRecorded } from './playSessionRecorded'; + +vi.mock('../utils/library/libraryReady', () => ({ + libraryIsReady: vi.fn(async () => true), +})); + +vi.mock('../utils/playback/playbackServer', () => ({ + getPlaybackServerId: vi.fn(() => 'server-1'), +})); + +import type { Track } from './playerStoreTypes'; + +const testTrack: Track = { + id: 't1', + title: 'A', + artist: 'B', + album: '', + albumId: '', + duration: 180, +}; + +describe('playListenSession', () => { + beforeEach(async () => { + _resetPlayListenSessionForTest(); + useLibraryIndexStore.setState({ + masterEnabled: true, + syncExcludedByServer: {}, + }); + const { usePlayerStore } = await import('./playerStore'); + const { usePreviewStore } = await import('./previewStore'); + usePlayerStore.setState({ + currentRadio: null, + isPlaying: true, + currentTrack: testTrack, + }); + usePreviewStore.setState({ previewingId: null }); + onInvoke('library_record_play_session', () => undefined); + }); + + it('does not invoke when listenedSec <= 10', async () => { + await playListenSessionOpen(testTrack, 'server-1'); + await playListenSessionOnProgress(5, false); + await playListenSessionFinalize('ended'); + expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything()); + }); + + it('invokes once after >10s listened', async () => { + vi.useFakeTimers(); + await playListenSessionOpen(testTrack, 'server-1'); + vi.setSystemTime(Date.now() + 15_000); + await playListenSessionOnProgress(12, false); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + expect(invoke).toHaveBeenCalledWith( + 'library_record_play_session', + expect.objectContaining({ + input: expect.objectContaining({ + trackId: 't1', + serverId: 'server-1', + endReason: 'ended', + durationSecHint: 180, + }), + }), + ); + }); + + it('picks up engine duration from progress ticks', async () => { + const { usePlayerStore } = await import('./playerStore'); + usePlayerStore.setState({ + currentTrack: { ...testTrack, duration: 0 }, + }); + vi.useFakeTimers(); + await playListenSessionOpen({ ...testTrack, duration: 0 }, 'server-1', 240); + vi.setSystemTime(Date.now() + 15_000); + await playListenSessionOnProgress(12, false, 240); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + expect(invoke).toHaveBeenCalledWith( + 'library_record_play_session', + expect.objectContaining({ + input: expect.objectContaining({ durationSecHint: 240 }), + }), + ); + }); + + it('skips when index disabled', async () => { + useLibraryIndexStore.setState({ masterEnabled: false }); + await playListenSessionOpen(testTrack, 'server-1'); + vi.useFakeTimers(); + vi.setSystemTime(Date.now() + 20_000); + await playListenSessionOnProgress(15, false); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything()); + }); + + it('skips preview playback', async () => { + const { usePreviewStore } = await import('./previewStore'); + usePreviewStore.setState({ previewingId: 'preview-1' }); + vi.useFakeTimers(); + await playListenSessionOpen(testTrack, 'server-1'); + vi.setSystemTime(Date.now() + 20_000); + await playListenSessionOnProgress(15, false); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything()); + }); + + it('skips radio playback', async () => { + const { usePlayerStore } = await import('./playerStore'); + usePlayerStore.setState({ + currentRadio: { id: 'r1', name: 'Radio', streamUrl: 'http://x' }, + }); + vi.useFakeTimers(); + await playListenSessionOpen(testTrack, 'server-1'); + vi.setSystemTime(Date.now() + 20_000); + await playListenSessionOnProgress(15, false); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything()); + }); + + it('does not accumulate listened time while paused or buffering', async () => { + const { usePlayerStore } = await import('./playerStore'); + vi.useFakeTimers(); + await playListenSessionOpen(testTrack, 'server-1'); + vi.setSystemTime(Date.now() + 15_000); + usePlayerStore.setState({ isPlaying: false }); + await playListenSessionOnProgress(12, false); + vi.setSystemTime(Date.now() + 30_000); + await playListenSessionOnProgress(12, true); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything()); + }); + + it('skips when library is not ready', async () => { + const { libraryIsReady } = await import('../utils/library/libraryReady'); + vi.mocked(libraryIsReady).mockResolvedValueOnce(false); + vi.useFakeTimers(); + await playListenSessionOpen(testTrack, 'server-1'); + vi.setSystemTime(Date.now() + 20_000); + await playListenSessionOnProgress(15, false); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + expect(invoke).not.toHaveBeenCalledWith('library_record_play_session', expect.anything()); + }); + + it('emits play-session-recorded after a persisted listen', async () => { + const listener = vi.fn(); + const unsub = onPlaySessionRecorded(listener); + vi.useFakeTimers(); + await playListenSessionOpen(testTrack, 'server-1'); + vi.setSystemTime(Date.now() + 15_000); + await playListenSessionOnProgress(12, false); + await playListenSessionFinalize('ended'); + vi.useRealTimers(); + unsub(); + expect(listener).toHaveBeenCalledWith({ + serverId: 'server-1', + trackId: 't1', + startedAtMs: expect.any(Number), + }); + }); +}); + +describe('resolveDurationSecHint', () => { + it('returns zero when no positive durations are available', () => { + expect(resolveDurationSecHint(null)).toBe(0); + expect(resolveDurationSecHint({ duration: 0 }, 0, undefined)).toBe(0); + }); + + it('prefers the largest finite positive hint', () => { + expect(resolveDurationSecHint({ duration: 180 }, 240, 200)).toBe(240); + }); +}); diff --git a/src/store/playListenSession.ts b/src/store/playListenSession.ts new file mode 100644 index 00000000..6a86ef1a --- /dev/null +++ b/src/store/playListenSession.ts @@ -0,0 +1,170 @@ +import type { Track } from './playerStoreTypes'; +import { + libraryRecordPlaySession, + type PlaySessionEndReason, +} from '../api/library'; +import { libraryIsReady } from '../utils/library/libraryReady'; +import { getPlaybackServerId } from '../utils/playback/playbackServer'; +import { emitPlaySessionRecorded } from './playSessionRecorded'; +import { useLibraryIndexStore } from './libraryIndexStore'; + +const MIN_LISTENED_SEC = 10; + +type OpenSession = { + serverId: string; + trackId: string; + startedAtMs: number; + listenedSec: number; + positionMaxSec: number; + durationSecHint: number; + lastTickMs: number; + recordingEnabled: boolean; +}; + +let open: OpenSession | null = null; +let finalizeInFlight: Promise | null = null; + +function clearOpen(): void { + open = null; +} + +/** Best-known track length in seconds from player metadata and/or engine. */ +export function resolveDurationSecHint( + track: Pick | null | undefined, + ...extraSec: (number | undefined)[] +): number { + const values = [track?.duration, ...extraSec].filter( + (d): d is number => typeof d === 'number' && Number.isFinite(d) && d > 0, + ); + if (values.length === 0) return 0; + return Math.round(Math.max(...values)); +} + +function noteDurationHint(durationSec?: number): void { + if (!open || !durationSec || durationSec <= 0) return; + const rounded = Math.round(durationSec); + if (rounded > open.durationSecHint) { + open.durationSecHint = rounded; + } +} + +async function playerGateBlocks(): Promise { + const { usePlayerStore } = await import('./playerStore'); + const { usePreviewStore } = await import('./previewStore'); + if (usePreviewStore.getState().previewingId) return true; + if (usePlayerStore.getState().currentRadio) return true; + return false; +} + +async function recordingEnabledForServer(serverId: string): Promise { + if (!serverId) return false; + if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false; + if (await playerGateBlocks()) return false; + return libraryIsReady(serverId); +} + +export async function playListenSessionOpen( + track: Track, + serverId: string, + engineDurationSec?: number, +): Promise { + if (open && open.trackId === track.id && open.serverId === serverId) { + noteDurationHint(resolveDurationSecHint(track, engineDurationSec)); + return; + } + await playListenSessionFinalize('skip'); + const enabled = await recordingEnabledForServer(serverId); + if (!enabled) return; + open = { + serverId, + trackId: track.id, + startedAtMs: Date.now(), + listenedSec: 0, + positionMaxSec: 0, + durationSecHint: resolveDurationSecHint(track, engineDurationSec), + lastTickMs: Date.now(), + recordingEnabled: true, + }; +} + +export async function playListenSessionOnProgress( + currentTime: number, + buffering: boolean, + durationSecHint?: number, +): Promise { + if (!open?.recordingEnabled) return; + noteDurationHint(durationSecHint); + const { usePlayerStore } = await import('./playerStore'); + const store = usePlayerStore.getState(); + const track = store.currentTrack; + if (track?.id === open.trackId) { + noteDurationHint(resolveDurationSecHint(track, durationSecHint)); + } + if (!store.isPlaying || buffering) { + open.lastTickMs = Date.now(); + return; + } + const now = Date.now(); + const deltaSec = Math.max(0, (now - open.lastTickMs) / 1000); + open.lastTickMs = now; + open.listenedSec += deltaSec; + if (Number.isFinite(currentTime) && currentTime > open.positionMaxSec) { + open.positionMaxSec = currentTime; + } +} + +export async function playListenSessionFinalize(reason: PlaySessionEndReason): Promise { + if (finalizeInFlight) { + await finalizeInFlight; + } + if (!open) return; + + const session = open; + clearOpen(); + + if (!session.recordingEnabled || session.listenedSec <= MIN_LISTENED_SEC) { + return; + } + + const { usePlayerStore } = await import('./playerStore'); + const track = usePlayerStore.getState().currentTrack; + const durationSecHint = resolveDurationSecHint( + track?.id === session.trackId ? track : null, + session.durationSecHint, + ); + + finalizeInFlight = libraryRecordPlaySession({ + serverId: session.serverId, + trackId: session.trackId, + startedAtMs: session.startedAtMs, + listenedSec: session.listenedSec, + positionMaxSec: session.positionMaxSec, + endReason: reason, + durationSecHint: durationSecHint > 0 ? durationSecHint : undefined, + }) + .then(() => { + emitPlaySessionRecorded({ + serverId: session.serverId, + trackId: session.trackId, + startedAtMs: session.startedAtMs, + }); + }) + .catch(() => undefined) + .finally(() => { + finalizeInFlight = null; + }); + + await finalizeInFlight; +} + +export async function playListenSessionOnTrackSwitched(nextTrack: Track): Promise { + const serverId = getPlaybackServerId(); + await playListenSessionFinalize('switch'); + await playListenSessionOpen(nextTrack, serverId, nextTrack.duration); +} + +/** Test-only reset */ +export function _resetPlayListenSessionForTest(): void { + open = null; + finalizeInFlight = null; +} diff --git a/src/store/playSessionRecorded.test.ts b/src/store/playSessionRecorded.test.ts new file mode 100644 index 00000000..af234db5 --- /dev/null +++ b/src/store/playSessionRecorded.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import { emitPlaySessionRecorded, onPlaySessionRecorded } from './playSessionRecorded'; + +describe('playSessionRecorded', () => { + it('notifies subscribers when a listen is persisted', () => { + const listener = vi.fn(); + const unsub = onPlaySessionRecorded(listener); + const detail = { serverId: 's1', trackId: 't1', startedAtMs: 123 }; + emitPlaySessionRecorded(detail); + expect(listener).toHaveBeenCalledWith(detail); + unsub(); + listener.mockClear(); + emitPlaySessionRecorded(detail); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/playSessionRecorded.ts b/src/store/playSessionRecorded.ts new file mode 100644 index 00000000..d438504a --- /dev/null +++ b/src/store/playSessionRecorded.ts @@ -0,0 +1,25 @@ +export type PlaySessionRecordedDetail = { + serverId: string; + trackId: string; + startedAtMs: number; +}; + +const EVENT_NAME = 'psysonic:play-session-recorded'; + +export function emitPlaySessionRecorded(detail: PlaySessionRecordedDetail): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail })); +} + +export function onPlaySessionRecorded( + listener: (detail: PlaySessionRecordedDetail) => void, +): () => void { + if (typeof window === 'undefined') return () => {}; + const wrapped = (evt: Event) => { + const ce = evt as CustomEvent; + if (!ce?.detail) return; + listener(ce.detail); + }; + window.addEventListener(EVENT_NAME, wrapped as EventListener); + return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener); +} diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 90dafcb4..60a55dc8 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -36,6 +36,7 @@ import { import type { PlayerState, Track } from './playerStoreTypes'; import { promoteCompletedStreamToHotCache } from './promoteStreamCache'; import { syncQueueToServer } from './queueSync'; +import { playListenSessionFinalize } from './playListenSession'; import { pushQueueUndoFromGetter } from './queueUndo'; import { stopRadio } from './radioPlayer'; import { clearAllPlaybackScheduleTimers } from './scheduleTimers'; @@ -152,6 +153,8 @@ export function runPlayTrack( return; } + void playListenSessionFinalize('skip'); + clearAllPlaybackScheduleTimers(); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); diff --git a/src/store/queueMutationActions.ts b/src/store/queueMutationActions.ts index 4f63c825..7f91c6f5 100644 --- a/src/store/queueMutationActions.ts +++ b/src/store/queueMutationActions.ts @@ -18,6 +18,7 @@ import { clearSeekDebounce } from './seekDebounce'; import { clearSeekFallbackRetry } from './seekFallbackState'; import { clearSeekTarget } from './seekTargetState'; import i18n from '../i18n'; +import { playListenSessionFinalize } from './playListenSession'; import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer'; import { useLuckyMixStore } from './luckyMixStore'; import { showToast } from '../utils/ui/toast'; @@ -207,6 +208,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick< }, clearQueue: () => { + void playListenSessionFinalize('stop'); invoke('audio_stop').catch(console.error); setIsAudioPaused(false); clearSeekFallbackRetry(); diff --git a/src/store/transportLightActions.ts b/src/store/transportLightActions.ts index 16ff132c..c45e4d07 100644 --- a/src/store/transportLightActions.ts +++ b/src/store/transportLightActions.ts @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { setIsAudioPaused } from './engineState'; import type { PlayerState } from './playerStoreTypes'; import { flushQueueSyncToServer } from './queueSync'; +import { playListenSessionFinalize } from './playListenSession'; import { pauseRadio, stopRadio } from './radioPlayer'; import { clearAllPlaybackScheduleTimers } from './scheduleTimers'; import { clearSeekDebounce } from './seekDebounce'; @@ -28,6 +29,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick< > { return { stop: () => { + void playListenSessionFinalize('stop'); clearAllPlaybackScheduleTimers(); if (get().currentRadio) { stopRadio(); diff --git a/src/styles/components/statistics-page.css b/src/styles/components/statistics-page.css index fa944362..4ef1ae87 100644 --- a/src/styles/components/statistics-page.css +++ b/src/styles/components/statistics-page.css @@ -3,6 +3,8 @@ display: flex; flex-direction: column; gap: 3rem; + min-width: 0; + max-width: 100%; } .stats-overview { @@ -103,10 +105,273 @@ overflow: hidden; } -.genre-bar-fill { - height: 100%; - background: var(--accent); - border-radius: 3px; - transition: width 0.8s ease-out; +.player-heatmap-wrap { + width: 100%; + max-width: 100%; + min-width: 0; + margin-bottom: var(--space-4); + overflow-x: auto; + -webkit-overflow-scrolling: touch; } +.player-heatmap { + --hm-cell: 12px; + --hm-gap: 2px; + --hm-label-w: 24px; + --hm-body-gap: 8px; + --hm-pitch: calc(var(--hm-cell) + var(--hm-gap)); + --hm-grid-w: calc(var(--hm-weeks) * var(--hm-pitch) - var(--hm-gap)); + display: flex; + flex-direction: column; + gap: var(--space-2); + width: 100%; + max-width: 100%; +} + +.player-heatmap-months { + position: relative; + height: calc(var(--hm-cell) + 2px); + width: var(--hm-grid-w); + margin-left: calc(var(--hm-label-w) + var(--hm-body-gap)); + flex-shrink: 0; +} + +.player-heatmap-month { + position: absolute; + top: 0; + left: calc(var(--column-index, 0) * var(--hm-pitch)); + font-size: max(8px, calc(var(--hm-cell) * 0.72)); + line-height: calc(var(--hm-cell) + 2px); + color: var(--text-muted); + white-space: nowrap; +} + +.player-heatmap-body { + display: flex; + gap: var(--hm-body-gap); + align-items: flex-start; + min-width: 0; + max-width: 100%; +} + +.player-heatmap-weekdays { + display: flex; + flex-direction: column; + gap: var(--hm-gap); + width: var(--hm-label-w); + flex-shrink: 0; +} + +.player-heatmap-weekdays span { + height: var(--hm-cell); + line-height: var(--hm-cell); + font-size: max(8px, calc(var(--hm-cell) * 0.72)); + color: var(--text-muted); + text-align: right; +} + +.player-heatmap-columns { + display: flex; + gap: var(--hm-gap); + width: var(--hm-grid-w); + flex-shrink: 1; + min-width: 0; +} + +.player-heatmap-col { + display: flex; + flex-direction: column; + gap: var(--hm-gap); + flex: 0 0 var(--hm-cell); + width: var(--hm-cell); +} + +.player-heatmap-cell { + width: var(--hm-cell); + height: var(--hm-cell); + flex: 0 0 var(--hm-cell); + border-radius: clamp(2px, calc(var(--hm-cell) * 0.22), 3px); + border: 1px solid transparent; + padding: 0; + box-sizing: border-box; + cursor: default; +} + +.player-heatmap-cell--pad { + background: transparent; + border-color: transparent; + pointer-events: none; +} + +.player-heatmap-cell--empty { + background: color-mix(in srgb, var(--text-muted) 14%, transparent); + border-color: color-mix(in srgb, var(--text-muted) 10%, transparent); +} + +button.player-heatmap-cell { + cursor: pointer; +} + +button.player-heatmap-cell:hover:not(.player-heatmap-cell--pad) { + border-color: color-mix(in srgb, var(--accent) 55%, transparent); +} + +.player-heatmap-cell--selected { + border-color: var(--accent) !important; + outline: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + outline-offset: 1px; +} + +.player-heatmap-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + font-size: max(8px, calc(var(--hm-cell) * 0.72)); + color: var(--text-muted); + padding-left: calc(var(--hm-label-w) + var(--hm-body-gap)); +} + +.player-heatmap-cell--sample { + cursor: default; +} + +.player-stats-partial-index-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.player-stats-partial-index-link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent); + text-decoration: underline; + cursor: pointer; +} + +.player-stats-partial-index-link:hover { + color: var(--accent-hover, var(--accent)); +} + +.player-stats-year-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + gap: var(--space-3); +} + +.player-stats-year-nav .btn:disabled { + opacity: 0.38; + cursor: not-allowed; + pointer-events: none; + filter: grayscale(0.35); + border-color: var(--border-subtle); + color: var(--text-muted); + background: var(--bg-card); + transform: none; + box-shadow: none; +} + +.player-stats-year-nav .btn:disabled:hover { + background: var(--bg-card); + border-color: var(--border-subtle); + transform: none; +} + +.player-stats-recent { + margin-top: var(--space-5); +} + +.player-stats-day-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.player-stats-day-item { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.player-stats-day-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + width: 100%; + padding: var(--space-3) var(--space-4); + background: transparent; + border: none; + color: inherit; + text-align: left; + cursor: pointer; +} + +.player-stats-day-header:hover { + background: var(--bg-hover); +} + +.player-stats-day-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.player-stats-day-label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); +} + +.player-stats-day-summary { + font-size: 0.75rem; + color: var(--text-muted); +} + +.player-stats-day-chevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.15s ease; +} + +.player-stats-day-item--open .player-stats-day-chevron { + transform: rotate(180deg); +} + +.player-stats-day-body { + padding: 0 var(--space-4) var(--space-4); + border-top: 1px solid var(--border-subtle); +} + +.player-stats-day-meta { + font-size: 0.875rem; + color: var(--text-muted); + margin: var(--space-3) 0 var(--space-3); +} + +.player-stats-day-tracks { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.player-stats-day-track-title { + font-size: 0.875rem; + font-weight: 500; +} + +.player-stats-day-track-meta { + font-size: 0.75rem; + color: var(--text-muted); +} diff --git a/src/utils/format/formatHumanDuration.test.ts b/src/utils/format/formatHumanDuration.test.ts index 95861c8f..8d98b8d2 100644 --- a/src/utils/format/formatHumanDuration.test.ts +++ b/src/utils/format/formatHumanDuration.test.ts @@ -5,10 +5,12 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('../../i18n', () => ({ default: { t: (key: string, params: Record) => `${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"}'); + }); +}); diff --git a/src/utils/format/formatHumanDuration.ts b/src/utils/format/formatHumanDuration.ts index c24fa4c3..d3321dec 100644 --- a/src/utils/format/formatHumanDuration.ts +++ b/src/utils/format/formatHumanDuration.ts @@ -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) }); +} diff --git a/src/utils/playerStats/formatPlayerStatsDay.test.ts b/src/utils/playerStats/formatPlayerStatsDay.test.ts new file mode 100644 index 00000000..b07bbb86 --- /dev/null +++ b/src/utils/playerStats/formatPlayerStatsDay.test.ts @@ -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'); + }); +}); diff --git a/src/utils/playerStats/formatPlayerStatsDay.ts b/src/utils/playerStats/formatPlayerStatsDay.ts new file mode 100644 index 00000000..b347b3f3 --- /dev/null +++ b/src/utils/playerStats/formatPlayerStatsDay.ts @@ -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, + 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); +} diff --git a/src/utils/playerStats/heatmapLevels.test.ts b/src/utils/playerStats/heatmapLevels.test.ts new file mode 100644 index 00000000..000a3509 --- /dev/null +++ b/src/utils/playerStats/heatmapLevels.test.ts @@ -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); + }); +}); diff --git a/src/utils/playerStats/heatmapLevels.ts b/src/utils/playerStats/heatmapLevels.ts new file mode 100644 index 00000000..a16f287a --- /dev/null +++ b/src/utils/playerStats/heatmapLevels.ts @@ -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): { + 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 }; +}