38.3 KiB · text History d9460a8
use crate::color::colo;
use crate::do_tui::{Editable, Editablez};
use crate::entities::ppl::Model;
use crate::entities::prelude::Tier;
use crate::entities::prelude::Traits;
use crate::entities::prelude::{
    Calendar, Contact, Ppl, Relation, SigDate, TierDefaults, TraitDefaults,
};
use crate::entities::{
    calendar, contact, ppl, relation, sig_date, tier, tier_defaults, trait_defaults, traits,
};
use crate::holidays::{self, CalendarDef, Group};
use crate::PplError;
use chrono::{Local, NaiveDate};
use color_eyre::owo_colors::OwoColorize;
use log::warn;
use ratatui::style::palette::tailwind::PURPLE;
use sea_orm::ActiveValue::Set;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, NotSet, QueryFilter,
};
use std::fmt;
use std::io;
use std::str::FromStr;

fn bool_from_string(input: Option<String>) -> bool {
    if input.is_none() {
        return false;
    }

    let input = input.unwrap_or("false".to_string());
    // Check if the input is an empty string
    if input.is_empty() {
        return false;
    }

    // Convert the input to lowercase for case-insensitive comparison
    let input = input.to_lowercase();

    // Check if the input matches any of the bool-like values
    match input.as_str() {
        "true" | "yes" | "t" | "y" => true,
        "false" | "no" | "f" | "n" => false,
        _ => false,
    }
}

/// The date's anniversary in `year`; Feb 29 falls on Feb 28 in non-leap years.
pub fn anniversary_in(date: NaiveDate, year: i32) -> Option<NaiveDate> {
    use chrono::Datelike;
    NaiveDate::from_ymd_opt(year, date.month(), date.day())
        .or_else(|| NaiveDate::from_ymd_opt(year, date.month(), date.day() - 1))
}

fn option_naive_date_from_option_string(input: Option<String>) -> Option<NaiveDate> {
    match input {
        Some(s) => chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok(),
        None => None,
    }
}

pub struct ContactOps {}

impl ContactOps {
    pub async fn create(
        db: &DatabaseConnection,
        ppl_id: i32,
        typ: String,
        designator: String,
        value: String,
    ) -> Result<(), PplError> {
        let p = contact::ActiveModel {
            id: Default::default(),
            ppl_id: Set(ppl_id),
            r#type: Set(typ),
            designator: Set(Option::from(designator)),
            value: Set(value),
            date_acq: Set(Some(Local::now().date_naive())),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
        };

        let pp = p.insert(db).await;
        match pp {
            Ok(item) => {
                let (r, g, b) = colo(PURPLE.c500);
                warn!("contact {:?}", item.id.truecolor(r, g, b));
                Ok(())
            }
            Err(err) => {
                warn!("contact {}", err.red());
                Err(err.into())
            }
        }
    }

    pub async fn list(db: &DatabaseConnection) -> Result<Vec<contact::Model>, DbErr> {
        Contact::find().all(db).await
    }

    pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
        Self::update(
            db,
            editable.id,
            editable.first,
            editable.second,
            editable.third.unwrap_or("".to_string()),
            None,
        )
        .await
    }

    pub async fn update(
        db: &DatabaseConnection,
        model_id: i32,
        typ: String,
        designator: Option<String>,
        value: String,
        date_acq: Option<String>,
    ) -> Result<(), PplError> {
        let loaded_model = Contact::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: contact::ActiveModel = instance.into();
                match date_acq {
                    None => {}
                    Some(d) => {
                        am.date_acq = Set(option_naive_date_from_option_string(Option::from(d)))
                    }
                }
                am.designator = Set(designator);
                am.r#type = Set(typ);
                am.value = Set(value);
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }
}

pub struct PplOps {}

