motd: make reminders configurable, and stop the TUI crashing

1aa586c · Gabriel Morell · 2026-10-15 20:38

8 files +1120 -189
Message
{commit_body(@commit)}

Files changed

modified README.md
+17 −0
@@ -22,6 +22,10 @@ Shipping a syncable database for the following information
22 22 ### MOTD
23 23
24 24 Put a message of the day in your bashrc to remind you of important dates.
25 +`ppl shell [bash|zsh|fish]` adds it to your rc file for you (defaults to `$SHELL`, `--print` to just show the snippet).
26 +
27 +Dates only show up once they're inside their tier's reminder window. Set it with
28 +`ppl tiers set Family 14` (`ppl tiers clear Family` to remove, `ppl tiers` to list), or in the TUI's tiers tab with `enter`.
25 29 `ppl motd` outputs:
26 30
27 31 ![motd.png](screenshots/motd.png)
@@ -35,6 +39,15 @@ By default reminders will only be shown once per day unless otherwise configured
35 39 - `Always`, // Will always show this person's upcoming
36 40 - `Never`, // Never show this person's upcoming
37 41
42 +Set the mode per tier with `ppl tiers remind Family hourly` (`daily`, `hourly`, `random:30`, `always`, `never`).
43 +
44 +Any of these can be overridden for one person with `--person` (name, nick or id), e.g.
45 +`ppl tiers set Family 3 -p Anji`, `ppl tiers remind Family always -p Anji`; `ppl tiers clear Family -p Anji`
46 +goes back to the tier defaults and `ppl tiers -p Anji` shows what applies.
47 +
48 +In the TUI the same keys work on the tiers tab (defaults) and on a selected tier while editing a person (overrides):
49 +`m` cycles the mode, `+`/`-` change the window, `[`/`]` change the random %, `x` clears.
50 +
38 51
39 52
40 53 ### TUI
@@ -45,6 +58,10 @@ A Terminal UI for managing all the various stored information in the database.
45 58
46 59 PPL List, shows all configured ppl, their tiers, traits and information
47 60
61 +Press `e` to edit the selected person; the `👤` row at the top edits their name and nickname
62 +(the nickname is what the list, calendar and motd show). From the CLI: `ppl edit Anjali --nick Anji`,
63 +`--name` to rename, `--nick ""` to clear.
64 +
48 65 ![default.png](screenshots/default.png)
49 66
50 67
modified src/data.rs
+253 −24
@@ -14,6 +14,9 @@ use log::warn;
14 14 use ratatui::style::palette::tailwind::PURPLE;
15 15 use sea_orm::ActiveValue::Set;
16 16 use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, NotSet};
17 +use std::fmt;
18 +use std::io;
19 +use std::str::FromStr;
17 20
18 21 fn bool_from_string(input: Option<String>) -> bool {
19 22 if input.is_none() {
@@ -37,6 +40,13 @@ fn bool_from_string(input: Option<String>) -> bool {
37 40 }
38 41 }
39 42
43 +/// The date's anniversary in `year`; Feb 29 falls on Feb 28 in non-leap years.
44 +pub fn anniversary_in(date: NaiveDate, year: i32) -> Option<NaiveDate> {
45 + use chrono::Datelike;
46 + NaiveDate::from_ymd_opt(year, date.month(), date.day())
47 + .or_else(|| NaiveDate::from_ymd_opt(year, date.month(), date.day() - 1))
48 +}
49 +
40 50 fn option_naive_date_from_option_string(input: Option<String>) -> Option<NaiveDate> {
41 51 match input {
42 52 Some(s) => chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok(),
@@ -83,22 +93,21 @@ impl ContactOps {
83 93 Contact::find().all(db).await
84 94 }
85 95
86 − pub async fn updatee(db: &DatabaseConnection, editable: Editable) {
96 + pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
87 97 Self::update(
88 98 db,
89 − editable.id as u8,
99 + editable.id,
90 100 editable.first,
91 101 editable.second,
92 102 editable.third.unwrap_or("".to_string()),
93 103 None,
94 104 )
95 105 .await
96 − .expect("TODO: panic message");
97 106 }
98 107
99 108 pub async fn update(
100 109 db: &DatabaseConnection,
101 − model_id: u8,
110 + model_id: i32,
102 111 typ: String,
103 112 designator: Option<String>,
104 113 value: String,
@@ -178,19 +187,40 @@ impl PplOps {
178 187 Ppl::find().all(db).await
179 188 }
180 189
190 + pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
191 + // An empty nick clears it
192 + let nick = editable
193 + .second
194 + .map(|n| n.trim().to_string())
195 + .filter(|n| !n.is_empty());
196 + Self::update(db, editable.id, Some(editable.first), Some(nick)).await
197 + }
198 +
199 + /// None leaves a field alone; for nick, Some(None) clears it.
181 200 pub async fn update(
182 201 db: &DatabaseConnection,
183 − id: u8,
202 + id: i32,
184 203 name: Option<String>,
204 + nick: Option<Option<String>>,
185 205 ) -> Result<(), PplError> {
186 206 let loaded_model = Ppl::find_by_id(id).one(db).await?;
187 207 match loaded_model {
188 208 None => Ok(()),
189 209 Some(instance) => {
190 210 let mut am: ppl::ActiveModel = instance.into();
191 − match name {
211 + match name.map(|n| n.trim().to_string()) {
192 212 None => {}
193 − Some(i) => am.name = Set(i),
213 + Some(n) if n.is_empty() => {
214 + return Err(io::Error::new(
215 + io::ErrorKind::InvalidInput,
216 + "name can't be empty",
217 + )
218 + .into())
219 + }
220 + Some(n) => am.name = Set(n),
221 + }
222 + if let Some(nick) = nick {
223 + am.nick = Set(nick);
194 224 }
195 225 am.date_up = Set(Local::now().date_naive());
196 226 am.update(db).await?;
@@ -200,6 +230,45 @@ impl PplOps {
200 230 }
201 231 }
202 232
233 +/// Find a person by id, or by name or nick ignoring case.
234 +pub fn find_person<'a>(ppl: &'a [ppl::Model], query: &str) -> Result<&'a ppl::Model, PplError> {
235 + let query = query.trim();
236 + if let Ok(id) = query.parse::<i32>() {
237 + if let Some(p) = ppl.iter().find(|p| p.id == id) {
238 + return Ok(p);
239 + }
240 + }
241 + let matches = ppl
242 + .iter()
243 + .filter(|p| {
244 + p.name.trim().eq_ignore_ascii_case(query)
245 + || p.nick
246 + .as_deref()
247 + .is_some_and(|n| n.trim().eq_ignore_ascii_case(query))
248 + })
249 + .collect::<Vec<_>>();
250 + match matches.as_slice() {
251 + [p] => Ok(p),
252 + [] => Err(io::Error::new(
253 + io::ErrorKind::NotFound,
254 + format!("no person named '{}'", query),
255 + )
256 + .into()),
257 + many => Err(io::Error::new(
258 + io::ErrorKind::InvalidInput,
259 + format!(
260 + "'{}' matches several ppl, use an id: {}",
261 + query,
262 + many.iter()
263 + .map(|p| format!("{} ({})", p.name.trim(), p.id))
264 + .collect::<Vec<_>>()
265 + .join(", ")
266 + ),
267 + )
268 + .into()),
269 + }
270 +}
271 +
203 272 pub struct RelationOps {}
204 273
205 274 impl RelationOps {
@@ -240,10 +309,10 @@ impl RelationOps {
240 309 Relation::find().all(db).await
241 310 }
242 311
243 − pub async fn updatee(db: &DatabaseConnection, editable: Editable) {
312 + pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
244 313 Self::update(
245 314 db,
246 − editable.id as u8,
315 + editable.id,
247 316 None,
248 317 None,
249 318 editable.first,
@@ -252,12 +321,11 @@ impl RelationOps {
252 321 option_naive_date_from_option_string(editable.third),
253 322 )
254 323 .await
255 − .expect("huh");
256 324 }
257 325
258 326 pub async fn update(
259 327 db: &DatabaseConnection,
260 − model_id: u8,
328 + model_id: i32,
261 329 a: Option<i32>,
262 330 b: Option<i32>,
263 331 typ: String,
@@ -338,22 +406,21 @@ impl SigDateOps {
338 406 SigDate::find().all(db).await
339 407 }
340 408
341 − pub async fn updatee(db: &DatabaseConnection, editable: Editable) {
409 + pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
342 410 Self::update(
343 411 db,
344 − editable.id as u8,
412 + editable.id,
345 413 editable.first,
346 414 option_naive_date_from_option_string(editable.second),
347 415 bool_from_string(editable.third),
348 416 None,
349 417 )
350 418 .await
351 − .expect("TODO: panic message");
352 419 }
353 420
354 421 pub async fn update(
355 422 db: &DatabaseConnection,
356 − id: u8,
423 + id: i32,
357 424 event: String,
358 425 date: Option<NaiveDate>,
359 426 do_remind: bool,
@@ -363,10 +430,17 @@ impl SigDateOps {
363 430 match loaded_model {
364 431 None => Ok(()),
365 432 Some(instance) => {
433 + let Some(date) = date else {
434 + return Err(io::Error::new(
435 + io::ErrorKind::InvalidInput,
436 + "date must look like YYYY-MM-DD",
437 + )
438 + .into());
439 + };
366 440 let mut am: sig_date::ActiveModel = instance.into();
367 441
368 442 am.event = Set(event);
369 − am.date = Set(date.unwrap_or_default());
443 + am.date = Set(date);
370 444 am.do_remind = Set(do_remind);
371 445 am.date_up = Set(Local::now().date_naive());
372 446 am.update(db).await?;
@@ -413,21 +487,20 @@ impl TierOps {
413 487 Tier::find().all(db).await
414 488 }
415 489
416 − pub async fn updatee(db: &DatabaseConnection, editable: Editable) {
490 + pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
417 491 Self::update(
418 492 db,
419 − editable.id as u8,
493 + editable.id,
420 494 editable.first,
421 495 editable.second,
422 496 editable.third,
423 497 )
424 498 .await
425 − .expect("TODO: panic message");
426 499 }
427 500
428 501 pub async fn update(
429 502 db: &DatabaseConnection,
430 − model_id: u8,
503 + model_id: i32,
431 504 name: String,
432 505 color: Option<String>,
433 506 symbol: Option<String>,
@@ -447,6 +520,46 @@ impl TierOps {
447 520 }
448 521 }
449 522 }
523 +
524 + /// Per-person override of the tier default's reminder window; None falls back to the default.
525 + pub async fn set_sig_date_delta(
526 + db: &DatabaseConnection,
527 + model_id: i32,
528 + sig_date_delta: Option<u32>,
529 + ) -> Result<(), PplError> {
530 + let loaded_model = Tier::find_by_id(model_id).one(db).await?;
531 + match loaded_model {
532 + None => Ok(()),
533 + Some(instance) => {
534 + let mut am: tier::ActiveModel = instance.into();
535 +
536 + am.sig_date_delta = Set(sig_date_delta);
537 + am.date_up = Set(Local::now().date_naive());
538 + am.update(db).await?;
539 + Ok(())
540 + }
541 + }
542 + }
543 +
544 + /// Per-person override of the tier default's remind mode; None falls back to the default.
545 + pub async fn set_sig_remind_enum(
546 + db: &DatabaseConnection,
547 + model_id: i32,
548 + remind: Option<MotdRemindEnum>,
549 + ) -> Result<(), PplError> {
550 + let loaded_model = Tier::find_by_id(model_id).one(db).await?;
551 + match loaded_model {
552 + None => Ok(()),
553 + Some(instance) => {
554 + let mut am: tier::ActiveModel = instance.into();
555 +
556 + am.sig_remind_enum = Set(remind.map(|r| r.to_string()));
557 + am.date_up = Set(Local::now().date_naive());
558 + am.update(db).await?;
559 + Ok(())
560 + }
561 + }
562 + }
450 563 }
451 564
452 565 pub struct TraitOps {}
@@ -484,21 +597,20 @@ impl TraitOps {
484 597 Traits::find().all(db).await
485 598 }
486 599
487 − pub async fn updatee(db: &DatabaseConnection, editable: Editable) {
600 + pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
488 601 Self::update(
489 602 db,
490 − editable.id as u8,
603 + editable.id,
491 604 Option::from(editable.first),
492 605 editable.second,
493 606 bool_from_string(editable.third),
494 607 )
495 608 .await
496 − .expect("TODO: panic message");
497 609 }
498 610
499 611 pub async fn update(
500 612 db: &DatabaseConnection,
501 − model_id: u8,
613 + model_id: i32,
502 614 key: Option<String>,
503 615 value: Option<String>,
504 616 hidden: bool,
@@ -525,6 +637,83 @@ impl TraitOps {
525 637 }
526 638 }
527 639
640 +/// How often the motd shows a person's upcoming dates, stored as `sig_remind_enum` on tiers.
641 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
642 +pub enum MotdRemindEnum {
643 + #[default]
644 + OnlyOnce, // Default, Only the first time the motd is run that day
645 + OnceHourly, // Only once per hour, resetting at the top of the hour
646 + Randomly(u8), // Will show this motd person randomly u8% every run
647 + Always, // Will always show this person's upcoming
648 + Never, // Never show this person's upcoming
649 +}
650 +
651 +impl MotdRemindEnum {
652 + /// Next mode, for cycling through them in the TUI
653 + pub fn next(self) -> Self {
654 + match self {
655 + MotdRemindEnum::OnlyOnce => MotdRemindEnum::OnceHourly,
656 + MotdRemindEnum::OnceHourly => MotdRemindEnum::Randomly(50),
657 + MotdRemindEnum::Randomly(_) => MotdRemindEnum::Always,
658 + MotdRemindEnum::Always => MotdRemindEnum::Never,
659 + MotdRemindEnum::Never => MotdRemindEnum::OnlyOnce,
660 + }
661 + }
662 +
663 + /// Short label for lists
664 + pub fn label(&self) -> String {
665 + match self {
666 + MotdRemindEnum::OnlyOnce => "daily".to_string(),
667 + MotdRemindEnum::OnceHourly => "hourly".to_string(),
668 + MotdRemindEnum::Randomly(p) => format!("random {}%", p),
669 + MotdRemindEnum::Always => "always".to_string(),
670 + MotdRemindEnum::Never => "never".to_string(),
671 + }
672 + }
673 +}
674 +
675 +impl fmt::Display for MotdRemindEnum {
676 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
677 + match self {
678 + MotdRemindEnum::OnlyOnce => write!(f, "OnlyOnce"),
679 + MotdRemindEnum::OnceHourly => write!(f, "OnceHourly"),
680 + MotdRemindEnum::Randomly(p) => write!(f, "Randomly({})", p),
681 + MotdRemindEnum::Always => write!(f, "Always"),
682 + MotdRemindEnum::Never => write!(f, "Never"),
683 + }
684 + }
685 +}
686 +
687 +impl FromStr for MotdRemindEnum {
688 + type Err = String;
689 +
690 + /// Accepts the stored form (`OnlyOnce`, `Randomly(30)`) and short forms (`daily`, `random:30`)
691 + fn from_str(s: &str) -> Result<Self, Self::Err> {
692 + let lower = s.trim().to_lowercase();
693 + let percent = lower
694 + .strip_prefix("randomly(")
695 + .and_then(|r| r.strip_suffix(')'))
696 + .or_else(|| lower.strip_prefix("random:"))
697 + .or_else(|| lower.strip_prefix("random "));
698 + if let Some(p) = percent {
699 + return match p.trim().parse::<u8>() {
700 + Ok(p) if p <= 100 => Ok(MotdRemindEnum::Randomly(p)),
701 + _ => Err(format!("random percent must be 0-100, got '{}'", p)),
702 + };
703 + }
704 + match lower.as_str() {
705 + "onlyonce" | "once" | "daily" => Ok(MotdRemindEnum::OnlyOnce),
706 + "oncehourly" | "hourly" => Ok(MotdRemindEnum::OnceHourly),
707 + "always" => Ok(MotdRemindEnum::Always),
708 + "never" => Ok(MotdRemindEnum::Never),
709 + _ => Err(format!(
710 + "unknown remind mode '{}', expected one of: daily, hourly, random:N, always, never",
711 + s
712 + )),
713 + }
714 + }
715 +}
716 +
528 717 pub struct TierDefaultOps {}
529 718
530 719 impl TierDefaultOps {
@@ -565,6 +754,46 @@ impl TierDefaultOps {
565 754 pub async fn list(db: &DatabaseConnection) -> Result<Vec<tier_defaults::Model>, DbErr> {
566 755 TierDefaults::find().all(db).await
567 756 }
757 +
758 + /// Set how many days ahead the motd starts reminding for this tier's dates; None clears it.
759 + pub async fn set_sig_date_delta(
760 + db: &DatabaseConnection,
761 + model_id: i32,
762 + sig_date_delta: Option<u32>,
763 + ) -> Result<(), PplError> {
764 + let loaded_model = TierDefaults::find_by_id(model_id).one(db).await?;
765 + match loaded_model {
766 + None => Ok(()),
767 + Some(instance) => {
768 + let mut am: tier_defaults::ActiveModel = instance.into();
769 +
770 + am.sig_date_delta = Set(sig_date_delta);
771 + am.date_up = Set(Local::now().date_naive());
772 + am.update(db).await?;
773 + Ok(())
774 + }
775 + }
776 + }
777 +
778 + /// Set how often the motd shows ppl in this tier; None falls back to OnlyOnce.
779 + pub async fn set_sig_remind_enum(
780 + db: &DatabaseConnection,
781 + model_id: i32,
782 + remind: Option<MotdRemindEnum>,
783 + ) -> Result<(), PplError> {
784 + let loaded_model = TierDefaults::find_by_id(model_id).one(db).await?;
785 + match loaded_model {
786 + None => Ok(()),
787 + Some(instance) => {
788 + let mut am: tier_defaults::ActiveModel = instance.into();
789 +
790 + am.sig_remind_enum = Set(remind.map(|r| r.to_string()));
791 + am.date_up = Set(Local::now().date_naive());
792 + am.update(db).await?;
793 + Ok(())
794 + }
795 + }
796 + }
568 797 }
569 798
570 799 pub struct TraitDefaultOps {}
added src/do_edit.rs
+50 −0
@@ -0,0 +1,50 @@
1 +use crate::data::{find_person, PplOps};
2 +use crate::PplError;
3 +use clap::Args;
4 +use sea_orm::DatabaseConnection;
5 +use std::io;
6 +
7 +#[derive(Args, Debug)]
8 +pub struct EditArgs {
9 + /// Who to edit (name, nick or id)
10 + pub person: String,
11 + /// New full name
12 + #[arg(long)]
13 + pub name: Option<String>,
14 + /// New nickname, shown in place of the name; "" clears it
15 + #[arg(long)]
16 + pub nick: Option<String>,
17 +}
18 +
19 +pub async fn do_edit(args: &EditArgs, db: &DatabaseConnection) -> Result<(), PplError> {
20 + let ppl = PplOps::list(db).await?;
21 + let p = find_person(&ppl, &args.person)?;
22 +
23 + if args.name.is_none() && args.nick.is_none() {
24 + // Nothing to change, show what's there
25 + println!(
26 + "{} ({}) nick: {}",
27 + p.name.trim(),
28 + p.id,
29 + p.nick.as_deref().unwrap_or("-")
30 + );
31 + return Ok(());
32 + }
33 + if args.name.as_deref().is_some_and(|n| n.trim().is_empty()) {
34 + return Err(io::Error::new(io::ErrorKind::InvalidInput, "name can't be empty").into());
35 + }
36 +
37 + let nick = args.nick.as_ref().map(|n| {
38 + let n = n.trim();
39 + (!n.is_empty()).then(|| n.to_string())
40 + });
41 + PplOps::update(db, p.id, args.name.clone(), nick.clone()).await?;
42 +
43 + let name = args.name.as_deref().map(str::trim).unwrap_or(p.name.trim());
44 + match nick {
45 + None => println!("{}: renamed", name),
46 + Some(None) => println!("{}: nick cleared", name),
47 + Some(Some(n)) => println!("{}: nick is now {}", name, n),
48 + }
49 + Ok(())
50 +}
added src/do_shell.rs
+103 −0
@@ -0,0 +1,103 @@
1 +use crate::PplError;
2 +use clap::{Args, ValueEnum};
3 +use directories::BaseDirs;
4 +use std::fs::{self, OpenOptions};
5 +use std::io::{self, Write};
6 +use std::path::PathBuf;
7 +
8 +const MARKER: &str = "# ppl motd";
9 +
10 +#[derive(Debug, Clone, Copy, ValueEnum)]
11 +pub enum Shell {
12 + Bash,
13 + Zsh,
14 + Fish,
15 +}
16 +
17 +#[derive(Args, Debug)]
18 +pub struct ShellArgs {
19 + /// Shell to install the motd hook for (defaults to $SHELL)
20 + #[arg(value_enum)]
21 + pub shell: Option<Shell>,
22 + /// Print the snippet instead of writing it to the rc file
23 + #[arg(long)]
24 + pub print: bool,
25 +}
26 +
27 +impl Shell {
28 + fn detect() -> Option<Shell> {
29 + let shell = std::env::var("SHELL").ok()?;
30 + match shell.rsplit('/').next()? {
31 + "bash" => Some(Shell::Bash),
32 + "zsh" => Some(Shell::Zsh),
33 + "fish" => Some(Shell::Fish),
34 + _ => None,
35 + }
36 + }
37 +
38 + fn rc_path(&self) -> Option<PathBuf> {
39 + let base = BaseDirs::new()?;
40 + Some(match self {
41 + Shell::Bash => base.home_dir().join(".bashrc"),
42 + Shell::Zsh => std::env::var_os("ZDOTDIR")
43 + .map(PathBuf::from)
44 + .unwrap_or_else(|| base.home_dir().to_path_buf())
45 + .join(".zshrc"),
46 + Shell::Fish => base.config_dir().join("fish").join("config.fish"),
47 + })
48 + }
49 +
50 + /// Only run in interactive shells, and only if ppl is on the PATH, so
51 + /// scp/rsync sessions and uninstalls don't break the shell.
52 + fn snippet(&self) -> &'static str {
53 + match self {
54 + Shell::Bash => "[[ $- == *i* ]] && command -v ppl >/dev/null 2>&1 && ppl motd",
55 + Shell::Zsh => "[[ -o interactive ]] && (( $+commands[ppl] )) && ppl motd",
56 + Shell::Fish => "if status is-interactive; and command -q ppl; ppl motd; end",
57 + }
58 + }
59 +}
60 +
61 +pub fn do_shell(args: &ShellArgs) -> Result<(), PplError> {
62 + let shell = args.shell.or_else(Shell::detect).ok_or_else(|| {
63 + io::Error::new(
64 + io::ErrorKind::InvalidInput,
65 + "could not detect shell from $SHELL, pass one of: bash, zsh, fish",
66 + )
67 + })?;
68 + let entry = format!("{}\n{}\n", MARKER, shell.snippet());
69 +
70 + if args.print {
71 + print!("{}", entry);
72 + return Ok(());
73 + }
74 +
75 + let path = shell
76 + .rc_path()
77 + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "could not find home directory"))?;
78 +
79 + let existing = match fs::read_to_string(&path) {
80 + Ok(s) => s,
81 + Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
82 + Err(e) => return Err(e.into()),
83 + };
84 + if existing.contains(MARKER) {
85 + println!("motd already installed in {}", path.display());
86 + return Ok(());
87 + }
88 +
89 + if let Some(parent) = path.parent() {
90 + fs::create_dir_all(parent)?;
91 + }
92 + let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
93 + let sep = if existing.is_empty() || existing.ends_with("\n\n") {
94 + ""
95 + } else if existing.ends_with('\n') {
96 + "\n"
97 + } else {
98 + "\n\n"
99 + };
100 + write!(file, "{}{}", sep, entry)?;
101 + println!("motd installed in {}", path.display());
102 + Ok(())
103 +}
added src/do_tiers.rs
+221 −0
@@ -0,0 +1,221 @@
1 +use crate::data::{find_person, MotdRemindEnum, PplOps, TierDefaultOps, TierOps};
2 +use crate::entities::{ppl, tier, tier_defaults};
3 +use crate::PplError;
4 +use clap::{Args, Subcommand};
5 +use sea_orm::DatabaseConnection;
6 +use std::io;
7 +
8 +#[derive(Args, Debug)]
9 +pub struct TierArgs {
10 + #[command(subcommand)]
11 + pub action: Option<TierAction>,
12 + /// Change one person's settings (name, nick or id) instead of the tier defaults
13 + #[arg(long, short, global = true)]
14 + pub person: Option<String>,
15 +}
16 +
17 +#[derive(Subcommand, Debug)]
18 +pub enum TierAction {
19 + /// List tiers and their motd reminder settings
20 + List,
21 + /// Remind DAYS days ahead of dates for ppl in this tier
22 + Set { tier: String, days: u32 },
23 + /// How often the motd shows ppl in this tier: daily, hourly, random:N, always, never
24 + Remind { tier: String, mode: MotdRemindEnum },
25 + /// Remove the reminder window and remind mode for this tier
26 + Clear { tier: String },
27 +}
28 +
29 +/// Which row gets the change: a tier default, or one person's tier
30 +#[derive(Clone, Copy)]
31 +enum Target {
32 + Default(i32),
33 + Person(i32),
34 +}
35 +
36 +fn not_found(msg: String) -> PplError {
37 + io::Error::new(io::ErrorKind::NotFound, msg).into()
38 +}
39 +
40 +fn describe(delta: Option<u32>, remind: Option<&str>) -> (String, String) {
41 + let window = match delta {
42 + Some(d) => format!("{} days", d),
43 + None => "-".to_string(),
44 + };
45 + let mode = remind
46 + .and_then(|r| r.parse::<MotdRemindEnum>().ok())
47 + .map(|r| r.label())
48 + .unwrap_or_else(|| "-".to_string());
49 + (window, mode)
50 +}
51 +
52 +fn find_default<'a>(
53 + tiers: &'a [tier_defaults::Model],
54 + name: &str,
55 +) -> Result<&'a tier_defaults::Model, PplError> {
56 + tiers
57 + .iter()
58 + .find(|t| t.key.eq_ignore_ascii_case(name))
59 + .ok_or_else(|| {
60 + let keys = tiers.iter().map(|t| t.key.as_str()).collect::<Vec<_>>();
61 + not_found(format!(
62 + "no tier '{}', expected one of: {}",
63 + name,
64 + keys.join(", ")
65 + ))
66 + })
67 +}
68 +
69 +fn find_person_tier<'a>(
70 + tiers: &'a [tier::Model],
71 + person: &ppl::Model,
72 + name: &str,
73 +) -> Result<&'a tier::Model, PplError> {
74 + let theirs = tiers
75 + .iter()
76 + .filter(|t| t.ppl_id == person.id)
77 + .collect::<Vec<_>>();
78 + theirs
79 + .iter()
80 + .find(|t| t.name.eq_ignore_ascii_case(name))
81 + .copied()
82 + .ok_or_else(|| {
83 + let keys = theirs.iter().map(|t| t.name.as_str()).collect::<Vec<_>>();
84 + not_found(format!(
85 + "{} isn't in tier '{}' (their tiers: {})",
86 + person.name.trim(),
87 + name,
88 + if keys.is_empty() {
89 + "none".to_string()
90 + } else {
91 + keys.join(", ")
92 + }
93 + ))
94 + })
95 +}
96 +
97 +pub async fn do_tiers(args: &TierArgs, db: &DatabaseConnection) -> Result<(), PplError> {
98 + let defaults = TierDefaultOps::list(db).await?;
99 + let person = match &args.person {
100 + Some(q) => {
101 + let ppl = PplOps::list(db).await?;
102 + Some(find_person(&ppl, q)?.clone())
103 + }
104 + None => None,
105 + };
106 + let person_tiers = TierOps::list(db).await?;
107 +
108 + let action = match &args.action {
109 + None | Some(TierAction::List) => {
110 + list(&defaults, &person_tiers, person.as_ref());
111 + return Ok(());
112 + }
113 + Some(a) => a,
114 + };
115 + let tier_name = match action {
116 + TierAction::Set { tier, .. }
117 + | TierAction::Remind { tier, .. }
118 + | TierAction::Clear { tier } => tier,
119 + TierAction::List => unreachable!(),
120 + };
121 +
122 + // Who we're changing, for the confirmation message
123 + let (target, label) = match &person {
124 + Some(p) => {
125 + let t = find_person_tier(&person_tiers, p, tier_name)?;
126 + (
127 + Target::Person(t.id),
128 + format!("{} ({})", t.name, p.name.trim()),
129 + )
130 + }
131 + None => {
132 + let t = find_default(&defaults, tier_name)?;
133 + (Target::Default(t.id), t.key.clone())
134 + }
135 + };
136 +
137 + let set_delta = |delta| async move {
138 + match target {
139 + Target::Default(id) => TierDefaultOps::set_sig_date_delta(db, id, delta).await,
140 + Target::Person(id) => TierOps::set_sig_date_delta(db, id, delta).await,
141 + }
142 + };
143 + let set_remind = |remind| async move {
144 + match target {
145 + Target::Default(id) => TierDefaultOps::set_sig_remind_enum(db, id, remind).await,
146 + Target::Person(id) => TierOps::set_sig_remind_enum(db, id, remind).await,
147 + }
148 + };
149 +
150 + match action {
151 + TierAction::Set { days, .. } => {
152 + set_delta(Some(*days)).await?;
153 + println!("{}: remind {} days ahead", label, days);
154 + }
155 + TierAction::Remind { mode, .. } => {
156 + set_remind(Some(*mode)).await?;
157 + println!("{}: remind {}", label, mode.label());
158 + }
159 + TierAction::Clear { .. } => {
160 + set_delta(None).await?;
161 + set_remind(None).await?;
162 + match person {
163 + Some(_) => println!("{}: using the tier defaults", label),
164 + None => println!("{}: reminder settings cleared", label),
165 + }
166 + }
167 + TierAction::List => unreachable!(),
168 + }
169 + Ok(())
170 +}
171 +
172 +fn list(
173 + defaults: &[tier_defaults::Model],
174 + person_tiers: &[tier::Model],
175 + person: Option<&ppl::Model>,
176 +) {
177 + match person {
178 + None => {
179 + for t in defaults {
180 + let (window, mode) = describe(t.sig_date_delta, t.sig_remind_enum.as_deref());
181 + println!(
182 + "{} {:<16} {:<10} {:<12} {}",
183 + t.symbol.as_deref().unwrap_or(" "),
184 + t.key,
185 + window,
186 + mode,
187 + if t.enabled { "" } else { "(disabled)" }
188 + );
189 + }
190 + }
191 + Some(p) => {
192 + let theirs = person_tiers
193 + .iter()
194 + .filter(|t| t.ppl_id == p.id)
195 + .collect::<Vec<_>>();
196 + if theirs.is_empty() {
197 + println!("{} isn't in any tiers", p.name.trim());
198 + }
199 + // Show the person's own value, or the tier default it falls back to
200 + for t in theirs {
201 + let d = defaults.iter().find(|d| d.key == t.name);
202 + let (window, mode) = describe(
203 + t.sig_date_delta.or(d.and_then(|d| d.sig_date_delta)),
204 + t.sig_remind_enum
205 + .as_deref()
206 + .or(d.and_then(|d| d.sig_remind_enum.as_deref())),
207 + );
208 + let mark = |own: bool| if own { "" } else { " (default)" };
209 + let window = format!("{}{}", window, mark(t.sig_date_delta.is_some()));
210 + println!(
211 + "{} {:<16} {:<20} {}{}",
212 + t.symbol.as_deref().unwrap_or(" "),
213 + t.name,
214 + window,
215 + mode,
216 + mark(t.sig_remind_enum.is_some()),
217 + );
218 + }
219 + }
220 + }
221 +}
modified src/do_tui.rs
+352 −75
@@ -1,5 +1,6 @@
1 1 use crate::data::{
2 − ContactOps, PplOps, RelationOps, SigDateOps, TierDefaultOps, TierOps, TraitDefaultOps, TraitOps,
2 + anniversary_in, ContactOps, MotdRemindEnum, PplOps, RelationOps, SigDateOps, TierDefaultOps,
3 + TierOps, TraitDefaultOps, TraitOps,
3 4 };
4 5 use crate::do_init::lcolor;
5 6 use crate::entities::ppl::Model;
@@ -7,7 +8,7 @@ use crate::entities::{
7 8 contact, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
8 9 };
9 10 use crate::PplError;
10 −use chrono::{Datelike, NaiveDate};
11 +use chrono::{Datelike, Local, NaiveDate};
11 12 use crossterm::event;
12 13 use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
13 14 use enum_iterator::{next, Sequence};
@@ -21,7 +22,7 @@ use ratatui::widgets::{Block, Borders, List, ListDirection, ListItem, ListState,
21 22 use ratatui::{DefaultTerminal, Frame};
22 23 use sea_orm::DatabaseConnection;
23 24 use std::ops::Index;
24 −use time::{Date, Month, OffsetDateTime};
25 +use time::{Date, Month};
25 26 use tui_input::backend::crossterm::EventHandler;
26 27 use tui_input::Input;
27 28
@@ -50,8 +51,12 @@ struct Tui {
50 51 ppl_show_relations: bool,
51 52 def_tier_list: Vec<tier_defaults::Model>,
52 53 def_tier_state: ListState,
54 + def_tier_editing: bool,
55 + def_tier_input: Input,
53 56 def_trait_list: Vec<trait_defaults::Model>,
54 57 def_trait_state: ListState,
58 + /// Shown in place of the welcome line until the next key press, e.g. a failed save
59 + status: Option<String>,
55 60 }
56 61
57 62 #[derive(Debug, PartialEq, Sequence, Clone)]
@@ -64,6 +69,7 @@ enum Tabs {
64 69
65 70 #[derive(Clone, Debug)]
66 71 pub enum Editablez {
72 + Ppl,
67 73 Trait,
68 74 Tier,
69 75 Contact,
@@ -140,8 +146,11 @@ impl Default for Tui {
140 146 ppl_show_relations: true,
141 147 def_tier_list: vec![],
142 148 def_tier_state: ListState::default(),
149 + def_tier_editing: false,
150 + def_tier_input: Input::default(),
143 151 def_trait_list: vec![],
144 152 def_trait_state: ListState::default(),
153 + status: None,
145 154 }
146 155 }
147 156 }
@@ -154,17 +163,13 @@ fn get_style(mine: u8, cursor: u8, total: u8) -> Color {
154 163 }
155 164 }
156 165
157 −fn get_cal<'a>(
158 − month: Month,
159 − year: i32,
160 − events: &CalendarEventStore,
161 −) -> Monthly<'a, &CalendarEventStore> {
166 +fn get_cal<'a>(month_start: Date, events: &CalendarEventStore) -> Monthly<'a, &CalendarEventStore> {
162 167 let default_style = Style::default()
163 168 .add_modifier(Modifier::BOLD)
164 169 .fg(AMBER.c500)
165 170 .bg(SLATE.c800);
166 171
167 − Monthly::new(Date::from_calendar_date(year, month, 1).unwrap(), events)
172 + Monthly::new(month_start, events)
168 173 .show_month_header(Style::default())
169 174 .default_style(default_style)
170 175 }
@@ -177,7 +182,42 @@ pub async fn run_tui(
177 182 app.reload(&db).await?;
178 183 loop {
179 184 let _ = terminal.draw(|f| render(f, &mut app));
180 − match event::read()? {
185 + let ev = event::read()?;
186 + if matches!(ev, Event::Key(k) if k.kind == KeyEventKind::Press) {
187 + app.status = None;
188 + }
189 + match ev {
190 + Event::Key(key_event)
191 + if key_event.kind == KeyEventKind::Press
192 + && app.current_tab == Tabs::TierSettings
193 + && app.def_tier_editing =>
194 + {
195 + match key_event.code {
196 + KeyCode::Esc => app.def_tier_editing = false,
197 + KeyCode::Enter => {
198 + let value = app.def_tier_input.value().trim();
199 + // Empty clears the reminder window, anything else must be a day count
200 + let delta = match value.is_empty() {
201 + true => Some(None),
202 + false => value.parse::<u32>().ok().map(Some),
203 + };
204 + if let (Some(delta), Some(t)) = (
205 + delta,
206 + app.def_tier_state
207 + .selected()
208 + .and_then(|idx| app.def_tier_list.get(idx)),
209 + ) {
210 + TierDefaultOps::set_sig_date_delta(&db, t.id, delta).await?;
211 + app.reload(&db).await?;
212 + app.def_tier_editing = false;
213 + }
214 + }
215 + KeyCode::Char(c) if !c.is_ascii_digit() => {}
216 + _ => {
217 + app.def_tier_input.handle_event(&Event::Key(key_event));
218 + }
219 + }
220 + }
181 221 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
182 222 match key_event.code {
183 223 KeyCode::Up => match app.current_tab {
@@ -207,7 +247,7 @@ pub async fn run_tui(
207 247 },
208 248 KeyCode::Tab => match app.ppl_field_editing {
209 249 true => {
210 − app.ppl_field_idx += 1;
250 + app.ppl_field_idx = app.ppl_field_idx.wrapping_add(1);
211 251 }
212 252 false => app.current_tab = next(&app.current_tab).unwrap_or(Tabs::Ppl),
213 253 },
@@ -252,49 +292,72 @@ pub async fn run_tui(
252 292 app.ppl_show_relations = !app.ppl_show_relations
253 293 }
254 294 }
255 − KeyCode::Enter => {
256 − if app.current_tab == Tabs::Ppl
257 − && app.ppl_editing
258 − && app.ppl_detail_state.selected().is_some()
295 + KeyCode::Enter if app.current_tab == Tabs::TierSettings => {
296 + if let Some(t) = app
297 + .def_tier_state
298 + .selected()
299 + .and_then(|idx| app.def_tier_list.get(idx))
259 300 {
260 − let idx = &app.ppl_detail_state.selected().unwrap();
261 − let e = app.ppl_editables.get(*idx).unwrap();
301 + app.def_tier_input = t
302 + .sig_date_delta
303 + .map(|d| d.to_string())
304 + .unwrap_or_default()
305 + .into();
306 + app.def_tier_editing = true;
307 + }
308 + }
309 + KeyCode::Enter => {
310 + if app.current_tab == Tabs::Ppl && app.ppl_editing {
311 + let Some(e) = app
312 + .ppl_detail_state
313 + .selected()
314 + .and_then(|idx| app.ppl_editables.get(idx))
315 + .cloned()
316 + else {
317 + continue;
318 + };
262 319 if !app.ppl_field_editing {
263 − // let k
264 320 app.ppl_input_a = e.first.clone().into();
265 − app.ppl_input_b = e.second.clone().unwrap_or("".to_string()).into();
266 − app.ppl_input_c = e.third.clone().unwrap_or("".to_string()).into();
321 + app.ppl_input_b = e.second.clone().unwrap_or_default().into();
322 + app.ppl_input_c = e.third.clone().unwrap_or_default().into();
267 323 } else {
268 324 let new_editable = Editable {
269 325 tgt: e.tgt.clone(),
270 326 id: e.id,
271 − first: app.ppl_input_a.value().parse().unwrap(),
327 + first: app.ppl_input_a.value().to_string(),
272 328 second: Option::from(app.ppl_input_b.value().to_string()),
273 329 third: Option::from(app.ppl_input_c.value().to_string()),
274 330 };
275 331
276 − match e.tgt {
277 − Editablez::Trait => {
278 − TraitOps::updatee(&db, new_editable).await;
279 − }
280 − Editablez::Tier => {
281 − TierOps::updatee(&db, new_editable).await;
282 − }
332 + let saved = match e.tgt {
333 + Editablez::Ppl => PplOps::updatee(&db, new_editable).await,
334 + Editablez::Trait => TraitOps::updatee(&db, new_editable).await,
335 + Editablez::Tier => TierOps::updatee(&db, new_editable).await,
283 336 Editablez::Contact => {
284 − ContactOps::updatee(&db, new_editable).await;
337 + ContactOps::updatee(&db, new_editable).await
285 338 }
286 339 Editablez::Relation => {
287 − RelationOps::updatee(&db, new_editable).await;
340 + RelationOps::updatee(&db, new_editable).await
288 341 }
289 342 Editablez::SigDate => {
290 − SigDateOps::updatee(&db, new_editable).await;
343 + SigDateOps::updatee(&db, new_editable).await
291 344 }
292 345 };
346 + // Keep the fields open so the input can be fixed
347 + if let Err(err) = saved {
348 + app.status = Some(format!("couldn't save: {}", err));
349 + continue;
350 + }
293 351 app.reload(&db).await?;
294 352 }
295 353 app.ppl_field_editing = !app.ppl_field_editing;
296 354 }
297 355 }
356 + KeyCode::Char(c @ ('m' | '+' | '-' | '[' | ']' | 'x'))
357 + if !app.ppl_field_editing =>
358 + {
359 + tier_reminder_key(&mut app, &db, c).await?;
360 + }
298 361 _ => {
299 362 default_editing_handler(&mut app, &key_event);
300 363 }
@@ -306,12 +369,144 @@ pub async fn run_tui(
306 369 }
307 370 }
308 371
372 +/// The tier row the reminder keys act on: a tier default on the tiers tab,
373 +/// or the selected tier of a person being edited on the ppl tab
374 +struct ReminderTarget {
375 + is_default: bool,
376 + id: i32,
377 + delta: Option<u32>,
378 + remind: Option<MotdRemindEnum>,
379 + /// The tier default a person's tier falls back to
380 + default_delta: Option<u32>,
381 + default_remind: Option<MotdRemindEnum>,
382 +}
383 +
384 +fn parse_remind(r: &Option<String>) -> Option<MotdRemindEnum> {
385 + r.as_deref().and_then(|r| r.parse().ok())
386 +}
387 +
388 +fn reminder_target(app: &Tui) -> Option<ReminderTarget> {
389 + match app.current_tab {
390 + Tabs::TierSettings => app
391 + .def_tier_state
392 + .selected()
393 + .and_then(|idx| app.def_tier_list.get(idx))
394 + .map(|t| ReminderTarget {
395 + is_default: true,
396 + id: t.id,
397 + delta: t.sig_date_delta,
398 + remind: parse_remind(&t.sig_remind_enum),
399 + default_delta: None,
400 + default_remind: None,
401 + }),
402 + Tabs::Ppl if app.ppl_editing => app
403 + .ppl_detail_state
404 + .selected()
405 + .and_then(|idx| app.ppl_editables.get(idx))
406 + .filter(|e| matches!(e.tgt, Editablez::Tier))
407 + .and_then(|e| app.tier_list.iter().find(|t| t.id == e.id))
408 + .map(|t| {
409 + let d = app.def_tier_list.iter().find(|d| d.key == t.name);
410 + ReminderTarget {
411 + is_default: false,
412 + id: t.id,
413 + delta: t.sig_date_delta,
414 + remind: parse_remind(&t.sig_remind_enum),
415 + default_delta: d.and_then(|d| d.sig_date_delta),
416 + default_remind: d.and_then(|d| parse_remind(&d.sig_remind_enum)),
417 + }
418 + }),
419 + _ => None,
420 + }
421 +}
422 +
423 +/// m: cycle remind mode, +/-: window days, [/]: random percent, x: clear
424 +async fn tier_reminder_key(
425 + app: &mut Tui,
426 + db: &DatabaseConnection,
427 + key: char,
428 +) -> Result<(), PplError> {
429 + let Some(t) = reminder_target(app) else {
430 + return Ok(());
431 + };
432 + let delta = t.delta.or(t.default_delta);
433 + let remind = t.remind.or(t.default_remind);
434 +
435 + let (new_delta, new_remind) = match key {
436 + '+' => (Some(delta.unwrap_or(0).saturating_add(1)), t.remind),
437 + '-' => (Some(delta.unwrap_or(0).saturating_sub(1)), t.remind),
438 + // A person's tier goes back to the default after Never; a default just wraps around
439 + 'm' => (
440 + t.delta,
441 + match (t.is_default, t.remind) {
442 + (false, Some(MotdRemindEnum::Never)) => None,
443 + (false, None) => Some(MotdRemindEnum::OnlyOnce),
444 + (_, r) => Some(r.unwrap_or_default().next()),
445 + },
446 + ),
447 + '[' | ']' => match remind {
448 + Some(MotdRemindEnum::Randomly(p)) => {
449 + let p = match key {
450 + ']' => p.saturating_add(10).min(100),
451 + _ => p.saturating_sub(10),
452 + };
453 + (t.delta, Some(MotdRemindEnum::Randomly(p)))
454 + }
455 + _ => return Ok(()),
456 + },
457 + 'x' => (None, None),
458 + _ => return Ok(()),
459 + };
460 +
461 + if t.is_default {
462 + TierDefaultOps::set_sig_date_delta(db, t.id, new_delta).await?;
463 + TierDefaultOps::set_sig_remind_enum(db, t.id, new_remind).await?;
464 + } else {
465 + TierOps::set_sig_date_delta(db, t.id, new_delta).await?;
466 + TierOps::set_sig_remind_enum(db, t.id, new_remind).await?;
467 + }
468 + app.reload(db).await
469 +}
470 +
471 +/// "⏰ 14d · hourly", with a person's values that come from the tier default dimmed
472 +fn reminder_spans(
473 + delta: Option<u32>,
474 + remind: Option<MotdRemindEnum>,
475 + default_delta: Option<u32>,
476 + default_remind: Option<MotdRemindEnum>,
477 +) -> Vec<Span<'static>> {
478 + let style = |own: bool| match own {
479 + true => Style::default().fg(ORANGE.c400),
480 + false => Style::default().fg(SLATE.c500),
481 + };
482 + let window = match delta.or(default_delta) {
483 + Some(d) => format!("{}d", d),
484 + None => "-".to_string(),
485 + };
486 + let mode = remind
487 + .or(default_remind)
488 + .map(|r| r.label())
489 + .unwrap_or_else(|| "-".to_string());
490 + vec![
491 + Span::styled(" ⏰ ", style(false)),
492 + Span::styled(window, style(delta.is_some())),
493 + Span::styled(" · ", style(false)),
494 + Span::styled(mode, style(remind.is_some())),
495 + ]
496 +}
497 +
309 498 fn default_editing_handler(app: &mut Tui, key_event: &KeyEvent) {
310 − if app.ppl_editing {
311 − let idx = &app.ppl_detail_state.selected().unwrap();
312 − let e = app.ppl_editables.get(*idx).unwrap();
499 + // ppl_editing stays on when switching tabs, so check we're still on the ppl tab
500 + if app.ppl_editing && app.current_tab == Tabs::Ppl {
501 + let Some(e) = app
502 + .ppl_detail_state
503 + .selected()
504 + .and_then(|idx| app.ppl_editables.get(idx))
505 + else {
506 + return;
507 + };
313 508 match e.tgt {
314 − Editablez::Trait => match app.ppl_field_idx % 2 {
509 + Editablez::Ppl | Editablez::Trait => match app.ppl_field_idx % 2 {
315 510 0 => {
316 511 app.ppl_input_a.handle_event(&Event::Key(*key_event));
317 512 }
@@ -395,7 +590,10 @@ fn render(f: &mut Frame, app: &mut Tui) {
395 590 ),
396 591 Span::raw(" :)"),
397 592 ];
398 − let textw = Text::from(Line::from(msgw)).style(Style::default());
593 + let textw = match &app.status {
594 + Some(status) => Text::from(Line::styled(format!("⚠ {}", status), ORANGE.c400)),
595 + None => Text::from(Line::from(msgw)).style(Style::default()),
596 + };
399 597 let welcome = Paragraph::new(textw);
400 598 f.render_widget(welcome, chunks[0]);
401 599
@@ -403,7 +601,7 @@ fn render(f: &mut Frame, app: &mut Tui) {
403 601 Tabs::Ppl => {
404 602 match app.ppl_editing {
405 603 true => {
406 − let msgw = vec![
604 + let mut msgw = vec![
407 605 Span::styled(
408 606 "Enter",
409 607 Style::default().add_modifier(Modifier::BOLD).fg(WHITE),
@@ -415,6 +613,12 @@ fn render(f: &mut Frame, app: &mut Tui) {
415 613 ),
416 614 Span::raw(" to add a ppl entry"),
417 615 ];
616 + if reminder_target(app).is_some() {
617 + msgw.push(Span::styled(
618 + " · m mode · +/- days · [/] random% · x use defaults",
619 + Style::default().fg(SLATE.c500),
620 + ));
621 + }
418 622 let textw = Text::from(Line::from(msgw)).style(Style::default());
419 623 let welcome = Paragraph::new(textw);
420 624 f.render_widget(welcome, chunks[2]);
@@ -549,15 +753,37 @@ fn render(f: &mut Frame, app: &mut Tui) {
549 753
550 754 f.render_stateful_widget(&ppl, layout_ppl[0], &mut app.ppl_state);
551 755
552 − let idx = app.ppl_state.selected();
553 − match idx {
756 + let curr = app
757 + .ppl_state
758 + .selected()
759 + .and_then(|idx| app.ppl_list.get(idx))
760 + .cloned();
761 + match curr {
554 762 None => {}
555 − Some(val) => {
556 − let curr = app.ppl_list.index(val);
557 −
763 + Some(curr) => {
558 764 let mut msgs: Vec<Line> = vec![];
559 765 let mut editables: Vec<Editable> = vec![];
560 766
767 + editables.push(Editable {
768 + tgt: Editablez::Ppl,
769 + id: curr.id,
770 + first: curr.name.trim().to_string(),
771 + second: curr.nick.clone(),
772 + third: None,
773 + });
774 + msgs.push(Line::from(vec![
775 + Span::styled(
776 + "👤 name: ",
777 + Style::default().add_modifier(Modifier::BOLD).fg(WHITE),
778 + ),
779 + Span::raw(curr.name.trim().to_string()),
780 + Span::styled(
781 + " nick: ",
782 + Style::default().add_modifier(Modifier::BOLD).fg(WHITE),
783 + ),
784 + Span::raw(curr.nick.clone().unwrap_or_else(|| "-".to_string())),
785 + ]));
786 +
561 787 app.trait_list.sort_by_key(|k| {
562 788 (
563 789 key_in_tier(k.key.clone(), &app.def_tier_list),
@@ -626,7 +852,7 @@ fn render(f: &mut Frame, app: &mut Tui) {
626 852 Span::styled(
627 853 format!(
628 854 "{} {}: ",
629 − &other.symbol.clone().unwrap(),
855 + &other.symbol.clone().unwrap_or_default(),
630 856 t.name.clone()
631 857 ),
632 858 Style::default().add_modifier(Modifier::BOLD).fg(WHITE),
@@ -656,6 +882,16 @@ fn render(f: &mut Frame, app: &mut Tui) {
656 882 Span::raw(t.name.clone()),
657 883 ])),
658 884 }
885 + if let Some(line) = msgs.last_mut() {
886 + for span in reminder_spans(
887 + t.sig_date_delta,
888 + parse_remind(&t.sig_remind_enum),
889 + othersym.and_then(|d| d.sig_date_delta),
890 + othersym.and_then(|d| parse_remind(&d.sig_remind_enum)),
891 + ) {
892 + line.push_span(span);
893 + }
894 + }
659 895 }
660 896 }
661 897
@@ -789,10 +1025,33 @@ fn render(f: &mut Frame, app: &mut Tui) {
789 1025 match app.ppl_editing {
790 1026 true => match app.ppl_field_editing {
791 1027 true => {
792 − let ced = editables
793 − .get(app.ppl_detail_state.selected().unwrap())
794 − .unwrap();
1028 + let Some(ced) = app
1029 + .ppl_detail_state
1030 + .selected()
1031 + .and_then(|idx| editables.get(idx))
1032 + else {
1033 + return;
1034 + };
795 1035 match ced.tgt.clone() {
1036 + Editablez::Ppl => {
1037 + let input_a = Paragraph::new(app.ppl_input_a.value())
1038 + .block(
1039 + Block::default()
1040 + .borders(Borders::ALL)
1041 + .title("name"),
1042 + )
1043 + .style(get_style(0, app.ppl_field_idx, 2));
1044 + f.render_widget(input_a, layout_edit[1]);
1045 +
1046 + let input_b = Paragraph::new(app.ppl_input_b.value())
1047 + .block(
1048 + Block::default()
1049 + .borders(Borders::ALL)
1050 + .title("nick (empty clears)"),
1051 + )
1052 + .style(get_style(1, app.ppl_field_idx, 2));
1053 + f.render_widget(input_b, layout_edit[2]);
1054 + }
796 1055 Editablez::Trait => {
797 1056 let input_a = Paragraph::new(app.ppl_input_a.value())
798 1057 .block(
@@ -970,38 +1229,31 @@ fn render(f: &mut Frame, app: &mut Tui) {
970 1229 .constraints([Constraint::Percentage(26), Constraint::Percentage(74)])
971 1230 .split(chunks[1]);
972 1231
973 − let mut start = OffsetDateTime::now_local()
974 − .unwrap()
975 − .date()
976 − .replace_month(Month::January)
977 − .unwrap()
978 − .replace_day(1)
979 − .unwrap();
1232 + // Build the year start from chrono; time's now_local() can fail depending on platform and threads
1233 + let Ok(mut start) = Date::from_calendar_date(Local::now().year(), Month::January, 1)
1234 + else {
1235 + return;
1236 + };
980 1237 let as_upcoming = &app
981 1238 .sigdate_list
982 1239 .iter()
983 − .map(|d| {
1240 + .filter_map(|d| {
984 1241 let mut n = d.clone();
985 − n.date = NaiveDate::from_ymd_opt(start.year(), n.date.month(), n.date.day())
986 − .unwrap();
987 − let p = app
988 − .ppl_list
989 − .iter()
990 − .find(|p| p.id == n.ppl_id)
991 − .unwrap()
992 − .clone();
1242 + n.date = anniversary_in(n.date, start.year())?;
1243 + // Skip dates whose person is gone
1244 + let p = app.ppl_list.iter().find(|p| p.id == n.ppl_id)?.clone();
993 1245 let tr = app
994 1246 .trait_list
995 1247 .iter()
996 − .filter(|p| p.id == n.ppl_id)
1248 + .filter(|t| t.ppl_id == n.ppl_id)
997 1249 .map(|t| t.to_owned())
998 1250 .collect::<Vec<traits::Model>>();
999 − crate::do_tui::SigDateAndProps {
1251 + Some(crate::do_tui::SigDateAndProps {
1000 1252 date: n,
1001 1253 ppl: p,
1002 1254 traits: tr,
1003 1255 trait_defaults: app.def_trait_list.clone(),
1004 − }
1256 + })
1005 1257 })
1006 1258 .sorted_by(|a, b| a.date.date.cmp(&b.date.date))
1007 1259 .collect::<Vec<SigDateAndProps>>();
@@ -1012,14 +1264,13 @@ fn render(f: &mut Frame, app: &mut Tui) {
1012 1264 );
1013 1265
1014 1266 for u in as_upcoming.iter().filter(|d| d.date.do_remind) {
1267 + let Ok(day) = Month::try_from(u.date.date.month() as u8).and_then(|month| {
1268 + Date::from_calendar_date(start.year(), month, u.date.date.day() as u8)
1269 + }) else {
1270 + continue;
1271 + };
1015 1272 event_list.add(
1016 − // Date::from_calendar_date(start.year(), Month::try_from(4 as u8).expect("oops"), 15 as u8).expect("doubleoops"),
1017 − Date::from_calendar_date(
1018 − start.year(),
1019 − Month::try_from(u.date.date.month() as u8).expect("oops"),
1020 − u.date.date.day() as u8,
1021 − )
1022 − .expect("doubleoops"),
1273 + day,
1023 1274 Style::default()
1024 1275 .add_modifier(Modifier::BOLD)
1025 1276 .fg(GREEN.c300)
@@ -1045,9 +1296,9 @@ fn render(f: &mut Frame, app: &mut Tui) {
1045 1296 .to_vec()
1046 1297 });
1047 1298 for col in cols {
1048 − let cal = get_cal(start.month(), start.year(), &event_list);
1299 + let cal = get_cal(start, &event_list);
1049 1300 f.render_widget(cal, col);
1050 − start = start.replace_month(start.month().next()).unwrap();
1301 + start = start.replace_month(start.month().next()).unwrap_or(start);
1051 1302 }
1052 1303
1053 1304 let dates = List::new(as_upcoming)
@@ -1091,6 +1342,23 @@ fn render(f: &mut Frame, app: &mut Tui) {
1091 1342 .direction(ListDirection::TopToBottom);
1092 1343
1093 1344 f.render_stateful_widget(ppl, chunks[1], &mut app.def_tier_state);
1345 +
1346 + let footer = match app.def_tier_editing {
1347 + true => Line::from(vec![
1348 + Span::raw("remind days ahead: "),
1349 + Span::styled(app.def_tier_input.value(), Style::default().fg(WHITE)),
1350 + Span::styled("▏", Style::default().fg(ORANGE.c500)),
1351 + Span::styled(
1352 + " enter save · empty clears · esc cancel",
1353 + Style::default().fg(SLATE.c500),
1354 + ),
1355 + ]),
1356 + false => Line::styled(
1357 + "enter/+/-: days · m: mode · [/]: random% · x: clear · tab: next tab · esc: quit",
1358 + SLATE.c500,
1359 + ),
1360 + };
1361 + f.render_widget(Paragraph::new(footer), chunks[2]);
1094 1362 }
1095 1363 Tabs::TraitSettings => {
1096 1364 let ppl = List::new(&app.def_trait_list)
@@ -1134,7 +1402,7 @@ impl From<PplAndProps> for ListItem<'_> {
1134 1402 WHITE,
1135 1403 ),
1136 1404 _ => {
1137 − let t = value.tiers.first().unwrap();
1405 + let t = &value.tiers[0];
1138 1406 match value.tier_defaults.iter().find(|q| q.key == t.name) {
1139 1407 None => Line::styled(
1140 1408 format!(" {}", value.ppl.nick.unwrap_or(value.ppl.name)),
@@ -1222,6 +1490,15 @@ impl From<&tier_defaults::Model> for ListItem<'_> {
1222 1490 ),
1223 1491 false => Line::styled(format!(" ☐ {:?} {}", value.symbol, value.key), SLATE.c500),
1224 1492 };
1493 + let mut line = line;
1494 + for span in reminder_spans(
1495 + value.sig_date_delta,
1496 + parse_remind(&value.sig_remind_enum),
1497 + None,
1498 + None,
1499 + ) {
1500 + line.push_span(span);
1501 + }
1225 1502
1226 1503 ListItem::new(line)
1227 1504 }
modified src/main.rs
+124 −90
@@ -3,18 +3,27 @@ use color_eyre::eyre::ErrReport;
3 3 use interim::DateError;
4 4 use log::{debug, info};
5 5 use sea_orm::*;
6 +use std::collections::hash_map::RandomState;
7 +use std::hash::{BuildHasher, Hasher};
6 8 use std::io::{self};
7 9
8 10 mod color;
9 11 mod data;
10 12 mod db;
11 13 mod do_add;
14 +mod do_edit;
12 15 mod do_init;
16 +mod do_shell;
17 +mod do_tiers;
13 18 mod do_tui;
14 19 mod entities;
15 20 mod migrator;
16 21
22 +use crate::data::{anniversary_in, MotdRemindEnum};
17 23 use crate::do_add::{do_add, AddArgs};
24 +use crate::do_edit::{do_edit, EditArgs};
25 +use crate::do_shell::{do_shell, ShellArgs};
26 +use crate::do_tiers::{do_tiers, TierArgs};
18 27 use crate::entities::ppl::Column::Me;
19 28 use entities::prelude::*;
20 29
@@ -27,6 +36,18 @@ enum PplError {
27 36 XDG(xdg::BaseDirectoriesError),
28 37 }
29 38
39 +impl std::fmt::Display for PplError {
40 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 + match self {
42 + PplError::DateError(e) => write!(f, "{:?}", e),
43 + PplError::DbError(e) => write!(f, "{}", e),
44 + PplError::EyreError(e) => write!(f, "{}", e),
45 + PplError::Std(e) => write!(f, "{}", e),
46 + PplError::XDG(e) => write!(f, "{}", e),
47 + }
48 + }
49 +}
50 +
30 51 impl From<io::Error> for PplError {
31 52 fn from(value: io::Error) -> Self {
32 53 PplError::Std(value)
@@ -74,16 +95,18 @@ enum Commands {
74 95 Calendar,
75 96 /// Show MOTD Version
76 97 MOTD,
98 + /// Add `ppl motd` to your shell's rc file (bash, zsh, fish)
99 + Shell(ShellArgs),
77 100 /// Add ppl
78 101 Add(AddArgs),
79 102 /// Aug ppl
80 103 Aug,
81 − /// Edit ppl
82 − Edit { name: Option<String> },
104 + /// Edit a person's name or nickname
105 + Edit(EditArgs),
83 106 /// Show ppl
84 107 Show,
85 − /// Tiers
86 − Tiers,
108 + /// Tiers and their motd reminder windows
109 + Tiers(TierArgs),
87 110 /// Traits
88 111 Traits,
89 112 /// Stats
@@ -110,8 +133,8 @@ async fn main() -> Result<(), PplError> {
110 133 let is_init = Ppl::find().filter(Me.eq(true)).one(&db).await?.is_some();
111 134
112 135 match &cli.command {
113 − Some(Commands::Edit { name }) => {
114 − println!("'edit: {:?}", name)
136 + Some(Commands::Edit(args)) => {
137 + do_edit(args, &db).await?;
115 138 }
116 139 None => {
117 140 println!("ppl");
@@ -120,6 +143,9 @@ async fn main() -> Result<(), PplError> {
120 143 Some(Commands::MOTD) => {
121 144 motd(&db).await?;
122 145 }
146 + Some(Commands::Shell(args)) => {
147 + do_shell(args)?;
148 + }
123 149 Some(Commands::Init) => match is_init {
124 150 false => {
125 151 info!("Uninitialized");
@@ -156,12 +182,9 @@ async fn main() -> Result<(), PplError> {
156 182 info!("pls run init");
157 183 }
158 184 },
159 − Some(Commands::Tiers) => match is_init {
185 + Some(Commands::Tiers(args)) => match is_init {
160 186 true => {
161 − let tiers = TierDefaults::find().all(&db).await?;
162 − for t in tiers {
163 − println!("{:?}", t)
164 − }
187 + do_tiers(args, &db).await?;
165 188 }
166 189 false => {
167 190 info!("pls run init");
@@ -191,14 +214,6 @@ async fn main() -> Result<(), PplError> {
191 214 Ok(())
192 215 }
193 216
194 −enum MotdRemindEnum {
195 − OnlyOnce, // Default, Only the first time the motd is run that day
196 − OnceHourly, // Only once per hour, resetting at the top of the hour
197 − Randomly(u8), // Will show this motd person randomly u8% every run
198 − Always, // Will always show this person's upcoming
199 − Never, // Never show this person's upcoming
200 −}
201 −
202 217 async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
203 218 use chrono::{Datelike, Local, NaiveDate};
204 219
@@ -210,6 +225,20 @@ async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
210 225 let people = Ppl::find().all(db).await?;
211 226 debug!("DEBUG: Found {} people", people.len());
212 227
228 + // Get all tier defaults
229 + let tier_defaults = TierDefaults::find().all(db).await?;
230 + debug!("DEBUG: Found {} tier defaults", tier_defaults.len());
231 +
232 + // Get all trait defaults for date matching
233 + let trait_defaults = TraitDefaults::find()
234 + .filter(entities::trait_defaults::Column::IsDate.eq(true))
235 + .all(db)
236 + .await?;
237 + debug!("DEBUG: Found {} date trait defaults", trait_defaults.len());
238 +
239 + let now = Local::now();
240 + let this_hour = now.format("%Y-%m-%dT%H").to_string();
241 +
213 242 for person in people {
214 243 // Get all sigdates for this person
215 244 let sigdates = SigDate::find()
@@ -235,65 +264,70 @@ async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
235 264 person_tiers.len()
236 265 );
237 266
238 − // Determine the remind enum for this person (default to OnlyOnce if null)
239 − // Also get the tier color and symbol for display
240 − let mut remind_enum_value: Option<String> = None;
267 + // Get the tier color and symbol for display
241 268 let mut tier_color: Option<String> = None;
242 269 let mut tier_symbol: Option<String> = None;
243 270
244 271 for tier in &person_tiers {
245 − if tier.sig_remind_enum.is_some() {
246 − remind_enum_value = tier.sig_remind_enum.clone();
247 − }
248 272 if tier_color.is_none() {
249 273 tier_color = tier.color.clone();
250 274 }
251 275 if tier_symbol.is_none() {
252 276 tier_symbol = tier.symbol.clone();
253 277 }
254 − // Break once we have all we need
255 − if remind_enum_value.is_some() && tier_color.is_some() && tier_symbol.is_some() {
256 − break;
257 − }
258 278 }
259 279
260 − // Check if we should show this person based on remind_enum (default OnlyOnce)
261 − // If remind_enum is null or "OnlyOnce", check if we've shown them today
262 − let should_show =
263 − if remind_enum_value.is_none() || remind_enum_value.as_deref() == Some("OnlyOnce") {
264 − // Check meta field for last_reminded date
265 − if let Some(meta_json) = &person.meta {
266 − if let Some(last_reminded) = meta_json.get("last_reminded") {
267 − if let Some(last_reminded_str) = last_reminded.as_str() {
268 − if let Ok(last_reminded_date) =
269 − NaiveDate::parse_from_str(last_reminded_str, "%Y-%m-%d")
270 − {
271 − debug!(
272 − "DEBUG: Person '{}' was last reminded on {}",
273 − person.name, last_reminded_date
274 − );
275 − // Only show if last reminded was before today
276 − last_reminded_date < today
277 − } else {
278 − true // Invalid date format, show anyway
279 − }
280 − } else {
281 − true // Not a string, show anyway
282 − }
283 − } else {
284 − true // No last_reminded field, show
285 − }
286 − } else {
287 − true // No meta, show
280 + // Remind mode: the person's own tier first, then the defaults for their tiers, then OnlyOnce
281 + let remind = person_tiers
282 + .iter()
283 + .filter_map(|t| t.sig_remind_enum.as_deref())
284 + .chain(
285 + tier_defaults
286 + .iter()
287 + .filter(|td| person_tiers.iter().any(|t| t.name == td.key))
288 + .filter_map(|td| td.sig_remind_enum.as_deref()),
289 + )
290 + .find_map(|r| match r.parse::<MotdRemindEnum>() {
291 + Ok(r) => Some(r),
292 + Err(e) => {
293 + debug!("DEBUG: Ignoring remind mode for '{}': {}", person.name, e);
294 + None
288 295 }
289 − } else {
290 − true // Other remind modes not yet implemented, show for now
291 − };
296 + })
297 + .unwrap_or_default();
298 + debug!("DEBUG: Person '{}' remind mode {}", person.name, remind);
299 +
300 + let meta_str = |key: &str| {
301 + person
302 + .meta
303 + .as_ref()
304 + .and_then(|m| m.get(key))
305 + .and_then(|v| v.as_str())
306 + .map(|v| v.to_string())
307 + };
308 +
309 + let should_show = match remind {
310 + MotdRemindEnum::Never => false,
311 + MotdRemindEnum::Always => true,
312 + MotdRemindEnum::Randomly(percent) => {
313 + // std's RandomState is randomly seeded per instance, good enough for a coin flip
314 + let roll = RandomState::new().build_hasher().finish() % 100;
315 + roll < percent as u64
316 + }
317 + // Only show if last reminded was before today
318 + MotdRemindEnum::OnlyOnce => meta_str("last_reminded")
319 + .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok())
320 + .is_none_or(|d| d < today),
321 + // Only show if not already shown during this clock hour
322 + MotdRemindEnum::OnceHourly => {
323 + meta_str("last_reminded_hour").is_none_or(|h| h != this_hour)
324 + }
325 + };
292 326
293 327 if !should_show {
294 328 debug!(
295 − "DEBUG: Skipping person '{}' - already shown today",
296 − person.name
329 + "DEBUG: Skipping person '{}' - remind mode {}",
330 + person.name, remind
297 331 );
298 332 continue;
299 333 }
@@ -301,22 +335,15 @@ async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
301 335 // Track if we showed any sigdates for this person
302 336 let mut showed_sigdate = false;
303 337
304 − // Get all tier defaults
305 − let tier_defaults = TierDefaults::find().all(db).await?;
306 − debug!("DEBUG: Found {} tier defaults", tier_defaults.len());
307 −
308 − // Get all trait defaults for date matching
309 − let trait_defaults = TraitDefaults::find()
310 − .filter(entities::trait_defaults::Column::IsDate.eq(true))
311 − .all(db)
312 − .await?;
313 − debug!("DEBUG: Found {} date trait defaults", trait_defaults.len());
314 −
315 338 for sigdate in sigdates {
316 339 debug!(
317 340 "DEBUG: Processing sigdate: {} on {}",
318 341 sigdate.event, sigdate.date
319 342 );
343 + if !sigdate.do_remind {
344 + debug!("DEBUG: Skipping sigdate - reminders are off for it");
345 + continue;
346 + }
320 347 let mut tier_delta: Option<u32> = None;
321 348
322 349 // First check person's assigned tiers
@@ -331,9 +358,12 @@ async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
331 358 }
332 359 }
333 360
334 − // If no tier delta found, check tier_defaults
361 + // If no tier delta found, check the tier_defaults for the person's tiers
335 362 if tier_delta.is_none() {
336 − for tier_default in &tier_defaults {
363 + for tier_default in tier_defaults
364 + .iter()
365 + .filter(|td| person_tiers.iter().any(|t| t.name == td.key))
366 + {
337 367 debug!(
338 368 "DEBUG: Checking tier_default '{}' with delta {:?}",
339 369 tier_default.key, tier_default.sig_date_delta
@@ -348,8 +378,7 @@ async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
348 378 // If we have a tier delta, check if we're within the reminder window for this year's anniversary
349 379 if let Some(delta) = tier_delta {
350 380 // Calculate this year's anniversary
351 − let this_year_anniversary =
352 − NaiveDate::from_ymd_opt(today.year(), sigdate.date.month(), sigdate.date.day());
381 + let this_year_anniversary = anniversary_in(sigdate.date, today.year());
353 382
354 383 if let Some(anniversary) = this_year_anniversary {
355 384 let days_until = (anniversary - today).num_days();
@@ -420,11 +449,7 @@ async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
420 449 showed_sigdate = true;
421 450 } else if days_until < 0 {
422 451 // Check next year's anniversary
423 − let next_year_anniversary = NaiveDate::from_ymd_opt(
424 − today.year() + 1,
425 − sigdate.date.month(),
426 − sigdate.date.day(),
427 − );
452 + let next_year_anniversary = anniversary_in(sigdate.date, today.year() + 1);
428 453 if let Some(next_anniversary) = next_year_anniversary {
429 454 let days_until_next = (next_anniversary - today).num_days();
430 455 debug!(
@@ -513,24 +538,33 @@ async fn motd(db: &DatabaseConnection) -> Result<(), PplError> {
513 538 }
514 539 }
515 540
516 − // Update person's meta field with last_reminded if we showed them and remind_enum is OnlyOnce
541 + // Record when we showed them, so OnlyOnce and OnceHourly can skip them next time
517 542 if showed_sigdate
518 − && (remind_enum_value.is_none() || remind_enum_value.as_deref() == Some("OnlyOnce"))
543 + && matches!(
544 + remind,
545 + MotdRemindEnum::OnlyOnce | MotdRemindEnum::OnceHourly
546 + )
519 547 {
520 548 use sea_orm::ActiveValue::Set;
521 549
522 − let today_str = today.format("%Y-%m-%d").to_string();
523 − let meta_value = serde_json::json!({
524 − "last_reminded": today_str
525 − });
550 + // Keep any other keys already in meta
551 + let mut meta_value = match &person.meta {
552 + Some(serde_json::Value::Object(m)) => m.clone(),
553 + _ => serde_json::Map::new(),
554 + };
555 + meta_value.insert(
556 + "last_reminded".to_string(),
557 + today.format("%Y-%m-%d").to_string().into(),
558 + );
559 + meta_value.insert("last_reminded_hour".to_string(), this_hour.clone().into());
526 560
527 561 let mut person_active: entities::ppl::ActiveModel = person.into();
528 − person_active.meta = Set(Some(meta_value));
562 + person_active.meta = Set(Some(serde_json::Value::Object(meta_value)));
529 563
530 564 if let Err(e) = person_active.update(db).await {
531 565 debug!("DEBUG: Failed to update person meta: {}", e);
532 566 } else {
533 − debug!("DEBUG: Updated last_reminded for person to {}", today);
567 + debug!("DEBUG: Updated last_reminded for person to {}", this_hour);
534 568 }
535 569 }
536 570 }

Parents: dc41247