motd: --rich details, and notes and todos on upcoming dates

d31eadb · Gabriel Morell · 2026-10-19 17:26

17 files +980 -100
Message
{commit_body(@commit)}

Files changed

modified README.md
+22 −0
@@ -50,6 +50,28 @@ with reminders off, and doesn't count as having shown anything, so the next plai
50 50 reminds as usual. `--days 30` looks 30 days ahead for everyone and every calendar instead of their
51 51 windows, including people in no tier.
52 52
53 +`ppl motd --rich` (with or without `--all`) prints what each holiday is under it, like the TUI's `i`:
54 +the blurb and Wikipedia link for built-in holidays, and the location, description and link for iCal
55 +events, wrapped to the terminal and cut at four lines.
56 +
57 +#### Notes and todos
58 +
59 +Any one occurrence of a date (this year's Thanksgiving, next Tuesday's 1:1, Anji's birthday) can carry
60 +notes and todos, which `ppl motd --rich` prints under it.
61 +
62 +```
63 +ppl notes add thanksgiving bring pie # the next Thanksgiving
64 +ppl notes add "anji birthday" --todo buy a card # every word has to match
65 +ppl notes add "1:1" -d 2026-11-03 ask about budget
66 +ppl notes # from today on (--all for past ones)
67 +ppl notes done 3 # tick a todo (again to untick)
68 +ppl notes rm 3
69 +```
70 +
71 +In the TUI, `i` on any upcoming row (people's dates too) shows its notes: `n` adds a note, `t` a
72 +todo, space ticks one, `Delete` twice removes one. Rows with notes get a 📝, or ☐ and the number of
73 +open todos.
74 +
53 75 In the TUI the same keys work on the tiers tab (defaults) and on a selected tier while editing a person (overrides):
54 76 `m` cycles the mode, `+`/`-` change the window, `[`/`]` change the random %, `x` clears.
55 77
added src/do_notes.rs
+175 −0
@@ -0,0 +1,175 @@
1 +use crate::entities::prelude::{Ppl, SigDate};
2 +use crate::notes::{self, Kind, NoteOps, Occurrence};
3 +use crate::PplError;
4 +use chrono::{Local, NaiveDate};
5 +use clap::{Args, Subcommand};
6 +use interim::{parse_date_string, Dialect};
7 +use sea_orm::{DatabaseConnection, EntityTrait};
8 +use std::io;
9 +
10 +#[derive(Args, Debug)]
11 +pub struct NoteArgs {
12 + #[command(subcommand)]
13 + pub action: Option<NoteAction>,
14 +}
15 +
16 +#[derive(Subcommand, Debug)]
17 +pub enum NoteAction {
18 + /// Notes and todos from today on
19 + List {
20 + /// Past ones too
21 + #[arg(long, short)]
22 + all: bool,
23 + },
24 + /// Add a note to the next occurrence of an event, e.g. `add thanksgiving bring pie`
25 + Add {
26 + /// Words from its name, like thanksgiving, "1:1" or "anji birthday" (quote several)
27 + event: String,
28 + #[arg(required = true, trailing_var_arg = true)]
29 + text: Vec<String>,
30 + /// A todo, which can be ticked off, rather than a note
31 + #[arg(long, short)]
32 + todo: bool,
33 + /// The occurrence on this day instead of the next one: 2026-12-25, "next friday"
34 + #[arg(long, short)]
35 + date: Option<String>,
36 + },
37 + /// Tick a todo off (or back on)
38 + Done { id: i32 },
39 + /// Remove a note or todo
40 + Rm { id: i32 },
41 +}
42 +
43 +fn invalid(msg: String) -> PplError {
44 + io::Error::new(io::ErrorKind::InvalidInput, msg).into()
45 +}
46 +
47 +pub async fn do_notes(args: &NoteArgs, db: &DatabaseConnection) -> Result<(), PplError> {
48 + match &args.action {
49 + None => list(db, false).await,
50 + Some(NoteAction::List { all }) => list(db, *all).await,
51 + Some(NoteAction::Add {
52 + event,
53 + text,
54 + todo,
55 + date,
56 + }) => add(db, event, &text.join(" "), *todo, date.as_deref()).await,
57 + Some(NoteAction::Done { id }) => {
58 + let n = NoteOps::toggle(db, *id).await?;
59 + println!("{}", notes::line(&n));
60 + Ok(())
61 + }
62 + Some(NoteAction::Rm { id }) => {
63 + NoteOps::delete(db, *id).await?;
64 + println!("removed note {}", id);
65 + Ok(())
66 + }
67 + }
68 +}
69 +
70 +async fn add(
71 + db: &DatabaseConnection,
72 + query: &str,
73 + text: &str,
74 + todo: bool,
75 + date: Option<&str>,
76 +) -> Result<(), PplError> {
77 + let today = Local::now().date_naive();
78 + let (from, days) = match date {
79 + Some(d) => (parse_day(d)?, 0),
80 + None => (today, 400),
81 + };
82 + // Every word has to match, so "anji birthday" picks one of Anji's dates
83 + let words = query.to_lowercase();
84 + let words = words.split_whitespace().collect::<Vec<_>>();
85 + let found = notes::upcoming(db, from, days)
86 + .await?
87 + .into_iter()
88 + .filter(|o| {
89 + let title = o.title.to_lowercase();
90 + words.iter().all(|w| title.contains(w))
91 + })
92 + .collect::<Vec<_>>();
93 + let Some(first) = found.first() else {
94 + return Err(invalid(match date {
95 + Some(d) => format!("nothing matching '{}' on {}", query, d),
96 + None => format!("nothing matching '{}' in the next year", query),
97 + }));
98 + };
99 + // Different events matched: say which, rather than guess
100 + let mut events: Vec<&Occurrence> = vec![];
101 + for o in &found {
102 + if !events.iter().any(|e| e.target == o.target) {
103 + events.push(o);
104 + }
105 + }
106 + if events.len() > 1 {
107 + let list = events
108 + .iter()
109 + .take(8)
110 + .map(|o| format!(" {} {}", o.date.format("%a %Y-%m-%d"), o.title))
111 + .collect::<Vec<_>>()
112 + .join("\n");
113 + return Err(invalid(format!(
114 + "'{}' matches more than one event; be more specific:\n{}",
115 + query, list
116 + )));
117 + }
118 + let kind = if todo { Kind::Todo } else { Kind::Note };
119 + let n = NoteOps::add(db, first, kind, text).await?;
120 + println!(
121 + "{} on {}, {} (#{})",
122 + notes::line(&n),
123 + first.title,
124 + first.date.format("%a %Y-%m-%d"),
125 + n.id
126 + );
127 + if date.is_none() && found.len() > 1 {
128 + println!("(that's the next one; --date picks another)");
129 + }
130 + Ok(())
131 +}
132 +
133 +fn parse_day(s: &str) -> Result<NaiveDate, PplError> {
134 + if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
135 + return Ok(d);
136 + }
137 + parse_date_string(s, Local::now(), Dialect::Us)
138 + .map(|d| d.date_naive())
139 + .map_err(|_| invalid(format!("can't read '{}' as a date; try YYYY-MM-DD", s)))
140 +}
141 +
142 +async fn list(db: &DatabaseConnection, all: bool) -> Result<(), PplError> {
143 + let today = Local::now().date_naive();
144 + let mut all_notes = NoteOps::list(db).await?;
145 + all_notes.retain(|n| all || n.date >= today);
146 + all_notes.sort_by_key(|n| (n.date, n.id));
147 + if all_notes.is_empty() {
148 + println!("no notes; add one with `ppl notes add <event> <text>`");
149 + return Ok(());
150 + }
151 + let ppl = Ppl::find().all(db).await?;
152 + let dates = SigDate::find().all(db).await?;
153 + let title = |n: &crate::entities::note::Model| match (&n.calendar, &n.event, n.sig_date_id) {
154 + (Some(c), Some(e), _) => format!("{} ({})", e, c),
155 + (_, _, Some(id)) => dates
156 + .iter()
157 + .find(|d| d.id == id)
158 + .and_then(|d| {
159 + let p = ppl.iter().find(|p| p.id == d.ppl_id)?;
160 + Some(Occurrence::of_person(d, n.date, p).title)
161 + })
162 + .unwrap_or_else(|| "(a date that's gone)".to_string()),
163 + _ => "?".to_string(),
164 + };
165 + let mut last: Option<(NaiveDate, String)> = None;
166 + for n in &all_notes {
167 + let heading = (n.date, title(n));
168 + if last.as_ref() != Some(&heading) {
169 + println!("{} {}", heading.0.format("%a %Y-%m-%d"), heading.1);
170 + last = Some(heading);
171 + }
172 + println!(" #{:<4} {}", n.id, notes::line(n));
173 + }
174 + Ok(())
175 +}
modified src/do_tui.rs
+79 −44
@@ -5,13 +5,13 @@ use crate::data::{
5 5 use crate::do_init::lcolor;
6 6 use crate::entities::ppl::Model;
7 7 use crate::entities::{
8 − calendar, contact, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
8 + calendar, contact, note, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
9 9 };
10 10 use crate::holidays::{group_of, holidays_in, Holiday};
11 +use crate::notes::{self, NoteOps, Occurrence};
11 12 use crate::tui_add::{render_person_menu, AddContext, AddForm, AddKind, FormAction};
12 −use crate::tui_calendars::{
13 − holiday_color, open_url, render_holiday_info, CalendarMenu, MenuAction,
14 −};
13 +use crate::tui_calendars::{holiday_color, open_url, CalendarMenu, MenuAction};
14 +use crate::tui_notes::{NotePopup, PopupAction};
15 15 use crate::PplError;
16 16 use chrono::{Datelike, Local, NaiveDate};
17 17 use crossterm::event;
@@ -77,9 +77,11 @@ struct Tui {
77 77 /// Calendar rows, for their motd reminder settings
78 78 calendar_rows: Vec<calendar::Model>,
79 79 /// The holiday behind each row of the upcoming list (None for people's dates), for `i`
80 − upcoming_holidays: Vec<Option<Holiday>>,
80 + upcoming_rows: Vec<(Occurrence, Option<Holiday>)>,
81 81 /// The holiday whose blurb is showing
82 − holiday_info: Option<Holiday>,
82 + note_popup: Option<NotePopup>,
83 + /// Every note and todo
84 + notes: Vec<note::Model>,
83 85 /// The year the calendar tab shows; scrolling past either end moves it
84 86 cal_year: i32,
85 87 /// Holiday groups (by key) and "people" hidden on the calendar tab
@@ -167,6 +169,7 @@ impl Tui {
167 169 self.sigdate_list = SigDateOps::list(db).await?;
168 170 self.calendars_enabled = CalendarOps::enabled(db).await?;
169 171 self.calendar_rows = CalendarOps::rows(db).await?;
172 + self.notes = NoteOps::list(db).await?;
170 173 self.holidays = holidays_in(self.cal_year, &self.calendars_enabled);
171 174
172 175 Ok(())
@@ -212,8 +215,9 @@ impl Default for Tui {
212 215 holidays: vec![],
213 216 calendar_menu: None,
214 217 calendar_rows: vec![],
215 − upcoming_holidays: vec![],
216 − holiday_info: None,
218 + upcoming_rows: vec![],
219 + note_popup: None,
220 + notes: vec![],
217 221 cal_year: Local::now().year(),
218 222 cal_hidden: vec![],
219 223 }
@@ -256,21 +260,38 @@ pub async fn run_tui(
256 260 }
257 261 match ev {
258 262 Event::Key(key_event)
259 − if key_event.kind == KeyEventKind::Press && app.holiday_info.is_some() =>
263 + if key_event.kind == KeyEventKind::Press && app.note_popup.is_some() =>
260 264 {
261 − match (key_event.code, &app.holiday_info) {
262 − (KeyCode::Char('o'), Some(h)) => {
263 − let url = crate::holidays::about(h).url;
264 − app.status = Some(match url.is_empty() {
265 − true => "ℹ this event has no link".to_string(),
266 − false => match open_url(&url) {
267 − Ok(()) => format!("✓ opening {}", url),
268 − Err(err) => format!("⚠ couldn't open a browser: {}", err),
269 − },
270 − });
265 + let Some(mut popup) = app.note_popup.take() else {
266 + continue;
267 + };
268 + let occ = popup.occ.clone();
269 + let done = match popup.handle_key(&key_event, &app.notes) {
270 + PopupAction::Continue => Ok(None),
271 + PopupAction::Close => {
272 + continue;
271 273 }
272 − _ => app.holiday_info = None,
274 + PopupAction::Status(msg) => Ok(Some(msg)),
275 + PopupAction::Open(url) => Ok(Some(match open_url(&url) {
276 + Ok(()) => format!("✓ opening {}", url),
277 + Err(err) => format!("⚠ couldn't open a browser: {}", err),
278 + })),
279 + PopupAction::Add(kind, text) => NoteOps::add(&db, &occ, kind, &text)
280 + .await
281 + .map(|n| Some(format!("✓ added {}", notes::line(&n)))),
282 + PopupAction::Toggle(id) => NoteOps::toggle(&db, id)
283 + .await
284 + .map(|n| Some(format!("✓ {}", notes::line(&n)))),
285 + PopupAction::Delete(id) => NoteOps::delete(&db, id)
286 + .await
287 + .map(|()| Some("✓ removed".to_string())),
288 + };
289 + match done {
290 + Ok(msg) => app.status = msg.or(app.status.take()),
291 + Err(err) => app.status = Some(format!("⚠ {}", err)),
273 292 }
293 + app.notes = NoteOps::list(&db).await?;
294 + app.note_popup = Some(popup);
274 295 }
275 296 Event::Key(key_event)
276 297 if key_event.kind == KeyEventKind::Press && app.calendar_menu.is_some() =>
@@ -406,7 +427,7 @@ pub async fn run_tui(
406 427 false => app.ppl_state.scroll_down_by(1),
407 428 },
408 429 Tabs::Calendar => match app.sigdate_cal_state.selected() {
409 − Some(i) if i.saturating_add(1) >= app.upcoming_holidays.len() => {
430 + Some(i) if i.saturating_add(1) >= app.upcoming_rows.len() => {
410 431 app.set_cal_year(app.cal_year + 1, Some(0))
411 432 }
412 433 _ => app.sigdate_cal_state.scroll_down_by(1),
@@ -479,17 +500,12 @@ pub async fn run_tui(
479 500 KeyCode::Char('i') => match app.current_tab {
480 501 Tabs::Ppl => app.ppl_show_tiers = !app.ppl_show_tiers,
481 502 Tabs::Calendar => {
482 − let selected = app
503 + if let Some((occ, h)) = app
483 504 .sigdate_cal_state
484 505 .selected()
485 − .and_then(|idx| app.upcoming_holidays.get(idx));
486 − match selected {
487 − Some(Some(h)) => app.holiday_info = Some(h.clone()),
488 − Some(None) => {
489 − app.status =
490 − Some("ℹ i tells you about built-in holidays".to_string())
491 − }
492 − None => {}
506 + .and_then(|idx| app.upcoming_rows.get(idx))
507 + {
508 + app.note_popup = Some(NotePopup::new(occ.clone(), h.clone()));
493 509 }
494 510 }
495 511 _ => {}
@@ -1605,13 +1621,17 @@ fn render(f: &mut Frame, app: &mut Tui) {
1605 1621 let mut upcoming = as_upcoming
1606 1622 .iter()
1607 1623 .filter(|_| show_people)
1608 − .map(|u| (u.date.date, None, ListItem::from(u)))
1624 + .map(|u| {
1625 + let occ = Occurrence::of_person(&u.date, u.date.date, &u.ppl);
1626 + let item = with_badge(person_line(u), &occ, &app.notes);
1627 + (u.date.date, (occ, None), item)
1628 + })
1609 1629 .collect::<Vec<_>>();
1610 − upcoming.extend(
1611 − shown
1612 − .iter()
1613 − .map(|h| (h.date, Some((*h).clone()), holiday_item(h))),
1614 − );
1630 + upcoming.extend(shown.iter().map(|h| {
1631 + let occ = Occurrence::of_holiday(h);
1632 + let item = with_badge(holiday_line(h), &occ, &app.notes);
1633 + (h.date, (occ, Some((*h).clone())), item)
1634 + }));
1615 1635 upcoming.sort_by_key(|(d, _, _)| *d);
1616 1636 // Open on the first date from today on, with a couple of past ones above it
1617 1637 // (the first date of a later year, the last of an earlier one)
@@ -1630,8 +1650,8 @@ fn render(f: &mut Frame, app: &mut Tui) {
1630 1650 app.sigdate_cal_state.select(Some(upcoming.len() - 1));
1631 1651 }
1632 1652 }
1633 − // Remember which rows are holidays, for `i`
1634 − app.upcoming_holidays = upcoming.iter().map(|(_, h, _)| h.clone()).collect();
1653 + // Remember what each row is, for `i`
1654 + app.upcoming_rows = upcoming.iter().map(|(_, row, _)| row.clone()).collect();
1635 1655
1636 1656 // The selected row's day stands out in the grid, so scrolling shows where you are
1637 1657 let selected_day = app
@@ -1802,8 +1822,8 @@ fn render(f: &mut Frame, app: &mut Tui) {
1802 1822 if let Some(menu) = app.calendar_menu.as_mut() {
1803 1823 menu.render(f, &app.calendars_enabled, &app.calendar_rows);
1804 1824 }
1805 − if let Some(h) = &app.holiday_info {
1806 − render_holiday_info(f, h);
1825 + if let Some(popup) = &app.note_popup {
1826 + popup.render(f, &app.notes);
1807 1827 }
1808 1828 if let Some(form) = &app.add_form {
1809 1829 let subtitle = match form.ppl_id {
@@ -1882,6 +1902,13 @@ impl From<PplAndProps> for ListItem<'_> {
1882 1902
1883 1903 impl From<&SigDateAndProps> for ListItem<'_> {
1884 1904 fn from(value: &SigDateAndProps) -> Self {
1905 + ListItem::new(person_line(value))
1906 + }
1907 +}
1908 +
1909 +/// A person's date in the upcoming list
1910 +fn person_line(value: &SigDateAndProps) -> Line<'static> {
1911 + {
1885 1912 let n = value.ppl.nick.clone().unwrap_or(value.ppl.name.clone());
1886 1913 let default = value
1887 1914 .trait_defaults
@@ -1907,16 +1934,24 @@ impl From<&SigDateAndProps> for ListItem<'_> {
1907 1934 ),
1908 1935 };
1909 1936
1910 − ListItem::new(line)
1937 + line
1911 1938 }
1912 1939 }
1913 1940
1914 −fn holiday_item(h: &Holiday) -> ListItem<'static> {
1915 − ListItem::new(Line::from(vec![
1941 +fn holiday_line(h: &Holiday) -> Line<'static> {
1942 + Line::from(vec![
1916 1943 Span::styled(" ◆ ", holiday_color(h)),
1917 1944 Span::styled(format!("{} {}", h.date, h.name), WHITE),
1918 1945 Span::styled(format!(" {}", h.calendar.key), SLATE.c500),
1919 − ]))
1946 + ])
1947 +}
1948 +
1949 +/// An upcoming row, with its notes' badge (open todos, or 📝) at the end
1950 +fn with_badge(mut line: Line<'static>, occ: &Occurrence, all: &[note::Model]) -> ListItem<'static> {
1951 + if let Some(b) = notes::badge(&occ.notes(all)) {
1952 + line.push_span(Span::styled(format!(" {}", b), ORANGE.c400));
1953 + }
1954 + ListItem::new(line)
1920 1955 }
1921 1956
1922 1957 impl From<&ppl::Model> for ListItem<'_> {
modified src/entities/mod.rs
+1 −0
@@ -5,6 +5,7 @@ pub mod prelude;
5 5 pub mod calendar;
6 6 pub mod contact;
7 7 pub mod entitys;
8 +pub mod note;
8 9 pub mod ppl;
9 10 pub mod relation;
10 11 pub mod sig_date;
added src/entities/note.rs
+27 −0
@@ -0,0 +1,27 @@
1 +use sea_orm::entity::prelude::*;
2 +
3 +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
4 +#[sea_orm(table_name = "note")]
5 +pub struct Model {
6 + #[sea_orm(primary_key)]
7 + pub id: i32,
8 + /// "note" or "todo"
9 + pub kind: String,
10 + pub text: String,
11 + /// For todos
12 + pub done: bool,
13 + /// The day of the occurrence it's on
14 + pub date: Date,
15 + /// For a holiday or iCal event: its calendar's key and its name
16 + pub calendar: Option<String>,
17 + pub event: Option<String>,
18 + /// For a person's date
19 + pub sig_date_id: Option<i32>,
20 + pub date_ins: Date,
21 + pub date_up: Date,
22 +}
23 +
24 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
25 +pub enum Relation {}
26 +
27 +impl ActiveModelBehavior for ActiveModel {}
modified src/entities/prelude.rs
+1 −0

Click to load diff…

modified src/holidays.rs
+4 −1

Click to load diff…

modified src/ical.rs
+18 −10

Click to load diff…

modified src/main.rs
+107 −3

Click to load diff…

added src/migrator/m20261019_000016_create_note.rs
+64 −0

Click to load diff…

modified src/migrator/mod.rs
+2 −0

Click to load diff…

added src/notes.rs
+274 −0

Click to load diff…

modified src/tui_calendars.rs
+0 −42

Click to load diff…

added src/tui_notes.rs
+206 −0

Click to load diff…

Parents: 0f89366