impl PplOps {
    pub async fn create_me(db: &DatabaseConnection, name: String) -> Result<ppl::Model, PplError> {
        let p = ppl::ActiveModel {
            id: Default::default(),
            name: Set(name),
            nick: NotSet,
            me: Set(true),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
            meta: NotSet,
        };
        let pp = p.insert(db).await;
        match pp {
            Ok(item) => {
                warn!("ppl {:?}", item.id);
                Ok(item)
            }
            Err(err) => {
                warn!("ppl {}", err.red());
                Err(err.into())
            }
        }
    }
    pub async fn create(db: &DatabaseConnection, name: String) -> Result<ppl::Model, PplError> {
        let p = ppl::ActiveModel {
            id: Default::default(),
            name: Set(name),
            me: Set(false),
            nick: NotSet,
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
            meta: NotSet,
        };
        let pp = p.insert(db).await;
        match pp {
            Ok(item) => {
                warn!("ppl {:?}", item.id);
                Ok(item)
            }
            Err(err) => {
                warn!("ppl {}", err.red());
                Err(err.into())
            }
        }
    }

    pub async fn list(db: &DatabaseConnection) -> Result<Vec<Model>, DbErr> {
        Ppl::find().all(db).await
    }

    pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
        // An empty nick clears it
        let nick = editable
            .second
            .map(|n| n.trim().to_string())
            .filter(|n| !n.is_empty());
        Self::update(db, editable.id, Some(editable.first), Some(nick)).await
    }

    /// None leaves a field alone; for nick, Some(None) clears it.
    pub async fn update(
        db: &DatabaseConnection,
        id: i32,
        name: Option<String>,
        nick: Option<Option<String>>,
    ) -> Result<(), PplError> {
        let loaded_model = Ppl::find_by_id(id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: ppl::ActiveModel = instance.into();
                match name.map(|n| n.trim().to_string()) {
                    None => {}
                    Some(n) if n.is_empty() => {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidInput,
                            "name can't be empty",
                        )
                        .into())
                    }
                    Some(n) => am.name = Set(n),
                }
                if let Some(nick) = nick {
                    am.nick = Set(nick);
                }
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }
}

/// Find a person by id, or by name or nick ignoring case.
pub fn find_person<'a>(ppl: &'a [ppl::Model], query: &str) -> Result<&'a ppl::Model, PplError> {
    let query = query.trim();
    if let Ok(id) = query.parse::<i32>() {
        if let Some(p) = ppl.iter().find(|p| p.id == id) {
            return Ok(p);
        }
    }
    let matches = ppl
        .iter()
        .filter(|p| {
            p.name.trim().eq_ignore_ascii_case(query)
                || p.nick
                    .as_deref()
                    .is_some_and(|n| n.trim().eq_ignore_ascii_case(query))
        })
        .collect::<Vec<_>>();
    match matches.as_slice() {
        [p] => Ok(p),
        [] => Err(io::Error::new(
            io::ErrorKind::NotFound,
            format!("no person named '{}'", query),
        )
        .into()),
        many => Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!(
                "'{}' matches several ppl, use an id: {}",
                query,
                many.iter()
                    .map(|p| format!("{} ({})", p.name.trim(), p.id))
                    .collect::<Vec<_>>()
                    .join(", ")
            ),
        )
        .into()),
    }
}

pub struct RelationOps {}

impl RelationOps {
    pub async fn create(
        db: &DatabaseConnection,
        a: i32,
        b: i32,
        typ: String,
        superseded: bool,
        entered: Option<NaiveDate>,
        ended: Option<NaiveDate>,
    ) -> Result<(), PplError> {
        let p = relation::ActiveModel {
            id: Default::default(),
            ppl_id_a: Set(a),
            ppl_id_b: Set(b),
            r#type: Set(typ),
            date_entered: Set(entered),
            date_ended: Set(ended),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
            superseded: Set(superseded),
        };
        let pp = p.insert(db).await;
        match pp {
            Ok(item) => {
                warn!("relation {:?}", item.id);
                Ok(())
            }
            Err(err) => {
                warn!("relation {}", err.red());
                Err(err.into())
            }
        }
    }

    pub async fn list(db: &DatabaseConnection) -> Result<Vec<relation::Model>, DbErr> {
        Relation::find().all(db).await
    }

    pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
        Self::update(
            db,
            editable.id,
            None,
            None,
            editable.first,
            None,
            option_naive_date_from_option_string(editable.second),
            option_naive_date_from_option_string(editable.third),
        )
        .await
    }

    pub async fn update(
        db: &DatabaseConnection,
        model_id: i32,
        a: Option<i32>,
        b: Option<i32>,
        typ: String,
        superseded: Option<bool>,
        entered: Option<NaiveDate>,
        ended: Option<NaiveDate>,
    ) -> Result<(), PplError> {
        let loaded_model = Relation::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: relation::ActiveModel = instance.into();
                match a {
                    None => {}
                    Some(i) => am.ppl_id_a = Set(i),
                }
                match b {
                    None => {}
                    Some(j) => am.ppl_id_b = Set(j),
                }
                match superseded {
                    None => {}
                    Some(bool_val) => am.superseded = Set(bool_val),
                }
                match entered {
                    None => {}
                    Some(date) => am.date_entered = Set(Option::from(date)),
                }
                match ended {
                    None => {}
                    Some(date) => am.date_ended = Set(Option::from(date)),
                }
                am.r#type = Set(typ);
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }
}

pub struct SigDateOps {}

impl SigDateOps {
    pub async fn create(
        db: &DatabaseConnection,
        ppl_id: i32,
        date: NaiveDate,
        event: String,
        do_remind: bool,
    ) -> Result<(), PplError> {
        let p = sig_date::ActiveModel {
            id: Default::default(),
            ppl_id: Set(ppl_id),
            date: Set(date),
            event: Set(event),
            do_remind: Set(do_remind),
            with_ppl: Default::default(),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
        };

        let pp = p.insert(db).await;
        match pp {
            Ok(item) => {
                let (r, g, b) = colo(PURPLE.c500);
                warn!("sigDate {:?}", item.id.truecolor(r, g, b));
                Ok(())
            }
            Err(err) => {
                warn!("sigDate {}", err.red());
                Err(err.into())
            }
        }
    }

    pub async fn list(db: &DatabaseConnection) -> Result<Vec<sig_date::Model>, DbErr> {
        SigDate::find().all(db).await
    }

    pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
        Self::update(
            db,
            editable.id,
            editable.first,
            option_naive_date_from_option_string(editable.second),
            bool_from_string(editable.third),
            None,
        )
        .await
    }

    pub async fn update(
        db: &DatabaseConnection,
        id: i32,
        event: String,
        date: Option<NaiveDate>,
        do_remind: bool,
        _with_ppl: Option<String>,
    ) -> Result<(), PplError> {
        let loaded_model = SigDate::find_by_id(id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let Some(date) = date else {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidInput,
                        "date must look like YYYY-MM-DD",
                    )
                    .into());
                };
                let mut am: sig_date::ActiveModel = instance.into();

                am.event = Set(event);
                am.date = Set(date);
                am.do_remind = Set(do_remind);
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }
}

pub struct TierOps {}

impl TierOps {
    pub async fn create(
        db: &DatabaseConnection,
        ppl_id: i32,
        tier: String,
        sig_date_delta: Option<u32>,
    ) -> Result<(), PplError> {
        let t = tier::ActiveModel {
            id: Default::default(),
            ppl_id: Set(ppl_id),
            name: Set(tier.to_string()),
            color: Default::default(),
            symbol: Default::default(),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
            sig_date_delta: Set(sig_date_delta),
            sig_remind_enum: NotSet,
        };
        let tt = t.insert(db).await;
        match tt {
            Ok(item) => {
                warn!("tier {:?}", item.id);
                Ok(())
            }
            Err(err) => {
                warn!("tier {}", err.red());
                Err(err.into())
            }
        }
    }

    pub async fn list(db: &DatabaseConnection) -> Result<Vec<tier::Model>, DbErr> {
        Tier::find().all(db).await
    }

    pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
        Self::update(
            db,
            editable.id,
            editable.first,
            editable.second,
            editable.third,
        )
        .await
    }

    pub async fn update(
        db: &DatabaseConnection,
        model_id: i32,
        name: String,
        color: Option<String>,
        symbol: Option<String>,
    ) -> Result<(), PplError> {
        let loaded_model = Tier::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: tier::ActiveModel = instance.into();

                am.name = Set(name);
                am.color = Set(color);
                am.symbol = Set(symbol);
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }

    /// Per-person override of the tier default's reminder window; None falls back to the default.
    pub async fn set_sig_date_delta(
        db: &DatabaseConnection,
        model_id: i32,
        sig_date_delta: Option<u32>,
    ) -> Result<(), PplError> {
        let loaded_model = Tier::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: tier::ActiveModel = instance.into();

                am.sig_date_delta = Set(sig_date_delta);
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }

    /// Per-person override of the tier default's remind mode; None falls back to the default.
    pub async fn set_sig_remind_enum(
        db: &DatabaseConnection,
        model_id: i32,
        remind: Option<MotdRemindEnum>,
    ) -> Result<(), PplError> {
        let loaded_model = Tier::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: tier::ActiveModel = instance.into();

                am.sig_remind_enum = Set(remind.map(|r| r.to_string()));
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }
}

pub struct TraitOps {}

impl TraitOps {
    pub async fn create(
        db: &DatabaseConnection,
        ppl_id: i32,
        key: String,
        value: String,
        hidden: bool,
    ) -> Result<(), PplError> {
        let t = traits::ActiveModel {
            id: Default::default(),
            ppl_id: Set(ppl_id),
            key: Set(key.to_string()),
            value: Set(value),
            hidden: Set(hidden),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
        };
        let tt = t.insert(db).await;
        match tt {
            Ok(item) => {
                warn!("trait {:?}", item.id);
                Ok(())
            }
            Err(err) => {
                warn!("trait {}", err.red());
                Err(err.into())
            }
        }
    }
    pub async fn list(db: &DatabaseConnection) -> Result<Vec<traits::Model>, DbErr> {
        Traits::find().all(db).await
    }

    pub async fn updatee(db: &DatabaseConnection, editable: Editable) -> Result<(), PplError> {
        Self::update(
            db,
            editable.id,
            Option::from(editable.first),
            editable.second,
            bool_from_string(editable.third),
        )
        .await
    }

    pub async fn update(
        db: &DatabaseConnection,
        model_id: i32,
        key: Option<String>,
        value: Option<String>,
        hidden: bool,
    ) -> Result<(), PplError> {
        let loaded_model = Traits::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: traits::ActiveModel = instance.into();
                match key {
                    None => {}
                    Some(i) => am.key = Set(i),
                }
                match value {
                    None => {}
                    Some(j) => am.value = Set(j),
                }
                am.hidden = Set(hidden);
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }
}

/// How often the motd shows a person's upcoming dates, stored as `sig_remind_enum` on tiers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MotdRemindEnum {
    #[default]
    OnlyOnce, // Default, Only the first time the motd is run that day
    OnceHourly,   // Only once per hour, resetting at the top of the hour
    Randomly(u8), // Will show this motd person randomly u8% every run
    Always,       // Will always show this person's upcoming
    Never,        // Never show this person's upcoming
}

impl MotdRemindEnum {
    /// Next mode, for cycling through them in the TUI
    pub fn next(self) -> Self {
        match self {
            MotdRemindEnum::OnlyOnce => MotdRemindEnum::OnceHourly,
            MotdRemindEnum::OnceHourly => MotdRemindEnum::Randomly(50),
            MotdRemindEnum::Randomly(_) => MotdRemindEnum::Always,
            MotdRemindEnum::Always => MotdRemindEnum::Never,
            MotdRemindEnum::Never => MotdRemindEnum::OnlyOnce,
        }
    }

    /// Short label for lists
    pub fn label(&self) -> String {
        match self {
            MotdRemindEnum::OnlyOnce => "daily".to_string(),
            MotdRemindEnum::OnceHourly => "hourly".to_string(),
            MotdRemindEnum::Randomly(p) => format!("random {}%", p),
            MotdRemindEnum::Always => "always".to_string(),
            MotdRemindEnum::Never => "never".to_string(),
        }
    }
}

impl fmt::Display for MotdRemindEnum {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MotdRemindEnum::OnlyOnce => write!(f, "OnlyOnce"),
            MotdRemindEnum::OnceHourly => write!(f, "OnceHourly"),
            MotdRemindEnum::Randomly(p) => write!(f, "Randomly({})", p),
            MotdRemindEnum::Always => write!(f, "Always"),
            MotdRemindEnum::Never => write!(f, "Never"),
        }
    }
}

