A living almanac of seasons, soil, and the quiet knowledge that used to be common. Rust axum with minijinja and Vite.
agriculturealmanacaxumfolk-knowledgegardeningminijinjarustseasonalvite
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(§ions);
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