use std::collections::{BTreeMap, BTreeSet};
use chrono::{Datelike, NaiveDate};
use crate::art::{self, Grid, GLYPH_ROWS};
use crate::thousands;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Want {
Lit,
Hole,
Around,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Day {
pub date: NaiveDate,
pub want: Want,
pub have: u32,
pub need: u32,
pub ceiling: Option<u32>,
}
impl Day {
pub fn done(&self) -> bool {
self.have >= self.need && self.ceiling.is_none_or(|most| self.have <= most)
}
pub fn short(&self) -> u32 {
self.need.saturating_sub(self.have)
}
pub fn over(&self) -> u32 {
match self.ceiling {
Some(most) => self.have.saturating_sub(most),
None => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Done,
Reachable,
Holed {
holes: usize,
},
}
#[derive(Debug, Clone)]
pub struct Plan {
pub text: String,
pub year: i32,
pub peak: u32,
pub peak_day: Option<NaiveDate>,
pub need: u32,
pub shades: art::Shades,
pub field_need: u32,
pub field_ceiling: Option<u32>,
pub days: Vec<Day>,
pub start_week: usize,
pub columns: usize,
}
impl Plan {
pub fn build(
text: &str,
grid: &Grid,
placed: &art::Placed,
columns: usize,
top: usize,
actual: &BTreeMap<NaiveDate, u32>,
shades: art::Shades,
) -> Self {
let peak = actual.values().copied().max().unwrap_or(0);
let peak_day = actual
.iter()
.filter(|(_, count)| **count == peak && peak > 0)
.map(|(date, _)| *date)
.next();
let measured = peak.max(shades.min_peak());
let need = art::commits_to_reach(shades.ink, measured);
let field_need = shades.commits(measured).field;
let field_ceiling = shades.ceiling(measured);
let inside: BTreeSet<NaiveDate> = (placed.start_week..placed.start_week + columns)
.flat_map(|week| (top..top + GLYPH_ROWS).map(move |row| (week, row)))
.map(|(week, row)| grid.date_at(week, row))
.filter(|date| grid.holds(*date))
.collect();
let mut days = Vec::new();
let mut date = grid.first;
while date <= grid.last {
let have = actual.get(&date).copied().unwrap_or(0);
let want = if placed.lit.contains_key(&date) {
Want::Lit
} else if inside.contains(&date) {
Want::Hole
} else {
Want::Around
};
let (day_need, ceiling) = match want {
Want::Lit => (need, None),
Want::Hole => (field_need, field_ceiling),
Want::Around if shades.field > 0 => (field_need, field_ceiling),
Want::Around => (0, None),
};
if want != Want::Around || have > 0 || day_need > 0 {
days.push(Day {
date,
want,
have,
need: day_need,
ceiling,
});
}
date = date.succ_opt().unwrap_or(date);
if date == grid.last.succ_opt().unwrap_or(grid.last) {
break;
}
}
Self {
text: text.to_uppercase(),
year: grid.year,
peak,
peak_day,
need,
shades,
field_need,
field_ceiling,
days,
start_week: placed.start_week,
columns,
}
}
pub fn letters(&self) -> impl Iterator<Item = &Day> {
self.days.iter().filter(|day| day.want == Want::Lit)
}
pub fn bright(&self) -> usize {
self.letters().filter(|day| day.done()).count()
}
pub fn owing(&self) -> (usize, u32) {
tally(self.letters())
}
pub fn field(&self) -> impl Iterator<Item = &Day> {
self.days
.iter()
.filter(|day| day.want != Want::Lit && day.need > 0)
}
pub fn field_bright(&self) -> usize {
self.field().filter(|day| day.done()).count()
}
pub fn field_owing(&self) -> (usize, u32) {
tally(self.field())
}
pub fn holes(&self) -> Vec<&Day> {
self.days
.iter()
.filter(|day| day.want == Want::Hole && day.over() > 0)
.collect()
}
pub fn around(&self) -> usize {
self.days
.iter()
.filter(|day| day.want == Want::Around)
.filter(|day| match day.ceiling {
Some(most) => day.have > most,
None => day.have > 0,
})
.count()
}
pub fn verdict(&self) -> Verdict {
let holes = self.holes().len();
if holes > 0 {
return Verdict::Holed { holes };
}
if self.owing().0 == 0 && self.field_owing().0 == 0 {
return Verdict::Done;
}
Verdict::Reachable
}
pub fn on(&self, date: NaiveDate) -> Option<&Day> {
self.days.iter().find(|day| day.date == date)
}
pub fn schedule(&self, from: NaiveDate, count: usize) -> Vec<Day> {
let mut out = Vec::with_capacity(count);
let mut date = from.max(self.first_day());
for _ in 0..count {
if date > self.last_day() {
break;
}
out.push(self.on(date).copied().unwrap_or(Day {
date,
want: Want::Around,
have: 0,
need: 0,
ceiling: None,
}));
date = match date.succ_opt() {
Some(next) => next,
None => break,
};
}
out
}
pub fn overdue(&self, today: NaiveDate) -> (usize, u32) {
tally(self.letters().filter(|day| day.date < today))
}
pub fn ahead(&self, today: NaiveDate) -> (usize, u32) {
tally(self.letters().filter(|day| day.date >= today))
}
fn first_day(&self) -> NaiveDate {
NaiveDate::from_ymd_opt(self.year, 1, 1).unwrap_or(self.days[0].date)
}
fn last_day(&self) -> NaiveDate {
NaiveDate::from_ymd_opt(self.year, 12, 31).unwrap_or(self.days[0].date)
}
pub fn under_way(&self, today: NaiveDate) -> bool {
today >= self.first_day()
}
pub fn holds(&self, date: NaiveDate) -> bool {
self.first_day() <= date && date <= self.last_day()
}
}
fn tally<'a>(days: impl Iterator<Item = &'a Day>) -> (usize, u32) {
days.map(Day::short)
.filter(|short| *short > 0)
.fold((0, 0u32), |(count, sum), owed| {
(count + 1, sum.saturating_add(owed))
})
}
pub fn best_start_week(
grid: &Grid,
columns: usize,
top: usize,
lit_shape: &[[bool; GLYPH_ROWS]],
actual: &BTreeMap<NaiveDate, u32>,
ceiling: u32,
) -> Option<(usize, usize)> {
if columns > grid.weeks {
return None;
}
(0..=grid.weeks - columns)
.map(|start| {
let holes = lit_shape
.iter()
.enumerate()
.flat_map(|(offset, column)| {
column
.iter()
.enumerate()
.filter(|(_, lit)| !**lit)
.map(move |(row, _)| (start + offset, top + row))
})
.map(|(week, row)| grid.date_at(week, row))
.filter(|date| grid.holds(*date))
.filter(|date| actual.get(date).is_some_and(|count| *count > ceiling))
.count();
(start, holes)
})
.min_by_key(|(start, holes)| (*holes, *start))
}
pub fn contributions(calendar: &crate::calendar::Calendar) -> BTreeMap<NaiveDate, u32> {
calendar
.days()
.filter(|day| day.count > 0)
.map(|day| (day.date, day.count))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Spec {
pub text: String,
pub year: i32,
pub start_week: usize,
pub top: usize,
pub commits: u32,
#[serde(default)]
pub background: u8,
pub user: Option<String>,
}
pub const DEFAULT_SPEC: &str = "mossaic-plan.json";
impl Spec {
pub fn validate(&self) -> Result<(), String> {
let bounded = |what: &str, value: i128, low: i128, high: i128| {
if (low..=high).contains(&value) {
Ok(())
} else {
Err(format!(
"{what} is {value}, which is not between {low} and {high}"
))
}
};
bounded(
"year",
i128::from(self.year),
i128::from(*crate::cli::YEARS.start()),
i128::from(*crate::cli::YEARS.end()),
)?;
bounded(
"top",
self.top as i128,
0,
(crate::art::WEEKDAYS - GLYPH_ROWS) as i128,
)?;
bounded("commits", i128::from(self.commits), 1, 1_000_000)?;
bounded("start_week", self.start_week as i128, 0, 60)?;
bounded("background", i128::from(self.background), 0, 4)?;
Ok(())
}
pub fn load(path: &std::path::Path) -> Result<Self, String> {
let body = std::fs::read_to_string(path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
let spec: Self = serde_json::from_str(&body)
.map_err(|error| format!("{} is not a mossaic plan: {error}", path.display()))?;
spec.validate().map_err(|why| {
format!(
"{} is not a plan these tools can use: {why}.\n \
Saving it again replaces it: mossaic-art TEXT --year YEAR --save",
path.display()
)
})?;
Ok(spec)
}
pub fn save(&self, path: &std::path::Path) -> Result<(), String> {
let body = serde_json::to_string_pretty(self)
.map_err(|error| format!("could not encode the plan: {error}"))?;
std::fs::write(path, body + "\n")
.map_err(|error| format!("could not write {}: {error}", path.display()))
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Standing {
pub date: String,
pub kind: &'static str,
pub need: u32,
pub have: u32,
pub short: u32,
pub ceiling: Option<u32>,
pub over: u32,
}
impl Standing {
fn of(day: &Day) -> Self {
Self {
date: day.date.to_string(),
kind: match day.want {
Want::Lit => "letter",
Want::Hole if day.over() > 0 => "hole",
Want::Hole if day.need > 0 => "background",
Want::Hole => "keep-dark",
Want::Around if day.need > 0 => "background",
Want::Around => "outside",
},
need: day.need,
have: day.have,
short: day.short(),
ceiling: day.ceiling,
over: day.over(),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Report {
pub text: String,
pub year: i32,
pub source: String,
pub start_week: usize,
pub columns: usize,
pub year_total: u32,
pub peak: u32,
pub peak_day: Option<String>,
pub need_per_day: u32,
pub ink_level: u8,
pub field_level: u8,
pub separation: f32,
pub legibility: &'static str,
pub field_need_per_day: u32,
pub field_ceiling: Option<u32>,
pub field_days: usize,
pub field_bright: usize,
pub field_owing_days: usize,
pub field_owing_commits: u32,
pub letters: usize,
pub bright: usize,
pub owing_days: usize,
pub owing_commits: u32,
pub holes: usize,
pub around: usize,
pub verdict: &'static str,
pub headline: String,
pub suggested_start_week: Option<usize>,
pub suggested_holes: Option<usize>,
pub today: Option<Standing>,
pub tomorrow: Option<Standing>,
pub ahead_days: usize,
pub ahead_commits: u32,
pub overdue_days: usize,
pub overdue_commits: u32,
}
impl Report {
pub fn of(
plan: &Plan,
user: &str,
year_total: u32,
today: NaiveDate,
suggestion: Option<(usize, usize)>,
) -> Self {
let (owing_days, owing_commits) = plan.owing();
let (field_owing_days, field_owing_commits) = plan.field_owing();
let (legibility, separation) = plan.shades.worst();
let (overdue_days, overdue_commits) = plan.overdue(today);
let (ahead_days, ahead_commits) = plan.ahead(today);
let standing = |date: NaiveDate| {
if !plan.holds(date) {
return None;
}
Some(plan.on(date).map_or_else(
|| Standing {
date: date.to_string(),
kind: "outside",
need: 0,
have: 0,
short: 0,
ceiling: None,
over: 0,
},
Standing::of,
))
};
let mut report = Self {
text: plan.text.clone(),
year: plan.year,
source: user.to_string(),
start_week: plan.start_week,
columns: plan.columns,
year_total,
peak: plan.peak,
peak_day: plan.peak_day.map(|date| date.to_string()),
need_per_day: plan.need,
ink_level: plan.shades.ink,
field_level: plan.shades.field,
separation,
legibility: legibility.as_str(),
field_need_per_day: plan.field_need,
field_ceiling: plan.field_ceiling,
field_days: plan.field().count(),
field_bright: plan.field_bright(),
field_owing_days,
field_owing_commits,
letters: plan.letters().count(),
bright: plan.bright(),
owing_days,
owing_commits,
holes: plan.holes().len(),
around: plan.around(),
verdict: match plan.verdict() {
Verdict::Done => "drawn",
Verdict::Reachable => "reachable",
Verdict::Holed { .. } => "holed",
},
headline: String::new(), suggested_start_week: suggestion.map(|(week, _)| week),
suggested_holes: suggestion.map(|(_, holes)| holes),
today: standing(today),
tomorrow: today.succ_opt().and_then(standing),
ahead_days,
ahead_commits,
overdue_days,
overdue_commits,
};
report.headline = report.summarise();
report
}
fn summarise(&self) -> String {
match self.verdict {
"drawn" => format!("{} · {} — drawn", self.text, self.year),
"holed" => format!(
"{} · {} — {} of {} bright, {} hole(s) that cannot be unlit",
self.text, self.year, self.bright, self.letters, self.holes
),
_ => format!(
"{} · {} — {} of {} bright, {} contributions to go{}",
self.text,
self.year,
self.bright,
self.letters,
thousands(self.owing_commits),
match self.field_owing_commits {
0 => String::new(),
owed => format!(" (+{} for the background)", thousands(owed)),
}
),
}
}
pub fn markdown(&self) -> String {
let mut out = format!("### {} · {}\n\n", self.text, self.year);
out.push_str(&match self.verdict {
"drawn" => format!("**{} is drawn.**\n\n", self.text),
"holed" => format!(
"**Cannot be drawn cleanly** — {} day(s) inside the letters are already \
lit, and nothing takes those away.\n\n",
self.holes
),
_ => format!(
"**On track** — {} contribution(s) to go{}.\n\n",
thousands(self.owing_commits),
match self.field_owing_commits {
0 => String::new(),
owed => format!(", and {} for the background", thousands(owed)),
}
),
});
out.push_str("| | |\n| --- | --- |\n");
out.push_str(&format!(
"| letters bright | {} of {} |\n",
self.bright, self.letters
));
if self.owing_days > 0 {
out.push_str(&format!(
"| still owing | {} day(s) · {} contributions |\n",
self.owing_days,
thousands(self.owing_commits)
));
}
if self.field_level > 0 {
out.push_str(&format!(
"| background | {} of {} at level {} (ΔE {:.0}, {}) |\n",
self.field_bright,
self.field_days,
self.field_level,
self.separation,
self.legibility
));
if self.field_owing_days > 0 {
out.push_str(&format!(
"| background owing | {} day(s) · {} contributions |\n",
self.field_owing_days,
thousands(self.field_owing_commits)
));
}
}
out.push_str(&format!(
"| a letter day costs | {}{} |\n",
thousands(self.need_per_day),
match (&self.peak_day, self.peak) {
(Some(date), peak) if peak > 0 => format!(" (peak {peak} on {date})"),
_ => String::new(),
}
));
for (label, day) in [("today", &self.today), ("tomorrow", &self.tomorrow)] {
if let Some(day) = day {
out.push_str(&format!(
"| {label} | {} |\n",
match (day.kind, day.short) {
("letter", 0) => "letter day — already bright enough".to_string(),
("letter", short) => format!(
"letter day — {} of {} there, {} to go",
thousands(day.have),
thousands(day.need),
thousands(short)
),
("hole", _) => {
"inside the letters and already lit — a permanent hole".to_string()
}
("keep-dark", _) => "inside the letters — keep it dark".to_string(),
("background", _) if day.over > 0 => format!(
"background — {} contributions, {} too many for level {}",
thousands(day.have),
thousands(day.over),
self.field_level
),
("background", 0) => "background — already the right shade".to_string(),
("background", short) => format!(
"background — {} of {} there, {} to go",
thousands(day.have),
thousands(day.need),
thousands(short)
),
_ => "not part of the text".to_string(),
}
));
}
}
if self.overdue_days > 0 {
out.push_str(&format!(
"| already past | {} day(s) · {} contributions, back-dating only |\n",
self.overdue_days,
thousands(self.overdue_commits)
));
}
if let (Some(week), Some(holes)) = (self.suggested_start_week, self.suggested_holes) {
if holes < self.holes {
out.push_str(&format!(
"\n`--start-week {week}` would leave {holes} hole(s) instead of {}.\n",
self.holes
));
}
}
out
}
}
pub fn preview(plan: &Plan, grid: &Grid, palette: Option<&crate::primer::Palette>) -> String {
const NAMES: [&str; 7] = ["", "Mon", "", "Wed", "", "Fri", ""];
enum Mark {
Hole,
Smudge,
LetterDone,
LetterShort,
FieldDone,
FieldShort,
Stray,
Idle,
}
let mark = |day: Option<&Day>| match day {
Some(day) if day.want == Want::Hole && day.over() > 0 => Mark::Hole,
Some(day) if day.over() > 0 => Mark::Smudge,
Some(day) if day.want == Want::Lit && day.done() => Mark::LetterDone,
Some(day) if day.want == Want::Lit => Mark::LetterShort,
Some(day) if day.need > 0 && day.done() => Mark::FieldDone,
Some(day) if day.need > 0 => Mark::FieldShort,
Some(day) if day.have > 0 => Mark::Stray,
_ => Mark::Idle,
};
let mut out = Vec::new();
for (row, name) in NAMES.iter().enumerate() {
let mut line = format!("{name:<4}");
for week in 0..grid.weeks {
let date = grid.date_at(week, row);
if !grid.holds(date) {
line.push_str(" ");
continue;
}
let (glyph, colour) = match mark(plan.on(date)) {
Mark::Hole => ("XX", palette.map(|p| p.danger)),
Mark::Smudge => ("++", palette.map(|p| p.danger)),
Mark::LetterDone => ("██", palette.map(|p| p.levels[4])),
Mark::LetterShort => ("▒▒", palette.map(|p| p.levels[1])),
Mark::FieldDone => ("░░", palette.map(|p| p.levels[2])),
Mark::FieldShort => ("··", palette.map(|p| p.levels[0])),
Mark::Stray => ("··", palette.map(|p| p.levels[2])),
Mark::Idle => (" ", None),
};
match colour {
Some(colour) => line.push_str(&format!(
"\x1b[38;2;{};{};{}m{}\x1b[0m",
colour.0,
colour.1,
colour.2,
if matches!(mark(plan.on(date)), Mark::Hole | Mark::Smudge) {
glyph
} else {
"██"
}
)),
None => line.push_str(glyph),
}
}
out.push(line);
}
out.join("\n")
}
pub fn weekday(date: NaiveDate) -> &'static str {
const NAMES: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
NAMES[date.weekday().num_days_from_sunday() as usize]
}