impl FromStr for MotdRemindEnum {
    type Err = String;

    /// Accepts the stored form (`OnlyOnce`, `Randomly(30)`) and short forms (`daily`, `random:30`)
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let lower = s.trim().to_lowercase();
        let percent = lower
            .strip_prefix("randomly(")
            .and_then(|r| r.strip_suffix(')'))
            .or_else(|| lower.strip_prefix("random:"))
            .or_else(|| lower.strip_prefix("random "));
        if let Some(p) = percent {
            return match p.trim().parse::<u8>() {
                Ok(p) if p <= 100 => Ok(MotdRemindEnum::Randomly(p)),
                _ => Err(format!("random percent must be 0-100, got '{}'", p)),
            };
        }
        match lower.as_str() {
            "onlyonce" | "once" | "daily" => Ok(MotdRemindEnum::OnlyOnce),
            "oncehourly" | "hourly" => Ok(MotdRemindEnum::OnceHourly),
            "always" => Ok(MotdRemindEnum::Always),
            "never" => Ok(MotdRemindEnum::Never),
            _ => Err(format!(
                "unknown remind mode '{}', expected one of: daily, hourly, random:N, always, never",
                s
            )),
        }
    }
}

/// Color names the TUI knows how to draw (see `do_init::lcolor`)
pub const COLORS: &[&str] = &[
    "red", "gold", "amber", "green", "teal", "cyan", "blue", "violet", "pink", "slate",
];

fn invalid(msg: String) -> PplError {
    io::Error::new(io::ErrorKind::InvalidInput, msg).into()
}

/// Empty is fine (no color); otherwise it has to be one of COLORS
fn check_color(color: &str) -> Result<String, PplError> {
    let color = color.trim().to_lowercase();
    if color.is_empty() || COLORS.contains(&color.as_str()) {
        Ok(color)
    } else {
        Err(invalid(format!(
            "unknown color '{}', expected one of: {}",
            color,
            COLORS.join(", ")
        )))
    }
}

/// What a trait default describes: a plain trait, a significant date, or a way to contact someone
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TraitKind {
    #[default]
    Trait,
    Date,
    Contact,
}

impl FromStr for TraitKind {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.trim().to_lowercase().as_str() {
            "" | "trait" => Ok(TraitKind::Trait),
            "date" => Ok(TraitKind::Date),
            "contact" => Ok(TraitKind::Contact),
            other => Err(format!(
                "unknown kind '{}', expected one of: trait, date, contact",
                other
            )),
        }
    }
}

/// Remove the row behind a TUI detail line. People themselves aren't deleted this way.
pub async fn delete_editable(db: &DatabaseConnection, editable: &Editable) -> Result<(), PplError> {
    match editable.tgt {
        Editablez::Ppl => return Err(invalid("people can't be removed from here".to_string())),
        Editablez::Trait => Traits::delete_by_id(editable.id).exec(db).await?,
        Editablez::Tier => Tier::delete_by_id(editable.id).exec(db).await?,
        Editablez::Contact => Contact::delete_by_id(editable.id).exec(db).await?,
        Editablez::Relation => Relation::delete_by_id(editable.id).exec(db).await?,
        Editablez::SigDate => SigDate::delete_by_id(editable.id).exec(db).await?,
    };
    Ok(())
}

pub struct CalendarOps {}

impl CalendarOps {
    /// Keys of the built-in calendars that are switched on
    pub async fn enabled(db: &DatabaseConnection) -> Result<Vec<String>, DbErr> {
        Ok(Calendar::find()
            .filter(calendar::Column::Enabled.eq(true))
            .all(db)
            .await?
            .into_iter()
            .filter_map(|c| holidays::find(&c.key).map(|d| d.key.to_string()))
            .collect())
    }

