1841 lines
63 KiB
Rust
1841 lines
63 KiB
Rust
//! 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 <dir>`
|
|
//!
|
|
//! ```text
|
|
//! <dir>/
|
|
//! ├── index.html ← properly indented, human-readable HTML
|
|
//! └── covers/
|
|
//! ├── <mbid1>.jpg ← cover images copied from the enrichment cache
|
|
//! └── <mbid2>.jpg
|
|
//! ```
|
|
//!
|
|
//! Cover images are referenced via relative `covers/<filename>` 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<String> {
|
|
let mut out: Vec<String> = Vec::new();
|
|
let mut last_selected_time: Option<i64> = 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<db::TopArtist>,
|
|
/// Top albums ranked by play count.
|
|
pub top_albums: Vec<db::TopAlbum>,
|
|
/// Top tracks ranked by play count.
|
|
pub top_tracks: Vec<db::TopTrack>,
|
|
/// Top genres derived from cached album metadata.
|
|
pub top_genres: Vec<db::TopGenre>,
|
|
/// Most recent scrobbles (newest first), limited to 20.
|
|
pub recent_scrobbles: Vec<db::Scrobble>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// ISO 8601 end of the period (current time), or None for "all".
|
|
pub to: Option<String>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<String>],
|
|
bar_max: Option<i64>,
|
|
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<usize> = 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<usize> = 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<String> = 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!(" {:<width$} ", h, width = widths[i]);
|
|
if bar_max.is_some() && i == ncols - 1 {
|
|
print!("│ {:<width$}", "", width = bar_col - 1);
|
|
}
|
|
if i < ncols - 1 {
|
|
print!("│");
|
|
}
|
|
}
|
|
println!("│");
|
|
|
|
// Header separator: ├──────┼──────┤
|
|
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!("┤");
|
|
|
|
// Data rows: │ data │ data │ ████░░░░ │
|
|
for row in rows {
|
|
print!(" │");
|
|
for (i, cell) in row.iter().enumerate() {
|
|
if i < ncols {
|
|
let shown = truncate_cell(cell, widths[i]);
|
|
print!(" {:<width$} ", shown, width = widths[i]);
|
|
if bar_max.is_some() && i == ncols - 1 {
|
|
// Render bar chart in the extra column.
|
|
let val: i64 = cell.parse().unwrap_or(0);
|
|
let max = bar_max.unwrap_or(1).max(1);
|
|
let filled = ((val as f64 / max as f64) * bar_width as f64).round() as usize;
|
|
let empty = bar_width - filled;
|
|
print!("│ {}{} ", "█".repeat(filled), "░".repeat(empty));
|
|
}
|
|
if i < ncols - 1 {
|
|
print!("│");
|
|
}
|
|
}
|
|
}
|
|
println!("│");
|
|
}
|
|
|
|
// Bottom 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!("┘");
|
|
}
|
|
|
|
/// Best-effort terminal width in character columns.
|
|
///
|
|
/// Tries (in order):
|
|
/// 1. `ioctl` TIOCGWINSZ on stdout
|
|
/// 2. `COLUMNS` environment variable
|
|
/// 3. Fallback to 80
|
|
fn terminal_columns() -> 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::<Winsize>::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::<usize>()
|
|
&& 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<ReportData, rusqlite::Error> {
|
|
// 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<Vec<String>> = 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<Vec<String>> = 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<Vec<String>> = 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<Vec<String>> = 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<Vec<String>> = 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 `<output_dir>/covers/<filename>`.
|
|
pub cover_files: Vec<std::path::PathBuf>,
|
|
}
|
|
|
|
/// 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/<filename>` 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<ReportData> = periods
|
|
.iter()
|
|
.filter_map(|(p, l)| gather_report(conn, p, *l).ok())
|
|
.collect();
|
|
|
|
let mut cover_files: Vec<std::path::PathBuf> = Vec::new();
|
|
let mut h = HtmlWriter::new();
|
|
|
|
// --- HTML head + CSS ---
|
|
h.line("<!doctype html>");
|
|
h.line("<html lang=\"en\">");
|
|
h.line("<head>");
|
|
h.indent();
|
|
h.line("<meta charset=\"utf-8\">");
|
|
h.line("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
|
|
h.line("<title>Listening Report</title>");
|
|
h.line("<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">");
|
|
h.line("<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>");
|
|
h.line("<link href=\"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap\" rel=\"stylesheet\">");
|
|
h.line("<style>");
|
|
h.raw(CSS);
|
|
h.line("</style>");
|
|
h.dedent();
|
|
h.line("</head>");
|
|
h.line("<body>");
|
|
h.open("<div class=\"wrap\">");
|
|
|
|
// --- Hero header ---
|
|
let generated_at = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string();
|
|
h.open("<div class=\"hero\">");
|
|
h.line("<h1>Listening Report</h1>");
|
|
h.linef(format_args!(
|
|
"<div class=\"sub\">Generated by mpris-scrobbler on {}</div>",
|
|
html_escape(&generated_at)
|
|
));
|
|
h.close("</div>");
|
|
|
|
// --- Mobile-friendly jump menu ---
|
|
// Show links only for periods that will actually render.
|
|
let nav_periods: Vec<(&str, &str)> = reports
|
|
.iter()
|
|
.filter(|d| d.overview.total_scrobbles > 0)
|
|
.map(|d| match d.period.name.as_str() {
|
|
"today" => ("Today", "period-today"),
|
|
"week" => ("This Week", "period-week"),
|
|
"month" => ("This Month", "period-month"),
|
|
_ => ("All Time", "period-all"),
|
|
})
|
|
.collect();
|
|
if !nav_periods.is_empty() {
|
|
h.open("<nav class=\"jump-nav\" aria-label=\"Report sections\">");
|
|
for (label, id) in &nav_periods {
|
|
h.linef(format_args!(
|
|
"<a href=\"#{}\">{}</a>",
|
|
html_attr_escape(id),
|
|
html_escape(label)
|
|
));
|
|
}
|
|
h.close("</nav>");
|
|
}
|
|
|
|
// --- Period sections (Today → Week → Month → All Time) ---
|
|
for data in &reports {
|
|
let (label, is_all_time, section_id) = match data.period.name.as_str() {
|
|
"today" => ("Today", false, "period-today"),
|
|
"week" => ("This Week", false, "period-week"),
|
|
"month" => ("This Month", false, "period-month"),
|
|
_ => ("All Time", true, "period-all"),
|
|
};
|
|
let range_str = match (&data.period.from, &data.period.to) {
|
|
(Some(from), Some(_)) if data.period.name == "today" => from[..10].to_string(),
|
|
(Some(from), Some(to)) => format!("{} → {}", &from[..10], &to[..10]),
|
|
_ => String::new(),
|
|
};
|
|
|
|
// Skip empty periods.
|
|
if data.overview.total_scrobbles == 0 {
|
|
continue;
|
|
}
|
|
|
|
h.blank();
|
|
h.open(&format!(
|
|
"<div class=\"period-block\" id=\"{}\">",
|
|
section_id
|
|
));
|
|
h.linef(format_args!(
|
|
"<div class=\"period-title\">{}</div>",
|
|
html_escape(label)
|
|
));
|
|
if !range_str.is_empty() {
|
|
h.linef(format_args!(
|
|
"<div class=\"period-range\">{}</div>",
|
|
html_escape(&range_str)
|
|
));
|
|
}
|
|
|
|
let mood = mood_labels(&data.top_genres, 6);
|
|
if !mood.is_empty() && !is_all_time {
|
|
h.open("<div class=\"mood\">");
|
|
h.linef(format_args!(
|
|
"<span class=\"mood-title\">Mood of {}:</span>",
|
|
html_escape(label)
|
|
));
|
|
for genre in mood {
|
|
h.linef(format_args!(
|
|
"<span class=\"mood-pill\">{}</span>",
|
|
html_escape(&genre)
|
|
));
|
|
}
|
|
h.close("</div>");
|
|
}
|
|
|
|
// KPIs
|
|
h.open("<div class=\"kpis\">");
|
|
write_kpi(
|
|
&mut h,
|
|
"Scrobbles",
|
|
&data.overview.total_scrobbles.to_string(),
|
|
);
|
|
write_kpi(
|
|
&mut h,
|
|
"Listen time",
|
|
&format_duration(data.overview.total_listen_time_secs),
|
|
);
|
|
write_kpi(&mut h, "Artists", &data.overview.unique_artists.to_string());
|
|
write_kpi(&mut h, "Albums", &data.overview.unique_albums.to_string());
|
|
write_kpi(&mut h, "Tracks", &data.overview.unique_tracks.to_string());
|
|
h.close("</div>");
|
|
|
|
// 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("<section>");
|
|
h.line("<h3 class=\"section-title\">Top Album Covers</h3>");
|
|
h.open("<div class=\"grid\">");
|
|
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("<article class=\"album\">");
|
|
|
|
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!(
|
|
"<img class=\"cover\" src=\"{}\" alt=\"{}\">",
|
|
html_attr_escape(rel),
|
|
html_attr_escape(&format!("{} - {}", a.artist, a.album))
|
|
));
|
|
} else {
|
|
h.line("<div class=\"ph\">No cover</div>");
|
|
}
|
|
h.linef(format_args!(
|
|
"<button class=\"pin-dot\" data-cmd=\"{}\" title=\"Copy pin-album command\" \
|
|
onclick=\"var b=this;navigator.clipboard.writeText(b.dataset.cmd)\
|
|
.then(function(){{b.textContent='✓';setTimeout(function(){{b.textContent='📌'}},1500)}})\
|
|
.catch(function(){{b.textContent='!'}})\" \
|
|
>📌</button>",
|
|
html_attr_escape(&cmd)
|
|
));
|
|
|
|
h.open("<div class=\"meta\">");
|
|
h.linef(format_args!(
|
|
"<div class=\"t\">{}</div>",
|
|
html_escape(&a.album)
|
|
));
|
|
h.linef(format_args!(
|
|
"<div class=\"a\">{}</div>",
|
|
html_escape(&a.artist)
|
|
));
|
|
h.linef(format_args!(
|
|
"<div class=\"a\">{} · {}</div>",
|
|
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("<div class=\"genre\">");
|
|
for label in labels {
|
|
h.linef(format_args!(
|
|
"<span class=\"pill\">{}</span>",
|
|
html_escape(&label)
|
|
));
|
|
}
|
|
h.close("</div>");
|
|
}
|
|
}
|
|
h.close("</div>"); // .meta
|
|
h.close("</article>");
|
|
}
|
|
h.close("</div>"); // .grid
|
|
h.close("</section>");
|
|
}
|
|
|
|
// Top Albums as a bar table, matching the visual language used by
|
|
// Top Artists and Top Tracks.
|
|
let album_rows: Vec<BarRow> = 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<BarRow> = 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<BarRow> = 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<BarRow> = 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::<Vec<_>>(),
|
|
);
|
|
}
|
|
|
|
h.close("</div>"); // .period-block
|
|
}
|
|
|
|
h.close("</div>"); // .wrap
|
|
h.line("</body>");
|
|
h.line("</html>");
|
|
|
|
// Deduplicate cover files — the same album may appear in multiple
|
|
// period sections, but we only need to copy each image once.
|
|
cover_files.sort();
|
|
cover_files.dedup();
|
|
|
|
HtmlReport {
|
|
html: h.finish(),
|
|
cover_files,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSS (kept minified since it's a style block, not meant to be read in HTML)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CSS: &str = "\
|
|
:root {
|
|
--bg: #11100d; --panel: #1b1813; --panel2: #241f18; --text: #f1eadf;
|
|
--muted: #b8a892; --line: #3b3228; --accent: #f0c06a; --accent2: #d89a3d;
|
|
--bar: #3a2a16; --bar-fill: #d89a3d;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; color: var(--text);
|
|
font-family: \"JetBrains Mono\", \"Fira Code\", \"IBM Plex Mono\", \"Cascadia Code\", \"SFMono-Regular\", Menlo, Consolas, monospace;
|
|
background:
|
|
radial-gradient(1200px 420px at 10% -10%, #2a231a, transparent),
|
|
radial-gradient(1000px 380px at 100% 0%, #31230f, transparent),
|
|
var(--bg);
|
|
}
|
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 18px 56px; }
|
|
h1, h2, h3 { margin: 0; }
|
|
.hero {
|
|
padding: 22px 24px; border: 1px solid var(--line);
|
|
background: linear-gradient(145deg, var(--panel), #15120d);
|
|
border-radius: 14px; box-shadow: 0 14px 40px rgba(0,0,0,.25);
|
|
}
|
|
.sub { margin-top: 6px; color: var(--muted); font-size: 14px; }
|
|
.jump-nav {
|
|
position: sticky; top: 0; z-index: 20;
|
|
margin-top: 12px; padding: 8px;
|
|
display: flex; gap: 8px; overflow-x: auto;
|
|
background: rgba(17, 16, 13, 0.84);
|
|
border: 1px solid var(--line); border-radius: 10px;
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
.jump-nav a {
|
|
white-space: nowrap; text-decoration: none; color: var(--text);
|
|
font-size: 12px; font-weight: 600;
|
|
padding: 7px 10px; border-radius: 8px;
|
|
border: 1px solid #5b4631;
|
|
background: linear-gradient(140deg, #2b2218, #34281c);
|
|
}
|
|
.jump-nav a:hover { border-color: #a87937; }
|
|
.period-block {
|
|
margin-top: 32px; padding: 20px 24px; border: 1px solid var(--line);
|
|
background: var(--panel); border-radius: 14px;
|
|
}
|
|
.period-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
|
.period-range { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
|
|
.mood {
|
|
margin-bottom: 12px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.mood-title { font-size: 11px; color: var(--muted); }
|
|
.mood-pill {
|
|
font-size: 9px;
|
|
line-height: 1;
|
|
letter-spacing: 0.01em;
|
|
color: #e9c98f;
|
|
padding: 2px 5px;
|
|
border: 1px solid #7e5e2f;
|
|
border-radius: 999px;
|
|
}
|
|
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; }
|
|
.kpi { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px; }
|
|
.kpi .k { font-size: 11px; color: var(--muted); }
|
|
.kpi .v { margin-top: 4px; font-size: 22px; font-weight: 700; }
|
|
section { margin-top: 18px; }
|
|
.section-title { font-size: 16px; margin-bottom: 8px; }
|
|
.card { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
|
th { font-size: 11px; color: var(--muted); letter-spacing: .02em; text-transform: uppercase; }
|
|
tr:last-child td { border-bottom: none; }
|
|
.mono { font-family: \"JetBrains Mono\", \"Fira Code\", \"IBM Plex Mono\", \"Cascadia Code\", \"SFMono-Regular\", Menlo, Consolas, monospace; }
|
|
.bar-cell { width: 40%; }
|
|
.bar-wrap { display: flex; align-items: center; gap: 8px; }
|
|
.bar { height: 18px; border-radius: 4px; background: var(--bar-fill); min-width: 2px; }
|
|
.bar-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
.grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
|
|
.album { position: relative; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
.cover { width: 100%; aspect-ratio: 1/1; object-fit: cover; background: #0d1116; }
|
|
.ph { width: 100%; aspect-ratio: 1/1; display: grid; place-items: center; place-content: center;
|
|
color: #a99984;
|
|
background: linear-gradient(135deg, #17130d, #2c2116); font-size: 12px; }
|
|
.pin-dot { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px;
|
|
border-radius: 50%; background: rgba(0,0,0,0.45); border: none;
|
|
cursor: pointer; font-size: 12px; line-height: 22px; text-align: center;
|
|
opacity: 0; transition: opacity 0.15s; z-index: 1; }
|
|
.album:hover .pin-dot { opacity: 1; }
|
|
.pin-dot:hover { background: rgba(0,0,0,0.75); }
|
|
.meta { padding: 8px; }
|
|
.t { font-size: 13px; font-weight: 700; line-height: 1.2; }
|
|
.a { margin-top: 3px; font-size: 11px; color: var(--muted); }
|
|
.genre { margin-top: 5px; display: flex; gap: 5px; flex-wrap: wrap; }
|
|
.pill {
|
|
display: inline-block;
|
|
font-size: 9px;
|
|
line-height: 1;
|
|
letter-spacing: 0.01em;
|
|
color: #e9c98f;
|
|
padding: 2px 5px;
|
|
border: 1px solid #7e5e2f;
|
|
border-radius: 999px;
|
|
}
|
|
.mini-cover { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; vertical-align: middle; }
|
|
.mini-ph { display: inline-block; width: 36px; height: 36px; border-radius: 4px;
|
|
background: linear-gradient(135deg, #17130d, #2c2116); vertical-align: middle; }
|
|
.nowrap { white-space: nowrap; }
|
|
.note { margin-top: 18px; color: var(--muted); font-size: 12px; }
|
|
@media (max-width: 980px) { .grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
|
@media (max-width: 700px) {
|
|
.wrap { padding: 16px 12px 36px; }
|
|
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.jump-nav { margin-top: 10px; padding: 7px; }
|
|
}
|
|
";
|
|
|
|
/// Desktop column count for the album cover grid.
|
|
const ALBUM_GRID_COLUMNS: i64 = 6;
|
|
|
|
/// Round a user-provided limit up to the nearest full grid row.
|
|
///
|
|
/// Examples with 6-column rows:
|
|
/// - limit 20 -> 24
|
|
/// - limit 18 -> 18
|
|
/// - limit 1 -> 6
|
|
fn album_cover_grid_limit(limit: i64) -> i64 {
|
|
let columns = ALBUM_GRID_COLUMNS;
|
|
let safe = limit.max(1);
|
|
((safe + columns - 1) / columns) * columns
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HtmlWriter — helper for producing indented HTML output
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A simple helper that tracks indentation depth so the generated HTML is
|
|
/// human-readable. Each `open()` increases indent, each `close()` decreases.
|
|
struct HtmlWriter {
|
|
buf: String,
|
|
depth: usize,
|
|
}
|
|
|
|
impl HtmlWriter {
|
|
fn new() -> Self {
|
|
Self {
|
|
buf: String::with_capacity(128 * 1024),
|
|
depth: 0,
|
|
}
|
|
}
|
|
|
|
/// Write a self-contained line at the current indent level.
|
|
fn line(&mut self, s: &str) {
|
|
self.write_indent();
|
|
self.buf.push_str(s);
|
|
self.buf.push('\n');
|
|
}
|
|
|
|
/// Write a formatted line at the current indent level.
|
|
fn linef(&mut self, args: std::fmt::Arguments<'_>) {
|
|
self.write_indent();
|
|
let _ = write!(self.buf, "{}", args);
|
|
self.buf.push('\n');
|
|
}
|
|
|
|
/// Write raw text (no indent, no newline added — the text provides its own).
|
|
fn raw(&mut self, s: &str) {
|
|
self.buf.push_str(s);
|
|
}
|
|
|
|
/// Write an opening tag and increase indent for children.
|
|
fn open(&mut self, tag: &str) {
|
|
self.line(tag);
|
|
self.indent();
|
|
}
|
|
|
|
/// Decrease indent and write a closing tag.
|
|
fn close(&mut self, tag: &str) {
|
|
self.dedent();
|
|
self.line(tag);
|
|
}
|
|
|
|
/// Write a blank line for visual separation.
|
|
fn blank(&mut self) {
|
|
self.buf.push('\n');
|
|
}
|
|
|
|
fn indent(&mut self) {
|
|
self.depth += 1;
|
|
}
|
|
|
|
fn dedent(&mut self) {
|
|
self.depth = self.depth.saturating_sub(1);
|
|
}
|
|
|
|
fn write_indent(&mut self) {
|
|
for _ in 0..self.depth {
|
|
self.buf.push_str(" ");
|
|
}
|
|
}
|
|
|
|
/// Consume the writer and return the finished HTML string.
|
|
fn finish(self) -> String {
|
|
self.buf
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTML content helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert an absolute cover file path (from the DB) into a relative path
|
|
/// for use in HTML (`covers/<filename>`), and track the source file for
|
|
/// copying into the output directory. Returns `None` if the path is missing
|
|
/// or the file doesn't exist on disk.
|
|
fn resolve_cover(
|
|
abs_path: Option<String>,
|
|
cover_files: &mut Vec<std::path::PathBuf>,
|
|
) -> Option<String> {
|
|
let src = abs_path?;
|
|
let src_path = std::path::Path::new(&src);
|
|
let filename = src_path.file_name()?;
|
|
if src_path.exists() {
|
|
cover_files.push(src_path.to_path_buf());
|
|
}
|
|
Some(format!("covers/{}", filename.to_string_lossy()))
|
|
}
|
|
|
|
fn write_kpi(h: &mut HtmlWriter, key: &str, val: &str) {
|
|
h.open("<div class=\"kpi\">");
|
|
h.linef(format_args!("<div class=\"k\">{}</div>", html_escape(key)));
|
|
h.linef(format_args!(
|
|
"<div class=\"v mono\">{}</div>",
|
|
html_escape(val)
|
|
));
|
|
h.close("</div>");
|
|
}
|
|
|
|
/// 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<String>,
|
|
value: i64,
|
|
suffix: String,
|
|
/// Relative path to a cover image (e.g., "covers/UUID.jpg"), or None.
|
|
cover: Option<String>,
|
|
}
|
|
|
|
/// 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("<section>");
|
|
h.linef(format_args!(
|
|
"<h3 class=\"section-title\">{}</h3>",
|
|
html_escape(title)
|
|
));
|
|
h.open("<div class=\"card\">");
|
|
h.open("<table>");
|
|
|
|
// 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("<thead>");
|
|
h.open("<tr>");
|
|
if has_covers {
|
|
h.line("<th></th>"); // empty header for the cover column
|
|
}
|
|
for hdr in headers {
|
|
h.linef(format_args!("<th>{}</th>", html_escape(hdr)));
|
|
}
|
|
h.close("</tr>");
|
|
h.close("</thead>");
|
|
|
|
// Body
|
|
h.open("<tbody>");
|
|
for row in rows {
|
|
h.open("<tr>");
|
|
if has_covers {
|
|
if let Some(ref src) = row.cover {
|
|
h.linef(format_args!(
|
|
"<td><img class=\"mini-cover\" src=\"{}\" alt=\"\"></td>",
|
|
html_attr_escape(src)
|
|
));
|
|
} else {
|
|
h.line("<td><span class=\"mini-ph\"></span></td>");
|
|
}
|
|
}
|
|
for cell in &row.cells {
|
|
h.linef(format_args!("<td>{}</td>", html_escape(cell)));
|
|
}
|
|
let pct = (row.value as f64 / max_val as f64 * 100.0).round() as u32;
|
|
h.linef(format_args!("<td>{}</td>", row.value));
|
|
h.linef(format_args!(
|
|
"<td class=\"bar-cell\">\n\
|
|
{s} <div class=\"bar-wrap\">\n\
|
|
{s} <div class=\"bar\" style=\"width:{pct}%\"></div>\n\
|
|
{s} <span class=\"bar-label\">{suffix}</span>\n\
|
|
{s} </div>\n\
|
|
{s}</td>",
|
|
s = " ".repeat(h.depth),
|
|
pct = pct,
|
|
suffix = html_escape(&row.suffix)
|
|
));
|
|
h.close("</tr>");
|
|
}
|
|
h.close("</tbody>");
|
|
|
|
h.close("</table>");
|
|
h.close("</div>"); // .card
|
|
h.close("</section>");
|
|
}
|
|
|
|
/// Render a plain table (no bar chart).
|
|
fn write_plain_table(h: &mut HtmlWriter, title: &str, headers: &[&str], rows: &[Vec<String>]) {
|
|
if rows.is_empty() {
|
|
return;
|
|
}
|
|
h.blank();
|
|
h.open("<section>");
|
|
h.linef(format_args!(
|
|
"<h3 class=\"section-title\">{}</h3>",
|
|
html_escape(title)
|
|
));
|
|
h.open("<div class=\"card\">");
|
|
h.open("<table>");
|
|
|
|
h.open("<thead>");
|
|
h.open("<tr>");
|
|
for hdr in headers {
|
|
h.linef(format_args!("<th>{}</th>", html_escape(hdr)));
|
|
}
|
|
h.close("</tr>");
|
|
h.close("</thead>");
|
|
|
|
h.open("<tbody>");
|
|
for row in rows {
|
|
h.open("<tr>");
|
|
for (i, col) in row.iter().enumerate() {
|
|
if i == 0 {
|
|
h.linef(format_args!(
|
|
"<td class=\"nowrap\">{}</td>",
|
|
html_escape(col)
|
|
));
|
|
} else {
|
|
h.linef(format_args!("<td>{}</td>", html_escape(col)));
|
|
}
|
|
}
|
|
h.close("</tr>");
|
|
}
|
|
h.close("</tbody>");
|
|
|
|
h.close("</table>");
|
|
h.close("</div>");
|
|
h.close("</section>");
|
|
}
|
|
|
|
/// Build the `pin-album` shell command for the given artist and album.
|
|
/// Uses the known MBID when available, otherwise substitutes `MBID_HERE`.
|
|
/// Double-quotes in string values are escaped so the command is safe to paste.
|
|
fn pin_album_cmd(artist: &str, album: &str, mbid: Option<&str>) -> String {
|
|
let a = artist.replace('"', "\\\"");
|
|
let b = album.replace('"', "\\\"");
|
|
let m = mbid.unwrap_or("MBID_HERE");
|
|
format!(
|
|
"mpris-scrobbler pin-album --artist \"{a}\" --album \"{b}\" --mbid \"{m}\""
|
|
)
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
}
|
|
|
|
fn html_attr_escape(s: &str) -> String {
|
|
html_escape(s).replace('"', """)
|
|
}
|
|
|
|
/// Split a comma-separated genre list and keep only the first `max_labels`
|
|
/// cleaned labels.
|
|
fn top_genre_labels(genre_csv: &str, max_labels: usize) -> Vec<String> {
|
|
genre_csv
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.take(max_labels)
|
|
.map(|s| s.to_string())
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// =======================================================================
|
|
// Duration formatting
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn test_format_duration_seconds() {
|
|
assert_eq!(format_duration(45), "45s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_duration_minutes() {
|
|
assert_eq!(format_duration(120), "2m");
|
|
assert_eq!(format_duration(300), "5m");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_duration_hours() {
|
|
assert_eq!(format_duration(3600), "1h");
|
|
assert_eq!(format_duration(3660), "1h 01m");
|
|
assert_eq!(format_duration(7380), "2h 03m");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_duration_zero() {
|
|
assert_eq!(format_duration(0), "0s");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_play_count() {
|
|
assert_eq!(format_play_count(0), "0 plays");
|
|
assert_eq!(format_play_count(1), "1 play");
|
|
assert_eq!(format_play_count(2), "2 plays");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_cell() {
|
|
assert_eq!(truncate_cell("abcdef", 6), "abcdef");
|
|
assert_eq!(truncate_cell("abcdef", 5), "ab...");
|
|
assert_eq!(truncate_cell("abcdef", 3), "abc");
|
|
}
|
|
|
|
#[test]
|
|
fn test_album_cover_grid_limit_rounds_to_full_rows() {
|
|
assert_eq!(album_cover_grid_limit(20), 24);
|
|
assert_eq!(album_cover_grid_limit(18), 18);
|
|
assert_eq!(album_cover_grid_limit(1), 6);
|
|
assert_eq!(album_cover_grid_limit(0), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_top_genre_labels_caps_to_three() {
|
|
let labels = top_genre_labels("alternative rock, electronic, industrial, darkwave", 3);
|
|
assert_eq!(
|
|
labels,
|
|
vec![
|
|
"alternative rock".to_string(),
|
|
"electronic".to_string(),
|
|
"industrial".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mood_labels_caps_to_three() {
|
|
let genres = vec![
|
|
db::TopGenre {
|
|
genre: "trip hop".to_string(),
|
|
plays: 5,
|
|
listen_time_secs: 1000,
|
|
},
|
|
db::TopGenre {
|
|
genre: "electronic".to_string(),
|
|
plays: 4,
|
|
listen_time_secs: 900,
|
|
},
|
|
db::TopGenre {
|
|
genre: "ambient".to_string(),
|
|
plays: 3,
|
|
listen_time_secs: 800,
|
|
},
|
|
db::TopGenre {
|
|
genre: "downtempo".to_string(),
|
|
plays: 2,
|
|
listen_time_secs: 700,
|
|
},
|
|
];
|
|
assert_eq!(
|
|
mood_labels(&genres, 3),
|
|
vec![
|
|
"trip hop".to_string(),
|
|
"electronic".to_string(),
|
|
"ambient".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
// =======================================================================
|
|
// Report gathering
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn test_gather_report_empty_db() {
|
|
// An empty database should produce a valid report with all zeros.
|
|
let conn = db::open_memory_db().unwrap();
|
|
let report = gather_report(&conn, "all", 10).unwrap();
|
|
assert_eq!(report.overview.total_scrobbles, 0);
|
|
assert!(report.top_artists.is_empty());
|
|
assert!(report.top_genres.is_empty());
|
|
assert!(report.recent_scrobbles.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_gather_report_with_data() {
|
|
// Populate a test database and verify the report contains expected data.
|
|
let conn = db::open_memory_db().unwrap();
|
|
let scrobbles = vec![
|
|
db::NewScrobble {
|
|
artist: "††† (Crosses)".into(),
|
|
album: "††† (Crosses)".into(),
|
|
title: "This Is a Trick".into(),
|
|
track_duration_secs: Some(186),
|
|
played_duration_secs: 186,
|
|
scrobbled_at: "2026-03-19T10:00:00".into(),
|
|
},
|
|
db::NewScrobble {
|
|
artist: "††† (Crosses)".into(),
|
|
album: "††† (Crosses)".into(),
|
|
title: "Telepathy".into(),
|
|
track_duration_secs: Some(215),
|
|
played_duration_secs: 200,
|
|
scrobbled_at: "2026-03-19T10:05:00".into(),
|
|
},
|
|
db::NewScrobble {
|
|
artist: "Deftones".into(),
|
|
album: "White Pony".into(),
|
|
title: "Digital Bath".into(),
|
|
track_duration_secs: Some(291),
|
|
played_duration_secs: 291,
|
|
scrobbled_at: "2026-03-19T10:10:00".into(),
|
|
},
|
|
];
|
|
for s in &scrobbles {
|
|
db::insert_scrobble(&conn, s).unwrap();
|
|
}
|
|
|
|
let report = gather_report(&conn, "all", 10).unwrap();
|
|
|
|
// Overview should reflect all 3 scrobbles.
|
|
assert_eq!(report.overview.total_scrobbles, 3);
|
|
// Two distinct artists: ††† (Crosses) and Deftones.
|
|
assert_eq!(report.top_artists.len(), 2);
|
|
// ††† (Crosses) has 2 plays, so it should be ranked first.
|
|
assert_eq!(report.top_artists[0].artist, "††† (Crosses)");
|
|
// All 3 tracks are unique.
|
|
assert_eq!(report.top_tracks.len(), 3);
|
|
// No cached genres yet in this test fixture.
|
|
assert!(report.top_genres.is_empty());
|
|
// Recent scrobbles should contain all 3.
|
|
assert_eq!(report.recent_scrobbles.len(), 3);
|
|
// Period should be "all" with no date range.
|
|
assert_eq!(report.period.name, "all");
|
|
}
|
|
|
|
#[test]
|
|
fn test_json_serialization() {
|
|
// Verify that the report can be serialized to JSON and contains
|
|
// expected fields.
|
|
let conn = db::open_memory_db().unwrap();
|
|
db::insert_scrobble(
|
|
&conn,
|
|
&db::NewScrobble {
|
|
artist: "Test".into(),
|
|
album: "Album".into(),
|
|
title: "Song".into(),
|
|
track_duration_secs: Some(180),
|
|
played_duration_secs: 180,
|
|
scrobbled_at: "2026-03-19T10:00:00".into(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let report = gather_report(&conn, "all", 10).unwrap();
|
|
let json = serde_json::to_string(&report).unwrap();
|
|
assert!(json.contains("\"total_scrobbles\":1"));
|
|
assert!(json.contains("\"artist\":\"Test\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_html_render_contains_sections() {
|
|
let conn = db::open_memory_db().unwrap();
|
|
db::insert_scrobble(
|
|
&conn,
|
|
&db::NewScrobble {
|
|
artist: "Test Artist".into(),
|
|
album: "Test Album".into(),
|
|
title: "Test Song".into(),
|
|
track_duration_secs: Some(180),
|
|
played_duration_secs: 170,
|
|
scrobbled_at: "2026-03-19T10:00:00".into(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
db::upsert_album_cache(
|
|
&conn,
|
|
&db::AlbumCacheEntry {
|
|
artist: "Test Artist".into(),
|
|
album: "Test Album".into(),
|
|
musicbrainz_id: None,
|
|
cover_url: None,
|
|
genre: Some("trip hop".into()),
|
|
fetched_at: "2026-03-19T10:10:00".into(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let report = render_html_report(&conn, 10, 25);
|
|
assert!(report.html.contains("<html"));
|
|
// Multi-period report should contain All Time section.
|
|
assert!(report.html.contains("All Time"));
|
|
assert!(report.html.contains("jump-nav"));
|
|
assert!(report.html.contains("#period-all"));
|
|
assert!(report.html.contains("Top Albums"));
|
|
assert!(report.html.contains("Top Artists"));
|
|
assert!(report.html.contains("Top Genres"));
|
|
assert!(report.html.contains("Mood of"));
|
|
assert!(!report.html.contains("Mood of All Time"));
|
|
assert!(report.html.contains("Recent Scrobbles"));
|
|
assert!(report.html.contains("Test Artist"));
|
|
// Bar chart elements should be present.
|
|
assert!(report.html.contains("bar-wrap"));
|
|
// No covers cached in test DB, so no cover files to copy.
|
|
assert!(report.cover_files.is_empty());
|
|
}
|
|
|
|
// =======================================================================
|
|
// Fuzzy time labels
|
|
// =======================================================================
|
|
|
|
#[test]
|
|
fn test_fuzzy_time_recent() {
|
|
let ten_min_ago = (chrono::Local::now().naive_local() - chrono::Duration::minutes(10))
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string();
|
|
assert_eq!(fuzzy_time(&ten_min_ago), "Moments ago");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_time_within_hour() {
|
|
let fifty_min_ago = (chrono::Local::now().naive_local() - chrono::Duration::minutes(50))
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string();
|
|
assert_eq!(fuzzy_time(&fifty_min_ago), "Within hour");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_time_hours() {
|
|
let two_hours_ago = (chrono::Local::now().naive_local() - chrono::Duration::hours(2))
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string();
|
|
assert_eq!(fuzzy_time(&two_hours_ago), "A while ago");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_time_yesterday() {
|
|
// Use calendar noon of yesterday so the result is unambiguously
|
|
// "Yesterday" regardless of the time of day the test runs.
|
|
let yesterday_noon = (chrono::Local::now().date_naive() - chrono::Duration::days(1))
|
|
.and_hms_opt(12, 0, 0)
|
|
.unwrap()
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string();
|
|
assert_eq!(fuzzy_time(&yesterday_noon), "Yesterday");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_time_calendar_boundary() {
|
|
// A track scrobbled at 20:00 yesterday must say "Yesterday", not "Today",
|
|
// even when fewer than 12 hours have elapsed (e.g. run just after midnight).
|
|
// This is the calendar-date boundary bug the function was written to fix.
|
|
let yesterday_evening = (chrono::Local::now().date_naive() - chrono::Duration::days(1))
|
|
.and_hms_opt(20, 0, 0)
|
|
.unwrap()
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string();
|
|
assert_eq!(fuzzy_time(&yesterday_evening), "Yesterday");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_time_old() {
|
|
let long_ago = (chrono::Local::now().naive_local() - chrono::Duration::days(400))
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string();
|
|
assert_eq!(fuzzy_time(&long_ago), "Over year");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_time_invalid() {
|
|
assert_eq!(fuzzy_time("not-a-date"), "Some time");
|
|
}
|
|
|
|
#[test]
|
|
fn test_terse_time_labels() {
|
|
let mins_ago = |m: i64| {
|
|
(chrono::Local::now().naive_local() - chrono::Duration::minutes(m))
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string()
|
|
};
|
|
let hours_ago = |h: i64| {
|
|
(chrono::Local::now().naive_local() - chrono::Duration::hours(h))
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string()
|
|
};
|
|
let days_ago = |d: i64| {
|
|
(chrono::Local::now().naive_local() - chrono::Duration::days(d))
|
|
.format("%Y-%m-%dT%H:%M:%S")
|
|
.to_string()
|
|
};
|
|
|
|
assert_eq!(terse_time(&mins_ago(5)), "5m");
|
|
assert_eq!(terse_time(&mins_ago(45)), "45m");
|
|
assert_eq!(terse_time(&hours_ago(2)), "2h");
|
|
assert_eq!(terse_time(&days_ago(3)), "3d");
|
|
assert_eq!(terse_time(&days_ago(20)), "2w");
|
|
assert_eq!(terse_time(&days_ago(400)), "13mo");
|
|
assert_eq!(terse_time("not-a-date"), "?");
|
|
}
|
|
}
|