diff --git a/src/core/app.rs b/src/core/app.rs index 264c25e..7cbd21b 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -5,6 +5,7 @@ use crate::core::plugin_api::{ use crate::core::sort::{SortContext, SortField, SortOrder, SortState}; use crate::core::source::Source; use crate::core::user_config::{color_to_string, normalize_tick_rate_milliseconds, UserConfig}; +use crate::infra::history::{RecapPeriod, StatsData, StreakSummary}; use crate::infra::network::sync::{PartySession, PartyStatus}; use crate::infra::network::IoEvent; #[cfg(any(feature = "streaming", feature = "audio-decode"))] @@ -21,6 +22,7 @@ use rspotify::{ prelude::*, // Adds Id trait for .id() method }; use std::cell::Cell; +use std::path::PathBuf; use std::sync::mpsc::Sender; #[cfg(any(feature = "streaming", all(feature = "mpris", target_os = "linux")))] use std::sync::Arc; @@ -46,10 +48,11 @@ use rspotify::model::{ /// Sidebar library entries. The "Local Files" entry only appears when the /// `local-files` feature is built in (otherwise there is nothing to browse). #[cfg(feature = "local-files")] -pub const LIBRARY_OPTIONS: [&str; 8] = [ +pub const LIBRARY_OPTIONS: [&str; 9] = [ "Discover", "Recently Played", "Friends", + "Stats", "Liked Songs", "Albums", "Artists", @@ -57,10 +60,11 @@ pub const LIBRARY_OPTIONS: [&str; 8] = [ "Local Files", ]; #[cfg(not(feature = "local-files"))] -pub const LIBRARY_OPTIONS: [&str; 7] = [ +pub const LIBRARY_OPTIONS: [&str; 8] = [ "Discover", "Recently Played", "Friends", + "Stats", "Liked Songs", "Albums", "Artists", @@ -250,6 +254,13 @@ pub struct PendingKeybindingPersist { pub open_settings_key: Key, } +/// State backing the monthly recap popup. +#[derive(Clone, Debug)] +pub struct RecapPromptState { + pub path: PathBuf, + pub listens: usize, +} + #[derive(Clone, Copy, PartialEq, Debug)] pub enum ActiveBlock { Analysis, @@ -278,6 +289,7 @@ pub enum ActiveBlock { Dialog(DialogContext), AnnouncementPrompt, + RecapPrompt, ExitPrompt, Settings, SortMenu, @@ -286,6 +298,7 @@ pub enum ActiveBlock { CreatePlaylistForm, Friends, LocalBrowser, + Stats, } #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] @@ -318,6 +331,7 @@ pub enum RouteId { Dialog, AnnouncementPrompt, + RecapPrompt, ExitPrompt, Settings, HelpMenu, @@ -326,6 +340,7 @@ pub enum RouteId { CreatePlaylist, Friends, LocalBrowser, + Stats, } impl RouteId { @@ -339,6 +354,7 @@ impl RouteId { RouteId::Discover, RouteId::Artists, RouteId::AlbumList, + RouteId::Stats, ]; /// Parse a `startup_route` config token. Unknown / non-context-free strings @@ -351,6 +367,7 @@ impl RouteId { "discover" => Some(RouteId::Discover), "artists" | "library" => Some(RouteId::Artists), "album_list" | "albums" => Some(RouteId::AlbumList), + "stats" => Some(RouteId::Stats), _ => None, } } @@ -364,6 +381,7 @@ impl RouteId { RouteId::Discover => "discover", RouteId::Artists => "artists", RouteId::AlbumList => "album_list", + RouteId::Stats => "stats", _ => "home", } } @@ -1121,6 +1139,18 @@ pub struct App { pub discover_time_range: DiscoverTimeRange, /// Whether we're currently loading discover data pub discover_loading: bool, + /// Period shown on the Stats screen + pub stats_period: RecapPeriod, + /// Whether we're currently loading stats data + pub stats_loading: bool, + /// Selected index in the Stats screen's Top Tracks list + pub stats_selected_track: usize, + /// Aggregated listening stats for the Stats screen + pub stats_data: Option, + /// Cached listening streak summary (Home strip + Stats screen) + pub listening_streaks: Option, + /// Pending monthly recap popup (path + listen count) + pub recap_prompt: Option, // Sort menu state /// Whether the sort menu popup is visible pub sort_menu_visible: bool, @@ -1290,6 +1320,12 @@ impl Default for App { discover_artists_mix: vec![], discover_time_range: DiscoverTimeRange::default(), discover_loading: false, + stats_period: RecapPeriod::ThirtyDays, + stats_loading: false, + stats_selected_track: 0, + stats_data: None, + listening_streaks: None, + recap_prompt: None, artists_list_index: 0, local_playlists: Vec::new(), local_playlists_index: 0, @@ -4971,6 +5007,12 @@ impl App { description: "Show one-time announcements from remote JSON feed".to_string(), value: SettingValue::Bool(self.user_config.behavior.enable_announcements), }, + SettingItem { + id: "behavior.enable_monthly_recap_prompt".to_string(), + name: "Monthly Recap Prompt".to_string(), + description: "Show a popup once a month when your listening recap is ready".to_string(), + value: SettingValue::Bool(self.user_config.behavior.enable_monthly_recap_prompt), + }, #[cfg(feature = "self-update")] SettingItem { id: "behavior.disable_auto_update".to_string(), @@ -5282,7 +5324,9 @@ impl App { SettingItem { id: "keys.generate_recap".to_string(), name: "Generate Listening Recap".to_string(), - description: "Generate and open the 30-day listening recap HTML card".to_string(), + description: + "Generate and open the listening recap HTML card (uses the selected period on the Stats screen, 30 days elsewhere)" + .to_string(), value: SettingValue::Key(key_to_string(&self.user_config.keys.generate_recap)), }, SettingItem { @@ -5591,6 +5635,11 @@ impl App { self.user_config.behavior.enable_announcements = *v; } } + "behavior.enable_monthly_recap_prompt" => { + if let SettingValue::Bool(v) = &setting.value { + self.user_config.behavior.enable_monthly_recap_prompt = *v; + } + } #[cfg(feature = "self-update")] "behavior.disable_auto_update" => { if let SettingValue::Bool(v) = &setting.value { diff --git a/src/core/user_config.rs b/src/core/user_config.rs index 030599e..a9ef51d 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -786,6 +786,7 @@ pub struct BehaviorConfigString { pub enable_announcements: Option, pub announcement_feed_url: Option, pub seen_announcement_ids: Option>, + pub enable_monthly_recap_prompt: Option, pub shuffle_enabled: Option, pub active_source: Option, pub liked_icon: Option, @@ -864,6 +865,7 @@ pub struct BehaviorConfig { pub enable_announcements: bool, pub announcement_feed_url: Option, pub seen_announcement_ids: Vec, + pub enable_monthly_recap_prompt: bool, pub shuffle_enabled: bool, /// The last active source — persisted so it survives restarts. pub active_source: Source, @@ -1170,6 +1172,7 @@ impl UserConfig { enable_announcements: true, announcement_feed_url: None, seen_announcement_ids: Vec::new(), + enable_monthly_recap_prompt: true, shuffle_enabled: false, active_source: Source::default(), liked_icon: "♥".to_string(), @@ -1495,6 +1498,10 @@ impl UserConfig { self.behavior.enable_announcements = enable_announcements; } + if let Some(enable_monthly_recap_prompt) = behavior_config.enable_monthly_recap_prompt { + self.behavior.enable_monthly_recap_prompt = enable_monthly_recap_prompt; + } + if let Some(announcement_feed_url) = behavior_config.announcement_feed_url { let trimmed = announcement_feed_url.trim(); self.behavior.announcement_feed_url = if trimmed.is_empty() { @@ -2041,6 +2048,7 @@ impl UserConfig { enable_announcements: Some(self.behavior.enable_announcements), announcement_feed_url: self.behavior.announcement_feed_url.clone(), seen_announcement_ids: Some(self.behavior.seen_announcement_ids.clone()), + enable_monthly_recap_prompt: Some(self.behavior.enable_monthly_recap_prompt), shuffle_enabled: Some(self.behavior.shuffle_enabled), active_source: Some(self.behavior.active_source.to_config_str().to_string()), liked_icon: Some(self.behavior.liked_icon.clone()), diff --git a/src/infra/history.rs b/src/infra/history.rs index 3aa1a0d..ea24aa4 100644 --- a/src/infra/history.rs +++ b/src/infra/history.rs @@ -1,11 +1,11 @@ -use crate::core::app::App; +use crate::core::app::{ActiveBlock, App, RecapPromptState, RouteId}; use crate::infra::media_metadata::{ current_playback_snapshot, PlaybackItemKind, PlaybackSnapshot, PlaybackSource, }; use anyhow::{anyhow, Result}; -use chrono::{DateTime, Datelike, Duration, Timelike, Utc}; +use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, Timelike, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; @@ -207,6 +207,143 @@ pub enum RecapPeriod { All, } +impl RecapPeriod { + pub const ALL_PERIODS: [RecapPeriod; 5] = [ + RecapPeriod::SevenDays, + RecapPeriod::ThirtyDays, + RecapPeriod::Month, + RecapPeriod::Year, + RecapPeriod::All, + ]; + + pub fn next(self) -> Self { + self.cycle(1) + } + + pub fn prev(self) -> Self { + self.cycle(Self::ALL_PERIODS.len() - 1) + } + + fn cycle(self, offset: usize) -> Self { + let index = Self::ALL_PERIODS + .iter() + .position(|period| *period == self) + .unwrap_or(0); + Self::ALL_PERIODS[(index + offset) % Self::ALL_PERIODS.len()] + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StreakSummary { + pub current_days: u32, + pub longest_days: u32, + pub today_ms: u64, +} + +/// Aggregated listening stats backing the in-app Stats screen. +#[derive(Clone, Debug)] +pub struct StatsData { + pub total_plays: usize, + pub total_time_ms: u64, + pub top_tracks: Vec, + pub top_artists: Vec, + pub top_albums: Vec, + pub days: Vec, +} + +const STATS_LIST_LIMIT: usize = 20; + +pub fn build_stats_data(filtered: &[ListenRecord]) -> StatsData { + StatsData { + total_plays: filtered.len(), + total_time_ms: filtered.iter().map(|record| record.listened_ms).sum(), + top_tracks: aggregate_top_tracks(filtered, STATS_LIST_LIMIT), + top_artists: aggregate_top_artists(filtered, STATS_LIST_LIMIT), + top_albums: aggregate_top_albums(filtered, STATS_LIST_LIMIT), + days: aggregate_days(filtered), + } +} + +pub fn compute_streaks(listens: &[ListenRecord]) -> StreakSummary { + streaks_from_day_totals(&qualified_day_totals(listens)) +} + +/// Listened milliseconds per local day, qualified records only. The history +/// collector keeps one of these updated incrementally so streaks don't require +/// re-reading the listens file after every track. +fn qualified_day_totals(listens: &[ListenRecord]) -> BTreeMap { + let mut totals: BTreeMap = BTreeMap::new(); + for record in listens.iter().filter(|record| record.qualified) { + let date = record.ended_at.with_timezone(&Local).date_naive(); + *totals.entry(date).or_default() += record.listened_ms; + } + totals +} + +fn streaks_from_day_totals(day_totals: &BTreeMap) -> StreakSummary { + let today = Local::now().date_naive(); + let days: BTreeSet = day_totals.keys().copied().collect(); + streaks_from_days( + &days, + today, + day_totals.get(&today).copied().unwrap_or_default(), + ) +} + +fn streaks_from_days(days: &BTreeSet, today: NaiveDate, today_ms: u64) -> StreakSummary { + let mut longest_days = 0u32; + let mut run = 0u32; + let mut prev: Option = None; + let mut last_run = 0u32; + for &day in days { + run = match prev { + Some(prev_day) if day == prev_day + Duration::days(1) => run + 1, + _ => 1, + }; + longest_days = longest_days.max(run); + last_run = run; + prev = Some(day); + } + + // A streak stays alive until midnight: count the trailing run if it ends + // today or yesterday. + let current_days = match prev { + Some(last_day) if last_day == today || last_day + Duration::days(1) == today => last_run, + _ => 0, + }; + + StreakSummary { + current_days, + longest_days, + today_ms, + } +} + +/// Spawn a cloud history sync unless one is already in flight. Overlapping +/// syncs would race on the last-synced timestamp and double-send records; +/// a skipped sync is harmless because every sync sends everything newer than +/// that timestamp, so the next trigger (next song or exit) catches up. +fn spawn_cloud_history_sync( + client: &reqwest::Client, + sync_token: &str, + in_flight: &Arc, +) { + use std::sync::atomic::Ordering; + + if in_flight.swap(true, Ordering::AcqRel) { + return; + } + let client = client.clone(); + let token = sync_token.to_string(); + let in_flight = Arc::clone(in_flight); + tokio::spawn(async move { + if let Err(e) = sync_history_to_cloud_with_client(&client, &token).await { + log::warn!("failed to sync listening history to cloud: {}", e); + } + in_flight.store(false, Ordering::Release); + }); +} + pub fn spawn_history_collector(app: Arc>) { tokio::spawn(async move { let mut collector = HistoryCollector::default(); @@ -215,7 +352,26 @@ pub fn spawn_history_collector(app: Arc>) { let http_client = crate::infra::network::requests::shared_http_client().clone(); // Check on startup - perform_auto_recap_check(&app); + perform_auto_recap_check(&app).await; + + // Streak cache: load the full history once, then keep per-day totals + // updated incrementally from the records this collector appends (avoids + // re-reading the ever-growing listens file after every track). + let mut day_totals: Option> = + match tokio::task::spawn_blocking(load_listens).await { + Ok(Ok(listens)) => Some(qualified_day_totals(&listens)), + Ok(Err(error)) => { + log::warn!("failed to load listens for streak cache: {}", error); + None + } + Err(error) => { + log::warn!("streak cache task failed: {}", error); + None + } + }; + if let Some(totals) = &day_totals { + app.lock().await.listening_streaks = Some(streaks_from_day_totals(totals)); + } // Sync history to cloud on startup let sync_token_opt: Option = if let Ok(app_guard) = app.try_lock() { @@ -224,14 +380,9 @@ pub fn spawn_history_collector(app: Arc>) { None }; + let history_sync_in_flight = Arc::new(std::sync::atomic::AtomicBool::new(false)); if let Some(ref token) = sync_token_opt { - let token = token.clone(); - let client = http_client.clone(); - tokio::spawn(async move { - if let Err(e) = sync_history_to_cloud_with_client(&client, &token).await { - log::warn!("failed to run startup history cloud sync: {}", e); - } - }); + spawn_cloud_history_sync(&http_client, token, &history_sync_in_flight); } // Now-playing tracking state @@ -242,7 +393,7 @@ pub fn spawn_history_collector(app: Arc>) { if last_auto_check.elapsed().as_secs() >= 3600 { last_auto_check = Instant::now(); - perform_auto_recap_check(&app); + perform_auto_recap_check(&app).await; } let snapshot = if let Ok(app) = app.try_lock() { @@ -278,13 +429,43 @@ pub fn spawn_history_collector(app: Arc>) { } } - if let Err(error) = collector.observe(snapshot) { - log::warn!("listening history collector failed: {}", error); + match collector.observe(snapshot) { + Ok(Some(record)) => { + if record.qualified { + if let Some(totals) = &mut day_totals { + let date = record.ended_at.with_timezone(&Local).date_naive(); + *totals.entry(date).or_default() += record.listened_ms; + app.lock().await.listening_streaks = Some(streaks_from_day_totals(totals)); + } + } + // Push the finished listen to the cloud right away instead of only + // at startup/exit; long sessions otherwise pile up records that + // stall the exit sync. + if let Some(ref token) = sync_token_opt { + spawn_cloud_history_sync(&http_client, token, &history_sync_in_flight); + } + } + Ok(None) => {} + Err(error) => { + log::warn!("listening history collector failed: {}", error); + } } } }); } +/// Default output path for the shareable recap page, used by the automatic +/// monthly check and the in-app `generate_recap` key. +pub fn recap_output_path() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("No $HOME directory found for history"))?; + Ok( + home + .join(".config") + .join("spotatui") + .join("spotatui-recap.html"), + ) +} + fn last_recap_file_path() -> Result { let home = dirs::home_dir().ok_or_else(|| anyhow!("No $HOME directory found for history"))?; Ok( @@ -296,7 +477,27 @@ fn last_recap_file_path() -> Result { ) } -fn perform_auto_recap_check(app: &Arc>) { +fn auto_recap_is_due(last_recap_contents: Option<&str>, now_ts: i64) -> bool { + match last_recap_contents { + Some(content) => match content.trim().parse::() { + Ok(ts) => now_ts - ts >= 30 * 24 * 3600, + Err(_) => true, + }, + None => true, + } +} + +async fn perform_auto_recap_check(app: &Arc>) { + if !app + .lock() + .await + .user_config + .behavior + .enable_monthly_recap_prompt + { + return; + } + let path = match last_recap_file_path() { Ok(p) => p, Err(e) => { @@ -306,49 +507,55 @@ fn perform_auto_recap_check(app: &Arc>) { }; let now = Utc::now(); - let should_generate = match fs::read_to_string(&path) { - Ok(content) => { - if let Ok(ts) = content.trim().parse::() { - let diff_secs = now.timestamp() - ts; - diff_secs >= 30 * 24 * 3600 - } else { - true - } - } - Err(_) => true, - }; + if !auto_recap_is_due(fs::read_to_string(&path).ok().as_deref(), now.timestamp()) { + return; + } - if should_generate { - let home = match dirs::home_dir() { - Some(h) => h, - None => return, - }; - let output_path = home - .join(".config") - .join("spotatui") - .join("spotatui-recap.html"); + let output_path = match recap_output_path() { + Ok(p) => p, + Err(_) => return, + }; - match export_history_recap(RecapPeriod::ThirtyDays, &output_path) { - Ok(count) => { - if count > 0 { - if let Err(e) = fs::write(&path, now.timestamp().to_string()) { - log::warn!("failed to write last recap timestamp: {}", e); - } + // The export reads and renders the whole history file; keep it off the + // runtime worker threads. + let export_path = output_path.clone(); + let result = tokio::task::spawn_blocking(move || { + export_history_recap(RecapPeriod::ThirtyDays, &export_path) + }) + .await + .map_err(anyhow::Error::from) + .and_then(|result| result); + + match result { + Ok(count) => { + if count > 0 { + if let Err(e) = fs::write(&path, now.timestamp().to_string()) { + log::warn!("failed to write last recap timestamp: {}", e); + } - if let Ok(mut app_guard) = app.try_lock() { - app_guard.set_status_message( - format!( - "30-day listening recap generated at ~/.config/spotatui/spotatui-recap.html ({} listens)", - count - ), - 10, - ); - } + let mut app_guard = app.lock().await; + if app_guard.get_current_route().active_block == ActiveBlock::Input { + // Never steal keystrokes from an input field; fall back to a status + // message instead of the popup. + app_guard.set_status_message( + format!( + "Monthly listening recap ready at {} ({} listens)", + output_path.display(), + count + ), + 10, + ); + } else { + app_guard.recap_prompt = Some(RecapPromptState { + path: output_path, + listens: count, + }); + app_guard.push_navigation_stack(RouteId::RecapPrompt, ActiveBlock::RecapPrompt); } } - Err(e) => { - log::warn!("failed to automatically generate recap: {}", e); - } + } + Err(e) => { + log::warn!("failed to automatically generate recap: {}", e); } } } @@ -356,7 +563,15 @@ fn perform_auto_recap_check(app: &Arc>) { pub fn export_history_recap(period: RecapPeriod, output_path: &Path) -> Result { let listens = load_listens()?; let filtered = filter_listens_for_period(&listens, period); - let html = render_history_recap_html(period, &filtered); + // The share card is toggleable between the requested period and All Time + // (or Last 30 Days when All Time itself was requested). + let alt_period = if period == RecapPeriod::All { + RecapPeriod::ThirtyDays + } else { + RecapPeriod::All + }; + let alt_filtered = filter_listens_for_period(&listens, alt_period); + let html = render_history_recap_html(period, &filtered, alt_period, &alt_filtered); if let Some(parent) = output_path.parent() { fs::create_dir_all(parent)?; @@ -404,7 +619,9 @@ pub fn load_listens() -> Result> { } impl HistoryCollector { - fn observe(&mut self, snapshot: Option) -> Result<()> { + /// Returns the listen record appended to the history file, if any. + fn observe(&mut self, snapshot: Option) -> Result> { + let mut appended = None; let now_utc = Utc::now(); let now_instant = Instant::now(); @@ -432,12 +649,12 @@ impl HistoryCollector { }); if should_roll { - self.finalize_current(now_utc)?; + appended = self.finalize_current(now_utc)?; } if self.current.is_none() { self.current = Some(ActiveListenSession::from_snapshot(snapshot, now_utc)); - return Ok(()); + return Ok(appended); } if let Some(current) = &mut self.current { @@ -447,23 +664,25 @@ impl HistoryCollector { } } None => { - self.finalize_current(now_utc)?; + appended = self.finalize_current(now_utc)?; } } - Ok(()) + Ok(appended) } - fn finalize_current(&mut self, ended_at: DateTime) -> Result<()> { + fn finalize_current(&mut self, ended_at: DateTime) -> Result> { let Some(current) = self.current.take() else { - return Ok(()); + return Ok(None); }; if current.listened_ms == 0 { - return Ok(()); + return Ok(None); } - append_listen_record(ListenRecord::from_active_session(current, ended_at)) + let record = ListenRecord::from_active_session(current, ended_at); + append_listen_record(&record)?; + Ok(Some(record)) } } @@ -550,14 +769,14 @@ fn listens_file_path() -> Result { ) } -fn append_listen_record(record: ListenRecord) -> Result<()> { +fn append_listen_record(record: &ListenRecord) -> Result<()> { let path = listens_file_path()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut file = OpenOptions::new().create(true).append(true).open(path)?; - serde_json::to_writer(&mut file, &record)?; + serde_json::to_writer(&mut file, record)?; writeln!(file)?; Ok(()) } @@ -579,7 +798,10 @@ fn history_source_from_snapshot(snapshot: &PlaybackSnapshot) -> HistoryPlaybackS } } -fn filter_listens_for_period(listens: &[ListenRecord], period: RecapPeriod) -> Vec { +pub fn filter_listens_for_period( + listens: &[ListenRecord], + period: RecapPeriod, +) -> Vec { let now = Utc::now(); listens .iter() @@ -597,42 +819,109 @@ fn filter_listens_for_period(listens: &[ListenRecord], period: RecapPeriod) -> V .collect() } -fn render_history_recap_html(period: RecapPeriod, listens: &[ListenRecord]) -> String { - let total_listening_ms = listens.iter().map(|record| record.listened_ms).sum::(); - let top_tracks = aggregate_top_tracks(listens); - let top_artists = aggregate_top_artists(listens); - let top_albums = aggregate_top_albums(listens); - let listening_days = aggregate_days(listens); - let listening_hours = aggregate_hours(listens); +/// The raw (unescaped) values the share card shows for one period. +struct CardData { + period: RecapPeriod, + track_title: String, + track_artist: String, + top_artist: String, + top_album: String, + plays: usize, + time: String, +} + +impl CardData { + fn from_listens(period: RecapPeriod, listens: &[ListenRecord]) -> Self { + Self::from_tops( + period, + aggregate_top_tracks(listens, 1).first(), + aggregate_top_artists(listens, 1).first(), + aggregate_top_albums(listens, 1).first(), + listens.len(), + listens.iter().map(|record| record.listened_ms).sum(), + ) + } - let top_album_title = escape_html( - top_albums - .first() + /// Build from already-aggregated ranked lists; the first entry of each wins. + fn from_tops( + period: RecapPeriod, + top_track: Option<&RankedEntry>, + top_artist: Option<&RankedEntry>, + top_album: Option<&RankedEntry>, + plays: usize, + total_ms: u64, + ) -> Self { + let top_track_raw = top_track .map(|entry| entry.display.as_str()) - .unwrap_or("No data"), - ); + .unwrap_or("No data"); + let (track_title, track_artist) = if top_track_raw == "No data" { + ("No data".to_string(), "No data".to_string()) + } else { + ( + top_track_raw + .split(" - ") + .next() + .unwrap_or(top_track_raw) + .to_string(), + top_track_raw.split(" - ").nth(1).unwrap_or("").to_string(), + ) + }; - let top_track_raw = top_tracks - .first() - .map(|entry| entry.display.as_str()) - .unwrap_or("No data"); - let top_track_title_clean = if top_track_raw == "No data" { - "No data".to_string() - } else { - top_track_raw - .split(" - ") - .next() - .unwrap_or(top_track_raw) - .to_string() - }; - let top_track_title_clean = escape_html(&top_track_title_clean); + Self { + period, + track_title, + track_artist, + top_artist: top_artist + .map(|entry| entry.display.as_str()) + .unwrap_or("No data") + .to_string(), + top_album: top_album + .map(|entry| entry.display.as_str()) + .unwrap_or("No data") + .to_string(), + plays, + time: format_duration(total_ms), + } + } - let top_track_artist = if top_track_raw == "No data" { - "No data".to_string() - } else { - top_track_raw.split(" - ").nth(1).unwrap_or("").to_string() - }; - let top_track_artist = escape_html(&top_track_artist); + /// Render as a JS object literal for the recap page's inline script. + fn to_js_object(&self) -> String { + format!( + "{{ period: {}, track: {}, artist: {}, topArtist: {}, topAlbum: {}, plays: {}, time: {} }}", + js_string(period_label(self.period)), + js_string(&self.track_title), + js_string(&self.track_artist), + js_string(&self.top_artist), + js_string(&self.top_album), + self.plays, + js_string(&self.time), + ) + } +} + +fn render_history_recap_html( + period: RecapPeriod, + listens: &[ListenRecord], + alt_period: RecapPeriod, + alt_listens: &[ListenRecord], +) -> String { + let top_tracks = aggregate_top_tracks(listens, 5); + let top_artists = aggregate_top_artists(listens, 5); + let top_albums = aggregate_top_albums(listens, 5); + let listening_days = aggregate_days(listens); + let listening_hours = aggregate_hours(listens); + + // The primary card's No. 1 entries fall out of the limit-5 lists above; only + // the alternative period needs its own aggregation. + let card = CardData::from_tops( + period, + top_tracks.first(), + top_artists.first(), + top_albums.first(), + listens.len(), + listens.iter().map(|record| record.listened_ms).sum(), + ); + let alt_card = CardData::from_listens(alt_period, alt_listens); format!( r#" @@ -941,6 +1230,37 @@ fn render_history_recap_html(period: RecapPeriod, listens: &[ListenRecord]) -> S font-family: var(--font-mono); color: var(--accent); }} + .card-toggle {{ + display: flex; + gap: 8px; + width: 100%; + max-width: 440px; + margin-bottom: 16px; + }} + .toggle-btn {{ + flex: 1; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 99px; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 700; + font-family: var(--font-sans); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 9px 16px; + cursor: pointer; + transition: all 0.2s ease; + }} + .toggle-btn:hover {{ + color: var(--text); + border-color: rgba(29, 185, 84, 0.3); + }} + .toggle-btn.active {{ + background: rgba(29, 185, 84, 0.12); + border-color: rgba(29, 185, 84, 0.4); + color: var(--accent); + }} .download-container {{ margin-top: 20px; width: 100%; @@ -1122,14 +1442,18 @@ fn render_history_recap_html(period: RecapPeriod, listens: &[ListenRecord]) -> S