    /// Switch a calendar on or off. A state switches its country on with it, and a
    /// country switched off takes its states with it. Returns every calendar changed.
    pub async fn set(
        db: &DatabaseConnection,
        key: &str,
        on: bool,
    ) -> Result<Vec<&'static CalendarDef>, PplError> {
        let Some(def) = holidays::find(key) else {
            return Err(invalid(format!(
                "no calendar '{}', see `ppl calendars` for the list",
                key
            )));
        };
        let mut affected = vec![def];
        match on {
            true => affected.extend(def.parent.and_then(holidays::find)),
            false => affected.extend(holidays::regions(def.key)),
        }
        let enabled = Self::enabled(db).await?;
        let mut changed = vec![];
        for d in affected {
            if enabled.iter().any(|k| k == d.key) == on {
                continue;
            }
            Self::upsert(db, d.key, |row| row.enabled = Set(on)).await?;
            changed.push(d);
        }
        Ok(changed)
    }

    /// Every row: calendars that have been switched on or given reminder settings, and group defaults
    pub async fn rows(db: &DatabaseConnection) -> Result<Vec<calendar::Model>, DbErr> {
        Calendar::find().all(db).await
    }

    /// Set the motd window and mode of a calendar or group; None goes back to the default
    pub async fn set_reminder(
        db: &DatabaseConnection,
        target: ReminderTarget,
        delta: Option<u32>,
        remind: Option<MotdRemindEnum>,
    ) -> Result<(), PplError> {
        Self::upsert(db, &target.row_key(), |row| {
            row.sig_date_delta = Set(delta);
            row.sig_remind_enum = Set(remind.map(|r| r.to_string()));
        })
        .await
    }

    /// Note that the motd just showed this calendar, for the daily and hourly modes
    pub async fn mark_reminded(
        db: &DatabaseConnection,
        row: &calendar::Model,
        day: &str,
        hour: &str,
    ) -> Result<(), PplError> {
        let mut meta = match &row.meta {
            Some(serde_json::Value::Object(m)) => m.clone(),
            _ => serde_json::Map::new(),
        };
        meta.insert("last_reminded".to_string(), day.into());
        meta.insert("last_reminded_hour".to_string(), hour.into());
        Self::upsert(db, &row.key, |r| {
            r.meta = Set(Some(serde_json::Value::Object(meta)))
        })
        .await
    }

    /// Change a row, creating it (switched off) if it isn't there yet
    async fn upsert(
        db: &DatabaseConnection,
        key: &str,
        change: impl FnOnce(&mut calendar::ActiveModel),
    ) -> Result<(), PplError> {
        let today = Local::now().date_naive();
        let existing = Calendar::find()
            .filter(calendar::Column::Key.eq(key))
            .one(db)
            .await?;
        let exists = existing.is_some();
        let mut row = match existing {
            Some(row) => row.into(),
            None => calendar::ActiveModel {
                id: NotSet,
                key: Set(key.to_string()),
                enabled: Set(false),
                sig_date_delta: Set(None),
                sig_remind_enum: Set(None),
                meta: Set(None),
                date_ins: Set(today),
                date_up: Set(today),
            },
        };
        change(&mut row);
        row.date_up = Set(today);
        match exists {
            true => row.update(db).await?,
            false => row.insert(db).await?,
        };
        Ok(())
    }
}

