mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(player-stats): local listening history tab with heatmap and summaries (#849)
* feat(player-stats): local listening history tab with heatmap and summaries Record play sessions in library.sqlite when the library index is enabled, add Rust read APIs and Tauri commands for year/day aggregates, and ship the Player stats UI with session clustering, event-driven live refresh, and a notice when some servers are excluded from indexing. * test(player-stats): split play_session repo and expand test coverage Move the repository into play_session/ (completion, cluster, integration tests), add remap/purge/FK coverage in Rust, and cover ingestion gates plus live-refresh hooks on the frontend per spec v0.3. * docs(release): CHANGELOG and credits for player stats (PR #849) * fix(player-stats): satisfy tsc and clippy CI gates Use InternetRadioStation field names in the radio skip test and replace manual month/day range checks with RangeInclusive::contains.
This commit is contained in:
@@ -18,6 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Added
|
## 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
|
### Servers — edit existing profiles
|
||||||
|
|
||||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)**
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -20,12 +20,13 @@ use crate::dto::{
|
|||||||
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
||||||
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
||||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
||||||
LibraryTracksEnvelope, OfflinePathDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||||
|
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||||
TrackArtifactDto, TrackFactDto, TrackRefDto,
|
TrackArtifactDto, TrackFactDto, TrackRefDto,
|
||||||
};
|
};
|
||||||
use crate::live_search;
|
use crate::live_search;
|
||||||
use crate::payload::LibrarySyncProgressPayload;
|
use crate::payload::LibrarySyncProgressPayload;
|
||||||
use crate::repos::{SyncStateRepository, TrackRepository};
|
use crate::repos::{PlaySessionRepository, SyncStateRepository, TrackRepository};
|
||||||
use crate::runtime::{CurrentJob, LibraryRuntime, SyncSession};
|
use crate::runtime::{CurrentJob, LibraryRuntime, SyncSession};
|
||||||
use crate::search::search_tracks;
|
use crate::search::search_tracks;
|
||||||
use crate::store::LibraryStore;
|
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())
|
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<PlaySessionYearSummaryDto, String> {
|
||||||
|
PlaySessionRepository::new(&runtime.store).year_summary(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn library_get_player_stats_heatmap(
|
||||||
|
runtime: State<'_, LibraryRuntime>,
|
||||||
|
year: i32,
|
||||||
|
) -> Result<Vec<PlaySessionHeatmapDayDto>, String> {
|
||||||
|
PlaySessionRepository::new(&runtime.store).heatmap(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn library_get_player_stats_day_detail(
|
||||||
|
runtime: State<'_, LibraryRuntime>,
|
||||||
|
date_iso: String,
|
||||||
|
) -> Result<PlaySessionDayDetailDto, String> {
|
||||||
|
PlaySessionRepository::new(&runtime.store).day_detail(&date_iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn library_get_player_stats_year_bounds(
|
||||||
|
runtime: State<'_, LibraryRuntime>,
|
||||||
|
) -> Result<PlaySessionYearBoundsDto, String> {
|
||||||
|
PlaySessionRepository::new(&runtime.store).year_bounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn library_get_player_stats_recent_days(
|
||||||
|
runtime: State<'_, LibraryRuntime>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
) -> Result<Vec<PlaySessionRecentDayDto>, String> {
|
||||||
|
PlaySessionRepository::new(&runtime.store).recent_days(limit.unwrap_or(30))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn library_purge_server(
|
pub fn library_purge_server(
|
||||||
runtime: State<'_, LibraryRuntime>,
|
runtime: State<'_, LibraryRuntime>,
|
||||||
@@ -1013,6 +1061,10 @@ pub fn library_purge_server(
|
|||||||
"DELETE FROM track_id_history WHERE server_id = ?1",
|
"DELETE FROM track_id_history WHERE server_id = ?1",
|
||||||
params![server_id],
|
params![server_id],
|
||||||
)?;
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM play_session WHERE server_id = ?1",
|
||||||
|
params![server_id],
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM track WHERE server_id = ?1",
|
"DELETE FROM track WHERE server_id = ?1",
|
||||||
params![server_id],
|
params![server_id],
|
||||||
|
|||||||
@@ -281,6 +281,94 @@ fn default_confidence() -> f64 {
|
|||||||
1.0
|
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<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
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<PlaySessionDayTrackDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<i32>,
|
||||||
|
pub max_year: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// `library_purge_server` outcome.
|
/// `library_purge_server` outcome.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
pub mod artifact;
|
pub mod artifact;
|
||||||
pub mod fact;
|
pub mod fact;
|
||||||
|
pub mod play_session;
|
||||||
pub mod sync_state;
|
pub mod sync_state;
|
||||||
pub mod track;
|
pub mod track;
|
||||||
pub mod track_id_history;
|
pub mod track_id_history;
|
||||||
|
|
||||||
pub use artifact::ArtifactRepository;
|
pub use artifact::ArtifactRepository;
|
||||||
pub use fact::FactRepository;
|
pub use fact::FactRepository;
|
||||||
|
pub use play_session::PlaySessionRepository;
|
||||||
pub use sync_state::SyncStateRepository;
|
pub use sync_state::SyncStateRepository;
|
||||||
pub use track::{RemapEntry, RemapStats, TrackRepository, TrackRow};
|
pub use track::{RemapEntry, RemapStats, TrackRepository, TrackRow};
|
||||||
pub use track_id_history::TrackIdHistoryRepository;
|
pub use track_id_history::TrackIdHistoryRepository;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PlaySpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PlaySessionYearSummaryDto, String> {
|
||||||
|
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::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
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<Vec<PlaySessionHeatmapDayDto>, 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::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn day_detail(&self, date_iso: &str) -> Result<PlaySessionDayDetailDto, String> {
|
||||||
|
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::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
let plays: Vec<PlaySpan> = 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<PlaySessionYearBoundsDto, String> {
|
||||||
|
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<i64>>(0)?.map(|y| y as i32),
|
||||||
|
max_year: row.get::<_, Option<i64>>(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<Vec<PlaySessionRecentDayDto>, 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<String, DayAgg> = 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<PlaySessionRecentDayDto> = 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -365,6 +365,7 @@ fn remap_existing_to_new(
|
|||||||
"track_fact",
|
"track_fact",
|
||||||
"track_artifact",
|
"track_artifact",
|
||||||
"track_canonical_link",
|
"track_canonical_link",
|
||||||
|
"play_session",
|
||||||
] {
|
] {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
&format!(
|
&format!(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use tauri::Manager;
|
|||||||
|
|
||||||
/// Current head of the embedded migrations. Bump each time a new
|
/// Current head of the embedded migrations. Bump each time a new
|
||||||
/// `migrations/NNN_*.sql` is added.
|
/// `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
|
/// Lowest applied schema version the current code can advance from purely
|
||||||
/// additively. If a DB carries a version below this, the breaking-bump hook
|
/// 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");
|
include_str!("../migrations/004_track_title_index.sql");
|
||||||
const MIGRATION_005_TRACK_GENRE_YEAR_INDEXES: &str =
|
const MIGRATION_005_TRACK_GENRE_YEAR_INDEXES: &str =
|
||||||
include_str!("../migrations/005_track_genre_year_indexes.sql");
|
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
|
/// Embedded migrations. Ordered ascending by `version`; the runner sorts
|
||||||
/// defensively before applying so the source order can stay readable.
|
/// defensively before applying so the source order can stay readable.
|
||||||
@@ -38,6 +39,7 @@ const MIGRATIONS: &[(i64, &str)] = &[
|
|||||||
(3, MIGRATION_003_TRACK_REMAP_INDEXES),
|
(3, MIGRATION_003_TRACK_REMAP_INDEXES),
|
||||||
(4, MIGRATION_004_TRACK_TITLE_INDEX),
|
(4, MIGRATION_004_TRACK_TITLE_INDEX),
|
||||||
(5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES),
|
(5, MIGRATION_005_TRACK_GENRE_YEAR_INDEXES),
|
||||||
|
(6, MIGRATION_006_PLAY_SESSION),
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -594,6 +594,12 @@ pub fn run() {
|
|||||||
psysonic_library::commands::library_patch_track,
|
psysonic_library::commands::library_patch_track,
|
||||||
psysonic_library::commands::library_put_artifact,
|
psysonic_library::commands::library_put_artifact,
|
||||||
psysonic_library::commands::library_put_fact,
|
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_purge_server,
|
||||||
psysonic_library::commands::library_delete_server_data,
|
psysonic_library::commands::library_delete_server_data,
|
||||||
psysonic_syncfs::cache::offline::download_track_offline,
|
psysonic_syncfs::cache::offline::download_track_offline,
|
||||||
|
|||||||
@@ -464,6 +464,95 @@ export function libraryDeleteServerData(serverId: string): Promise<void> {
|
|||||||
return invoke<void>('library_delete_server_data', { serverId });
|
return invoke<void>('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<void> {
|
||||||
|
return invoke<void>('library_record_play_session', { input });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryGetPlayerStatsYearSummary(year: number): Promise<PlaySessionYearSummary> {
|
||||||
|
return invoke<PlaySessionYearSummary>('library_get_player_stats_year_summary', { year });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryGetPlayerStatsHeatmap(year: number): Promise<PlaySessionHeatmapDay[]> {
|
||||||
|
return invoke<PlaySessionHeatmapDay[]>('library_get_player_stats_heatmap', { year });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryGetPlayerStatsDayDetail(dateIso: string): Promise<PlaySessionDayDetail> {
|
||||||
|
return invoke<PlaySessionDayDetail>('library_get_player_stats_day_detail', { dateIso });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryGetPlayerStatsYearBounds(): Promise<PlaySessionYearBounds> {
|
||||||
|
return invoke<PlaySessionYearBounds>('library_get_player_stats_year_bounds');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryGetPlayerStatsRecentDays(limit = 30): Promise<PlaySessionRecentDay[]> {
|
||||||
|
return invoke<PlaySessionRecentDay[]>('library_get_player_stats_recent_days', { limit });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event subscriptions ───────────────────────────────────────────────
|
// ── Event subscriptions ───────────────────────────────────────────────
|
||||||
|
|
||||||
export interface LibrarySyncProgressPayload {
|
export interface LibrarySyncProgressPayload {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export default function AppRoutes() {
|
|||||||
<Route path="/search" element={<SearchResults />} />
|
<Route path="/search" element={<SearchResults />} />
|
||||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||||
<Route path="/statistics" element={<Statistics />} />
|
<Route path="/statistics" element={<Statistics />} />
|
||||||
|
<Route path="/player-stats" element={<Statistics />} />
|
||||||
<Route path="/most-played" element={<MostPlayed />} />
|
<Route path="/most-played" element={<MostPlayed />} />
|
||||||
<Route path="/lossless-albums" element={<LosslessAlbums />} />
|
<Route path="/lossless-albums" element={<LosslessAlbums />} />
|
||||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||||
|
|||||||
@@ -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<PlaySessionYearBounds | null>(null);
|
||||||
|
const [summary, setSummary] = useState<PlaySessionYearSummary | null>(null);
|
||||||
|
const [dayCounts, setDayCounts] = useState<Map<string, number>>(new Map());
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(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 (
|
||||||
|
<div className="stats-page">
|
||||||
|
<PlayerStatsPartialIndexNotice />
|
||||||
|
<div className="player-stats-year-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-surface btn-sm"
|
||||||
|
onClick={() => canGoPrev && setYear(y => y - 1)}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label={t('statistics.playerYearPrev')}
|
||||||
|
aria-disabled={!canGoPrev}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{year}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-surface btn-sm"
|
||||||
|
onClick={() => canGoNext && setYear(y => y + 1)}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label={t('statistics.playerYearNext')}
|
||||||
|
aria-disabled={!canGoNext}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-center"><div className="spinner" /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!empty && summary && (
|
||||||
|
<div className="stats-overview" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<div className="stats-card">
|
||||||
|
<span className="stats-card-value">{formatPlayerStatsListeningTotal(summary.totalListenedSec)}</span>
|
||||||
|
<span className="stats-card-label">{t('statistics.playerSummaryTime')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stats-card">
|
||||||
|
<span className="stats-card-value">{summary.sessionCount.toLocaleString()}</span>
|
||||||
|
<span className="stats-card-label">{t('statistics.playerSummarySessions')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stats-card">
|
||||||
|
<span className="stats-card-value">{summary.trackPlayCount.toLocaleString()}</span>
|
||||||
|
<span className="stats-card-label">{t('statistics.playerSummaryTracks')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stats-card">
|
||||||
|
<span className="stats-card-value">{summary.uniqueTrackCount.toLocaleString()}</span>
|
||||||
|
<span className="stats-card-label">{t('statistics.playerSummaryUniqueTracks')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stats-card">
|
||||||
|
<span className="stats-card-value">{summary.listeningDayCount.toLocaleString()}</span>
|
||||||
|
<span className="stats-card-label">{t('statistics.playerSummaryDays')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stats-card">
|
||||||
|
<span className="stats-card-value">{summary.fullCount} / {summary.partialCount}</span>
|
||||||
|
<span className="stats-card-label">{t('statistics.playerSummaryCompletion')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{empty && (
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>{t('statistics.playerEmpty')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlayerStatsHeatmap
|
||||||
|
year={year}
|
||||||
|
dayCounts={dayCounts}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
onSelectDate={setSelectedDate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<PlayerStatsRecentDays
|
||||||
|
heatmapSelectedDate={selectedDate}
|
||||||
|
refreshKey={recentRefreshKey}
|
||||||
|
liveRefreshKey={liveRefreshKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ul className="player-stats-day-tracks">
|
||||||
|
{detail.tracks.map(tr => (
|
||||||
|
<li key={`${tr.serverId}:${tr.trackId}:${tr.startedAtMs}`}>
|
||||||
|
<div className="player-stats-day-track-title">{tr.title}</div>
|
||||||
|
<div className="player-stats-day-track-meta">
|
||||||
|
{tr.artist ?? '—'}
|
||||||
|
{' · '}
|
||||||
|
{formatPlayerStatsListenedSec(tr.listenedSec)}
|
||||||
|
{' · '}
|
||||||
|
{tr.completion === 'full'
|
||||||
|
? t('statistics.completionFull')
|
||||||
|
: t('statistics.completionPartial')}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, number>;
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div ref={wrapRef} className="player-heatmap-wrap">
|
||||||
|
<div className="player-heatmap" style={heatmapVars}>
|
||||||
|
<div className="player-heatmap-months" aria-hidden="true">
|
||||||
|
{monthLabels.map(m => (
|
||||||
|
<span
|
||||||
|
key={`${m.columnIndex}-${m.label}`}
|
||||||
|
className="player-heatmap-month"
|
||||||
|
style={{ '--column-index': m.columnIndex } as CSSProperties}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="player-heatmap-body">
|
||||||
|
<div className="player-heatmap-weekdays" aria-hidden="true">
|
||||||
|
{weekdayLabels.map((label, i) => (
|
||||||
|
<span key={i}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="player-heatmap-columns">
|
||||||
|
{weeks.map((week, wi) => (
|
||||||
|
<div key={wi} className="player-heatmap-col">
|
||||||
|
{week.map((cellItem, di) => {
|
||||||
|
if (!cellItem.date) {
|
||||||
|
return <span key={di} className="player-heatmap-cell player-heatmap-cell--pad" />;
|
||||||
|
}
|
||||||
|
const level = heatmapLevel(cellItem.count, maxCount);
|
||||||
|
const active = selectedDate === cellItem.date;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cellItem.date}
|
||||||
|
type="button"
|
||||||
|
className={cellClass(cellItem, level, active)}
|
||||||
|
style={cellStyle(cellItem, level)}
|
||||||
|
title={`${cellItem.date}: ${t('statistics.playerDayTrackPlays', { count: cellItem.count })}`}
|
||||||
|
aria-label={`${cellItem.date}: ${t('statistics.playerDayTrackPlays', { count: cellItem.count })}`}
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onSelectDate(cellItem.date)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="player-heatmap-legend" aria-hidden="true">
|
||||||
|
<span>{t('statistics.playerHeatmapLess')}</span>
|
||||||
|
{Array.from({ length: HEATMAP_LEVEL_COUNT }, (_, level) => (
|
||||||
|
<span
|
||||||
|
key={level}
|
||||||
|
className={`player-heatmap-cell player-heatmap-cell--sample${level === 0 ? ' player-heatmap-cell--empty' : ''}`}
|
||||||
|
style={level === 0 ? undefined : {
|
||||||
|
background: `color-mix(in srgb, var(--accent) ${Math.round(LEVEL_OPACITY[level as 0 | 1 | 2 | 3 | 4] * 100)}%, transparent)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span>{t('statistics.playerHeatmapMore')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="settings-hint settings-hint-info player-stats-partial-index-notice" role="status">
|
||||||
|
<Info size={16} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<span>
|
||||||
|
{t('statistics.playerPartialIndexNotice')}
|
||||||
|
{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="player-stats-partial-index-link"
|
||||||
|
onClick={() => navigate('/settings', { state: { tab: 'library' } })}
|
||||||
|
>
|
||||||
|
{t('statistics.playerPartialIndexSettings')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, unknown>) => 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<PlaySessionRecentDay[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedDates, setExpandedDates] = useState<Set<string>>(() => new Set());
|
||||||
|
const [details, setDetails] = useState<Map<string, PlaySessionDayDetail>>(() => new Map());
|
||||||
|
const [loadingDates, setLoadingDates] = useState<Set<string>>(() => 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 (
|
||||||
|
<section className="player-stats-recent">
|
||||||
|
<h2 className="section-title">{t('statistics.playerRecentDaysTitle')}</h2>
|
||||||
|
<div className="loading-center" style={{ minHeight: '4rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div key={date} className={`player-stats-day-item${open ? ' player-stats-day-item--open' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="player-stats-day-header"
|
||||||
|
onClick={() => toggleDate(date)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="player-stats-day-header-text">
|
||||||
|
<span className="player-stats-day-label">{label}</span>
|
||||||
|
{meta && <span className="player-stats-day-summary">{meta}</span>}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={16} className="player-stats-day-chevron" aria-hidden />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="player-stats-day-body">
|
||||||
|
{pending && !detail && (
|
||||||
|
<div className="loading-center" style={{ minHeight: '2.5rem' }}>
|
||||||
|
<div className="spinner" style={{ width: 16, height: 16 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail && <PlayerStatsDayTracks detail={detail} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="player-stats-recent">
|
||||||
|
<h2 className="section-title">{t('statistics.playerRecentDaysTitle')}</h2>
|
||||||
|
<div className="player-stats-day-list">
|
||||||
|
{extraHeatmapDay && renderRow(extraHeatmapDay, null)}
|
||||||
|
{days.map(day => renderRow(day.date, day))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${!isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
onClick={() => navigate('/statistics')}
|
||||||
|
>
|
||||||
|
{t('statistics.tabServer')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${isPlayer ? 'btn-primary' : 'btn-surface'}`}
|
||||||
|
onClick={() => navigate('/player-stats')}
|
||||||
|
>
|
||||||
|
{t('statistics.tabPlayer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -124,6 +124,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Home album rails: stable play/enqueue hover on WebKitGTK/Wayland (PR #787)',
|
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
import { flushPlayQueuePosition } from '../../store/queueSync';
|
import { flushPlayQueuePosition } from '../../store/queueSync';
|
||||||
|
import { playListenSessionFinalize } from '../../store/playListenSession';
|
||||||
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
|
import { getPlaybackProgressSnapshot } from '../../store/playbackProgress';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
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
|
// 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.
|
// is the safety net for anything that didn't make it out in time.
|
||||||
const performExit = async () => {
|
const performExit = async () => {
|
||||||
|
await Promise.race([
|
||||||
|
playListenSessionFinalize('close'),
|
||||||
|
new Promise(r => setTimeout(r, 1500)),
|
||||||
|
]);
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
flushPlayQueuePosition(),
|
flushPlayQueuePosition(),
|
||||||
new Promise(r => setTimeout(r, 1500)),
|
new Promise(r => setTimeout(r, 1500)),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -62,4 +62,33 @@ export const statistics = {
|
|||||||
exportSaving: 'Speichere…',
|
exportSaving: 'Speichere…',
|
||||||
exportSaved: 'Gespeichert.',
|
exportSaved: 'Gespeichert.',
|
||||||
exportSaveFailed: 'Bild konnte nicht gespeichert werden.',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,4 +62,33 @@ export const statistics = {
|
|||||||
exportSaving: 'Saving…',
|
exportSaving: 'Saving…',
|
||||||
exportSaved: 'Saved.',
|
exportSaved: 'Saved.',
|
||||||
exportSaveFailed: 'Could not save the image.',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,4 +44,33 @@ export const statistics = {
|
|||||||
topRatedArtists: 'Artistas Mejor Calificados',
|
topRatedArtists: 'Artistas Mejor Calificados',
|
||||||
noRatedSongs: 'Aún no hay canciones calificadas. Califica canciones en la vista de álbum o playlist.',
|
noRatedSongs: 'Aún no hay canciones calificadas. Califica canciones en la vista de álbum o playlist.',
|
||||||
noRatedArtists: 'Aún no hay artistas calificados.',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,4 +44,33 @@ export const statistics = {
|
|||||||
topRatedArtists: 'Artistes les mieux notés',
|
topRatedArtists: 'Artistes les mieux notés',
|
||||||
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
|
noRatedSongs: 'Aucun morceau noté. Notez des morceaux dans la vue album ou playlist.',
|
||||||
noRatedArtists: 'Aucun artiste noté.',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,4 +44,33 @@ export const statistics = {
|
|||||||
topRatedArtists: 'Høyest vurderte artister',
|
topRatedArtists: 'Høyest vurderte artister',
|
||||||
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
|
noRatedSongs: 'Ingen vurderte spor ennå. Vurder spor i album- eller spillelistevisning.',
|
||||||
noRatedArtists: 'Ingen vurderte artister ennå.',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,4 +44,33 @@ export const statistics = {
|
|||||||
topRatedArtists: 'Best beoordeelde artiesten',
|
topRatedArtists: 'Best beoordeelde artiesten',
|
||||||
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
|
noRatedSongs: 'Nog geen beoordeelde nummers. Beoordeel nummers in album- of afspeellijstweergave.',
|
||||||
noRatedArtists: 'Nog geen beoordeelde artiesten.',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,4 +62,33 @@ export const statistics = {
|
|||||||
exportSaving: 'Se salvează…',
|
exportSaving: 'Se salvează…',
|
||||||
exportSaved: 'Salvat.',
|
exportSaved: 'Salvat.',
|
||||||
exportSaveFailed: 'Nu s-a putut salva imaginea.',
|
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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,4 +55,33 @@ export const statistics = {
|
|||||||
topRatedArtists: 'Лучшие по рейтингу исполнители',
|
topRatedArtists: 'Лучшие по рейтингу исполнители',
|
||||||
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
|
noRatedSongs: 'Нет оценённых треков. Ставьте оценки в альбомах или плейлистах.',
|
||||||
noRatedArtists: 'Нет оценённых исполнителей.',
|
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: 'Частично',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,4 +44,33 @@ export const statistics = {
|
|||||||
topRatedArtists: '最高评分艺人',
|
topRatedArtists: '最高评分艺人',
|
||||||
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
|
noRatedSongs: '暂无已评分歌曲。请在专辑或播放列表中为歌曲评分。',
|
||||||
noRatedArtists: '暂无已评分艺人。',
|
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: '部分',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { Share2 } from 'lucide-react';
|
|||||||
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import StatsExportModal from '../components/StatsExportModal';
|
import StatsExportModal from '../components/StatsExportModal';
|
||||||
|
import PlayerStatisticsPanel from '../components/statistics/PlayerStatisticsPanel';
|
||||||
|
import StatisticsTabBar from '../components/statistics/StatisticsTabBar';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/authStore';
|
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';
|
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -31,7 +33,8 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
|
|||||||
|
|
||||||
export default function Statistics() {
|
export default function Statistics() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const location = useLocation();
|
||||||
|
const isPlayerStats = location.pathname === '/player-stats';
|
||||||
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||||
@@ -172,8 +175,11 @@ export default function Statistics() {
|
|||||||
return (
|
return (
|
||||||
<div className="content-body animate-fade-in">
|
<div className="content-body animate-fade-in">
|
||||||
<h1 className="page-title">{t('statistics.title')}</h1>
|
<h1 className="page-title">{t('statistics.title')}</h1>
|
||||||
|
<StatisticsTabBar />
|
||||||
|
|
||||||
{loading ? (
|
{isPlayerStats ? (
|
||||||
|
<PlayerStatisticsPanel />
|
||||||
|
) : loading ? (
|
||||||
<div className="loading-center"><div className="spinner" /></div>
|
<div className="loading-center"><div className="spinner" /></div>
|
||||||
) : (
|
) : (
|
||||||
<div className="stats-page">
|
<div className="stats-page">
|
||||||
@@ -394,11 +400,13 @@ export default function Statistics() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isPlayerStats && (
|
||||||
<StatsExportModal
|
<StatsExportModal
|
||||||
open={exportOpen}
|
open={exportOpen}
|
||||||
albums={frequent}
|
albums={frequent}
|
||||||
onClose={() => setExportOpen(false)}
|
onClose={() => setExportOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
|
||||||
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
|
import { notifyLibraryPlaybackHint } from './libraryPlaybackHint';
|
||||||
|
import {
|
||||||
|
playListenSessionFinalize,
|
||||||
|
playListenSessionOnProgress,
|
||||||
|
playListenSessionOnTrackSwitched,
|
||||||
|
playListenSessionOpen,
|
||||||
|
} from './playListenSession';
|
||||||
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
|
import { getPerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
|
||||||
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
import { getPlaybackServerId } from '../utils/playback/playbackServer';
|
||||||
@@ -75,13 +81,15 @@ export type NormalizationStatePayload = {
|
|||||||
targetLufs: number;
|
targetLufs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function handleAudioPlaying(_duration: number): void {
|
export function handleAudioPlaying(duration: number): void {
|
||||||
setDeferHotCachePrefetch(false);
|
setDeferHotCachePrefetch(false);
|
||||||
resetProgressEmitThrottles();
|
resetProgressEmitThrottles();
|
||||||
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
|
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');
|
notifyLibraryPlaybackHint('playing');
|
||||||
|
const track = usePlayerStore.getState().currentTrack;
|
||||||
|
if (track) {
|
||||||
|
void playListenSessionOpen(track, getPlaybackServerId(), duration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleAudioProgress(
|
export function handleAudioProgress(
|
||||||
@@ -142,6 +150,7 @@ export function handleAudioProgress(
|
|||||||
const dur = duration > 0 ? duration : track.duration;
|
const dur = duration > 0 ? duration : track.duration;
|
||||||
if (dur <= 0) return;
|
if (dur <= 0) return;
|
||||||
const progress = displayTime / dur;
|
const progress = displayTime / dur;
|
||||||
|
playListenSessionOnProgress(current_time, buffering, dur).catch(() => {});
|
||||||
if (!progressUiDisabled) {
|
if (!progressUiDisabled) {
|
||||||
const nowLive = Date.now();
|
const nowLive = Date.now();
|
||||||
const live = getPlaybackProgressSnapshot();
|
const live = getPlaybackProgressSnapshot();
|
||||||
@@ -311,16 +320,14 @@ export function handleAudioProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handleAudioEnded(): void {
|
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');
|
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) {
|
if (Date.now() - getLastGaplessSwitchTime() < 600) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void playListenSessionFinalize('ended');
|
||||||
|
|
||||||
// Radio stream disconnected — just stop; don't advance queue.
|
// Radio stream disconnected — just stop; don't advance queue.
|
||||||
if (usePlayerStore.getState().currentRadio) {
|
if (usePlayerStore.getState().currentRadio) {
|
||||||
setIsAudioPaused(false);
|
setIsAudioPaused(false);
|
||||||
@@ -392,6 +399,8 @@ export function handleAudioTrackSwitched(_duration: number): void {
|
|||||||
|
|
||||||
if (!nextTrack) return;
|
if (!nextTrack) return;
|
||||||
|
|
||||||
|
void playListenSessionOnTrackSwitched(nextTrack);
|
||||||
|
|
||||||
const switchServerId = getPlaybackServerId();
|
const switchServerId = getPlaybackServerId();
|
||||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> | null = null;
|
||||||
|
|
||||||
|
function clearOpen(): void {
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-known track length in seconds from player metadata and/or engine. */
|
||||||
|
export function resolveDurationSecHint(
|
||||||
|
track: Pick<Track, 'duration'> | 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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const serverId = getPlaybackServerId();
|
||||||
|
await playListenSessionFinalize('switch');
|
||||||
|
await playListenSessionOpen(nextTrack, serverId, nextTrack.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only reset */
|
||||||
|
export function _resetPlayListenSessionForTest(): void {
|
||||||
|
open = null;
|
||||||
|
finalizeInFlight = null;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<PlaySessionRecordedDetail>(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<PlaySessionRecordedDetail>;
|
||||||
|
if (!ce?.detail) return;
|
||||||
|
listener(ce.detail);
|
||||||
|
};
|
||||||
|
window.addEventListener(EVENT_NAME, wrapped as EventListener);
|
||||||
|
return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import type { PlayerState, Track } from './playerStoreTypes';
|
import type { PlayerState, Track } from './playerStoreTypes';
|
||||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||||
import { syncQueueToServer } from './queueSync';
|
import { syncQueueToServer } from './queueSync';
|
||||||
|
import { playListenSessionFinalize } from './playListenSession';
|
||||||
import { pushQueueUndoFromGetter } from './queueUndo';
|
import { pushQueueUndoFromGetter } from './queueUndo';
|
||||||
import { stopRadio } from './radioPlayer';
|
import { stopRadio } from './radioPlayer';
|
||||||
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
||||||
@@ -152,6 +153,8 @@ export function runPlayTrack(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void playListenSessionFinalize('skip');
|
||||||
|
|
||||||
clearAllPlaybackScheduleTimers();
|
clearAllPlaybackScheduleTimers();
|
||||||
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { clearSeekDebounce } from './seekDebounce';
|
|||||||
import { clearSeekFallbackRetry } from './seekFallbackState';
|
import { clearSeekFallbackRetry } from './seekFallbackState';
|
||||||
import { clearSeekTarget } from './seekTargetState';
|
import { clearSeekTarget } from './seekTargetState';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
|
import { playListenSessionFinalize } from './playListenSession';
|
||||||
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
import { playbackServerDiffersFromActive, clearQueueServerForPlayback } from '../utils/playback/playbackServer';
|
||||||
import { useLuckyMixStore } from './luckyMixStore';
|
import { useLuckyMixStore } from './luckyMixStore';
|
||||||
import { showToast } from '../utils/ui/toast';
|
import { showToast } from '../utils/ui/toast';
|
||||||
@@ -207,6 +208,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearQueue: () => {
|
clearQueue: () => {
|
||||||
|
void playListenSessionFinalize('stop');
|
||||||
invoke('audio_stop').catch(console.error);
|
invoke('audio_stop').catch(console.error);
|
||||||
setIsAudioPaused(false);
|
setIsAudioPaused(false);
|
||||||
clearSeekFallbackRetry();
|
clearSeekFallbackRetry();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import { setIsAudioPaused } from './engineState';
|
import { setIsAudioPaused } from './engineState';
|
||||||
import type { PlayerState } from './playerStoreTypes';
|
import type { PlayerState } from './playerStoreTypes';
|
||||||
import { flushQueueSyncToServer } from './queueSync';
|
import { flushQueueSyncToServer } from './queueSync';
|
||||||
|
import { playListenSessionFinalize } from './playListenSession';
|
||||||
import { pauseRadio, stopRadio } from './radioPlayer';
|
import { pauseRadio, stopRadio } from './radioPlayer';
|
||||||
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
import { clearAllPlaybackScheduleTimers } from './scheduleTimers';
|
||||||
import { clearSeekDebounce } from './seekDebounce';
|
import { clearSeekDebounce } from './seekDebounce';
|
||||||
@@ -28,6 +29,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
|
|||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
stop: () => {
|
stop: () => {
|
||||||
|
void playListenSessionFinalize('stop');
|
||||||
clearAllPlaybackScheduleTimers();
|
clearAllPlaybackScheduleTimers();
|
||||||
if (get().currentRadio) {
|
if (get().currentRadio) {
|
||||||
stopRadio();
|
stopRadio();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3rem;
|
gap: 3rem;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-overview {
|
.stats-overview {
|
||||||
@@ -103,10 +105,273 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genre-bar-fill {
|
.player-heatmap-wrap {
|
||||||
height: 100%;
|
width: 100%;
|
||||||
background: var(--accent);
|
max-width: 100%;
|
||||||
border-radius: 3px;
|
min-width: 0;
|
||||||
transition: width 0.8s ease-out;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { describe, it, expect, vi } from 'vitest';
|
|||||||
vi.mock('../../i18n', () => ({
|
vi.mock('../../i18n', () => ({
|
||||||
default: {
|
default: {
|
||||||
t: (key: string, params: Record<string, unknown>) => `${key}|${JSON.stringify(params)}`,
|
t: (key: string, params: Record<string, unknown>) => `${key}|${JSON.stringify(params)}`,
|
||||||
|
resolvedLanguage: 'en',
|
||||||
|
language: 'en',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { formatHumanHoursMinutes } from './formatHumanDuration';
|
import { formatHumanHoursMinutes, formatPlayerStatsListeningTotal, formatPlayerStatsListenedSec } from './formatHumanDuration';
|
||||||
|
|
||||||
describe('formatHumanHoursMinutes', () => {
|
describe('formatHumanHoursMinutes', () => {
|
||||||
it('rounds to the nearest minute instead of truncating', () => {
|
it('rounds to the nearest minute instead of truncating', () => {
|
||||||
@@ -33,3 +35,34 @@ describe('formatHumanHoursMinutes', () => {
|
|||||||
expect(formatHumanHoursMinutes(-5)).toBe('common.durationMinutesOnly|{"minutes":0}');
|
expect(formatHumanHoursMinutes(-5)).toBe('common.durationMinutesOnly|{"minutes":0}');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatPlayerStatsListeningTotal', () => {
|
||||||
|
it('formats 25.5 hours as compact day hour minute parts', () => {
|
||||||
|
expect(formatPlayerStatsListeningTotal(25.5 * 3600)).toBe(
|
||||||
|
'statistics.playerListeningDayShort|{"count":1} statistics.playerListeningHourShort|{"count":1} statistics.playerListeningMinuteShort|{"count":30}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows minutes only for sub-hour totals', () => {
|
||||||
|
expect(formatPlayerStatsListeningTotal(45 * 60)).toBe(
|
||||||
|
'statistics.playerListeningMinuteShort|{"count":45}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits zero day and hour parts', () => {
|
||||||
|
expect(formatPlayerStatsListeningTotal(2 * 24 * 3600)).toBe(
|
||||||
|
'statistics.playerListeningDayShort|{"count":2}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPlayerStatsListenedSec', () => {
|
||||||
|
it('uses seconds below one minute', () => {
|
||||||
|
expect(formatPlayerStatsListenedSec(45.6)).toBe('statistics.playerListenedSecShort|{"seconds":46}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses decimal minutes from one minute upward', () => {
|
||||||
|
expect(formatPlayerStatsListenedSec(90)).toBe('statistics.playerListenedMinDecimal|{"minutes":"1.5"}');
|
||||||
|
expect(formatPlayerStatsListenedSec(125)).toBe('statistics.playerListenedMinDecimal|{"minutes":"2.1"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,3 +16,36 @@ export function formatHumanHoursMinutes(seconds: number): string {
|
|||||||
}
|
}
|
||||||
return i18n.t('common.durationMinutesOnly', { minutes: totalMin });
|
return i18n.t('common.durationMinutesOnly', { minutes: totalMin });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Player stats totals: compact days, hours, minutes (omit zero parts). */
|
||||||
|
export function formatPlayerStatsListeningTotal(seconds: number): string {
|
||||||
|
const totalMin = Math.max(0, Math.round(seconds / 60));
|
||||||
|
const days = Math.floor(totalMin / 1440);
|
||||||
|
const hours = Math.floor((totalMin % 1440) / 60);
|
||||||
|
const minutes = totalMin % 60;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (days > 0) {
|
||||||
|
parts.push(i18n.t('statistics.playerListeningDayShort', { count: days }));
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
parts.push(i18n.t('statistics.playerListeningHourShort', { count: hours }));
|
||||||
|
}
|
||||||
|
if (minutes > 0 || parts.length === 0) {
|
||||||
|
parts.push(i18n.t('statistics.playerListeningMinuteShort', { count: minutes }));
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-track listened time in player stats drill-down. */
|
||||||
|
export function formatPlayerStatsListenedSec(seconds: number): string {
|
||||||
|
const sec = Math.max(0, seconds);
|
||||||
|
if (sec >= 60) {
|
||||||
|
const minutes = (sec / 60).toLocaleString(i18n.resolvedLanguage ?? i18n.language, {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
return i18n.t('statistics.playerListenedMinDecimal', { minutes });
|
||||||
|
}
|
||||||
|
return i18n.t('statistics.playerListenedSecShort', { seconds: Math.round(sec) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatPlayerStatsDayLabel } from './formatPlayerStatsDay';
|
||||||
|
|
||||||
|
const t = (key: string) => {
|
||||||
|
if (key === 'statistics.playerDayToday') return 'Today';
|
||||||
|
if (key === 'statistics.playerDayYesterday') return 'Yesterday';
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('formatPlayerStatsDayLabel', () => {
|
||||||
|
it('labels today and yesterday', () => {
|
||||||
|
const today = new Date();
|
||||||
|
const y = today.getFullYear();
|
||||||
|
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(today.getDate()).padStart(2, '0');
|
||||||
|
expect(formatPlayerStatsDayLabel(`${y}-${m}-${d}`, t, 'en')).toBe('Today');
|
||||||
|
|
||||||
|
const yest = new Date(today);
|
||||||
|
yest.setDate(yest.getDate() - 1);
|
||||||
|
const ym = String(yest.getMonth() + 1).padStart(2, '0');
|
||||||
|
const yd = String(yest.getDate()).padStart(2, '0');
|
||||||
|
expect(formatPlayerStatsDayLabel(`${yest.getFullYear()}-${ym}-${yd}`, t, 'en')).toBe('Yesterday');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const PLAYER_STATS_RECENT_DAYS_LIMIT = 30;
|
||||||
|
|
||||||
|
export function localTodayIso(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDate(dateIso: string): Date {
|
||||||
|
const [y, m, d] = dateIso.split('-').map(Number);
|
||||||
|
return new Date(y, m - 1, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfLocalDay(date: Date): Date {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable day header for recent-days accordion. */
|
||||||
|
export function formatPlayerStatsDayLabel(
|
||||||
|
dateIso: string,
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||||
|
locale?: string,
|
||||||
|
): string {
|
||||||
|
const date = parseLocalDate(dateIso);
|
||||||
|
const today = startOfLocalDay(new Date());
|
||||||
|
const day = startOfLocalDay(date);
|
||||||
|
const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000);
|
||||||
|
|
||||||
|
if (diffDays === 0) return t('statistics.playerDayToday');
|
||||||
|
if (diffDays === 1) return t('statistics.playerDayYesterday');
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale, {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: date.getFullYear() !== today.getFullYear() ? 'numeric' : undefined,
|
||||||
|
});
|
||||||
|
return fmt.format(date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { heatmapCellMetrics } from './heatmapLevels';
|
||||||
|
|
||||||
|
describe('heatmapCellMetrics', () => {
|
||||||
|
it('shrinks cells to fit narrow containers', () => {
|
||||||
|
const m = heatmapCellMetrics(400, 53);
|
||||||
|
expect(m.cell).toBeLessThan(14);
|
||||||
|
expect(m.cell).toBeGreaterThanOrEqual(4);
|
||||||
|
const total = m.labelW + m.bodyGap + 53 * m.cell + 52 * m.gap;
|
||||||
|
expect(total).toBeLessThanOrEqual(400 + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at 14px on wide containers', () => {
|
||||||
|
const m = heatmapCellMetrics(1200, 53);
|
||||||
|
expect(m.cell).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/** GitHub-style intensity bucket from track play count vs year max. */
|
||||||
|
export function heatmapLevel(count: number, maxCount: number): 0 | 1 | 2 | 3 | 4 {
|
||||||
|
if (count <= 0 || maxCount <= 0) return 0;
|
||||||
|
const ratio = count / maxCount;
|
||||||
|
if (ratio >= 0.75) return 4;
|
||||||
|
if (ratio >= 0.5) return 3;
|
||||||
|
if (ratio >= 0.25) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEATMAP_LEVEL_COUNT = 5;
|
||||||
|
|
||||||
|
export type HeatmapCell = { date: string; count: number };
|
||||||
|
|
||||||
|
export function yearDayKeys(year: number): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const start = new Date(year, 0, 1);
|
||||||
|
const end = new Date(year, 11, 31);
|
||||||
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
out.push(`${y}-${m}-${day}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Week columns (Sun→Sat rows) with leading/trailing padding cells. */
|
||||||
|
export function heatmapWeekColumns(year: number, dayCounts: Map<string, number>): {
|
||||||
|
weeks: HeatmapCell[][];
|
||||||
|
maxCount: number;
|
||||||
|
} {
|
||||||
|
const days = yearDayKeys(year);
|
||||||
|
const maxCount = Math.max(0, ...days.map(d => dayCounts.get(d) ?? 0));
|
||||||
|
const firstDow = new Date(year, 0, 1).getDay();
|
||||||
|
const cells: HeatmapCell[] = [];
|
||||||
|
for (let i = 0; i < firstDow; i++) {
|
||||||
|
cells.push({ date: '', count: 0 });
|
||||||
|
}
|
||||||
|
for (const date of days) {
|
||||||
|
cells.push({ date, count: dayCounts.get(date) ?? 0 });
|
||||||
|
}
|
||||||
|
while (cells.length % 7 !== 0) {
|
||||||
|
cells.push({ date: '', count: 0 });
|
||||||
|
}
|
||||||
|
const weeks: HeatmapCell[][] = [];
|
||||||
|
for (let i = 0; i < cells.length; i += 7) {
|
||||||
|
weeks.push(cells.slice(i, i + 7));
|
||||||
|
}
|
||||||
|
return { weeks, maxCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Month labels aligned to week columns (first day of each month). */
|
||||||
|
export function heatmapMonthLabels(
|
||||||
|
year: number,
|
||||||
|
locale?: string,
|
||||||
|
): { columnIndex: number; label: string }[] {
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale, { month: 'short' });
|
||||||
|
const firstDow = new Date(year, 0, 1).getDay();
|
||||||
|
const labels: { columnIndex: number; label: string }[] = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
const yearStart = new Date(year, 0, 1).getTime();
|
||||||
|
const end = new Date(year, 11, 31);
|
||||||
|
for (let d = new Date(year, 0, 1); d <= end; d.setDate(d.getDate() + 1)) {
|
||||||
|
const month = d.getMonth();
|
||||||
|
if (month === lastMonth) continue;
|
||||||
|
const dayOfYear = Math.floor((d.getTime() - yearStart) / 86_400_000);
|
||||||
|
labels.push({
|
||||||
|
columnIndex: Math.floor((firstDow + dayOfYear) / 7),
|
||||||
|
label: fmt.format(new Date(year, month, 1)),
|
||||||
|
});
|
||||||
|
lastMonth = month;
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekday row labels (Sun–Sat); empty string hides a row label like GitHub. */
|
||||||
|
export function heatmapWeekdayLabels(locale?: string): string[] {
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale, { weekday: 'narrow' });
|
||||||
|
// Jan 4 2026 is Sunday — anchor week for stable weekday order.
|
||||||
|
const anchor = new Date(2026, 0, 4);
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
if (i % 2 === 0) return '';
|
||||||
|
const d = new Date(anchor);
|
||||||
|
d.setDate(anchor.getDate() + i);
|
||||||
|
return fmt.format(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEATMAP_LABEL_W = 24;
|
||||||
|
const HEATMAP_BODY_GAP = 8;
|
||||||
|
const HEATMAP_CELL_MIN = 4;
|
||||||
|
const HEATMAP_CELL_MAX = 14;
|
||||||
|
|
||||||
|
/** Fit square cells to the heatmap container width (week columns only). */
|
||||||
|
export function heatmapCellMetrics(
|
||||||
|
containerWidth: number,
|
||||||
|
weekCount: number,
|
||||||
|
): { cell: number; gap: number; labelW: number; bodyGap: number } {
|
||||||
|
const gap = containerWidth > 640 ? 3 : 2;
|
||||||
|
const available = containerWidth - HEATMAP_LABEL_W - HEATMAP_BODY_GAP;
|
||||||
|
if (available <= 0 || weekCount <= 0) {
|
||||||
|
return { cell: HEATMAP_CELL_MIN, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
|
||||||
|
}
|
||||||
|
const cell = Math.min(
|
||||||
|
HEATMAP_CELL_MAX,
|
||||||
|
Math.max(HEATMAP_CELL_MIN, (available - (weekCount - 1) * gap) / weekCount),
|
||||||
|
);
|
||||||
|
return { cell, gap, labelW: HEATMAP_LABEL_W, bodyGap: HEATMAP_BODY_GAP };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user