15.4 KB
raw
use chrono::{DateTime, Datelike, NaiveDate, Timelike};
use chrono_tz::Tz;
use serde::Serialize;
use crate::astro::{moon_phase, sky_data_lines};
use crate::content::{
parse_frontmatter, parse_list_items, parse_named_lists, ListItems, MoonTip, Season, SiteData,
};
use crate::markdown::{render_block, render_inline};
use crate::rng::{day_hash, pick_items, Mulberry32};
#[derive(Serialize)]
pub struct Assembled {
pub date_line: String,
pub season_name: String,
pub season_note: String,
pub season_key: String,
pub time_key: String,
pub haiku_html: String,
pub sections_html: String,
pub footer_text: String,
pub season_nav_html: String,
}
struct Time {
name: &'static str,
start: u32,
end: u32,
}
const TIMES: &[Time] = &[
Time { name: "night", start: 0, end: 5 },
Time { name: "dawn", start: 5, end: 8 },
Time { name: "morning", start: 8, end: 12 },
Time { name: "afternoon", start: 12, end: 17 },
Time { name: "evening", start: 17, end: 21 },
Time { name: "night", start: 21, end: 24 },
];
fn time_of_day(date: DateTime<Tz>) -> &'static str {
let h = date.hour();
for t in TIMES {
if h >= t.start && h < t.end {
return t.name;
}
}
"night"
}
const MONTHS: [&str; 12] = [
"january", "february", "march", "april", "may", "june", "july", "august", "september",
"october", "november", "december",
];
const ORDINALS: [&str; 32] = [
"", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth",
"tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth",
"seventeenth", "eighteenth", "nineteenth", "twentieth", "twenty-first", "twenty-second",
"twenty-third", "twenty-fourth", "twenty-fifth", "twenty-sixth", "twenty-seventh",
"twenty-eighth", "twenty-ninth", "thirtieth", "thirty-first",
];
fn written_date(date: DateTime<Tz>) -> String {
let time = time_of_day(date);
let day = date.day() as usize;
let month = (date.month() - 1) as usize;
format!("{time}, the {} of {}", ORDINALS[day], MONTHS[month])
}
fn get_season_for_date(date: DateTime<Tz>, seasons: &[Season]) -> &Season {
let m = date.month();
let d = date.day();
for s in seasons {
let after_start = m > s.start.0 || (m == s.start.0 && d >= s.start.1);
let before_end = m < s.end.0 || (m == s.end.0 && d <= s.end.1);
if after_start && before_end {
return s;
}
}
&seasons[0]
}
struct NextSeason {
days: i64,
label: String,
}
fn days_until_next_season(date: DateTime<Tz>, seasons: &[Season]) -> NextSeason {
let current = get_season_for_date(date, seasons);
let today = date.date_naive();
for s in seasons {
let s_date = NaiveDate::from_ymd_opt(date.year(), s.start.0, s.start.1).unwrap();
if s_date > today && s.name != current.name {
return NextSeason {
days: (s_date - today).num_days(),
label: s.label.clone(),
};
}
}
let first = &seasons[0];
let next_date = NaiveDate::from_ymd_opt(date.year() + 1, first.start.0, first.start.1).unwrap();
NextSeason {
days: (next_date - today).num_days(),
label: first.label.clone(),
}
}
fn moon_garden_tip(phase: f64, tips: &[MoonTip]) -> String {
for t in tips {
if t.lo <= phase && phase < t.hi {
return t.text.clone();
}
}
tips.last().map(|t| t.text.clone()).unwrap_or_default()
}
fn weather_mood(season_name: &str, time: &str, moods: &std::collections::HashMap<String, std::collections::HashMap<String, String>>) -> String {
if let Some(season_moods) = moods.get(season_name) {
if let Some(text) = season_moods.get(time) {
if !text.is_empty() {
return render_inline(text);
}
}
}
String::new()
}
fn read_md_parts<'a>(path: &str, files: &'a std::collections::HashMap<String, String>) -> Option<ListItems> {
let body = files.get(path)?;
let parsed = parse_frontmatter(body);
Some(parse_list_items(&parsed.body))
}
fn read_named_lists(
path: &str,
files: &std::collections::HashMap<String, String>,
) -> Option<Vec<(String, Vec<String>)>> {
let body = files.get(path)?;
let parsed = parse_frontmatter(body);
Some(parse_named_lists(&parsed.body))
}
#[derive(Debug)]
struct Group {
label: &'static str,
items: Vec<String>,
}
#[derive(Debug)]
struct Section {
key: &'static str,
title: &'static str,
intro: String,
groups: Vec<Group>,
lore: Vec<String>,
}
fn section_sky(now: DateTime<Tz>, season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
let intro = weather_mood(&season.name, time_of_day(now), &data.moods);
let mut lore = Vec::new();
let tip = moon_garden_tip(moon_phase(now), &data.moon_tips);
if !tip.is_empty() {
lore.push(render_inline(&tip));
}
for path in [
format!("sky/{}.md", season.name),
format!("storms/{}.md", season.name),
] {
let Some(items) = read_md_parts(&path, &data.files) else {
continue;
};
let mut candidates: Vec<String> = items.bullets.clone();
candidates.extend(items.prose.clone());
if !candidates.is_empty() {
let pick = pick_items(&candidates, 1, rng).remove(0);
lore.push(render_inline(&pick));
}
}
Section {
key: "sky",
title: "sky",
intro,
groups: vec![Group {
label: "",
items: sky_data_lines(now),
}],
lore,
}
}
fn section_garden(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
let mut groups = Vec::new();
if let Some(items) = read_md_parts(&format!("planting/{}.md", season.name), &data.files) {
if !items.bullets.is_empty() {
let n = items.bullets.len().min(4);
let picks = pick_items(&items.bullets, n, rng);
groups.push(Group {
label: "in the ground now",
items: picks.iter().map(|s| render_inline(s)).collect(),
});
}
}
if let Some(items) = read_md_parts(
&format!("planting/{}-indoors.md", season.name),
&data.files,
) {
if !items.bullets.is_empty() {
let n = items.bullets.len().min(3);
let picks = pick_items(&items.bullets, n, rng);
groups.push(Group {
label: "starting indoors",
items: picks.iter().map(|s| render_inline(s)).collect(),
});
}
}
if let Some(items) = read_md_parts(&format!("chores/{}.md", season.name), &data.files) {
if !items.bullets.is_empty() {
let n = items.bullets.len().min(2);
let picks = pick_items(&items.bullets, n, rng);
groups.push(Group {
label: "this week",
items: picks.iter().map(|s| render_inline(s)).collect(),
});
}
}
if let Some(named) = read_named_lists(&format!("companions/{}.md", season.name), &data.files) {
// Map "good"/"bad" headings in the markdown to the labels rendered on
// the page. Other headings in the file are ignored.
let label_for = |name: &str| -> Option<&'static str> {
match name {
"good" => Some("good neighbors"),
"bad" => Some("bad neighbors"),
_ => None,
}
};
for (name, items) in &named {
let Some(label) = label_for(name) else { continue };
if items.is_empty() {
continue;
}
let n = items.len().min(3);
let picks = pick_items(items, n, rng);
groups.push(Group {
label,
items: picks.iter().map(|s| render_inline(s)).collect(),
});
}
}
Section {
key: "garden",
title: "garden",
intro: String::new(),
groups,
lore: Vec::new(),
}
}
fn section_kitchen(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
let mut groups = Vec::new();
if let Some(items) = read_md_parts(&format!("kitchen/{}.md", season.name), &data.files) {
if !items.bullets.is_empty() {
let bullets = items.bullets.clone();
let n = bullets.len().min(4);
let picks = pick_items(&bullets, n, rng);
groups.push(Group {
label: "in season",
items: picks.iter().map(|s| render_inline(s)).collect(),
});
let remaining: Vec<String> = bullets
.iter()
.filter(|b| !picks.contains(b))
.cloned()
.collect();
let tonight = if remaining.is_empty() {
picks.last().cloned().unwrap_or_default()
} else {
pick_items(&remaining, 1, rng).remove(0)
};
groups.push(Group {
label: "tonight",
items: vec![render_inline(&tonight)],
});
}
}
if let Some(items) = read_md_parts(&format!("preserving/{}.md", season.name), &data.files) {
if !items.bullets.is_empty() {
let n = items.bullets.len().min(2);
let picks = pick_items(&items.bullets, n, rng);
groups.push(Group {
label: "putting up",
items: picks.iter().map(|s| render_inline(s)).collect(),
});
}
}
Section {
key: "kitchen",
title: "kitchen",
intro: String::new(),
groups,
lore: Vec::new(),
}
}
fn section_foraging(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
let mut groups = Vec::new();
let mut lore = Vec::new();
if let Some(items) = read_md_parts(&format!("foraging/{}.md", season.name), &data.files) {
if !items.bullets.is_empty() {
let n = items.bullets.len().min(4);
let picks = pick_items(&items.bullets, n, rng);
groups.push(Group {
label: "",
items: picks.iter().map(|s| render_inline(s)).collect(),
});
}
if let Some(first) = items.prose.first() {
lore.push(render_inline(first));
}
}
Section {
key: "foraging",
title: "foraging",
intro: String::new(),
groups,
lore,
}
}
fn section_folklore(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
let mut lore = Vec::new();
if let Some(items) = read_md_parts(&format!("names/{}.md", season.name), &data.files) {
if let Some(first) = items.prose.first() {
lore.push(render_inline(first));
} else if !items.bullets.is_empty() {
let n = items.bullets.len().min(2);
let picks = pick_items(&items.bullets, n, rng);
let joined: Vec<String> = picks.iter().map(|s| render_inline(s)).collect();
lore.push(joined.join(" "));
}
}
if let Some(items) = read_md_parts(&format!("remedies/{}.md", season.name), &data.files) {
let mut parts = Vec::new();
if !items.bullets.is_empty() {
let pick = pick_items(&items.bullets, 1, rng).remove(0);
parts.push(render_inline(&pick));
}
if let Some(first) = items.prose.first() {
parts.push(render_inline(first));
}
if !parts.is_empty() {
lore.push(parts.join(" "));
}
}
if let Some(items) = read_md_parts(&format!("bugs/{}.md", season.name), &data.files) {
if !items.bullets.is_empty() {
let pick = pick_items(&items.bullets, 1, rng).remove(0);
lore.push(render_inline(&pick));
}
}
Section {
key: "folklore",
title: "folklore",
intro: String::new(),
groups: Vec::new(),
lore,
}
}
fn render_sections_html(sections: &[Section]) -> String {
let mut out = String::new();
for s in sections {
if s.groups.is_empty() && s.lore.is_empty() && s.intro.is_empty() {
continue;
}
out.push_str(&format!("<section class=\"bucket bucket-{}\">", s.key));
out.push_str(&format!("<h2>{}</h2>", s.title));
if !s.intro.is_empty() {
out.push_str(&format!("<p class=\"bucket-intro\">{}</p>", s.intro));
}
for g in &s.groups {
if !g.label.is_empty() {
out.push_str(&format!("<p class=\"bucket-label\">{}</p>", g.label));
}
out.push_str("<ul class=\"bucket-list\">");
for item in &g.items {
out.push_str(&format!("<li>{item}</li>"));
}
out.push_str("</ul>");
}
for line in &s.lore {
out.push_str(&format!("<p class=\"bucket-lore\">{line}</p>"));
}
out.push_str("</section>");
}
out
}
fn build_season_nav(active: &Season, data: &SiteData) -> String {
let mut out = String::new();
for name in &data.seasons_order {
let s = &data.seasons_by_name[name];
let cls = if s.name == active.name {
" class=\"active\" aria-current=\"true\""
} else {
""
};
out.push_str(&format!(
"<a data-season=\"{}\"{cls}>{}</a>",
s.name, s.label
));
}
out
}
fn get_haiku<'a>(season_name: &str, date: DateTime<Tz>, data: &'a SiteData) -> Option<&'a [String; 3]> {
let poems = data.haiku.get(season_name)?;
if poems.is_empty() {
return None;
}
let doy = date.ordinal() as usize;
Some(&poems[doy % poems.len()])
}
pub fn assemble_content(now: DateTime<Tz>, data: &SiteData, season_override: Option<&str>) -> Assembled {
let season = match season_override.and_then(|n| data.seasons_by_name.get(n)) {
Some(s) => s.clone(),
None => get_season_for_date(now, &data.seasons).clone(),
};
let real_time = time_of_day(now);
let mut note_html = render_block(&season.note);
let nxt = days_until_next_season(now, &data.seasons);
if nxt.days <= 7 {
let unit = if nxt.days == 1 { "day" } else { "days" };
note_html.push_str(&format!("<p>{} begins in {} {unit}.</p>", nxt.label, nxt.days));
}
let haiku_html = match get_haiku(&season.name, now, data) {
Some(lines) => lines
.iter()
.map(|l| format!("<span class=\"haiku-line\">{l}</span>"))
.collect::<Vec<_>>()
.join(""),
None => String::new(),
};
let mut rng = Mulberry32::new(day_hash(now));
let sky = section_sky(now, &season, data, &mut rng);
let garden = section_garden(&season, data, &mut rng);
let kitchen = section_kitchen(&season, data, &mut rng);
let foraging = section_foraging(&season, data, &mut rng);
let folklore = section_folklore(&season, data, &mut rng);
let sections = vec![sky, garden, kitchen, foraging, folklore];
let sections_html = render_sections_html(§ions);
let footer_text = format!(
"{} days until {} \u{00b7} zone 7a \u{00b7} north carolina",
nxt.days, nxt.label
);
Assembled {
date_line: written_date(now),
season_name: season.label.clone(),
season_note: note_html,
season_key: season.name.clone(),
time_key: real_time.to_string(),
haiku_html,
sections_html,
footer_text,
season_nav_html: build_season_nav(&season, data),
}
}