/// What a motd reminder setting is attached to: one calendar, or a group's default
#[derive(Clone, Copy, Debug)]
pub enum ReminderTarget {
    Calendar(&'static CalendarDef),
    Group(&'static Group),
}

impl ReminderTarget {
    /// A calendar key, or a group name: countries, religious or other
    pub fn parse(s: &str) -> Result<Self, PplError> {
        if let Some(g) = holidays::GROUPS
            .iter()
            .find(|g| g.key.eq_ignore_ascii_case(s))
        {
            return Ok(ReminderTarget::Group(g));
        }
        holidays::find(s)
            .map(ReminderTarget::Calendar)
            .ok_or_else(|| {
                let groups = holidays::GROUPS.iter().map(|g| g.key).collect::<Vec<_>>();
                invalid(format!(
                    "no calendar or group '{}' (groups: {}; calendars: `ppl calendars list --all`)",
                    s,
                    groups.join(", ")
                ))
            })
    }

    fn row_key(&self) -> String {
        match self {
            ReminderTarget::Calendar(c) => c.key.to_string(),
            ReminderTarget::Group(g) => format!("group:{}", g.key),
        }
    }

    pub fn label(&self) -> &'static str {
        match self {
            ReminderTarget::Calendar(c) => c.name,
            ReminderTarget::Group(g) => g.name,
        }
    }

    /// The setting itself, and what it falls back to: a state uses its country's, then a
    /// calendar uses its group's, then holidays show on the day, once a day
    pub fn reminder(&self, rows: &[calendar::Model]) -> CalendarReminder {
        let row = |key: &str| rows.iter().find(|r| r.key == key);
        let parse = |r: &calendar::Model| {
            r.sig_remind_enum
                .as_deref()
                .and_then(|m| m.parse::<MotdRemindEnum>().ok())
        };
        let own = row(&self.row_key());
        let mut fallbacks = vec![];
        if let ReminderTarget::Calendar(c) = self {
            fallbacks.extend(c.parent.and_then(row));
            fallbacks.extend(row(&format!("group:{}", holidays::group_of(c).key)));
        }
        CalendarReminder {
            delta: own.and_then(|r| r.sig_date_delta),
            remind: own.and_then(parse),
            default_delta: fallbacks.iter().find_map(|r| r.sig_date_delta).or(Some(0)),
            default_remind: fallbacks
                .iter()
                .find_map(|r| parse(r))
                .or(Some(MotdRemindEnum::OnlyOnce)),
        }
    }
}

/// A calendar's or group's motd settings, with the defaults they fall back to
#[derive(Clone, Copy, Debug)]
pub struct CalendarReminder {
    pub delta: Option<u32>,
    pub remind: Option<MotdRemindEnum>,
    pub default_delta: Option<u32>,
    pub default_remind: Option<MotdRemindEnum>,
}

impl CalendarReminder {
    pub fn window(&self) -> u32 {
        self.delta.or(self.default_delta).unwrap_or(0)
    }
    pub fn mode(&self) -> MotdRemindEnum {
        self.remind.or(self.default_remind).unwrap_or_default()
    }
}

pub struct TierDefaultOps {}

impl TierDefaultOps {
    pub async fn create(
        db: &DatabaseConnection,
        key: String,
        default: bool,
        enabled: bool,
        color: String,
        symbol: String,
        sig_date_delta: Option<u32>,
    ) -> Result<(), PplError> {
        let t = tier_defaults::ActiveModel {
            id: Default::default(),
            key: Set(key),
            default: Set(default),
            enabled: Set(enabled),
            color: Set(Option::from(color)),
            symbol: Set(Option::from(symbol)),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
            sig_date_delta: Set(sig_date_delta),
            sig_remind_enum: NotSet,
        };
        let tt = t.insert(db).await;
        match tt {
            Ok(item) => {
                warn!("tierDefault {:?}", item.id);
                Ok(())
            }
            Err(err) => {
                warn!("tierDefault {}", err.red());
                Err(err.into())
            }
        }
    }

    pub async fn list(db: &DatabaseConnection) -> Result<Vec<tier_defaults::Model>, DbErr> {
        TierDefaults::find().all(db).await
    }

    /// Add a user-defined tier, checking the key is new and the color is known
    pub async fn add(
        db: &DatabaseConnection,
        key: &str,
        symbol: &str,
        color: &str,
        sig_date_delta: Option<u32>,
    ) -> Result<String, PplError> {
        let key = key.trim();
        if key.is_empty() {
            return Err(invalid("tier needs a name".to_string()));
        }
        if let Some(t) = Self::list(db)
            .await?
            .iter()
            .find(|t| t.key.eq_ignore_ascii_case(key))
        {
            return Err(invalid(format!("tier '{}' already exists", t.key)));
        }
        let color = check_color(color)?;
        Self::create(
            db,
            key.to_string(),
            false,
            true,
            color,
            symbol.trim().to_string(),
            sig_date_delta,
        )
        .await?;
        Ok(key.to_string())
    }

    /// Set how many days ahead the motd starts reminding for this tier's dates; None clears it.
    pub async fn set_sig_date_delta(
        db: &DatabaseConnection,
        model_id: i32,
        sig_date_delta: Option<u32>,
    ) -> Result<(), PplError> {
        let loaded_model = TierDefaults::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: tier_defaults::ActiveModel = instance.into();

                am.sig_date_delta = Set(sig_date_delta);
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }

