//! Report module — generates listening statistics in three output formats.
//!
//! This module reads scrobble data from the database (via `db` module queries)
//! and presents it in one of three formats:
//!
//! 1. **Terminal tables** — box-drawing character tables with bar charts,
//! showing overview stats, top artists/albums/tracks, and recent scrobbles
//! for a single period.
//!
//! 2. **JSON** — the same single-period data serialized as pretty-printed JSON,
//! suitable for piping into other tools (e.g., `jq`).
//!
//! 3. **HTML** — a standalone, multi-period report with a dark theme. The HTML
//! output includes all periods (Today, This Week, This Month, All Time) in
//! one page, with:
//! - KPI cards for each period (scrobbles, listen time, unique counts)
//! - Horizontal bar charts for top artists, top albums, and top tracks
//! - Album cover art grids (fetched via the `enrich` module)
//! - Genre badges from MusicBrainz
//! - A recent scrobbles table (All Time section only)
//!
//! Terminal and JSON reports operate on a single `--period` flag.
//! HTML reports ignore `--period` and always render all periods together.
//!
//! ## Output structure for `--html --output
`
//!
//! ```text
//! /
//! ├── index.html ← properly indented, human-readable HTML
//! └── covers/
//! ├── .jpg ← cover images copied from the enrichment cache
//! └── .jpg
//! ```
//!
//! Cover images are referenced via relative `covers/` paths so the
//! directory is self-contained and can be moved or shared as-is.
use crate::db;
use rusqlite::Connection;
use serde::Serialize;
use std::fmt::Write as _;
/// Maximum bar width used in terminal tables.
const MAX_BAR_WIDTH: usize = 20;
/// Minimum bar width used in terminal tables when terminal is narrow.
const MIN_BAR_WIDTH: usize = 8;
// ---------------------------------------------------------------------------
// Duration formatting
// ---------------------------------------------------------------------------
/// Format a duration in seconds into a human-readable string.
///
/// Examples:
/// - 45 seconds → "45s"
/// - 120 seconds → "2m"
/// - 3660 seconds → "1h 01m"
/// - 0 seconds → "0s"
pub fn format_duration(secs: i64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
let hours = secs / 3600;
let mins = (secs % 3600) / 60;
if mins == 0 {
format!("{}h", hours)
} else {
format!("{}h {:02}m", hours, mins)
}
}
}
/// Format a play count with correct singular/plural grammar.
fn format_play_count(plays: i64) -> String {
if plays == 1 {
"1 play".to_string()
} else {
format!("{} plays", plays)
}
}
/// Build a short "mood" label from top genres.
///
/// Selection rule:
/// - take the top genre first
/// - then keep scanning in rank order and only take the next genre if its
/// listen time differs from the previously selected genre
///
/// This avoids showing multiple mood tags with identical play-time weight.
fn mood_labels(top_genres: &[db::TopGenre], max_labels: usize) -> Vec {
let mut out: Vec = Vec::new();
let mut last_selected_time: Option = None;
for g in top_genres {
if out.len() >= max_labels {
break;
}
if let Some(prev_time) = last_selected_time
&& g.listen_time_secs == prev_time
{
continue;
}
out.push(g.genre.clone());
last_selected_time = Some(g.listen_time_secs);
}
out
}
/// Convert an ISO 8601 timestamp into a short, privacy-friendly relative
/// time label. Avoids exposing exact listening times. Labels are kept compact
/// so they fit well in both terminal tables and HTML columns.
///
/// Examples:
/// - 10 minutes ago → "Moments ago"
/// - 50 minutes ago → "Within hour"
/// - 2 hours ago → "A while ago"
/// - 8 hours ago → "Today"
/// - yesterday → "Yesterday"
/// - 3 days ago → "Few days"
/// - 2 weeks ago → "About week"
/// - 5 weeks ago → "About month"
/// - 3 months ago → "Few months"
/// - 14 months ago → "Over year"
fn fuzzy_time(iso_timestamp: &str) -> String {
let Ok(then) = chrono::NaiveDateTime::parse_from_str(iso_timestamp, "%Y-%m-%dT%H:%M:%S") else {
return "Some time".to_string();
};
let now = chrono::Local::now().naive_local();
let delta = now.signed_duration_since(then);
let hours = delta.num_hours();
let days = delta.num_days();
// Use calendar-date comparison for "Today" and "Yesterday" so that a track
// scrobbled before midnight is never labelled "Today" after the date rolls over.
let today = now.date();
let then_date = then.date();
match () {
_ if delta.num_minutes() < 20 => "Moments ago".to_string(),
_ if delta.num_minutes() < 60 => "Within hour".to_string(),
_ if hours < 3 => "A while ago".to_string(),
_ if then_date == today => "Today".to_string(),
_ if then_date == today - chrono::Duration::days(1) => "Yesterday".to_string(),
_ if days < 5 => "Few days".to_string(),
_ if days < 14 => "About week".to_string(),
_ if days < 21 => "2 weeks".to_string(),
_ if days < 45 => "About month".to_string(),
_ if days < 90 => "Few months".to_string(),
_ if days < 365 => "Months ago".to_string(),
_ => "Over year".to_string(),
}
}
/// Terse relative time label for terminal tables.
///
/// Uses minimal-width labels like "2m", "1h", "3d" to avoid column bloat.
/// Privacy is still maintained — no exact timestamps are shown.
///
/// Examples:
/// - 2 minutes ago → "2m"
/// - 45 minutes ago → "45m"
/// - 2 hours ago → "2h"
/// - yesterday → "1d"
/// - 10 days ago → "10d"
/// - 5 weeks ago → "5w"
/// - 3 months ago → "3mo"
/// - 2 years ago → "2y"
fn terse_time(iso_timestamp: &str) -> String {
let Ok(then) = chrono::NaiveDateTime::parse_from_str(iso_timestamp, "%Y-%m-%dT%H:%M:%S") else {
return "?".to_string();
};
let now = chrono::Local::now().naive_local();
let delta = now.signed_duration_since(then);
let mins = delta.num_minutes();
let hours = delta.num_hours();
let days = delta.num_days();
match () {
_ if mins < 1 => "<1m".to_string(),
_ if mins < 60 => format!("{}m", mins),
_ if hours < 24 => format!("{}h", hours),
_ if days < 14 => format!("{}d", days),
_ if days < 90 => format!("{}w", days / 7),
_ if days < 730 => format!("{}mo", days / 30),
_ => format!("{}y", days / 365),
}
}
// ---------------------------------------------------------------------------
// Report data structures
// ---------------------------------------------------------------------------
/// The complete report data, containing all sections. This struct is used
/// both for terminal rendering and JSON serialization, so all fields
/// implement `Serialize`.
#[derive(Debug, Serialize)]
pub struct ReportData {
/// Which time period this report covers.
pub period: PeriodInfo,
/// Aggregate statistics (total scrobbles, listen time, unique counts).
pub overview: db::Overview,
/// Top artists ranked by play count.
pub top_artists: Vec,
/// Top albums ranked by play count.
pub top_albums: Vec,
/// Top tracks ranked by play count.
pub top_tracks: Vec,
/// Top genres derived from cached album metadata.
pub top_genres: Vec,
/// Most recent scrobbles (newest first), limited to 20.
pub recent_scrobbles: Vec,
}
/// Metadata about the time period the report covers.
/// For "all time" reports, `from` and `to` will be `None`.
#[derive(Debug, Serialize)]
pub struct PeriodInfo {
/// The period name as provided by the user (e.g., "week", "month", "all").
pub name: String,
/// ISO 8601 start of the period, or None for "all".
pub from: Option,
/// ISO 8601 end of the period (current time), or None for "all".
pub to: Option,
}
// ---------------------------------------------------------------------------
// Terminal box-drawing table renderer
//
// Uses Unicode box-drawing characters (─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼) and block
// elements (█ ░) to produce tables with horizontal bar charts directly in
// the terminal. No external crate needed.
// ---------------------------------------------------------------------------
/// Render a table with box-drawing characters.
/// `headers` are the column names, `rows` are the cell values.
/// The last column in each row is treated as a numeric value for the bar chart
/// if `bar_max` is Some (the maximum value for scaling bars).
fn print_box_table(
headers: &[&str],
rows: &[Vec],
bar_max: Option,
shrinkable_cols: &[usize],
) {
if rows.is_empty() {
return;
}
// Calculate column widths (max of header and all cell widths).
let ncols = headers.len();
let mut widths: Vec = headers.iter().map(|h| visible_width(h)).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < ncols {
widths[i] = widths[i].max(visible_width(cell));
}
}
}
// Fit table to terminal width (best-effort): reduce bar width first, then
// shrink only selected text columns.
let terminal_width = terminal_columns();
let mut bar_width = if bar_max.is_some() { MAX_BAR_WIDTH } else { 0 };
while table_width(&widths, bar_width) > terminal_width && bar_width > MIN_BAR_WIDTH {
bar_width -= 1;
}
while table_width(&widths, bar_width) > terminal_width {
let mut changed = false;
let mut widest: Option = None;
for &idx in shrinkable_cols {
if idx >= widths.len() {
continue;
}
let min_w = 6usize;
if widths[idx] <= min_w {
continue;
}
if widest.is_none_or(|w| widths[idx] > widths[w]) {
widest = Some(idx);
}
}
if let Some(i) = widest {
widths[i] -= 1;
changed = true;
}
if !changed {
break;
}
}
// If we have a bar chart, add space for it after the last column.
let bar_col = if bar_max.is_some() { bar_width + 2 } else { 0 };
let header_cells: Vec = headers
.iter()
.enumerate()
.map(|(i, h)| truncate_cell(h, widths[i]))
.collect();
// Top border: ┌──────┬──────┐
print!(" ┌");
for (i, w) in widths.iter().enumerate() {
print!("{}", "─".repeat(w + 2));
if bar_max.is_some() && i == ncols - 1 {
print!("┬{}", "─".repeat(bar_col));
}
if i < ncols - 1 {
print!("┬");
}
}
println!("┐");
// Header row: │ Header │ Header │
print!(" │");
for (i, h) in header_cells.iter().enumerate() {
print!(" {: usize {
// Try ioctl first — this is the only reliable method.
#[cfg(unix)]
{
use std::mem::MaybeUninit;
#[repr(C)]
struct Winsize {
ws_row: libc::c_ushort,
ws_col: libc::c_ushort,
ws_xpixel: libc::c_ushort,
ws_ypixel: libc::c_ushort,
}
let mut ws = MaybeUninit::::uninit();
let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ws.as_mut_ptr()) };
if ret == 0 {
let ws = unsafe { ws.assume_init() };
if ws.ws_col >= 40 {
return ws.ws_col as usize;
}
}
}
if let Ok(cols) = std::env::var("COLUMNS")
&& let Ok(parsed) = cols.parse::()
&& parsed >= 40
{
return parsed;
}
80
}
/// Compute rendered table width for current settings.
fn table_width(widths: &[usize], bar_width: usize) -> usize {
let ncols = widths.len();
let mut total = 2 + 1 + 1; // indent + left border + right border
for (i, w) in widths.iter().enumerate() {
total += w + 2;
if i < ncols - 1 {
total += 1;
}
}
if bar_width > 0 {
total += 1 + (bar_width + 2);
}
total
}
/// Visible character width (simple char-count approximation).
fn visible_width(s: &str) -> usize {
s.chars().count()
}
/// Truncate a cell to a target width using ASCII ellipsis.
fn truncate_cell(s: &str, width: usize) -> String {
let len = visible_width(s);
if len <= width {
return s.to_string();
}
if width <= 3 {
return s.chars().take(width).collect();
}
let mut out: String = s.chars().take(width - 3).collect();
out.push_str("...");
out
}
// ---------------------------------------------------------------------------
// Report generation
// ---------------------------------------------------------------------------
/// Query the database and assemble all sections of the report.
///
/// This is the main entry point for report generation. It runs all the
/// necessary SQL queries for the given period and limit, and returns
/// a `ReportData` struct ready for rendering.
///
/// # Arguments
/// - `conn` — SQLite database connection
/// - `period` — time period filter ("today", "week", "month", "year", "all")
/// Return the set of `(artist, album)` pairs that will appear anywhere in the
/// HTML report at the given limits. Used to focus automatic enrichment on
/// exactly what the report needs rather than the entire scrobble library.
pub fn albums_needed_for_report(
conn: &Connection,
limit: i64,
all_time_limit: i64,
) -> std::collections::HashSet<(String, String)> {
let mut pairs = std::collections::HashSet::new();
let periods: &[(&str, i64)] = &[
("today", limit),
("week", limit),
("month", limit),
("all", all_time_limit),
];
for (period, lim) in periods {
let cover_lim = album_cover_grid_limit(*lim);
if let Ok(albums) = db::top_albums(conn, period, cover_lim) {
for a in albums {
pairs.insert((a.artist, a.album));
}
}
if let Ok(tracks) = db::top_tracks(conn, period, *lim) {
for t in tracks {
pairs.insert((t.artist, t.album));
}
}
// Include the most-played album per top artist so artist_cover()
// has something to work with even when that album is outside top_albums.
if let Ok(artists) = db::top_artists(conn, period, *lim) {
for a in artists {
if let Some(album) = db::artist_top_album(conn, &a.artist) {
pairs.insert((a.artist, album));
}
}
}
}
pairs
}
/// - `limit` — maximum number of entries in each top-N list
pub fn gather_report(
conn: &Connection,
period: &str,
limit: i64,
) -> Result {
// Compute the date range for the header/JSON metadata.
let range = db::period_range(period);
let period_info = PeriodInfo {
name: period.to_string(),
from: range.as_ref().map(|(f, _)| f.clone()),
to: range.as_ref().map(|(_, t)| t.clone()),
};
// Run all queries. Each query internally applies the same period filter.
Ok(ReportData {
period: period_info,
overview: db::overview(conn, period)?,
top_artists: db::top_artists(conn, period, limit)?,
top_albums: db::top_albums(conn, period, limit)?,
top_tracks: db::top_tracks(conn, period, limit)?,
top_genres: db::top_genres(conn, period, limit)?,
// Recent scrobbles are always limited to 20, regardless of the --limit flag.
recent_scrobbles: db::recent_scrobbles(conn, period, 20)?,
})
}
// ---------------------------------------------------------------------------
// Terminal output
// ---------------------------------------------------------------------------
/// Render the report as box-drawing tables with bar charts to stdout.
///
/// Output sections (each printed only if non-empty):
/// 1. Header with period name and date range
/// 2. Overview KPIs
/// 3. Top Artists with bar chart
/// 4. Top Albums with bar chart
/// 5. Top Genres with bar chart
/// 6. Top Tracks with bar chart
/// 7. Recent Scrobbles
pub fn print_terminal_report(data: &ReportData) {
// --- Header ---
let period_label = match data.period.name.as_str() {
"today" => "Today",
"week" => "This Week",
"month" => "This Month",
"year" => "This Year",
_ => "All Time",
};
let date_range = match (&data.period.from, &data.period.to) {
(Some(from), Some(_)) if data.period.name == "today" => format!(" {}", &from[..10]),
(Some(from), Some(to)) => format!(" {} → {}", &from[..10], &to[..10]),
_ => String::new(),
};
println!();
println!(
" ┌─ Listening Report ─── {} ──{}─┐",
period_label, date_range
);
println!();
// --- Overview KPIs ---
println!(
" Scrobbles: {} Listen time: {} Artists: {} Albums: {} Tracks: {}",
data.overview.total_scrobbles,
format_duration(data.overview.total_listen_time_secs),
data.overview.unique_artists,
data.overview.unique_albums,
data.overview.unique_tracks,
);
let mood = mood_labels(&data.top_genres, 6);
if !mood.is_empty() && data.period.name != "all" {
println!(" Mood: {}", mood.join(" · "));
}
// --- Top Artists ---
if !data.top_artists.is_empty() {
println!("\n Top Artists");
let max_plays = data.top_artists.first().map(|a| a.plays).unwrap_or(1);
let rows: Vec> = data
.top_artists
.iter()
.enumerate()
.map(|(i, a)| {
vec![
format!("{:>2}", i + 1),
a.artist.clone(),
format_duration(a.listen_time_secs),
a.plays.to_string(),
]
})
.collect();
print_box_table(
&["#", "Artist", "Time", "Plays"],
&rows,
Some(max_plays),
&[1],
);
}
// --- Top Albums ---
if !data.top_albums.is_empty() {
println!("\n Top Albums");
let max_plays = data.top_albums.first().map(|a| a.plays).unwrap_or(1);
let rows: Vec> = data
.top_albums
.iter()
.enumerate()
.map(|(i, a)| {
vec![
format!("{:>2}", i + 1),
a.artist.clone(),
a.album.clone(),
format_duration(a.listen_time_secs),
a.plays.to_string(),
]
})
.collect();
print_box_table(
&["#", "Artist", "Album", "Time", "Plays"],
&rows,
Some(max_plays),
&[1, 2],
);
}
// --- Top Genres ---
if !data.top_genres.is_empty() {
println!("\n Top Genres");
let max_plays = data.top_genres.first().map(|g| g.plays).unwrap_or(1);
let rows: Vec> = data
.top_genres
.iter()
.enumerate()
.map(|(i, g)| {
vec![
format!("{:>2}", i + 1),
g.genre.clone(),
format_duration(g.listen_time_secs),
g.plays.to_string(),
]
})
.collect();
print_box_table(
&["#", "Genre", "Time", "Plays"],
&rows,
Some(max_plays),
&[1],
);
}
// --- Top Tracks ---
if !data.top_tracks.is_empty() {
println!("\n Top Tracks");
let max_plays = data.top_tracks.first().map(|t| t.plays).unwrap_or(1);
let rows: Vec> = data
.top_tracks
.iter()
.enumerate()
.map(|(i, t)| {
vec![
format!("{:>2}", i + 1),
t.artist.clone(),
t.title.clone(),
format_duration(t.listen_time_secs),
t.plays.to_string(),
]
})
.collect();
print_box_table(
&["#", "Artist", "Title", "Time", "Plays"],
&rows,
Some(max_plays),
&[1, 2],
);
}
// --- Recent Scrobbles ---
if !data.recent_scrobbles.is_empty() {
println!("\n Recent Scrobbles");
let rows: Vec> = data
.recent_scrobbles
.iter()
.map(|s| {
vec![
terse_time(&s.scrobbled_at),
s.artist.clone(),
s.title.clone(),
s.album.clone(),
]
})
.collect();
print_box_table(
&["Ago", "Artist", "Title", "Album"],
&rows,
None,
&[1, 2, 3],
);
}
println!();
}
// ---------------------------------------------------------------------------
// JSON output
// ---------------------------------------------------------------------------
/// Serialize the report as pretty-printed JSON and write it to stdout.
/// This is the output format used with `--json`.
pub fn print_json_report(data: &ReportData) {
println!(
"{}",
serde_json::to_string_pretty(data).expect("Failed to serialize report")
);
}
/// Result of rendering an HTML report. Contains the HTML string and a list
/// of cover image files that should be copied into a `covers/` subdirectory
/// next to the HTML file.
pub struct HtmlReport {
/// The generated HTML document.
pub html: String,
/// Absolute paths to cover image files that the HTML references.
/// Each should be copied to `/covers/`.
pub cover_files: Vec,
}
/// Render a multi-period HTML report.
///
/// The report contains sections for Today, This Week, This Month, and All Time,
/// each with their own KPIs, top artists/albums/tracks with bar charts, and
/// album cover art cards.
///
/// Cover images are referenced via relative `covers/` paths.
pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) -> HtmlReport {
// Gather data for each period. All-time uses its own (larger) limit;
// shorter periods use the base limit as-is.
let periods: &[(&str, i64)] = &[
("today", limit),
("week", limit),
("month", limit),
("all", all_time_limit),
];
let reports: Vec = periods
.iter()
.filter_map(|(p, l)| gather_report(conn, p, *l).ok())
.collect();
let mut cover_files: Vec = Vec::new();
let mut h = HtmlWriter::new();
// --- HTML head + CSS ---
h.line("");
h.line("");
h.line("");
h.indent();
h.line("");
h.line("");
h.line("Listening Report");
h.line("");
h.line("");
h.line("");
h.line("");
h.dedent();
h.line("");
h.line("");
h.open("
");
// Album cover grid — shown first after KPIs to visually illustrate
// the period's listening at a glance.
//
// We intentionally round the cover count up to a full desktop row so
// the grid doesn't end with a partially filled row when the user picks
// a limit like 20 and desktop layout has 6 columns.
let cover_limit = album_cover_grid_limit(limit);
let cover_albums = db::top_albums(conn, &data.period.name, cover_limit)
.unwrap_or_else(|_| data.top_albums.clone());
if !cover_albums.is_empty() {
h.blank();
h.open("");
h.line("
Top Album Covers
");
h.open("
");
for a in &cover_albums {
let meta = db::album_cache_meta(conn, &a.artist, &a.album)
.ok()
.flatten();
let genre = meta.as_ref().and_then(|m| m.genre.as_ref());
let cmd = pin_album_cmd(
&a.artist,
&a.album,
meta.as_ref().and_then(|m| m.mbid.as_deref()),
);
h.open("");
let cover_rel = resolve_cover(
meta.as_ref().and_then(|m| m.cover_url.clone()),
&mut cover_files,
);
if let Some(ref rel) = cover_rel {
h.linef(format_args!(
"",
html_attr_escape(rel),
html_attr_escape(&format!("{} - {}", a.artist, a.album))
));
} else {
h.line("
",
html_escape(&format_play_count(a.plays)),
html_escape(&format_duration(a.listen_time_secs))
));
if let Some(g) = genre {
let labels = top_genre_labels(g, 3);
if !labels.is_empty() {
h.open("
");
for label in labels {
h.linef(format_args!(
"{}",
html_escape(&label)
));
}
h.close("
");
}
}
h.close("
"); // .meta
h.close("");
}
h.close("
"); // .grid
h.close("");
}
// Top Albums as a bar table, matching the visual language used by
// Top Artists and Top Tracks.
let album_rows: Vec = data
.top_albums
.iter()
.enumerate()
.map(|(i, a)| {
let cover_url = db::album_cache_meta(conn, &a.artist, &a.album)
.ok()
.flatten()
.and_then(|m| m.cover_url);
let cover = resolve_cover(cover_url, &mut cover_files);
BarRow {
cells: vec![(i + 1).to_string(), a.artist.clone(), a.album.clone()],
value: a.plays,
suffix: format_duration(a.listen_time_secs),
cover,
}
})
.collect();
write_bar_table(
&mut h,
"Top Albums",
&["#", "Artist", "Album", "Plays", ""],
&album_rows,
);
// Top Artists with bar chart and mini cover.
// Each artist's cover is taken from their most-played album in this
// specific period.
let artist_rows: Vec = data
.top_artists
.iter()
.enumerate()
.map(|(i, a)| {
let cover = resolve_cover(
db::artist_cover(conn, &a.artist, &data.period.name),
&mut cover_files,
);
BarRow {
cells: vec![(i + 1).to_string(), a.artist.clone()],
value: a.plays,
suffix: format_duration(a.listen_time_secs),
cover,
}
})
.collect();
write_bar_table(
&mut h,
"Top Artists",
&["#", "Artist", "Plays", ""],
&artist_rows,
);
// Top Genres with bar chart.
let genre_rows: Vec = data
.top_genres
.iter()
.enumerate()
.map(|(i, g)| BarRow {
cells: vec![(i + 1).to_string(), g.genre.clone()],
value: g.plays,
suffix: format_duration(g.listen_time_secs),
cover: None,
})
.collect();
write_bar_table(
&mut h,
"Top Genres",
&["#", "Genre", "Plays", ""],
&genre_rows,
);
// Top Tracks with bar chart and mini cover.
// Each track's cover comes from its album.
let track_rows: Vec = data
.top_tracks
.iter()
.enumerate()
.map(|(i, t)| {
let cover_url = db::album_cache_meta(conn, &t.artist, &t.album)
.ok()
.flatten()
.and_then(|m| m.cover_url);
let cover = resolve_cover(cover_url, &mut cover_files);
BarRow {
cells: vec![(i + 1).to_string(), t.artist.clone(), t.title.clone()],
value: t.plays,
suffix: format_duration(t.listen_time_secs),
cover,
}
})
.collect();
write_bar_table(
&mut h,
"Top Tracks",
&["#", "Artist", "Title", "Plays", ""],
&track_rows,
);
// Recent scrobbles (only for All Time section to avoid repetition)
if is_all_time && !data.recent_scrobbles.is_empty() {
write_plain_table(
&mut h,
"Recent Scrobbles",
&["When", "Artist", "Title", "Album"],
&data
.recent_scrobbles
.iter()
.map(|s| {
vec![
fuzzy_time(&s.scrobbled_at),
s.artist.clone(),
s.title.clone(),
s.album.clone(),
]
})
.collect::>(),
);
}
h.close("
");
}
/// A row in a bar-chart table. `cells` are the text columns, `value` is
/// the numeric value used for the bar width, `suffix` is shown after the
/// bar (e.g., listen time), and `cover` is an optional relative path to
/// a mini cover image shown at the start of the row.
struct BarRow {
cells: Vec,
value: i64,
suffix: String,
/// Relative path to a cover image (e.g., "covers/UUID.jpg"), or None.
cover: Option,
}
/// Render a table where the last column is a horizontal bar chart.
fn write_bar_table(h: &mut HtmlWriter, title: &str, headers: &[&str], rows: &[BarRow]) {
if rows.is_empty() {
return;
}
let max_val = rows.iter().map(|r| r.value).max().unwrap_or(1).max(1);
h.blank();
h.open("");
h.linef(format_args!(
"
{}
",
html_escape(title)
));
h.open("
");
h.open("
");
// Check if any row has a cover image — if so, add a cover column.
let has_covers = rows.iter().any(|r| r.cover.is_some());
// Header
h.open("");
h.open("
");
if has_covers {
h.line("
"); // empty header for the cover column
}
for hdr in headers {
h.linef(format_args!("
{}
", html_escape(hdr)));
}
h.close("
");
h.close("");
// Body
h.open("");
for row in rows {
h.open("
");
if has_covers {
if let Some(ref src) = row.cover {
h.linef(format_args!(
"
",
html_attr_escape(src)
));
} else {
h.line("
");
}
}
for cell in &row.cells {
h.linef(format_args!("
{}
", html_escape(cell)));
}
let pct = (row.value as f64 / max_val as f64 * 100.0).round() as u32;
h.linef(format_args!("