diff --git a/crates/vibescrobble-query/src/paper.rs b/crates/vibescrobble-query/src/paper.rs index 6b6bb4c1..3e1d73d3 100644 --- a/crates/vibescrobble-query/src/paper.rs +++ b/crates/vibescrobble-query/src/paper.rs @@ -12,6 +12,8 @@ use std::fmt::Write as _; use base64::Engine as _; +use vibescrobble_index::tally::Week; + use crate::card::{escape, hsl, hue_of, width, Faces}; /// The plate's and the kit's inks, as `web/plate.css` and @@ -487,6 +489,25 @@ pub fn month(number: u8) -> &'static str { MONTHS[usize::from(number.clamp(1, 12)) - 1] } +/// A week's days, Monday to Sunday with `dash` between: `12–18 Oct 2026`. +/// A week that runs into the next month names both, `26 Oct–1 Nov 2026`, +/// and one into the next year both years, `28 Dec 2026–3 Jan 2027`. `None` +/// for a week its year lacks. +#[must_use] +pub fn week_days(week: Week, dash: char) -> Option { + let monday = week.monday()?; + let sunday = monday + time::Duration::days(6); + let (d1, m1, y1) = (monday.day(), month(monday.month() as u8), monday.year()); + let (d2, m2, y2) = (sunday.day(), month(sunday.month() as u8), sunday.year()); + Some(if y1 != y2 { + format!("{d1} {m1} {y1}{dash}{d2} {m2} {y2}") + } else if m1 != m2 { + format!("{d1} {m1}{dash}{d2} {m2} {y2}") + } else { + format!("{d1}{dash}{d2} {m2} {y2}") + }) +} + /// `1,479`, and `9.4k` from ten thousand. #[must_use] pub fn count(n: u32) -> String { @@ -552,6 +573,21 @@ mod tests { assert_eq!(duration(600), "10m"); } + #[test] + fn a_week_names_the_month_and_year_of_each_end_it_does_not_share() { + let days = |week| week_days(Week::parse(week).expect("a week"), '–'); + assert_eq!(days("2026-W42").as_deref(), Some("12–18 Oct 2026")); + assert_eq!(days("2026-W44").as_deref(), Some("26 Oct–1 Nov 2026")); + assert_eq!(days("2026-W53").as_deref(), Some("28 Dec 2026–3 Jan 2027")); + assert_eq!(days("2026-W01").as_deref(), Some("29 Dec 2025–4 Jan 2026")); + assert_eq!(days("2027-W05").as_deref(), Some("1–7 Feb 2027")); + let missing = Week { + year: 2025, + number: 53, + }; + assert_eq!(week_days(missing, '-'), None); + } + #[test] fn a_repo_is_named_short() { let handle = Some("permadeath.com"); diff --git a/crates/vibescrobble-query/src/swarm.rs b/crates/vibescrobble-query/src/swarm.rs index 7919a976..3469c3ad 100644 --- a/crates/vibescrobble-query/src/swarm.rs +++ b/crates/vibescrobble-query/src/swarm.rs @@ -672,19 +672,9 @@ fn week_sheet(origin: &str, gathered: &Gathered, week: Week, unwrapped: Option String { - let Some(monday) = week.monday() else { - return week.to_string(); - }; - let sunday = monday + time::Duration::days(6); - format!( - "{}–{} {} {}", - monday.day(), - sunday.day(), - crate::paper::month(sunday.month() as u8), - sunday.year() - ) + crate::paper::week_days(week, '–').unwrap_or_else(|| week.to_string()) } /// Everything one year's poster is drawn from, read under the view's lock. @@ -965,6 +955,14 @@ mod tests { use super::*; use std::path::PathBuf; + #[test] + fn a_week_across_a_month_or_year_names_both_ends() { + let span = |week| span(Week::parse(week).expect("a week")); + assert_eq!(span("2026-W43"), "19–25 Oct 2026"); + assert_eq!(span("2026-W44"), "26 Oct–1 Nov 2026"); + assert_eq!(span("2026-W53"), "28 Dec 2026–3 Jan 2027"); + } + /// A real week's page, written out to look at: `SWARM_SUMMARY` names a /// week's summary as JSON, as `zine::tests::a_real_week_draws` writes /// it, `SWARM_SUBJECT` and `SWARM_HANDLE` whose it is, and `CARD_OUT` a diff --git a/crates/vibescrobble-query/src/zine.rs b/crates/vibescrobble-query/src/zine.rs index 13c2c60e..d6551bc1 100644 --- a/crates/vibescrobble-query/src/zine.rs +++ b/crates/vibescrobble-query/src/zine.rs @@ -71,21 +71,16 @@ fn seed(issue: &Issue<'_>, part: &str) -> String { format!("{} {week} {part}", issue.did) } -/// `12–18 oct 2026`. +/// `12-18 oct 2026`, or `26 oct-1 nov 2026` across a month's end. fn dates(issue: &Issue<'_>) -> String { - let Some(monday) = issue.summary.week.and_then(|week| week.monday()) else { - return String::new(); - }; - let sunday = monday + time::Duration::days(6); // A hyphen, not a dash: the typewriter's face is cut to ASCII and a // little more, and a line with one glyph it lacks is set in another face. - format!( - "{}-{} {} {}", - monday.day(), - sunday.day(), - paper::month(sunday.month() as u8).to_lowercase(), - sunday.year() - ) + issue + .summary + .week + .and_then(|week| paper::week_days(week, '-')) + .map(|days| days.to_lowercase()) + .unwrap_or_default() } fn number(issue: &Issue<'_>) -> String { @@ -761,6 +756,20 @@ pub(crate) mod tests { } } + #[test] + fn an_issue_line_names_both_months_of_a_week_across_them() { + let summary = quiet(); + let issue = Issue { + summary: &summary, + did: "did:plc:quernstone", + handle: None, + name: "tidepool", + so_far: false, + link: String::new(), + }; + assert_eq!(dates(&issue), "26 oct-1 nov 2026"); + } + /// Each week draws as a page and a spread, a picture missing or not. With /// `CARD_OUT` set to a directory, they are written there to look at. #[test]