    /// Set how often the motd shows ppl in this tier; None falls back to OnlyOnce.
    pub async fn set_sig_remind_enum(
        db: &DatabaseConnection,
        model_id: i32,
        remind: Option<MotdRemindEnum>,
    ) -> Result<(), PplError> {
        let loaded_model = TierDefaults::find_by_id(model_id).one(db).await?;
        match loaded_model {
            None => Ok(()),
            Some(instance) => {
                let mut am: tier_defaults::ActiveModel = instance.into();

                am.sig_remind_enum = Set(remind.map(|r| r.to_string()));
                am.date_up = Set(Local::now().date_naive());
                am.update(db).await?;
                Ok(())
            }
        }
    }
}

pub struct TraitDefaultOps {}

impl TraitDefaultOps {
    pub async fn create(
        db: &DatabaseConnection,
        key: String,
        color: String,
        symbol: String,
        default: bool,
        enabled: bool,
        is_date: bool,
        is_contact: bool,
    ) -> Result<(), PplError> {
        let t = trait_defaults::ActiveModel {
            id: Default::default(),
            key: Set(key.to_string()),
            default: Set(default),
            enabled: Set(enabled),
            is_date: Set(is_date),
            is_contact: Set(is_contact),
            color: Set(color),
            symbol: Set(symbol),
            date_ins: Set(Local::now().date_naive()),
            date_up: Set(Local::now().date_naive()),
        };
        let tt = t.insert(db).await;
        match tt {
            Ok(item) => {
                warn!("traitDefault {:?}", item.id);
                Ok(())
            }
            Err(err) => {
                warn!("traitDefault {}", err.red());
                Err(err.into())
            }
        }
    }
    pub async fn list(db: &DatabaseConnection) -> Result<Vec<trait_defaults::Model>, DbErr> {
        TraitDefaults::find().all(db).await
    }

    /// Add a user-defined trait, checking the key is new and the color is known
    pub async fn add(
        db: &DatabaseConnection,
        key: &str,
        symbol: &str,
        color: &str,
        kind: TraitKind,
    ) -> Result<String, PplError> {
        let key = key.trim();
        if key.is_empty() {
            return Err(invalid("trait needs a name".to_string()));
        }
        if let Some(t) = Self::list(db)
            .await?
            .iter()
            .find(|t| t.key.eq_ignore_ascii_case(key))
        {
            return Err(invalid(format!("trait '{}' already exists", t.key)));
        }
        let color = check_color(color)?;
        Self::create(
            db,
            key.to_string(),
            color,
            symbol.trim().to_string(),
            false,
            true,
            kind == TraitKind::Date,
            kind == TraitKind::Contact,
        )
        .await?;
        Ok(key.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn row(key: &str, delta: Option<u32>, remind: Option<&str>) -> calendar::Model {
        let today = NaiveDate::from_ymd_opt(2026, 10, 19).unwrap();
        calendar::Model {
            id: 0,
            key: key.to_string(),
            enabled: true,
            sig_date_delta: delta,
            sig_remind_enum: remind.map(|r| r.to_string()),
            meta: None,
            date_ins: today,
            date_up: today,
        }
    }

    #[test]
    fn calendar_reminders_fall_back() {
        let rows = vec![
            row("group:religious", Some(7), None),
            row("us", Some(3), None),
            row("us-ny", None, Some("OnceHourly")),
        ];
        let get = |t: &str| ReminderTarget::parse(t).unwrap().reminder(&rows);
        // A state takes its country's window, and keeps its own mode
        let ny = get("us-ny");
        assert_eq!((ny.window(), ny.mode()), (3, MotdRemindEnum::OnceHourly));
        assert_eq!(ny.delta, None);
        let jewish = get("jewish");
        assert_eq!(
            (jewish.window(), jewish.mode()),
            (7, MotdRemindEnum::OnlyOnce)
        );
        // With nothing set: on the day, once a day
        let lunar = get("lunar");
        assert_eq!(
            (lunar.window(), lunar.mode()),
            (0, MotdRemindEnum::OnlyOnce)
        );
        assert!(ReminderTarget::parse("nope").is_err());
        assert!(matches!(
            ReminderTarget::parse("Religious"),
            Ok(ReminderTarget::Group(_))
        ));
    }
}