8.3 KiB · text History d31eadb
//! Notes and todos on one occurrence of a date: this year's Thanksgiving, next Tuesday's
//! 1:1, Anji's birthday on the 28th

use crate::data::{anniversary_in, CalendarOps};
use crate::entities::prelude::{Note, Ppl, SigDate};
use crate::entities::{note, ppl, sig_date};
use crate::holidays::{self, Holiday};
use crate::PplError;
use chrono::{Datelike, Local, NaiveDate};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, DatabaseConnection, DbErr, EntityTrait, NotSet};
use std::io;

#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
    Note,
    Todo,
}

impl Kind {
    fn as_str(self) -> &'static str {
        match self {
            Kind::Note => "note",
            Kind::Todo => "todo",
        }
    }
}

/// What a note hangs on
#[derive(Clone, Debug, PartialEq)]
pub enum Target {
    Holiday { calendar: String, event: String },
    Person { sig_date_id: i32 },
}

/// One occurrence of a holiday, iCal event or person's date
#[derive(Clone, Debug)]
pub struct Occurrence {
    pub date: NaiveDate,
    pub target: Target,
    /// "Thanksgiving (us)" or "birthday · Anji"
    pub title: String,
}

impl Occurrence {
    pub fn of_holiday(h: &Holiday) -> Self {
        Occurrence {
            date: h.date,
            target: Target::Holiday {
                calendar: h.calendar.key.to_string(),
                event: h.name.clone(),
            },
            title: format!("{} ({})", h.name, h.calendar.key),
        }
    }

    pub fn of_person(date: &sig_date::Model, on: NaiveDate, person: &ppl::Model) -> Self {
        Occurrence {
            date: on,
            target: Target::Person {
                sig_date_id: date.id,
            },
            title: format!(
                "{} · {}",
                date.event,
                person.nick.as_deref().unwrap_or(person.name.trim())
            ),
        }
    }

    fn matches(&self, n: &note::Model) -> bool {
        n.date == self.date
            && match &self.target {
                Target::Holiday { calendar, event } => {
                    n.calendar.as_deref() == Some(calendar) && n.event.as_deref() == Some(event)
                }
                Target::Person { sig_date_id } => n.sig_date_id == Some(*sig_date_id),
            }
    }

    /// Its notes, then its todos, oldest first
    pub fn notes<'a>(&self, all: &'a [note::Model]) -> Vec<&'a note::Model> {
        let mut mine = all.iter().filter(|n| self.matches(n)).collect::<Vec<_>>();
        mine.sort_by_key(|n| (n.kind != "note", n.id));
        mine
    }
}

/// "📝 bring pie", "☐ buy a card", "✓ book a table"
pub fn line(n: &note::Model) -> String {
    match (n.kind.as_str(), n.done) {
        ("todo", true) => format!("✓ {}", n.text),
        ("todo", false) => format!("☐ {}", n.text),
        _ => format!("📝 {}", n.text),
    }
}

/// A short mark for an upcoming row with notes: open todos count, else 📝
pub fn badge(notes: &[&note::Model]) -> Option<String> {
    let open = notes.iter().filter(|n| n.kind == "todo" && !n.done).count();
    match (open, notes.is_empty()) {
        (0, true) => None,
        (0, false) => Some("📝".to_string()),
        (n, _) => Some(format!("☐{}", n)),
    }
}

