repos

darkfurrow.com-rust

mirror archived upstream

A living almanac of seasons, soil, and the quiet knowledge that used to be common. Rust axum with minijinja and Vite.

agriculturealmanacaxumfolk-knowledgegardeningminijinjarustseasonalvite

16.1 KB · 506 lines · Rust Raw History
  1use chrono::{DateTime, Datelike, NaiveDate, Timelike};
  2use chrono_tz::Tz;
  3use serde::Serialize;
  4
  5use crate::astro::{moon_phase, sky_data_lines};
  6use crate::content::{
  7    parse_frontmatter, parse_list_items, parse_named_lists, ListItems, MoonTip, Season, SiteData,
  8};
  9use crate::markdown::{render_block, render_inline};
 10use crate::rng::{day_hash, pick_items, Mulberry32};
 11
 12#[derive(Serialize)]
 13pub struct Assembled {
 14    pub date_line: String,
 15    pub season_name: String,
 16    pub season_note: String,
 17    pub season_key: String,
 18    pub time_key: String,
 19    pub haiku_html: String,
 20    pub sections_html: String,
 21    pub footer_text: String,
 22    pub season_nav_html: String,
 23}
 24
 25struct Time {
 26    name: &'static str,
 27    start: u32,
 28    end: u32,
 29}
 30
 31const TIMES: &[Time] = &[
 32    Time { name: "night", start: 0, end: 5 },
 33    Time { name: "dawn", start: 5, end: 8 },
 34    Time { name: "morning", start: 8, end: 12 },
 35    Time { name: "afternoon", start: 12, end: 17 },
 36    Time { name: "evening", start: 17, end: 21 },
 37    Time { name: "night", start: 21, end: 24 },
 38];
 39
 40fn time_of_day(date: DateTime<Tz>) -> &'static str {
 41    let h = date.hour();
 42    for t in TIMES {
 43        if h >= t.start && h < t.end {
 44            return t.name;
 45        }
 46    }
 47    "night"
 48}
 49
 50const MONTHS: [&str; 12] = [
 51    "january", "february", "march", "april", "may", "june", "july", "august", "september",
 52    "october", "november", "december",
 53];
 54
 55const ORDINALS: [&str; 32] = [
 56    "", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth",
 57    "tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth",
 58    "seventeenth", "eighteenth", "nineteenth", "twentieth", "twenty-first", "twenty-second",
 59    "twenty-third", "twenty-fourth", "twenty-fifth", "twenty-sixth", "twenty-seventh",
 60    "twenty-eighth", "twenty-ninth", "thirtieth", "thirty-first",
 61];
 62
 63fn written_date(date: DateTime<Tz>) -> String {
 64    let time = time_of_day(date);
 65    let day = date.day() as usize;
 66    let month = (date.month() - 1) as usize;
 67    format!("{time}, the {} of {}", ORDINALS[day], MONTHS[month])
 68}
 69
 70fn get_season_for_date(date: DateTime<Tz>, seasons: &[Season]) -> &Season {
 71    let m = date.month();
 72    let d = date.day();
 73    for s in seasons {
 74        let after_start = m > s.start.0 || (m == s.start.0 && d >= s.start.1);
 75        let before_end = m < s.end.0 || (m == s.end.0 && d <= s.end.1);
 76        if after_start && before_end {
 77            return s;
 78        }
 79    }
 80    &seasons[0]
 81}
 82
 83struct NextSeason {
 84    days: i64,
 85    label: String,
 86}
 87
 88fn days_until_next_season(date: DateTime<Tz>, seasons: &[Season]) -> NextSeason {
 89    let current = get_season_for_date(date, seasons);
 90    let today = date.date_naive();
 91    for s in seasons {
 92        let s_date = NaiveDate::from_ymd_opt(date.year(), s.start.0, s.start.1).unwrap();
 93        if s_date > today && s.name != current.name {
 94            return NextSeason {
 95                days: (s_date - today).num_days(),
 96                label: s.label.clone(),
 97            };
 98        }
 99    }
100    // No later start this year: wrap into next year, still skipping entries
101    // for the season we are already in. In December the current season is
102    // winter and seasons[0] is winter's 1/1 entry, so without the name check
103    // the almanac would count down the days until winter... during winter.
104    for s in seasons {
105        if s.name == current.name {
106            continue;
107        }
108        let s_date = NaiveDate::from_ymd_opt(date.year() + 1, s.start.0, s.start.1).unwrap();
109        return NextSeason {
110            days: (s_date - today).num_days(),
111            label: s.label.clone(),
112        };
113    }
114    let first = &seasons[0];
115    let next_date = NaiveDate::from_ymd_opt(date.year() + 1, first.start.0, first.start.1).unwrap();
116    NextSeason {
117        days: (next_date - today).num_days(),
118        label: first.label.clone(),
119    }
120}
121
122fn moon_garden_tip(phase: f64, tips: &[MoonTip]) -> String {
123    for t in tips {
124        if t.lo <= phase && phase < t.hi {
125            return t.text.clone();
126        }
127    }
128    tips.last().map(|t| t.text.clone()).unwrap_or_default()
129}
130
131fn weather_mood(season_name: &str, time: &str, moods: &std::collections::HashMap<String, std::collections::HashMap<String, String>>) -> String {
132    if let Some(season_moods) = moods.get(season_name) {
133        if let Some(text) = season_moods.get(time) {
134            if !text.is_empty() {
135                return render_inline(text);
136            }
137        }
138    }
139    String::new()
140}
141
142fn read_md_parts<'a>(path: &str, files: &'a std::collections::HashMap<String, String>) -> Option<ListItems> {
143    let body = files.get(path)?;
144    let parsed = parse_frontmatter(body);
145    Some(parse_list_items(&parsed.body))
146}
147
148fn read_named_lists(
149    path: &str,
150    files: &std::collections::HashMap<String, String>,
151) -> Option<Vec<(String, Vec<String>)>> {
152    let body = files.get(path)?;
153    let parsed = parse_frontmatter(body);
154    Some(parse_named_lists(&parsed.body))
155}
156
157#[derive(Debug)]
158struct Group {
159    label: &'static str,
160    items: Vec<String>,
161}
162
163#[derive(Debug)]
164struct Section {
165    key: &'static str,
166    title: &'static str,
167    intro: String,
168    groups: Vec<Group>,
169    lore: Vec<String>,
170}
171
172fn section_sky(now: DateTime<Tz>, season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
173    let intro = weather_mood(&season.name, time_of_day(now), &data.moods);
174
175    let mut lore = Vec::new();
176    let tip = moon_garden_tip(moon_phase(now), &data.moon_tips);
177    if !tip.is_empty() {
178        lore.push(render_inline(&tip));
179    }
180
181    for path in [
182        format!("sky/{}.md", season.name),
183        format!("storms/{}.md", season.name),
184    ] {
185        let Some(items) = read_md_parts(&path, &data.files) else {
186            continue;
187        };
188        let mut candidates: Vec<String> = items.bullets.clone();
189        candidates.extend(items.prose.clone());
190        if !candidates.is_empty() {
191            let pick = pick_items(&candidates, 1, rng).remove(0);
192            lore.push(render_inline(&pick));
193        }
194    }
195
196    Section {
197        key: "sky",
198        title: "sky",
199        intro,
200        groups: vec![Group {
201            label: "",
202            items: sky_data_lines(now),
203        }],
204        lore,
205    }
206}
207
208fn section_garden(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
209    let mut groups = Vec::new();
210
211    if let Some(items) = read_md_parts(&format!("planting/{}.md", season.name), &data.files) {
212        if !items.bullets.is_empty() {
213            let n = items.bullets.len().min(4);
214            let picks = pick_items(&items.bullets, n, rng);
215            groups.push(Group {
216                label: "in the ground now",
217                items: picks.iter().map(|s| render_inline(s)).collect(),
218            });
219        }
220    }
221
222    if let Some(items) = read_md_parts(
223        &format!("planting/{}-indoors.md", season.name),
224        &data.files,
225    ) {
226        if !items.bullets.is_empty() {
227            let n = items.bullets.len().min(3);
228            let picks = pick_items(&items.bullets, n, rng);
229            groups.push(Group {
230                label: "starting indoors",
231                items: picks.iter().map(|s| render_inline(s)).collect(),
232            });
233        }
234    }
235
236    if let Some(items) = read_md_parts(&format!("chores/{}.md", season.name), &data.files) {
237        if !items.bullets.is_empty() {
238            let n = items.bullets.len().min(2);
239            let picks = pick_items(&items.bullets, n, rng);
240            groups.push(Group {
241                label: "this week",
242                items: picks.iter().map(|s| render_inline(s)).collect(),
243            });
244        }
245    }
246
247    if let Some(named) = read_named_lists(&format!("companions/{}.md", season.name), &data.files) {
248        // Map "good"/"bad" headings in the markdown to the labels rendered on
249        // the page. Other headings in the file are ignored.
250        let label_for = |name: &str| -> Option<&'static str> {
251            match name {
252                "good" => Some("good neighbors"),
253                "bad" => Some("bad neighbors"),
254                _ => None,
255            }
256        };
257        for (name, items) in &named {
258            let Some(label) = label_for(name) else { continue };
259            if items.is_empty() {
260                continue;
261            }
262            let n = items.len().min(3);
263            let picks = pick_items(items, n, rng);
264            groups.push(Group {
265                label,
266                items: picks.iter().map(|s| render_inline(s)).collect(),
267            });
268        }
269    }
270
271    Section {
272        key: "garden",
273        title: "garden",
274        intro: String::new(),
275        groups,
276        lore: Vec::new(),
277    }
278}
279
280fn section_kitchen(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
281    let mut groups = Vec::new();
282
283    if let Some(items) = read_md_parts(&format!("kitchen/{}.md", season.name), &data.files) {
284        if !items.bullets.is_empty() {
285            let bullets = items.bullets.clone();
286            let n = bullets.len().min(4);
287            let picks = pick_items(&bullets, n, rng);
288            groups.push(Group {
289                label: "in season",
290                items: picks.iter().map(|s| render_inline(s)).collect(),
291            });
292            let remaining: Vec<String> = bullets
293                .iter()
294                .filter(|b| !picks.contains(b))
295                .cloned()
296                .collect();
297            let tonight = if remaining.is_empty() {
298                picks.last().cloned().unwrap_or_default()
299            } else {
300                pick_items(&remaining, 1, rng).remove(0)
301            };
302            groups.push(Group {
303                label: "tonight",
304                items: vec![render_inline(&tonight)],
305            });
306        }
307    }
308
309    if let Some(items) = read_md_parts(&format!("preserving/{}.md", season.name), &data.files) {
310        if !items.bullets.is_empty() {
311            let n = items.bullets.len().min(2);
312            let picks = pick_items(&items.bullets, n, rng);
313            groups.push(Group {
314                label: "putting up",
315                items: picks.iter().map(|s| render_inline(s)).collect(),
316            });
317        }
318    }
319
320    Section {
321        key: "kitchen",
322        title: "kitchen",
323        intro: String::new(),
324        groups,
325        lore: Vec::new(),
326    }
327}
328
329fn section_foraging(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
330    let mut groups = Vec::new();
331    let mut lore = Vec::new();
332    if let Some(items) = read_md_parts(&format!("foraging/{}.md", season.name), &data.files) {
333        if !items.bullets.is_empty() {
334            let n = items.bullets.len().min(4);
335            let picks = pick_items(&items.bullets, n, rng);
336            groups.push(Group {
337                label: "",
338                items: picks.iter().map(|s| render_inline(s)).collect(),
339            });
340        }
341        if let Some(first) = items.prose.first() {
342            lore.push(render_inline(first));
343        }
344    }
345    Section {
346        key: "foraging",
347        title: "foraging",
348        intro: String::new(),
349        groups,
350        lore,
351    }
352}
353
354fn section_folklore(season: &Season, data: &SiteData, rng: &mut Mulberry32) -> Section {
355    let mut lore = Vec::new();
356
357    if let Some(items) = read_md_parts(&format!("names/{}.md", season.name), &data.files) {
358        if let Some(first) = items.prose.first() {
359            lore.push(render_inline(first));
360        } else if !items.bullets.is_empty() {
361            let n = items.bullets.len().min(2);
362            let picks = pick_items(&items.bullets, n, rng);
363            let joined: Vec<String> = picks.iter().map(|s| render_inline(s)).collect();
364            lore.push(joined.join(" "));
365        }
366    }
367
368    if let Some(items) = read_md_parts(&format!("remedies/{}.md", season.name), &data.files) {
369        let mut parts = Vec::new();
370        if !items.bullets.is_empty() {
371            let pick = pick_items(&items.bullets, 1, rng).remove(0);
372            parts.push(render_inline(&pick));
373        }
374        if let Some(first) = items.prose.first() {
375            parts.push(render_inline(first));
376        }
377        if !parts.is_empty() {
378            lore.push(parts.join(" "));
379        }
380    }
381
382    if let Some(items) = read_md_parts(&format!("bugs/{}.md", season.name), &data.files) {
383        if !items.bullets.is_empty() {
384            let pick = pick_items(&items.bullets, 1, rng).remove(0);
385            lore.push(render_inline(&pick));
386        }
387    }
388
389    Section {
390        key: "folklore",
391        title: "folklore",
392        intro: String::new(),
393        groups: Vec::new(),
394        lore,
395    }
396}
397
398fn render_sections_html(sections: &[Section]) -> String {
399    let mut out = String::new();
400    for s in sections {
401        if s.groups.is_empty() && s.lore.is_empty() && s.intro.is_empty() {
402            continue;
403        }
404        out.push_str(&format!("<section class=\"bucket bucket-{}\">", s.key));
405        out.push_str(&format!("<h2>{}</h2>", s.title));
406        if !s.intro.is_empty() {
407            out.push_str(&format!("<p class=\"bucket-intro\">{}</p>", s.intro));
408        }
409        for g in &s.groups {
410            if !g.label.is_empty() {
411                out.push_str(&format!("<p class=\"bucket-label\">{}</p>", g.label));
412            }
413            out.push_str("<ul class=\"bucket-list\">");
414            for item in &g.items {
415                out.push_str(&format!("<li>{item}</li>"));
416            }
417            out.push_str("</ul>");
418        }
419        for line in &s.lore {
420            out.push_str(&format!("<p class=\"bucket-lore\">{line}</p>"));
421        }
422        out.push_str("</section>");
423    }
424    out
425}
426
427fn build_season_nav(active: &Season, data: &SiteData) -> String {
428    let mut out = String::new();
429    for name in &data.seasons_order {
430        let s = &data.seasons_by_name[name];
431        let cls = if s.name == active.name {
432            " class=\"active\" aria-current=\"page\""
433        } else {
434            ""
435        };
436        // Real hrefs so the nav works without JS (the click handler
437        // intercepts and swaps content in place when JS is available).
438        out.push_str(&format!(
439            "<a href=\"/?season={}\" data-season=\"{}\"{cls}>{}</a>",
440            s.name, s.name, s.label
441        ));
442    }
443    out
444}
445
446fn get_haiku<'a>(season_name: &str, date: DateTime<Tz>, data: &'a SiteData) -> Option<&'a [String; 3]> {
447    let poems = data.haiku.get(season_name)?;
448    if poems.is_empty() {
449        return None;
450    }
451    let doy = date.ordinal() as usize;
452    Some(&poems[doy % poems.len()])
453}
454
455pub fn assemble_content(now: DateTime<Tz>, data: &SiteData, season_override: Option<&str>) -> Assembled {
456    let season = match season_override.and_then(|n| data.seasons_by_name.get(n)) {
457        Some(s) => s.clone(),
458        None => get_season_for_date(now, &data.seasons).clone(),
459    };
460
461    let real_time = time_of_day(now);
462
463    let mut note_html = render_block(&season.note);
464    let nxt = days_until_next_season(now, &data.seasons);
465    if nxt.days <= 7 {
466        let unit = if nxt.days == 1 { "day" } else { "days" };
467        note_html.push_str(&format!("<p>{} begins in {} {unit}.</p>", nxt.label, nxt.days));
468    }
469
470    let haiku_html = match get_haiku(&season.name, now, data) {
471        Some(lines) => lines
472            .iter()
473            .map(|l| format!("<span class=\"haiku-line\">{l}</span>"))
474            .collect::<Vec<_>>()
475            .join(""),
476        None => String::new(),
477    };
478
479    let mut rng = Mulberry32::new(day_hash(now));
480    let sky = section_sky(now, &season, data, &mut rng);
481    let garden = section_garden(&season, data, &mut rng);
482    let kitchen = section_kitchen(&season, data, &mut rng);
483    let foraging = section_foraging(&season, data, &mut rng);
484    let folklore = section_folklore(&season, data, &mut rng);
485    let sections = vec![sky, garden, kitchen, foraging, folklore];
486    let sections_html = render_sections_html(&sections);
487
488    let footer_text = format!(
489        "{} days until {} \u{00b7} zone 7a \u{00b7} north carolina",
490        nxt.days, nxt.label
491    );
492
493    Assembled {
494        date_line: written_date(now),
495        season_name: season.label.clone(),
496        season_note: note_html,
497        season_key: season.name.clone(),
498        time_key: real_time.to_string(),
499        haiku_html,
500        sections_html,
501        footer_text,
502        season_nav_html: build_season_nav(&season, data),
503    }
504}
505