aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2022-03-19 22:03:58 +0100
committerJoris2022-03-19 22:03:58 +0100
commit35cc74578e969bae4812afd2ff041eba3746142d (patch)
treec0ef18d3c81554f0e70c91fcb1006d587470365c
parent199624dc03ead28ddc7454147457512d9568c593 (diff)
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.
-rw-r--r--README.md22
-rw-r--r--src/gui/app.rs4
-rw-r--r--src/gui/calendar.rs4
-rw-r--r--src/gui/form/mod.rs115
-rw-r--r--src/gui/form/repetition.rs40
-rw-r--r--src/gui/form/utils.rs21
-rw-r--r--src/gui/update.rs83
-rw-r--r--src/main.rs1
-rw-r--r--src/model/event.rs52
-rw-r--r--src/model/mod.rs1
-rw-r--r--src/model/repetition.rs21
-rw-r--r--src/model/time.rs22
-rw-r--r--src/validation/mod.rs21
13 files changed, 299 insertions, 108 deletions
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<Connection>,
pub window: Rc<gtk::ApplicationWindow>,
pub grid: gtk::Grid,
- pub events: Vec<Event>,
- pub recurring_events: Vec<Event>,
+ pub events: Vec<Event>, // TODO: use Hashmap to have fast access to events by id ?
+ pub recurring_events: Vec<Event>, // 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<Msg>, 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<NaiveTime>) -> 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<Event> {
+ 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<Option<Repetition>, String> {
+pub fn validate(
+ model: &Model,
+ removed_occurences: HashSet<usize>,
+) -> Result<Option<Repetition>, 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<Option<Repetition>, 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<NaiveTime>) -> 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<Msg>, 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<Msg>, 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<NaiveDate> {
}
/// 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<NaiveTime> {
- match t.split('h').collect::<Vec<&str>>()[..] {
- [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<Repetition>,
) -> Option<Event> {
- 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<Option<NaiveTime>> {
- let time = time.trim();
- if time.is_empty() {
- Some(None)
- } else {
- parse_time(time).map(Some)
- }
-}
-
-fn validate_name(name: String) -> Option<String> {
- 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<usize>,
+ pub until: Option<NaiveDate>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -48,6 +49,7 @@ impl Repetition {
let repeat = |mut date, next: Box<dyn Fn(NaiveDate) -> 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<NaiveTime> {
+ match t.split('h').collect::<Vec<&str>>()[..] {
+ [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<Option<NaiveTime>> {
+ let time = time.trim();
+ if time.is_empty() {
+ Some(None)
+ } else {
+ time::parse(time).map(Some)
+ }
+}
+
+pub fn non_empty(str: String) -> Option<String> {
+ let str = str.trim();
+ if str.is_empty() {
+ None
+ } else {
+ Some(str.to_string())
+ }
+}