pub struct NoteOps {}

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

    pub async fn add(
        db: &DatabaseConnection,
        occ: &Occurrence,
        kind: Kind,
        text: &str,
    ) -> Result<note::Model, PplError> {
        let text = text.trim();
        if text.is_empty() {
            return Err(
                io::Error::new(io::ErrorKind::InvalidInput, "a note needs some text").into(),
            );
        }
        let today = Local::now().date_naive();
        let (calendar, event, sig_date_id) = match &occ.target {
            Target::Holiday { calendar, event } => {
                (Some(calendar.clone()), Some(event.clone()), None)
            }
            Target::Person { sig_date_id } => (None, None, Some(*sig_date_id)),
        };
        Ok(note::ActiveModel {
            id: NotSet,
            kind: Set(kind.as_str().to_string()),
            text: Set(text.to_string()),
            done: Set(false),
            date: Set(occ.date),
            calendar: Set(calendar),
            event: Set(event),
            sig_date_id: Set(sig_date_id),
            date_ins: Set(today),
            date_up: Set(today),
        }
        .insert(db)
        .await?)
    }

    /// Tick a todo, or untick it
    pub async fn toggle(db: &DatabaseConnection, id: i32) -> Result<note::Model, PplError> {
        let Some(n) = Note::find_by_id(id).one(db).await? else {
            return Err(not_found(id));
        };
        let done = !n.done;
        let mut am: note::ActiveModel = n.into();
        am.done = Set(done);
        am.date_up = Set(Local::now().date_naive());
        Ok(am.update(db).await?)
    }

    pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<(), PplError> {
        match Note::delete_by_id(id).exec(db).await?.rows_affected {
            0 => Err(not_found(id)),
            _ => Ok(()),
        }
    }
}

fn not_found(id: i32) -> PplError {
    io::Error::new(
        io::ErrorKind::NotFound,
        format!("no note {}; see `ppl notes`", id),
    )
    .into()
}

/// Every occurrence from `from` through `days` days on: people's dates and the holidays
/// of the calendars that are on
pub async fn upcoming(
    db: &DatabaseConnection,
    from: NaiveDate,
    days: i64,
) -> Result<Vec<Occurrence>, PplError> {
    let to = from + chrono::Duration::days(days);
    let ppl = Ppl::find().all(db).await?;
    let dates = SigDate::find().all(db).await?;
    crate::ical::refresh_stale(&CalendarOps::subscriptions(db).await?);
    let enabled = CalendarOps::enabled(db).await?;

    let mut out = vec![];
    for year in from.year()..=to.year() {
        for d in &dates {
            let (Some(on), Some(p)) = (
                anniversary_in(d.date, year),
                ppl.iter().find(|p| p.id == d.ppl_id),
            ) else {
                continue;
            };
            out.push(Occurrence::of_person(d, on, p));
        }
        out.extend(
            holidays::holidays_in(year, &enabled)
                .iter()
                .map(Occurrence::of_holiday),
        );
    }
    out.retain(|o| o.date >= from && o.date <= to);
    out.sort_by_key(|o| o.date);
    Ok(out)
}

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

    fn n(
        id: i32,
        kind: &str,
        done: bool,
        date: NaiveDate,
        cal: Option<&str>,
        sig: Option<i32>,
    ) -> note::Model {
        note::Model {
            id,
            kind: kind.to_string(),
            text: format!("text {}", id),
            done,
            date,
            calendar: cal.map(|c| c.to_string()),
            event: cal.map(|_| "Thanksgiving".to_string()),
            sig_date_id: sig,
            date_ins: date,
            date_up: date,
        }
    }

    #[test]
    fn notes_find_their_occurrence() {
        let day = NaiveDate::from_ymd_opt(2026, 11, 26).unwrap();
        let next = NaiveDate::from_ymd_opt(2027, 11, 25).unwrap();
        let all = vec![
            n(1, "todo", false, day, Some("us"), None),
            n(2, "note", false, day, Some("us"), None),
            n(3, "note", false, next, Some("us"), None),
            n(4, "note", false, day, Some("ca"), None),
            n(5, "todo", true, day, None, Some(7)),
        ];
        let thanksgiving = Occurrence {
            date: day,
            target: Target::Holiday {
                calendar: "us".to_string(),
                event: "Thanksgiving".to_string(),
            },
            title: String::new(),
        };
        // Only this year's, in this calendar; notes before todos
        let ids = thanksgiving
            .notes(&all)
            .iter()
            .map(|n| n.id)
            .collect::<Vec<_>>();
        assert_eq!(ids, vec![2, 1]);
        assert_eq!(badge(&thanksgiving.notes(&all)), Some("☐1".to_string()));
        let birthday = Occurrence {
            date: day,
            target: Target::Person { sig_date_id: 7 },
            title: String::new(),
        };
        assert_eq!(badge(&birthday.notes(&all)), Some("📝".to_string()));
        assert_eq!(line(&all[4]), "✓ text 5");
    }
}