From 2d80413609130f1c121dcae39a150a27dd9f02ea Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 26 Feb 2022 18:35:24 +0100 Subject: Show repeated events --- README.md | 13 +++- flake.nix | 5 +- src/app/app.rs | 21 +++++- src/app/calendar.rs | 44 ++++++++----- src/app/form/mod.rs | 4 +- src/app/form/repetition.rs | 34 +++++----- src/app/update.rs | 91 +++++++++++++++++++------- src/db/migrations/1-init.sql | 16 ++--- src/db/mod.rs | 42 +++++++++++- src/model/event.rs | 20 ++++++ src/model/repetition.rs | 152 +++++++++++++++++++++++++++++++++++++++++-- 11 files changed, 361 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index e2517ad..ce0c140 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,17 @@ nix develop --command cargo run - gtk4-rs documentation: https://gtk-rs.org/gtk4-rs/stable/latest/docs/gtk4/ - Event-Driven GTK by Example — 2021 Edition: https://mmstick.github.io/gtkrs-tutorials/ +# Tests + +cargo test + # TODO ## V1 ### Repeat events -1. Show repeated events. +1. Run `cargo clippy --all-targets -- -D warnings` 2. Update / delete specific repetition occurences. 3. When validating repetition, don’t produce None if there is a validation error. @@ -31,6 +35,10 @@ nix develop --command cargo run ## V2 +### Optimizations + +- Optimize refresh + ### Categorize events 1. CRUD for list of types (name + color). @@ -46,6 +54,5 @@ nix develop --command cargo run - Apply a style on times in the calendar (bold ?). - Print errors on forms when validating. - Validate the form when pressing enter on any field. -- Remove event with right click. - Select the default focus with a button or a shortcut. -- Specify until which date a repeted event is +- Specify until which date a repeated event is diff --git a/flake.nix b/flake.nix index 4984bce..44e38e6 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,10 @@ { devShell = mkShell { buildInputs = [ - rust-bin.stable."1.58.0".default + (rust-bin.stable."1.58.0".default.override { + extensions = [ "rust-src" ]; + }) + rust-analyzer cargo-watch pkgconfig graphene diff --git a/src/app/app.rs b/src/app/app.rs index b1ee395..58240af 100644 --- a/src/app/app.rs +++ b/src/app/app.rs @@ -1,7 +1,7 @@ use gtk4 as gtk; use async_channel::Sender; -use chrono::{Datelike, NaiveDate, Weekday}; +use chrono::{Datelike, Duration, NaiveDate, Weekday}; use gtk::glib::signal::Inhibit; use gtk::prelude::*; use rusqlite::Connection; @@ -16,8 +16,10 @@ pub struct App { pub window: Rc, pub grid: gtk::Grid, pub events: Vec, + pub repeated_events: Vec, pub today: NaiveDate, pub start_date: NaiveDate, + pub end_date: NaiveDate, pub tx: Sender, } @@ -36,8 +38,19 @@ impl App { let today = chrono::offset::Local::today().naive_utc(); let start_date = NaiveDate::from_isoywd(today.year(), today.iso_week().week(), Weekday::Mon); - let events = db::list(&conn).unwrap_or(vec![]); - let grid = calendar::create(tx.clone(), &today, &start_date, &events); + let end_date = start_date + Duration::days(7 * 4 - 1); + + let events = db::list_non_repeated_between(&conn, start_date, end_date).unwrap_or(vec![]); + let repeated_events = db::list_repeated(&conn).unwrap_or(vec![]); + + let grid = calendar::create( + tx.clone(), + today, + start_date, + end_date, + &events, + &repeated_events, + ); window.set_child(Some(&grid)); @@ -53,8 +66,10 @@ impl App { window, grid, events, + repeated_events, today, start_date, + end_date, tx, } } diff --git a/src/app/calendar.rs b/src/app/calendar.rs index fa4ebe6..11eb893 100644 --- a/src/app/calendar.rs +++ b/src/app/calendar.rs @@ -4,8 +4,9 @@ use async_channel::Sender; use chrono::{Datelike, NaiveDate}; use gtk::glib; use gtk::prelude::*; +use std::collections::HashMap; -use crate::{app::update, app::update::Msg, app::App, model::event::Event}; +use crate::{app::update, app::update::Msg, app::App, model::event, model::event::Event}; static DAYS: [&str; 7] = ["LUN", "MAR", "MER", "JEU", "VEN", "SAM", "DIM"]; static MONTHES: [&str; 12] = [ @@ -14,9 +15,11 @@ static MONTHES: [&str; 12] = [ pub fn create( tx: Sender, - today: &NaiveDate, - start_date: &NaiveDate, + today: NaiveDate, + start_date: NaiveDate, + end_date: NaiveDate, events: &Vec, + repeated_events: &Vec, ) -> gtk::Grid { let grid = gtk::Grid::builder().build(); @@ -24,7 +27,8 @@ pub fn create( grid.attach(&day_title(col), col, 0, 1, 1); } - attach_days(tx, &grid, &start_date, &today, &events); + let repetitions = event::repetitions_between(repeated_events, start_date, end_date); + attach_days(tx, &grid, start_date, today, events, &repetitions); grid } @@ -32,27 +36,34 @@ pub fn create( fn attach_days( tx: Sender, grid: >k::Grid, - start_date: &NaiveDate, - today: &NaiveDate, + start_date: NaiveDate, + today: NaiveDate, events: &Vec, + repetitions: &HashMap>, ) { - let mut d = *start_date; + let mut d = start_date; for row in 1..5 { for col in 0..7 { - grid.attach(&day_entry(tx.clone(), &d, &today, &events), col, row, 1, 1); + grid.attach( + &day_entry(tx.clone(), d, today, events, repetitions), + col, + row, + 1, + 1, + ); d = d.succ(); } } } -pub fn refresh_date(app: &App, date: NaiveDate) { +pub fn refresh_date(app: &App, date: NaiveDate, repetitions: &HashMap>) { let d = date.signed_duration_since(app.start_date).num_days(); let col = (d % 7) as i32; let row = 1 + (d / 7) as i32; app.grid.attach( - &day_entry(app.tx.clone(), &date, &app.today, &app.events), + &day_entry(app.tx.clone(), date, app.today, &app.events, repetitions), col, row, 1, @@ -76,9 +87,10 @@ fn day_title(col: i32) -> gtk::Box { pub fn day_entry( tx: Sender, - date: &NaiveDate, - today: &NaiveDate, + date: NaiveDate, + today: NaiveDate, events: &Vec, + repetitions: &HashMap>, ) -> gtk::ScrolledWindow { let vbox = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -87,7 +99,7 @@ pub fn day_entry( vbox.add_css_class("g-Calendar__Day"); let gesture = gtk::GestureClick::new(); - gesture.connect_pressed(glib::clone!(@strong date, @strong tx => move |_, n, _, _| { + gesture.connect_pressed(glib::clone!(@strong tx => move |_, n, _, _| { if n == 2 { update::send(tx.clone(), Msg::ShowAddForm { date }); } @@ -102,8 +114,10 @@ pub fn day_entry( let mut events = events .iter() - .filter(|e| e.date == *date) + .filter(|e| e.date == date) .collect::>(); + let repeated_events = repetitions.get(&date).map(|e| e.clone()).unwrap_or(vec![]); + events.extend(repeated_events.iter()); events.sort_by_key(|e| e.start); if !events.is_empty() { @@ -120,7 +134,7 @@ pub fn day_entry( scrolled_window } -fn day_label(date: &NaiveDate) -> gtk::Label { +fn day_label(date: NaiveDate) -> gtk::Label { let label = gtk::Label::builder() .label(&format!( "{} {}", diff --git a/src/app/form/mod.rs b/src/app/form/mod.rs index 5c60bc5..9cb6ba7 100644 --- a/src/app/form/mod.rs +++ b/src/app/form/mod.rs @@ -85,10 +85,10 @@ pub async fn show(app: &App, event: Event, is_new: bool) { update::send(tx.clone(), msg); dialog.close() }, - Err(err) => println!("Error when upserting event: {err}") + Err(err) => eprintln!("Error when upserting event: {err}") } }, - None => println!("Event is not valid: {event:?}") + None => eprintln!("Event is not valid: {event:?}") } })); diff --git a/src/app/form/repetition.rs b/src/app/form/repetition.rs index ac56479..87c8d84 100644 --- a/src/app/form/repetition.rs +++ b/src/app/form/repetition.rs @@ -7,7 +7,7 @@ use crate::{ model::event::Event, model::{ repetition, - repetition::{MonthFrequency, Repetition}, + repetition::{DayOfMonth, Repetition}, }, }; @@ -19,14 +19,14 @@ static WEEKDAYS: [Weekday; 7] = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]; pub struct Model { pub view: gtk::Box, - pub no_radio: gtk::CheckButton, - pub day_interval_radio: gtk::CheckButton, - pub day_interval_entry: gtk::Entry, - pub monthly_radio: gtk::CheckButton, - pub monthly_entry: gtk::Entry, - pub first_day_radio: gtk::CheckButton, - pub first_day_dropdown: gtk::DropDown, - pub yearly_radio: gtk::CheckButton, + no_radio: gtk::CheckButton, + day_interval_radio: gtk::CheckButton, + day_interval_entry: gtk::Entry, + monthly_radio: gtk::CheckButton, + monthly_entry: gtk::Entry, + first_day_radio: gtk::CheckButton, + first_day_dropdown: gtk::DropDown, + yearly_radio: gtk::CheckButton, } pub fn view(event: &Event) -> Model { @@ -44,7 +44,7 @@ pub fn view(event: &Event) -> Model { view.append(&no_radio); let default = match event.repetition { - Some(Repetition::Daily { frequency }) => frequency.to_string(), + Some(Repetition::Daily { period }) => period.to_string(), _ => "".to_string(), }; let day_interval_entry = gtk::Entry::builder().text(&default).build(); @@ -58,7 +58,7 @@ pub fn view(event: &Event) -> Model { let default = match event.repetition { Some(Repetition::Monthly { - frequency: MonthFrequency::Day { day }, + day: DayOfMonth::Day { day }, }) => day.to_string(), _ => "".to_string(), }; @@ -69,8 +69,8 @@ pub fn view(event: &Event) -> Model { let (active, default) = match event.repetition { Some(Repetition::Monthly { - frequency: MonthFrequency::FirstDay { day }, - }) => (true, day), + day: DayOfMonth::Weekday { weekday }, + }) => (true, weekday), _ => (false, Mon), }; let first_day_dropdown = gtk::DropDown::from_strings(&WEEKDAYS_STR); @@ -131,17 +131,17 @@ pub fn validate(model: &Model) -> Option { None } else if model.day_interval_radio.is_active() { repetition::validate_day(&model.day_interval_entry.buffer().text()) - .map(|d| Repetition::Daily { frequency: d }) + .map(|d| Repetition::Daily { period: d }) } else if model.monthly_radio.is_active() { repetition::validate_day(&model.monthly_entry.buffer().text()).map(|d| { Repetition::Monthly { - frequency: MonthFrequency::Day { day: d }, + day: DayOfMonth::Day { day: d }, } }) } else if model.first_day_radio.is_active() { - let day = WEEKDAYS[model.first_day_dropdown.selected() as usize]; + let weekday = WEEKDAYS[model.first_day_dropdown.selected() as usize]; Some(Repetition::Monthly { - frequency: MonthFrequency::FirstDay { day }, + day: DayOfMonth::Weekday { weekday }, }) } else if model.yearly_radio.is_active() { Some(Repetition::Yearly) diff --git a/src/app/update.rs b/src/app/update.rs index 4e21050..4ef1eb1 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1,5 +1,6 @@ use async_channel::{Receiver, Sender}; use chrono::NaiveDate; +use std::collections::HashSet; use crate::{ app::{calendar, form, utils, App}, @@ -30,31 +31,75 @@ pub async fn event_handler(rx: Receiver, mut app: App) { form::show(&app, event, false).await; } Msg::AddEvent { new } => { - let date = new.date.clone(); - app.events.push(new); - calendar::refresh_date(&app, date); + let refresh_dates = add(&mut app, &new); + refresh(&app, &refresh_dates) } Msg::UpdateEvent { old, new } => { - let new_date = new.date.clone(); - match app.events.iter().position(|e| e.id == new.id) { - Some(index) => { - app.events.remove(index); - app.events.push(new); - calendar::refresh_date(&app, new_date); - if old.date != new_date { - calendar::refresh_date(&app, old.date.clone()) - } - } - None => println!("Event not found when updating from {:?} to {:?}", old, new), - } - } - Msg::DeleteEvent { event } => match app.events.iter().position(|e| e.id == event.id) { - Some(index) => { - app.events.remove(index); - calendar::refresh_date(&app, event.date); - } - None => println!("Event not found when trying to delete {:?}", event), - }, + let refresh_dates_1 = remove(&mut app, &old); + let refresh_dates_2 = add(&mut app, &new); + refresh(&app, &refresh_dates_1.union(&refresh_dates_2).map(|d| *d).collect::>()) + } + Msg::DeleteEvent { event } => { + let refresh_dates = remove(&mut app, &event); + refresh(&app, &refresh_dates) + } + } + } +} + +/// Remove event and return dates that should be refreshed. +fn remove(app: &mut App, event: &Event) -> HashSet { + if event.repetition.is_some() { + match app.repeated_events.iter().position(|e| e.id == event.id) { + Some(index) => { + app.repeated_events.remove(index); + let mut dates = repetition_dates(&app, event); + dates.insert(event.date); + dates + } + None => { + eprintln!("Event not found when trying to delete {:?}", event); + HashSet::new() + } + } + } else { + match app.events.iter().position(|e| e.id == event.id) { + Some(index) => { + app.events.remove(index); + HashSet::from([event.date]) + } + None => { + eprintln!("Event not found when trying to delete {:?}", event); + HashSet::new() + } } } } + +/// Add event and return dates that should be refreshed. +fn add(app: &mut App, event: &Event) -> HashSet { + if event.repetition.is_some() { + app.repeated_events.push(event.clone()); + let mut dates = repetition_dates(&app, event); + dates.insert(event.date); + dates + } else { + app.events.push(event.clone()); + HashSet::from([event.date]) + } +} + +/// Repetition dates of a repetead event. +fn repetition_dates(app: &App, event: &Event) -> HashSet { + let event_reps = event::repetitions_between(&vec!(event.clone()), app.start_date, app.end_date); + HashSet::from_iter(event_reps.keys().map(|d| *d)) +} + +/// Refresh app for the given dates. +fn refresh(app: &App, dates: &HashSet) { + let repetitions = event::repetitions_between(&app.repeated_events, app.start_date, app.end_date); + + for date in dates { + calendar::refresh_date(app, *date, &repetitions) + } +} diff --git a/src/db/migrations/1-init.sql b/src/db/migrations/1-init.sql index a7db8b8..7e49764 100644 --- a/src/db/migrations/1-init.sql +++ b/src/db/migrations/1-init.sql @@ -1,10 +1,10 @@ CREATE TABLE IF NOT EXISTS "events" ( - "id" VARCHAR PRIMARY KEY, - "date" VARCHAR NOT NULL, - "start" VARCHAR NULL, - "end" VARCHAR NULL, - "name" VARCHAR NOT NULL, - "repetition" VARCHAR NULL, - "created" TIMESTAMP NOT NULL, - "updated" TIMESTAMP NOT NULL + "id" TEXT PRIMARY KEY, /* UUID */ + "date" TEXT NOT NULL, /* DATE */ + "start" TEXT NULL, /* TIME */ + "end" TEXT NULL, /* TIME */ + "name" TEXT NOT NULL, + "repetition" TEXT NULL, /* JSON */ + "created" TEXT NOT NULL, /* DATETIME */ + "updated" TEXT NOT NULL /* DATETIME */ ); diff --git a/src/db/mod.rs b/src/db/mod.rs index 2cac0d2..b27226b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use chrono::NaiveDate; use rusqlite::{params, Connection}; use rusqlite_migration::{Migrations, M}; use uuid::Uuid; @@ -50,8 +51,13 @@ pub fn delete(conn: &Connection, id: &Uuid) -> Result<()> { } // TODO: Don’t use unwrap -pub fn list(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT id, date, start, end, name, repeated FROM events")?; +pub fn list_repeated(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + " + SELECT id, date, start, end, name, repetition + FROM events + WHERE repetition IS NOT NULL", + )?; let iter = stmt.query_map([], |row| { let uuid: String = row.get(0)?; @@ -68,3 +74,35 @@ pub fn list(conn: &Connection) -> Result> { Ok(iter.map(|r| r.unwrap()).collect()) } + +// TODO: Don’t use unwrap +pub fn list_non_repeated_between( + conn: &Connection, + start: NaiveDate, + end: NaiveDate, +) -> Result> { + let mut stmt = conn.prepare( + " + SELECT id, date, start, end, name + FROM events + WHERE + repetition IS NULL + AND date >= ? + AND date <= ? + ", + )?; + + let iter = stmt.query_map([start, end], |row| { + let uuid: String = row.get(0)?; + Ok(Event { + id: Uuid::parse_str(&uuid).unwrap(), + date: row.get(1)?, + start: row.get(2)?, + end: row.get(3)?, + name: row.get(4)?, + repetition: None, + }) + })?; + + Ok(iter.map(|r| r.unwrap()).collect()) +} diff --git a/src/model/event.rs b/src/model/event.rs index 3765fec..b18d811 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -1,5 +1,6 @@ use chrono::Timelike; use chrono::{NaiveDate, NaiveTime}; +use std::collections::HashMap; use uuid::Uuid; use crate::model::repetition::Repetition; @@ -43,6 +44,25 @@ impl Event { } } +/// Repeated events in an included date range +pub fn repetitions_between( + events: &Vec, + start: NaiveDate, + end: NaiveDate, +) -> HashMap> { + let mut res: HashMap> = HashMap::new(); + + for event in events { + for repetition in event.repetition.as_ref() { + for date in repetition.between(event.date, start, end) { + res.entry(date).or_insert(vec![]).push(event.clone()) + } + } + } + + res +} + pub fn pprint_time(t: NaiveTime) -> String { if t.minute() == 0 { format!("{}h", t.hour()) diff --git a/src/model/repetition.rs b/src/model/repetition.rs index 80387d9..ceb903b 100644 --- a/src/model/repetition.rs +++ b/src/model/repetition.rs @@ -1,21 +1,19 @@ -use chrono::Weekday; +use chrono::{Datelike, Duration, NaiveDate, Weekday}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Repetition { - Daily { frequency: u8 }, - Monthly { frequency: MonthFrequency }, + Daily { period: u8 }, + Monthly { day: DayOfMonth }, Yearly, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum MonthFrequency { +pub enum DayOfMonth { Day { day: u8 }, - FirstDay { day: Weekday }, + Weekday { weekday: Weekday }, } -// Validation - pub fn validate_day(str: &str) -> Option { let n = str.parse::().ok()?; if n >= 1 && n <= 31 { @@ -24,3 +22,143 @@ pub fn validate_day(str: &str) -> Option { None } } + +impl Repetition { + pub fn between(&self, event: NaiveDate, start: NaiveDate, end: NaiveDate) -> Vec { + let repeat = |mut date, next: Box NaiveDate>| { + let mut repetitions = vec![]; + while date <= end { + if date >= event && date >= start { + repetitions.push(date) + } + date = next(date) + } + repetitions + }; + + match self { + Repetition::Daily { period } => { + let n = start.signed_duration_since(event).num_days() % (*period as i64); + let duration = Duration::days(*period as i64); + repeat(start - Duration::days(n), Box::new(|d| d + duration)) + } + Repetition::Monthly { + day: DayOfMonth::Day { day }, + } => match start.with_day(*day as u32) { + Some(first_repetition) => repeat(first_repetition, Box::new(next_month)), + None => vec![], + }, + Repetition::Monthly { + day: DayOfMonth::Weekday { weekday }, + } => repeat( + first_weekday_of_month(start, *weekday), + Box::new(|d| first_weekday_of_month(next_month(d), *weekday)), + ), + Repetition::Yearly => repeat( + NaiveDate::from_ymd(start.year(), event.month(), event.day()), + Box::new(|d| NaiveDate::from_ymd(d.year() + 1, d.month(), d.day())), + ), + } + } +} + +fn first_weekday_of_month(date: NaiveDate, weekday: Weekday) -> NaiveDate { + NaiveDate::from_weekday_of_month(date.year(), date.month(), weekday, 1) +} + +fn next_month(date: NaiveDate) -> NaiveDate { + if date.month() == 12 { + NaiveDate::from_ymd(date.year() + 1, 1, date.day()) + } else { + NaiveDate::from_ymd(date.year(), date.month() + 1, date.day()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_day_event_before() { + let repetition = Repetition::Daily { period: 1 }; + assert_eq!( + repetition.between(d(2022, 6, 1), d(2022, 7, 1), d(2022, 8, 31)), + d(2022, 7, 1) + .iter_days() + .take(62) + .collect::>() + ) + } + + #[test] + fn every_day_event_between() { + let repetition = Repetition::Daily { period: 1 }; + assert_eq!( + repetition.between(d(2022, 8, 10), d(2022, 7, 1), d(2022, 8, 31)), + d(2022, 8, 10) + .iter_days() + .take(22) + .collect::>() + ) + } + + #[test] + fn every_day_event_after() { + let repetition = Repetition::Daily { period: 1 }; + assert!(repetition + .between(d(2022, 9, 1), d(2022, 7, 1), d(2022, 8, 31)) + .is_empty()) + } + + #[test] + fn every_three_days() { + let repetition = Repetition::Daily { period: 3 }; + assert_eq!( + repetition.between(d(2022, 2, 16), d(2022, 2, 21), d(2022, 3, 6)), + vec!( + d(2022, 2, 22), + d(2022, 2, 25), + d(2022, 2, 28), + d(2022, 3, 3), + d(2022, 3, 6) + ) + ) + } + + #[test] + fn day_of_month() { + let repetition = Repetition::Monthly { + day: DayOfMonth::Day { day: 8 }, + }; + assert_eq!( + repetition.between(d(2022, 2, 7), d(2022, 1, 1), d(2022, 4, 7)), + vec!(d(2022, 2, 8), d(2022, 3, 8)) + ) + } + + #[test] + fn weekday_of_month() { + let repetition = Repetition::Monthly { + day: DayOfMonth::Weekday { + weekday: Weekday::Tue, + }, + }; + assert_eq!( + repetition.between(d(2022, 1, 5), d(2022, 1, 1), d(2022, 4, 4)), + vec!(d(2022, 2, 1), d(2022, 3, 1)) + ) + } + + #[test] + fn yearly() { + let repetition = Repetition::Yearly; + assert_eq!( + repetition.between(d(2020, 5, 5), d(2018, 1, 1), d(2022, 5, 5)), + vec!(d(2020, 5, 5), d(2021, 5, 5), d(2022, 5, 5)) + ) + } + + fn d(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd(y, m, d) + } +} -- cgit v1.2.3