From 35cc74578e969bae4812afd2ff041eba3746142d Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 19 Mar 2022 22:03:58 +0100 Subject: Allow to repeat an event until a specific date Also provide a shortcut to modify a repetead event from a specific occurence. This set the end repetition date under the hood and create a new repeated event. --- README.md | 22 ++++----- src/gui/app.rs | 4 +- src/gui/calendar.rs | 4 +- src/gui/form/mod.rs | 115 +++++++++++++++++++++++++++++++-------------- src/gui/form/repetition.rs | 40 ++++++++++++++-- src/gui/form/utils.rs | 21 +++++++++ src/gui/update.rs | 83 ++++++++++++++++++++++++++++---- src/main.rs | 1 + src/model/event.rs | 52 +++----------------- src/model/mod.rs | 1 + src/model/repetition.rs | 21 +++++++++ src/model/time.rs | 22 +++++++++ src/validation/mod.rs | 21 +++++++++ 13 files changed, 299 insertions(+), 108 deletions(-) create mode 100644 src/gui/form/utils.rs create mode 100644 src/model/time.rs create mode 100644 src/validation/mod.rs diff --git a/README.md b/README.md index 567d487..de17573 100644 --- a/README.md +++ b/README.md @@ -16,30 +16,28 @@ cargo test # TODO -## V2 +## Description -### Optimizations +- Add description textarea field -- Optimize refresh - -### Recurring +## Optimizations -- Add end repetition date for a recurring event -- Give the possibility to update only future events when modifying recurring event +- Optimize refresh -### Categorize events +## Categorize events 1. CRUD for list of types (name + color). 2. Show / hide depending on the type. -### Nice to have +## Multi day events + +- Try width parameter using grid.attach + +## Nice to have - Drag & drop events. -- Show an indicator when a day can be scrolled vertically. -- Multi day events. - Prevent to launch multiple instances. - Show a date picker in dialog form. -- Apply a style on times in the calendar (bold ?). - Print errors on forms when validating. - Validate the form when pressing enter on any field. - Select the default focus with a button or a shortcut. diff --git a/src/gui/app.rs b/src/gui/app.rs index 9f37301..8cd7096 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -16,8 +16,8 @@ pub struct App { pub conn: Rc, pub window: Rc, pub grid: gtk::Grid, - pub events: Vec, - pub recurring_events: Vec, + pub events: Vec, // TODO: use Hashmap to have fast access to events by id ? + pub recurring_events: Vec, // TODO: use Hashmap to have fast access to events by id ? pub today: NaiveDate, pub start_date: NaiveDate, pub end_date: NaiveDate, diff --git a/src/gui/calendar.rs b/src/gui/calendar.rs index cad2465..547c087 100644 --- a/src/gui/calendar.rs +++ b/src/gui/calendar.rs @@ -181,9 +181,9 @@ fn day_events(date: NaiveDate, tx: Sender, events: Vec<&Event>) -> gtk::Box gesture.set_state(gtk::EventSequenceState::Claimed); if n == 2 { if event.repetition.is_some() { - update::send(tx.clone(), Msg::ShowRepetitionDialog { date, event: event.clone() }); + update::send(tx.clone(), Msg::ShowRepetitionDialog { date, event_id: event.id }); } else { - update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + update::send(tx.clone(), Msg::ShowUpdateForm { event_id: event.id }); } } }), diff --git a/src/gui/form/mod.rs b/src/gui/form/mod.rs index 68e6539..bb43ef5 100644 --- a/src/gui/form/mod.rs +++ b/src/gui/form/mod.rs @@ -1,22 +1,24 @@ mod repetition; +mod utils; use gtk4 as gtk; use anyhow::Result; -use chrono::{NaiveDate, NaiveTime}; +use chrono::{Duration, NaiveDate}; use gtk::glib; use gtk::prelude::*; use rusqlite::Connection; +use std::collections::HashSet; use thiserror::Error; use uuid::Uuid; use crate::{ db, gui::{update, update::Msg, App}, - model::{event, event::Event}, + model::{event, event::Event, repetition::Repetition}, }; -pub async fn repetition_dialog(app: &App, date: NaiveDate, event: Event) { +pub async fn repetition_dialog(app: &App, date: NaiveDate, event: &Event) { let dialog = gtk::Dialog::builder() .transient_for(&*app.window) .modal(true) @@ -38,17 +40,28 @@ pub async fn repetition_dialog(app: &App, date: NaiveDate, event: Event) { lines.append(&button); let tx = app.tx.clone(); button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { - update::send(tx.clone(), Msg::ShowUpdateRepetitionForm { date, event: event.clone() }); + update::send(tx.clone(), Msg::ShowUpdateRepetitionForm { date, event_id: event.id }); dialog.close() })); let button = gtk::Button::builder() .label("Toutes les occurences") + .margin_bottom(10) .build(); lines.append(&button); let tx = app.tx.clone(); button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { - update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + update::send(tx.clone(), Msg::ShowUpdateForm { event_id: event.id }); + dialog.close() + })); + + let button = gtk::Button::builder() + .label("À partir de cette occurence") + .build(); + lines.append(&button); + let tx = app.tx.clone(); + button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { + update::send(tx.clone(), Msg::ShowUpdateFromOccurence { date, event_id: event.id }); dialog.close() })); @@ -60,6 +73,7 @@ pub enum Target { New { date: NaiveDate }, Update { event: Event }, UpdateRepetition { event: Event, date: NaiveDate }, + UpdateFromOccurence { event: Event, date: NaiveDate }, } pub async fn show(app: &App, target: Target) { @@ -67,6 +81,7 @@ pub async fn show(app: &App, target: Target) { Target::New { .. } => None, Target::Update { ref event } => Some(event.clone()), Target::UpdateRepetition { ref event, .. } => Some(event.clone()), + Target::UpdateFromOccurence { ref event, .. } => Some(event.clone()), }; let title = if event.is_some() { @@ -91,6 +106,7 @@ pub async fn show(app: &App, target: Target) { let columns = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) + .spacing(10) .build(); lines.append(&columns); @@ -101,37 +117,42 @@ pub async fn show(app: &App, target: Target) { .build(); columns.append(&column1); - let name = event.as_ref().map(|e| entry(&e.name)).unwrap_or_default(); - column1.append(&label("Événement")); + let name = event + .as_ref() + .map(|e| utils::entry(&e.name)) + .unwrap_or_default(); + column1.append(&utils::label("Événement")); column1.append(&name); let date = match target { Target::New { date } => date, Target::Update { ref event } => event.date, Target::UpdateRepetition { date, .. } => date, + Target::UpdateFromOccurence { date, .. } => date, }; - let date = entry(&date.format(event::DATE_FORMAT).to_string()); - column1.append(&label("Jour")); + let date = utils::entry(&date.format(event::DATE_FORMAT).to_string()); + column1.append(&utils::label("Jour")); column1.append(&date); let start = event .as_ref() - .map(|e| time_entry(e.start)) - .unwrap_or_else(|| entry("")); - column1.append(&label("Début")); + .map(|e| utils::time_entry(e.start)) + .unwrap_or_else(|| utils::entry("")); + column1.append(&utils::label("Début")); column1.append(&start); let end = event .as_ref() - .map(|e| time_entry(e.end)) - .unwrap_or_else(|| entry("")); - column1.append(&label("Fin")); + .map(|e| utils::time_entry(e.end)) + .unwrap_or_else(|| utils::entry("")); + column1.append(&utils::label("Fin")); column1.append(&end); // Second column let repetition = match target { Target::Update { ref event } => event.repetition.as_ref(), + Target::UpdateFromOccurence { ref event, .. } => event.repetition.as_ref(), _ => None, }; let repetition_model = repetition::view(repetition); @@ -143,6 +164,7 @@ pub async fn show(app: &App, target: Target) { Target::New { .. } => "Créer", Target::Update { .. } => "Modifier", Target::UpdateRepetition { .. } => "Modifier l’occurence", + Target::UpdateFromOccurence { .. } => "Modifier à partir de l’occurence", }; let button = gtk::Button::builder() @@ -153,7 +175,13 @@ pub async fn show(app: &App, target: Target) { let conn = app.conn.clone(); let tx = app.tx.clone(); button.connect_clicked(glib::clone!(@weak dialog, @strong target, @strong event => move |_| { - match repetition::validate(&repetition_model) { + let removed_occurences = match &target { + Target::Update { event, .. } => { + event.repetition.as_ref().map(|r| r.removed_occurences.clone()).unwrap_or_default() + }, + _ => HashSet::new(), + }; + match repetition::validate(&repetition_model, removed_occurences) { Ok(repetition) => { let id = match &target { Target::Update {event} => event.id, @@ -200,6 +228,24 @@ pub async fn show(app: &App, target: Target) { Err(err) => eprintln!("Error when updating repetition: {}", err) } } + Target::UpdateFromOccurence { date, event } => { + match update_repetition_until(&conn, *date - Duration::days(1), event) { + Ok(updated) => { + match db::insert(&conn, &new) { + Ok(_) => { + update::send(tx.clone(), Msg::UpdateRepeatedFrom { + old: event.clone(), + updated, + new + }); + dialog.close() + }, + Err(err) => eprintln!("Error when inserting event: {}", err) + } + }, + Err(err) => eprintln!("Error when updating event: {}", err) + } + } } } None => eprintln!("Event is not valid.") @@ -212,6 +258,7 @@ pub async fn show(app: &App, target: Target) { if let Some(event) = event { let label = match target { Target::Update { .. } => "Supprimer", + Target::UpdateFromOccurence { .. } => "Supprimer à partir de l’occurence", _ => "Supprimer l’occurence", }; let button = gtk::Button::builder().label(label).build(); @@ -231,6 +278,15 @@ pub async fn show(app: &App, target: Target) { } } } + Target::UpdateFromOccurence { date, .. } => { + match update_repetition_until(&conn, date - Duration::days(1), &event) { + Ok(updated) => { + update::send(tx.clone(), Msg::UpdateEvent { old: event.clone(), new: updated }); + dialog.close() + }, + Err(err) => eprintln!("Error when updating event: {}", err) + } + } _ => { let operation = db::delete(&conn, &event.id); if operation.is_ok() { @@ -273,22 +329,13 @@ fn delete_repetition_occurence( } } -fn time_entry(time: Option) -> gtk::Entry { - entry( - &time - .map(event::pprint_time) - .unwrap_or_else(|| "".to_string()), - ) -} - -fn entry(text: &str) -> gtk::Entry { - gtk::Entry::builder().text(text).margin_bottom(10).build() -} - -fn label(text: &str) -> gtk::Label { - gtk::Label::builder() - .label(text) - .halign(gtk::Align::Start) - .margin_bottom(5) - .build() +fn update_repetition_until(conn: &Connection, date: NaiveDate, event: &Event) -> Result { + let mut with_repetition_until = event.clone(); + with_repetition_until.repetition = event.repetition.as_ref().map(|r| Repetition { + frequency: r.frequency.clone(), + removed_occurences: r.removed_occurences.clone(), + until: Some(date), + }); + db::update(conn, &with_repetition_until)?; + Ok(with_repetition_until) } diff --git a/src/gui/form/repetition.rs b/src/gui/form/repetition.rs index 1d36765..4da65ac 100644 --- a/src/gui/form/repetition.rs +++ b/src/gui/form/repetition.rs @@ -1,13 +1,15 @@ use gtk4 as gtk; -use chrono::{Weekday, Weekday::*}; +use chrono::{NaiveDate, Weekday, Weekday::*}; use gtk::prelude::*; use std::collections::HashSet; +use crate::gui::form::utils; use crate::model::{ - repetition, + event, repetition, repetition::{DayOfMonth, Frequency, Repetition}, }; +use crate::validation; static WEEKDAYS_STR: [&str; 7] = [ "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche", @@ -25,6 +27,7 @@ pub struct Model { first_day_radio: gtk::CheckButton, first_day_dropdown: gtk::DropDown, yearly_radio: gtk::CheckButton, + until: gtk::Entry, } pub fn view(repetition: Option<&Repetition>) -> Model { @@ -52,7 +55,7 @@ pub fn view(repetition: Option<&Repetition>) -> Model { &no_radio, !default.is_empty(), &day_interval_entry, - "Interval de jours", + "Intervalle de jours", ); view.append(&day_interval_box); @@ -84,9 +87,18 @@ pub fn view(repetition: Option<&Repetition>) -> Model { .group(&no_radio) .label("Annuel") .active(frequency == Some(Frequency::Yearly)) + .margin_bottom(10) .build(); view.append(&yearly_radio); + let until = repetition + .as_ref() + .and_then(|r| r.until) + .map(|u| utils::entry(&u.format(event::DATE_FORMAT).to_string())) + .unwrap_or_default(); + view.append(&utils::label("Répéter jusqu’au")); + view.append(&until); + Model { view, no_radio, @@ -97,6 +109,7 @@ pub fn view(repetition: Option<&Repetition>) -> Model { first_day_radio, first_day_dropdown, yearly_radio, + until, } } @@ -126,7 +139,10 @@ fn label(text: &str) -> gtk::Label { .build() } -pub fn validate(model: &Model) -> Result, String> { +pub fn validate( + model: &Model, + removed_occurences: HashSet, +) -> Result, String> { let frequency = if model.no_radio.is_active() { Ok(None) } else if model.day_interval_radio.is_active() { @@ -148,8 +164,22 @@ pub fn validate(model: &Model) -> Result, String> { Err("Aucune option n’a été sélectionnée".to_string()) }?; + // Check until + let until = (if frequency.is_some() { + match validation::non_empty(model.until.buffer().text()) { + Some(until) => match NaiveDate::parse_from_str(&until, event::DATE_FORMAT) { + Ok(until) => Ok(Some(until)), + Err(_) => Err(format!("Can’t parse date from {}", until)), + }, + None => Ok(None), + } + } else { + Ok(None) + })?; + Ok(frequency.map(|frequency| Repetition { frequency, - removed_occurences: HashSet::new(), + removed_occurences, + until, })) } diff --git a/src/gui/form/utils.rs b/src/gui/form/utils.rs new file mode 100644 index 0000000..5cf59e3 --- /dev/null +++ b/src/gui/form/utils.rs @@ -0,0 +1,21 @@ +use gtk4 as gtk; + +use chrono::NaiveTime; + +use crate::model::time; + +pub fn time_entry(time: Option) -> gtk::Entry { + entry(&time.map(time::pprint).unwrap_or_else(|| "".to_string())) +} + +pub fn entry(text: &str) -> gtk::Entry { + gtk::Entry::builder().text(text).margin_bottom(10).build() +} + +pub fn label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .halign(gtk::Align::Start) + .margin_bottom(5) + .build() +} diff --git a/src/gui/update.rs b/src/gui/update.rs index 372fb24..bd4e7a9 100644 --- a/src/gui/update.rs +++ b/src/gui/update.rs @@ -3,6 +3,7 @@ use chrono::{Duration, NaiveDate}; use gtk4::prelude::GridExt; use std::collections::HashSet; use std::iter::FromIterator; +use uuid::Uuid; use crate::{ db, @@ -21,15 +22,19 @@ pub enum Msg { date: NaiveDate, }, ShowRepetitionDialog { + event_id: Uuid, date: NaiveDate, - event: Event, }, ShowUpdateForm { - event: Event, + event_id: Uuid, }, ShowUpdateRepetitionForm { + event_id: Uuid, + date: NaiveDate, + }, + ShowUpdateFromOccurence { + event_id: Uuid, date: NaiveDate, - event: Event, }, AddEvent { new: Event, @@ -44,6 +49,11 @@ pub enum Msg { date: NaiveDate, new: Event, }, + UpdateRepeatedFrom { + old: Event, + updated: Event, + new: Event, + }, DeleteEvent { event: Event, }, @@ -60,12 +70,62 @@ pub async fn event_handler(rx: Receiver, mut app: App) { while let Ok(msg) = rx.recv().await { match msg { Msg::ShowAddForm { date } => form::show(&app, form::Target::New { date }).await, - Msg::ShowRepetitionDialog { date, event } => { - form::repetition_dialog(&app, date, event).await + Msg::ShowRepetitionDialog { date, event_id } => { + match app.recurring_events.iter().position(|e| e.id == event_id) { + Some(index) => { + form::repetition_dialog(&app, date, &app.recurring_events[index]).await + } + None => eprintln!("Event not found with id: {}", event_id), + } } - Msg::ShowUpdateForm { event } => form::show(&app, form::Target::Update { event }).await, - Msg::ShowUpdateRepetitionForm { date, event } => { - form::show(&app, form::Target::UpdateRepetition { event, date }).await + Msg::ShowUpdateForm { event_id } => { + match app.recurring_events.iter().position(|e| e.id == event_id) { + Some(index) => { + form::show( + &app, + form::Target::Update { + event: app.recurring_events[index].clone(), + }, + ) + .await + } + None => match app.events.iter().position(|e| e.id == event_id) { + Some(index) => { + form::show( + &app, + form::Target::Update { + event: app.events[index].clone(), + }, + ) + .await + } + None => eprintln!("Event not found with id: {}", event_id), + }, + } + } + Msg::ShowUpdateRepetitionForm { event_id, date } => { + match app.recurring_events.iter().position(|e| e.id == event_id) { + Some(index) => { + form::show( + &app, + form::Target::UpdateRepetition { + date, + event: app.recurring_events[index].clone(), + }, + ) + .await + } + None => eprintln!("Event not found with id: {}", event_id), + } + } + Msg::ShowUpdateFromOccurence { event_id, date } => { + match app.recurring_events.iter().position(|e| e.id == event_id) { + Some(index) => { + let event = app.recurring_events[index].clone(); + form::show(&app, form::Target::UpdateFromOccurence { date, event }).await + } + None => eprintln!("Event not found with id: {}", event_id), + } } Msg::AddEvent { new } => { let refresh_dates = add(&mut app, &new); @@ -87,6 +147,12 @@ pub async fn event_handler(rx: Receiver, mut app: App) { refresh_dates.insert(date); refresh(&app, &refresh_dates) } + Msg::UpdateRepeatedFrom { old, updated, new } => { + let mut refresh_dates = remove(&mut app, &old); + refresh_dates.extend(add(&mut app, &updated)); + refresh_dates.extend(add(&mut app, &new)); + refresh(&app, &refresh_dates); + } Msg::DeleteEvent { event } => { let refresh_dates = remove(&mut app, &event); refresh(&app, &refresh_dates) @@ -158,6 +224,7 @@ fn remove(app: &mut App, event: &Event) -> HashSet { } /// Remove event repetition +/// TODO: Completely remove the event if it’s the last remaining occurence fn remove_occurence(app: &mut App, event: &Event, occurence: usize) { match app.recurring_events.iter().position(|e| e.id == event.id) { Some(index) => { diff --git a/src/main.rs b/src/main.rs index dce577a..83a0446 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod db; mod gui; mod model; +mod validation; use anyhow::Result; use structopt::StructOpt; diff --git a/src/model/event.rs b/src/model/event.rs index 5e92692..e556f6e 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -1,9 +1,10 @@ -use chrono::Timelike; use chrono::{NaiveDate, NaiveTime}; use std::collections::HashMap; use uuid::Uuid; use crate::model::repetition::Repetition; +use crate::model::time; +use crate::validation; pub static DATE_FORMAT: &str = "%d/%m/%Y"; @@ -19,10 +20,10 @@ pub struct Event { impl Event { pub fn pprint(&self) -> String { - let start = self.start.map(pprint_time).unwrap_or_default(); + let start = self.start.map(time::pprint).unwrap_or_default(); let end = self .end - .map(|t| format!("-{}", pprint_time(t))) + .map(|t| format!("-{}", time::pprint(t))) .unwrap_or_default(); let space = if self.start.is_some() || self.end.is_some() { " " @@ -52,27 +53,6 @@ pub fn repetitions_between( res } -pub fn pprint_time(t: NaiveTime) -> String { - if t.minute() == 0 { - format!("{}h", t.hour()) - } else { - format!("{}h{}", t.hour(), t.minute()) - } -} - -fn parse_time(t: &str) -> Option { - match t.split('h').collect::>()[..] { - [hours, minutes] => { - if minutes.trim().is_empty() { - NaiveTime::from_hms_opt(hours.parse().ok()?, 0, 0) - } else { - NaiveTime::from_hms_opt(hours.parse().ok()?, minutes.parse().ok()?, 0) - } - } - _ => None, - } -} - // Validation pub fn validate( @@ -83,8 +63,8 @@ pub fn validate( end: String, repetition: Option, ) -> Option { - let start = validate_time(start)?; - let end = validate_time(end)?; + let start = validation::time(start)?; + let end = validation::time(end)?; match (start, end) { (Some(s), Some(e)) if s > e => None?, @@ -94,27 +74,9 @@ pub fn validate( Some(Event { id, date: NaiveDate::parse_from_str(&date, DATE_FORMAT).ok()?, - name: validate_name(name)?, + name: validation::non_empty(name)?, start, end, repetition, }) } - -fn validate_time(time: String) -> Option> { - let time = time.trim(); - if time.is_empty() { - Some(None) - } else { - parse_time(time).map(Some) - } -} - -fn validate_name(name: String) -> Option { - let name = name.trim(); - if name.is_empty() { - None - } else { - Some(name.to_string()) - } -} diff --git a/src/model/mod.rs b/src/model/mod.rs index c1beb62..0aefbc6 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,2 +1,3 @@ pub mod event; pub mod repetition; +pub mod time; diff --git a/src/model/repetition.rs b/src/model/repetition.rs index 872944a..eb8cb6d 100644 --- a/src/model/repetition.rs +++ b/src/model/repetition.rs @@ -6,6 +6,7 @@ use std::collections::HashSet; pub struct Repetition { pub frequency: Frequency, pub removed_occurences: HashSet, + pub until: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -48,6 +49,7 @@ impl Repetition { let repeat = |mut date, next: Box NaiveDate>| { let mut repetitions = vec![]; let mut iteration: usize = 0; + let end = self.until.unwrap_or(end); while date <= end { if date >= event { if date >= start && !self.removed_occurences.contains(&iteration) { @@ -195,6 +197,7 @@ mod tests { let repetition = Repetition { frequency: Frequency::Daily { period: 2 }, removed_occurences: HashSet::from([0, 2, 3]), + until: None, }; assert_eq!( repetition.between(d(2020, 7, 1), d(2020, 7, 1), d(2020, 7, 9)), @@ -209,6 +212,7 @@ mod tests { day: DayOfMonth::Day { day: 8 }, }, removed_occurences: HashSet::from([1, 3]), + until: None, }; assert_eq!( repetition.between(d(2020, 1, 8), d(2020, 1, 8), d(2020, 4, 8)), @@ -225,6 +229,7 @@ mod tests { }, }, removed_occurences: HashSet::from([1, 2, 3]), + until: None, }; assert_eq!( repetition.between(d(2020, 2, 1), d(2020, 2, 1), d(2020, 7, 1)), @@ -237,6 +242,7 @@ mod tests { let repetition = Repetition { frequency: Frequency::Yearly, removed_occurences: HashSet::from([3]), + until: None, }; assert_eq!( repetition.between(d(2018, 5, 5), d(2019, 8, 1), d(2022, 5, 5)), @@ -249,6 +255,7 @@ mod tests { let repetition = Repetition { frequency: Frequency::Yearly, removed_occurences: HashSet::from([1]), + until: None, }; assert_eq!( repetition.occurence_index(d(2020, 1, 1), d(2022, 1, 1)), @@ -256,6 +263,19 @@ mod tests { ) } + #[test] + fn repetition_stops_after_until() { + let repetition = Repetition { + frequency: Frequency::Yearly, + removed_occurences: HashSet::new(), + until: Some(d(2022, 1, 1)), + }; + assert_eq!( + repetition.between(d(2020, 1, 1), d(2020, 1, 1), d(2024, 1, 1)), + vec!(d(2020, 1, 1), d(2021, 1, 1), d(2022, 1, 1)) + ) + } + fn d(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd(y, m, d) } @@ -264,6 +284,7 @@ mod tests { Repetition { frequency, removed_occurences: HashSet::new(), + until: None, } } } diff --git a/src/model/time.rs b/src/model/time.rs new file mode 100644 index 0000000..10cf6d3 --- /dev/null +++ b/src/model/time.rs @@ -0,0 +1,22 @@ +use chrono::{NaiveTime, Timelike}; + +pub fn pprint(t: NaiveTime) -> String { + if t.minute() == 0 { + format!("{}h", t.hour()) + } else { + format!("{}h{}", t.hour(), t.minute()) + } +} + +pub fn parse(t: &str) -> Option { + match t.split('h').collect::>()[..] { + [hours, minutes] => { + if minutes.trim().is_empty() { + NaiveTime::from_hms_opt(hours.parse().ok()?, 0, 0) + } else { + NaiveTime::from_hms_opt(hours.parse().ok()?, minutes.parse().ok()?, 0) + } + } + _ => None, + } +} diff --git a/src/validation/mod.rs b/src/validation/mod.rs new file mode 100644 index 0000000..07a7c4c --- /dev/null +++ b/src/validation/mod.rs @@ -0,0 +1,21 @@ +use chrono::NaiveTime; + +use crate::model::time; + +pub fn time(time: String) -> Option> { + let time = time.trim(); + if time.is_empty() { + Some(None) + } else { + time::parse(time).map(Some) + } +} + +pub fn non_empty(str: String) -> Option { + let str = str.trim(); + if str.is_empty() { + None + } else { + Some(str.to_string()) + } +} -- cgit v1.2.3