⭐
Log in to mark this repository.
motd: make reminders configurable, and stop the TUI crashing
1aa586c · Gabriel Morell · 2026-10-15 20:38
✓
Rust CI and Docker Build / Create Release (push)
✓
Rust CI and Docker Build / Release (7z a, .exe, ppl-x86_64-windows.zip, windows-latest, windows-latest, 1.92) (push)
✓
Rust CI and Docker Build / Release (tar -czf, , ppl-aarch64-linux-gnu.tar.gz, ubuntu-24.04-arm, ubuntu-24.04-arm, 1.92) (push)
✓
Rust CI and Docker Build / Release (tar -czf, , ppl-aarch64-macos.tar.gz, macos-latest, macos-latest, 1.92) (push)
✓
Rust CI and Docker Build / Release (tar -czf, , ppl-x86_64-linux-gnu.tar.gz, ubuntu-latest, arc-runner-set, 1.92) (push)
✗
Rust CI and Docker Build / build (aarch64, ubuntu-24.04-arm) (push)
⏳
Rust CI and Docker Build
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 |  |
@@ -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 |  |
| 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