diff --git a/Cargo.toml b/Cargo.toml index 3e7bfff..30fbd12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,18 @@ -[package] -name = "jana_sessions_webpage" -version = "0.1.0" -edition = "2021" +[workspace] +resolver = "2" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +members = [ + "leptos_webpage", "session_iter", +] -default-run = "jana_sessions_webpage" +[profile] -[dependencies] -chrono = { version = "0.4", features = ["serde"] } -leptos = { version = "0.7", features = ["csr"] } -console_error_panic_hook = "0.1.7" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -clap = { version = "4.5", features = ["derive"] } \ No newline at end of file +[profile.wasm-dev] +inherits = "dev" +opt-level = 1 + +[profile.server-dev] +inherits = "dev" + +[profile.android-dev] +inherits = "dev" diff --git a/README.md b/README.md index 1c6b785..b976090 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,10 @@ # Jana Sessions Webpage -The webpage for Jana-Sessions (unofficial name), fully written in Rust. +The webpage of Jana Sessions (unofficial name) written in Rust. -## Building +The project currently consists of only a single crate, but a mayor refactoring is planned. -The project currently uses leptos, so you'll want to install trunk (`cargo install trunk`). -For the build to work you'll need the `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`). - -You can build the projekt with -```bash -trunk build --release -``` -which will create the app in the `target/dist` folder. - -Alternatively you can serve it locally with -```bash -trunk serve -``` - -To also access the local hosted page from other devices, use -```bash -trunk serve -a 0.0.0.0 -``` - -## Deployment - -Just use pythons webserver and point it to the dist folder -```bash -python3 -m http.server 8080 --directoy target/dist -``` +## leptos_webpage +The first functional version of the page, using leptos and trunk. +Please see the README there for more details. \ No newline at end of file diff --git a/leptos_webpage/Cargo.toml b/leptos_webpage/Cargo.toml new file mode 100644 index 0000000..82b0afa --- /dev/null +++ b/leptos_webpage/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "jana_sessions_webpage" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +default-run = "jana_sessions_webpage" + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +leptos = { version = "0.7", features = ["csr"] } +console_error_panic_hook = "0.1.7" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +session_iter = { path = "../session_iter"} \ No newline at end of file diff --git a/leptos_webpage/README.md b/leptos_webpage/README.md new file mode 100644 index 0000000..1c6b785 --- /dev/null +++ b/leptos_webpage/README.md @@ -0,0 +1,32 @@ +# Jana Sessions Webpage + +The webpage for Jana-Sessions (unofficial name), fully written in Rust. + +## Building + +The project currently uses leptos, so you'll want to install trunk (`cargo install trunk`). +For the build to work you'll need the `wasm32-unknown-unknown` target (`rustup target add wasm32-unknown-unknown`). + +You can build the projekt with +```bash +trunk build --release +``` +which will create the app in the `target/dist` folder. + +Alternatively you can serve it locally with +```bash +trunk serve +``` + +To also access the local hosted page from other devices, use +```bash +trunk serve -a 0.0.0.0 +``` + +## Deployment + +Just use pythons webserver and point it to the dist folder +```bash +python3 -m http.server 8080 --directoy target/dist +``` + diff --git a/Trunk.toml b/leptos_webpage/Trunk.toml similarity index 50% rename from Trunk.toml rename to leptos_webpage/Trunk.toml index a107850..04719dc 100644 --- a/Trunk.toml +++ b/leptos_webpage/Trunk.toml @@ -1,6 +1,6 @@ [build] filehash = false -dist = "target/dist" +dist = "../target/dist" public_url = "." [[hooks]] diff --git a/index.html b/leptos_webpage/index.html similarity index 100% rename from index.html rename to leptos_webpage/index.html diff --git a/leptosfmt.sh b/leptos_webpage/leptosfmt.sh similarity index 100% rename from leptosfmt.sh rename to leptos_webpage/leptosfmt.sh diff --git a/src/bin/create_default_configs.rs b/leptos_webpage/src/bin/create_default_configs.rs similarity index 79% rename from src/bin/create_default_configs.rs rename to leptos_webpage/src/bin/create_default_configs.rs index dff3530..c260e91 100644 --- a/src/bin/create_default_configs.rs +++ b/leptos_webpage/src/bin/create_default_configs.rs @@ -4,11 +4,11 @@ use std::fs::File; use std::path::Path; use std::{env, fs, io}; -fn main() -> io::Result<()> { +fn main() -> Result<(), String> { let out_dir = env::var_os("TRUNK_STAGING_DIR").unwrap_or("target/default_configs".into()); - fs::create_dir_all(&out_dir)?; + fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create target directory: {e}"))?; - create_default_config::("session_config", &out_dir)?; + create_default_config::("session_config", &out_dir).map_err(|e| format!("Failed to create session_config: {e}"))?; Ok(()) } diff --git a/src/lib.rs b/leptos_webpage/src/lib.rs similarity index 70% rename from src/lib.rs rename to leptos_webpage/src/lib.rs index f770fa1..e0f6f66 100644 --- a/src/lib.rs +++ b/leptos_webpage/src/lib.rs @@ -1,14 +1,12 @@ -use crate::session_date_calculator::Day; use chrono::Weekday; +use session_iter::day::Day; -pub mod session; -pub mod session_date_calculator; pub mod webpage; pub fn localize_day(day: &Day) -> String { format!( "{}, {}", - match day.weekday { + match day.weekday() { Weekday::Mon => "Montag", Weekday::Tue => "Dienstag", Weekday::Wed => "Mittwoch", @@ -17,6 +15,6 @@ pub fn localize_day(day: &Day) -> String { Weekday::Sat => "Samstag", Weekday::Sun => "Sonntag", }, - day.date.format("%d.%m.%Y") + day.date().format("%d.%m.%Y") ) } diff --git a/src/main.rs b/leptos_webpage/src/main.rs similarity index 99% rename from src/main.rs rename to leptos_webpage/src/main.rs index aeaae4b..08ee84d 100644 --- a/src/main.rs +++ b/leptos_webpage/src/main.rs @@ -6,4 +6,3 @@ fn main() { mount_to_body(webpage::App); } - diff --git a/src/webpage.rs b/leptos_webpage/src/webpage.rs similarity index 78% rename from src/webpage.rs rename to leptos_webpage/src/webpage.rs index e8f54cf..7e27e2c 100644 --- a/src/webpage.rs +++ b/leptos_webpage/src/webpage.rs @@ -1,12 +1,12 @@ use crate::localize_day; -use crate::session::{ - AlterException, CancelException, DatedSession, ExtraSession, RegularSession, Session, WithNote, -}; -use crate::session_date_calculator::{Day, DayIter, NthWeekday}; use chrono::{Datelike, Days, Months, NaiveDate, Weekday}; use leptos::prelude::*; use leptos::server_fn::request::browser::Request; use serde::{Deserialize, Serialize}; +use session_iter::day::Day; +use session_iter::session::iter::{DatedSession, DatedSessionIter}; +use session_iter::session::rule::WeekdayOfMonth; +use session_iter::session::{Alternation, Cancellation, Dated, ExtraSession, RegularSession, Session, WithNote}; #[component] pub fn App() -> impl IntoView { @@ -42,13 +42,7 @@ pub fn App() -> impl IntoView { #[component] fn Sessions(config: SessionConfig) -> impl IntoView { - let mut session_iter = DayIter::default().flat_map(move |day| { - config - .sessions - .clone() - .into_iter() - .filter_map(move |session| session.clone().into_dated(day.clone()).ok()) - }); + let mut session_iter = DatedSessionIter::new(Day::default(), config.sessions); let (dated_sessions, mut_dated_sessions) = signal(session_iter.by_ref().take(2).collect::>()); @@ -60,7 +54,7 @@ fn Sessions(config: SessionConfig) -> impl IntoView { @@ -93,26 +87,26 @@ fn DatedSession(session: DatedSession) -> impl IntoView { DatedSession::Extra(session) => view! {

""

-

{localize_day(&session.date.into())}

+

{localize_day(&session.day)}

{session.note}

} .into_any(), - DatedSession::Cancelled { session, day, .. } => view! { + DatedSession::Canceled { regular, day, .. } => view! {

""

{localize_day(&day)}

-

{session.note}

+

{regular.note}

} .into_any(), DatedSession::Altered { - session, + regular, day, - exception, + cause, .. } => { - let day = move || match exception.date.map(Day::from) { + let day = move || match cause.new_day { None => view! { {localize_day(&day)} }.into_any(), Some(new_day) => view! { {localize_day(&day)} @@ -126,7 +120,7 @@ fn DatedSession(session: DatedSession) -> impl IntoView {

"<Änderung>"

{day}

-

{exception.note.or(session.note)}

+

{cause.note.or(regular.note)}

} .into_any() @@ -171,11 +165,10 @@ macro_rules! date { } impl Default for SessionConfig { - #[allow(clippy::zero_prefixed_literal)] fn default() -> Self { const YEAR: i32 = 2024; - const NEXT_TUE_SESSION: NaiveDate = date!(18, 02, YEAR); - const NEXT_SUN_SESSION: NaiveDate = date!(02, 03, YEAR); + const NEXT_TUE_SESSION: NaiveDate = date!(18, 2, YEAR); + const NEXT_SUN_SESSION: NaiveDate = date!(2, 3, YEAR); let next_next_sun_session = { let some_day_next_month = NEXT_SUN_SESSION.checked_add_months(Months::new(1)).unwrap(); NaiveDate::from_weekday_of_month_opt( @@ -190,26 +183,26 @@ impl Default for SessionConfig { Self { motd: Some("Jeder 1. Sonntag und 3. Dienstag im Monat".into()), sessions: vec![ - RegularSession::from(NthWeekday::new(1, Weekday::Sun)) + RegularSession::from(WeekdayOfMonth::new(1, Weekday::Sun)) .with_note("10:00 Uhr") - .with_exception( + .except( NEXT_SUN_SESSION, - AlterException { - date: Some(NEXT_SUN_SESSION.checked_sub_days(Days::new(1)).unwrap()), + Alternation { + new_day: Some(NEXT_SUN_SESSION.checked_sub_days(Days::new(1)).unwrap().into()), ..Default::default() }, ) - .with_exception( + .except( next_next_sun_session, - AlterException { + Alternation { note: Some("11 Uhr".into()), ..Default::default() }, ) .into(), - RegularSession::from(NthWeekday::new(3, Weekday::Tue)) + RegularSession::from(WeekdayOfMonth::new(3, Weekday::Tue)) .with_note("18:30 Uhr") - .with_exception(NEXT_TUE_SESSION, CancelException::default()) + .except(NEXT_TUE_SESSION, Cancellation::new()) .into(), ExtraSession::from(NEXT_TUE_SESSION.checked_add_days(Days::new(2)).unwrap()) .with_note("18 Uhr") diff --git a/styles.css b/leptos_webpage/styles.css similarity index 100% rename from styles.css rename to leptos_webpage/styles.css diff --git a/session_iter/Cargo.toml b/session_iter/Cargo.toml new file mode 100644 index 0000000..e7bdb79 --- /dev/null +++ b/session_iter/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "session_iter" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json_any_key = "2.0" \ No newline at end of file diff --git a/session_iter/src/day.rs b/session_iter/src/day.rs new file mode 100644 index 0000000..4db6ced --- /dev/null +++ b/session_iter/src/day.rs @@ -0,0 +1,49 @@ +use chrono::{Datelike, Local, NaiveDate, Weekday}; +use serde::{Deserialize, Serialize}; +use std::ops::Deref; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +#[repr(transparent)] +pub struct Day { + date: NaiveDate, +} + +impl From for Day { + fn from(date: NaiveDate) -> Self { + Self { date } + } +} + +impl Default for Day { + fn default() -> Self { + Self::from(Local::now().date_naive()) + } +} + +impl Day { + pub fn date(&self) -> NaiveDate { + self.date + } + + pub fn weekday(&self) -> Weekday { + self.date.weekday() + } + + pub fn weekday_of_month(&self) -> u8 { + self.date.day0() as u8 / 7 + 1 + } +} + +impl Deref for Day { + type Target = NaiveDate; + + fn deref(&self) -> &Self::Target { + &self.date + } +} + +impl From for NaiveDate { + fn from(day: Day) -> Self { + day.date + } +} \ No newline at end of file diff --git a/session_iter/src/lib.rs b/session_iter/src/lib.rs new file mode 100644 index 0000000..e884830 --- /dev/null +++ b/session_iter/src/lib.rs @@ -0,0 +1,5 @@ +pub mod day; +pub mod session; +#[cfg(test)] +mod test_util; + diff --git a/session_iter/src/main.rs b/session_iter/src/main.rs new file mode 100644 index 0000000..e71fdf5 --- /dev/null +++ b/session_iter/src/main.rs @@ -0,0 +1 @@ +fn main() {} \ No newline at end of file diff --git a/session_iter/src/session.rs b/session_iter/src/session.rs new file mode 100644 index 0000000..5276905 --- /dev/null +++ b/session_iter/src/session.rs @@ -0,0 +1,196 @@ +pub mod iter; +pub mod rule; + +use crate::day::Day; +use crate::session::rule::{SessionRule, WeekdayOfMonth}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +type Note = Option; + +macro_rules! impl_opt_noted { + ($ty: ident) => { + impl OptNoted for $ty { + fn note(&self) -> Option<&str> { + self.note.as_ref().map(String::as_str) + } + } + }; + ($ty: ident with $($variant: ident),+) => { + impl OptNoted for $ty { + fn note(&self) -> Option<&str> { + match self { + $(Self::$variant(opt_noted) => opt_noted.note(),)+ + } + } + } + }; +} + +macro_rules! impl_with_note { + ($ty: ident) => { + impl WithNote for $ty { + fn with_note(self, note: &str) -> Self { + #[allow(clippy::needless_update)] + Self { + note: Some(note.to_owned()), + ..self + } + } + } + }; +} + +macro_rules! impl_from { + ($what: ident for $ty: ident by $intermediate: ident) => { + impl From<$what> for $ty { + fn from(value: $what) -> Self { + Self::from($intermediate::from(value)) + } + } + }; + ($what: ident for $ty: ident as $variant: ident) => { + impl From<$what> for $ty { + fn from(value: $what) -> Self { + Self::$variant(value) + } + } + }; + ($what: ident for $ty: ident) => { + impl_from!($what for $ty as $what); + }; +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum Session { + Regular(RegularSession), + Extra(ExtraSession), +} + +impl_from!(RegularSession for Session as Regular); +impl_from!(ExtraSession for Session as Extra); + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct RegularSession { + pub rule: SessionRule, + pub note: Note, + #[serde(with = "serde_json_any_key::any_key_map")] + pub except: BTreeMap, +} + +impl From for RegularSession { + fn from(rule: SessionRule) -> Self { + Self { + rule, + note: None, + except: Default::default(), + } + } +} + +impl RegularSession { + pub fn except(mut self, day: D, except: E) -> Self + where + D: Into, + E: Into, + { + self.except.insert(day.into(), except.into()); + self + } + + ///gets the next session day, where no except applies. Can possibly return the `current_date`. + pub fn next_regular_session_day(&self, current_date: D) -> Option + where + D: Into, + { + self + .rule + .to_session_day_iter(current_date) + .find(|day| !self.except.contains_key(day)) + } +} + +impl_from!(WeekdayOfMonth for RegularSession by SessionRule); +impl_opt_noted!(RegularSession); +impl_with_note!(RegularSession); + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub struct ExtraSession { + pub day: Day, + pub note: Note, +} + +impl From for ExtraSession { + fn from(day: Day) -> Self { + Self { day, note: None } + } +} + +impl_from!(NaiveDate for ExtraSession by Day); +impl_opt_noted!(ExtraSession); +impl_with_note!(ExtraSession); + +impl Dated for ExtraSession { + fn day(&self) -> Day { + self.day + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum Except { + Alternation(Alternation), + Cancellation(Cancellation), +} + +impl_from!(Alternation for Except); +impl_from!(Cancellation for Except); +impl_opt_noted!(Except with Alternation, Cancellation); + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Serialize, Deserialize)] +pub struct Alternation { + pub note: Note, + ///the date when the alternation should show up in the calendar, or the original date + pub new_day: Option, +} + +impl From for Alternation { + fn from(day: Day) -> Self { + Self { + new_day: Some(day), + ..Default::default() + } + } +} + +impl_from!(NaiveDate for Alternation by Day); +impl_opt_noted!(Alternation); +impl_with_note!(Alternation); + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Cancellation { + pub note: Note, +} + +impl Cancellation { + pub fn new() -> Self { + Self::default() + } +} + +impl_opt_noted!(Cancellation); +impl_with_note!(Cancellation); + +pub trait Dated { + /// The day when this should show up in a calendar + fn day(&self) -> Day; +} + +pub trait OptNoted { + fn note(&self) -> Option<&str>; +} + +pub trait WithNote { + fn with_note(self, note: &str) -> Self; +} diff --git a/session_iter/src/session/iter.rs b/session_iter/src/session/iter.rs new file mode 100644 index 0000000..78643a3 --- /dev/null +++ b/session_iter/src/session/iter.rs @@ -0,0 +1,317 @@ +use crate::day::Day; +use crate::session::rule::SessionRuleLike; +use crate::session::{ + Alternation, Cancellation, Dated, Except, ExtraSession, OptNoted, RegularSession, Session, +}; +use chrono::Days; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, VecDeque}; +use std::hash::Hash; + +#[derive(Debug, Clone)] +pub struct DatedSessionIter { + sessions: BTreeMap>, +} + +impl DatedSessionIter { + pub fn new(start_date: D, sessions: S) -> DatedSessionIter + where + D: Into, + S: IntoIterator, + { + let current_date = start_date.into(); + + //map every session and their excepts to a date when they should show up in the calendar + let sessions = + sessions + .into_iter() + .flat_map(|session| match session { + Session::Regular(session) => session + .except + .iter() + .filter(|(&day, _)| session.rule.accepts(day)) + .map(|(&day, except)| match except { + Except::Alternation(alternation) => DatedSession::Altered { + day, + regular: session.clone(), + cause: alternation.clone(), + }, + Except::Cancellation(cancellation) => DatedSession::Canceled { + day, + regular: session.clone(), + cause: cancellation.clone(), + }, + }) + .chain(session.next_regular_session_day(current_date).map(|day| { + DatedSession::Regular { + day, + session: session.clone(), + } + })) + .collect(), + Session::Extra(extra) => vec![extra.into()], + }) + //filter out and entries which would lay in the past + .filter(|dated_session| dated_session.day() >= current_date) + //group sessions on the same day together + .fold( + BTreeMap::<_, VecDeque<_>>::new(), + |mut map, dated_session| { + map + .entry(dated_session.day()) + .or_default() + .push_back(dated_session); + map + }, + ); + + Self { sessions } + } +} + +impl Iterator for DatedSessionIter { + type Item = DatedSession; + + fn next(&mut self) -> Option { + let mut entry = self.sessions.first_entry()?; + let session = entry.get_mut().pop_front()?; + if entry.get().is_empty() { + entry.remove(); + } + + //make sure regular sessions remain in map + if let DatedSession::Regular { ref session, day } = session { + //calculate next day, because next_regular_session_day of a session day returns that very session day + if let Some(next_day) = day.checked_add_days(Days::new(1)) { + if let Some(next_session_day) = session.next_regular_session_day(next_day) { + self + .sessions + .entry(next_session_day) + .or_default() + .push_back(DatedSession::Regular { + day: next_session_day, + session: session.clone(), + }) + } + } + } + Some(session) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum DatedSession { + Regular { + day: Day, + session: RegularSession, + }, + Extra(ExtraSession), + Canceled { + day: Day, + regular: RegularSession, + cause: Cancellation, + }, + Altered { + ///the day when the regular session would have applied + day: Day, + regular: RegularSession, + cause: Alternation, + }, +} + +impl From for DatedSession { + fn from(value: ExtraSession) -> Self { + Self::Extra(value) + } +} + +impl Dated for DatedSession { + fn day(&self) -> Day { + match *self { + DatedSession::Regular { day, .. } => day, + DatedSession::Extra(ref extra) => extra.day(), + DatedSession::Canceled { day, .. } => day, + DatedSession::Altered { day, ref cause, .. } => cause.new_day.unwrap_or(day), + } + } +} + +impl OptNoted for DatedSession { + fn note(&self) -> Option<&str> { + match self { + DatedSession::Regular { session, .. } => session.note(), + DatedSession::Extra(extra) => extra.note(), + DatedSession::Canceled { cause, .. } => cause.note(), + DatedSession::Altered { cause, .. } => cause.note(), + } + } +} + +#[cfg(test)] +mod test { + use crate::session::iter::{DatedSession, DatedSessionIter}; + use crate::session::rule::WeekdayOfMonth; + use crate::session::{Alternation, Cancellation, ExtraSession, RegularSession, WithNote}; + use crate::test_util::{date, day}; + use chrono::Weekday; + + #[test] + fn test_regular() { + let tue_session = RegularSession::from(WeekdayOfMonth::new(3, Weekday::Tue)); + let sun_session = RegularSession::from(WeekdayOfMonth::new(1, Weekday::Sun)); + let mut iter = DatedSessionIter::new( + date(17, 2, 2025), + [tue_session.clone().into(), sun_session.clone().into()], + ); + + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(18, 2, 2025), + session: tue_session.clone() + }) + ); + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(2, 3, 2025), + session: sun_session.clone() + }) + ); + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(18, 3, 2025), + session: tue_session.clone() + }) + ); + } + + #[test] + fn test_old() { + let extra_session = ExtraSession::from(day(15, 2, 2025)); + let regular_session = RegularSession::from(WeekdayOfMonth::new(3, Weekday::Tue)) + .with_note("18:30 Uhr") + .except(day(18, 2, 2025), Cancellation::new()) + .except(day(18, 3, 2025), Alternation::from(day(21, 1, 2025))); + + let mut iter = DatedSessionIter::new( + day(19, 2, 2025), + [regular_session.clone().into(), extra_session.into()], + ); + + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(15, 4, 2025), + session: regular_session.clone(), + }) + ); + } + + #[test] + fn test_invalid_except() { + let regular_session = RegularSession::from(WeekdayOfMonth::new(3, Weekday::Tue)) + .except(day(17, 2, 2025), Cancellation::new()); + + let mut iter = DatedSessionIter::new(day(17, 2, 2025), [regular_session.clone().into()]); + + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(18, 2, 2025), + session: regular_session.clone() + }) + ); + } + + #[test] + fn test_extra_altered_and_canceled() { + //an extra session on the same day as a tuesday session + let extra_session = ExtraSession::from(day(18, 2, 2025)).with_note("morning session"); + //an alternation of a tuesday session without moving it + let (note_alternation_day, note_alternation) = ( + day(18, 3, 2025), + Alternation { + note: Some("a little different today".into()), + ..Default::default() + }, + ); + let tue_session = RegularSession::from(WeekdayOfMonth::new(3, Weekday::Tue)) + .except(note_alternation_day, note_alternation.clone()); + //a sunday session moved in front of a tuesday session + let moved_sun_session_day = day(16, 2, 2025); + let (day_alternation_day, day_alternation) = + (day(2, 3, 2025), Alternation::from(moved_sun_session_day)); + //a canceled sunday session + let (cancel_day, cancellation) = (day(6, 4, 2025), Cancellation::new()); + let sun_session = RegularSession::from(WeekdayOfMonth::new(1, Weekday::Sun)) + .except(day_alternation_day, day_alternation.clone()) + .except(cancel_day, cancellation.clone()); + + let mut iter = DatedSessionIter::new( + day(15, 2, 2025), + [ + extra_session.clone().into(), + tue_session.clone().into(), + sun_session.clone().into(), + ], + ); + + //at first comes the moved sunday + assert_eq!( + iter.next(), + Some(DatedSession::Altered { + day: day_alternation_day, + regular: sun_session.clone(), + cause: day_alternation.clone(), + }) + ); + //then comes the extra session on tuesday (because it was first in the list) + assert_eq!( + iter.next(), + Some(DatedSession::Extra(extra_session.clone())) + ); + //after that comes a regular tuesday session + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(18, 2, 2025), + session: tue_session.clone() + }) + ); + //now we have an altered but not moved tuesday session + assert_eq!( + iter.next(), + Some(DatedSession::Altered { + day: note_alternation_day, + regular: tue_session.clone(), + cause: note_alternation.clone(), + }) + ); + //the next sunday session was canceled + assert_eq!( + iter.next(), + Some(DatedSession::Canceled { + day: cancel_day, + regular: sun_session.clone(), + cause: cancellation.clone(), + }) + ); + //and now we are back to regular sessions + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(15, 4, 2025), + session: tue_session.clone() + }) + ); + assert_eq!( + iter.next(), + Some(DatedSession::Regular { + day: day(4, 5, 2025), + session: sun_session.clone() + }) + ); + } +} diff --git a/session_iter/src/session/rule.rs b/session_iter/src/session/rule.rs new file mode 100644 index 0000000..b7793b2 --- /dev/null +++ b/session_iter/src/session/rule.rs @@ -0,0 +1,152 @@ +use crate::day::Day; +use chrono::{Datelike, Days, Months, NaiveDate, Weekday}; +use serde::{Deserialize, Serialize}; +use std::ops::Deref; + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum SessionRule { + WeekdayOfMonth(WeekdayOfMonth), +} + +impl From for SessionRule { + fn from(value: WeekdayOfMonth) -> Self { + Self::WeekdayOfMonth(value) + } +} + +impl SessionRule { + pub fn to_session_day_iter(&self, start_date: D) -> SessionDayIter<&Self> + where + D: Into, + { + SessionDayIter { + rule: self, + start_date: Some(start_date.into()), + } + } +} + +impl SessionRuleLike for SessionRule { + fn determine_next_date(&self, current_date: Day) -> Option { + match self { + SessionRule::WeekdayOfMonth(weekday_of_month) => { + weekday_of_month.determine_next_date(current_date) + } + } + } + + fn accepts(&self, day: Day) -> bool { + match self { + SessionRule::WeekdayOfMonth(weekday_of_month) => weekday_of_month.accepts(day), + } + } +} + +#[derive(Debug, Clone)] +pub struct SessionDayIter { + pub rule: R, + pub start_date: Option, +} + +impl Iterator for SessionDayIter +where + R: Deref, + S: SessionRuleLike, +{ + type Item = Day; + + fn next(&mut self) -> Option { + let start_date = self.start_date?; + let session_date = self.rule.determine_next_date(start_date); + self.start_date = session_date + .and_then(|session_date| session_date.checked_add_days(Days::new(1))) + .map(Day::from); + session_date + } +} + +impl SessionDayIter +where + R: Deref, +{ + pub fn to_owned(&self) -> SessionDayIter { + SessionDayIter { + rule: self.rule.clone(), + start_date: self.start_date, + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct WeekdayOfMonth { + pub n: u8, + pub weekday: Weekday, +} + +impl WeekdayOfMonth { + pub fn new(n: u8, weekday: Weekday) -> Self { + Self { n, weekday } + } +} + +impl SessionRuleLike for WeekdayOfMonth { + fn determine_next_date(&self, current_date: Day) -> Option { + let session_this_month = NaiveDate::from_weekday_of_month_opt( + current_date.year(), + current_date.month(), + self.weekday, + self.n, + )?; + + let date = if session_this_month >= current_date.date() { + session_this_month + } else { + let session_next_month = current_date.checked_add_months(Months::new(1))?; + NaiveDate::from_weekday_of_month_opt( + session_next_month.year(), + session_next_month.month(), + self.weekday, + self.n, + )? + }; + + Some(date.into()) + } + + fn accepts(&self, day: Day) -> bool { + day.weekday() == self.weekday && day.weekday_of_month() == self.n + } +} + +pub trait SessionRuleLike { + /// Determines the next session date in form of a [Day], possibly including the `current_date`. + fn determine_next_date(&self, current_date: Day) -> Option; + + /// Whether this rule would be able to produce the given `day`. + fn accepts(&self, day: Day) -> bool; +} + +#[cfg(test)] +mod test_weekday_of_month { + use crate::session::rule::{SessionRuleLike, WeekdayOfMonth}; + use crate::test_util::day; + use chrono::Weekday; + + #[test] + fn test_next_date() { + let rule = WeekdayOfMonth::new(3, Weekday::Tue); + + assert_eq!( + rule.determine_next_date(day(17, 2, 2025)), + Some(day(18, 2, 2025)) + ); + assert_eq!( + rule.determine_next_date(day(18, 2, 2025)), + Some(day(18, 2, 2025)) + ); + assert_eq!( + rule.determine_next_date(day(19, 2, 2025)), + Some(day(18, 3, 2025)) + ); + } +} diff --git a/session_iter/src/test_util.rs b/session_iter/src/test_util.rs new file mode 100644 index 0000000..9261d54 --- /dev/null +++ b/session_iter/src/test_util.rs @@ -0,0 +1,10 @@ +use crate::day::Day; +use chrono::NaiveDate; + +pub fn date(day: u32, month: u32, year: i32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") +} + +pub fn day(day: u32, month: u32, year: i32) -> Day { + date(day, month, year).into() +} \ No newline at end of file diff --git a/src/bin/cli.rs b/src/bin/cli.rs deleted file mode 100644 index 8babed3..0000000 --- a/src/bin/cli.rs +++ /dev/null @@ -1,21 +0,0 @@ -use clap::Parser; -use jana_sessions_webpage::localize_day; -use jana_sessions_webpage::session_date_calculator::{DayIter, NthWeekday}; -use std::io::stdin; - -#[derive(Debug, Parser)] -struct Args { - sessions: Vec -} - -fn main() { - let args = Args::parse(); - - let mut iter = DayIter::default().filter(|day| args.sessions.iter().any(|nth_weekday| nth_weekday.matches(day))); - - loop { - iter.by_ref().take(3).for_each(|day| println!("{}", localize_day(&day))); - println!("Press enter for more..."); - stdin().read_line(&mut String::new()).unwrap(); - } -} \ No newline at end of file diff --git a/src/session.rs b/src/session.rs deleted file mode 100644 index 8351433..0000000 --- a/src/session.rs +++ /dev/null @@ -1,259 +0,0 @@ -use crate::session_date_calculator::{Day, NthWeekday}; -use chrono::NaiveDate; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct OldDatedSession { - pub day: Day, - pub session: Session, - pub applying_exception: Option, -} - -impl Noted for OldDatedSession { - fn get_note(&self) -> Option<&String> { - match &self.applying_exception { - Some(e) => e.get_note(), - None => self.session.get_note(), - } - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub enum DatedSession { - Regular { - session: RegularSession, - day: Day, - }, - Extra(ExtraSession), - Cancelled { - session: RegularSession, - day: Day, - exception: CancelException, - }, - Altered { - session: RegularSession, - day: Day, - exception: AlterException, - }, -} - -impl DatedSession { - pub fn date(&self) -> &NaiveDate { - match self { - DatedSession::Regular { day, .. } => &day.date, - DatedSession::Extra(ExtraSession { date, .. }) => date, - DatedSession::Cancelled { day, .. } => &day.date, - DatedSession::Altered { day, .. } => &day.date, - } - } -} - -impl Noted for DatedSession { - fn get_note(&self) -> Option<&String> { - match self { - DatedSession::Regular { session, .. } => session.get_note(), - DatedSession::Extra(session) => session.get_note(), - DatedSession::Cancelled { exception, .. } => exception.get_note(), - DatedSession::Altered { exception, .. } => exception.get_note(), - } - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub enum Session { - Regular(RegularSession), - Extra(ExtraSession), -} - -impl From for Session { - fn from(value: RegularSession) -> Self { - Self::Regular(value) - } -} - -impl From for Session { - fn from(value: ExtraSession) -> Self { - Self::Extra(value) - } -} - -impl Session { - pub fn into_dated(self, day: Day) -> Result { - if !self.is_applicable_to(&day) { - return Err(self); - } - - let dated_session = match self { - Session::Regular(session) => match session.except.get(&day.date) { - Some(exception) => match exception.clone() { - Exception::Cancel(exception) => DatedSession::Cancelled { - session, - day, - exception, - }, - Exception::Alter(exception) => DatedSession::Altered { - session, - day, - exception, - }, - }, - None => DatedSession::Regular { session, day }, - }, - Session::Extra(session) => DatedSession::Extra(session), - }; - Ok(dated_session) - } - - pub fn is_applicable_to(&self, day: &Day) -> bool { - match *self { - Session::Regular(RegularSession { rule, .. }) => rule.matches(day), - Session::Extra(ExtraSession { date, .. }) => day.date == date, - } - } -} - -impl Noted for Session { - fn get_note(&self) -> Option<&String> { - match self { - Session::Regular(session) => session.get_note(), - Session::Extra(session) => session.get_note(), - } - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] -pub struct RegularSession { - pub rule: NthWeekday, - pub note: Option, - pub except: HashMap, -} - -impl From for RegularSession { - fn from(rule: NthWeekday) -> Self { - Self { - rule, - note: None, - except: Default::default(), - } - } -} - -impl RegularSession { - pub fn with_exception(mut self, date: D, exception: E) -> Self - where - D: Into, - E: Into, - { - self.except.insert(date.into(), exception.into()); - self - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct ExtraSession { - pub date: NaiveDate, - pub note: Option, -} - -impl From for ExtraSession { - fn from(date: NaiveDate) -> Self { - Self { date, note: None } - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub enum Exception { - Cancel(CancelException), - Alter(AlterException), -} - -impl From for Exception { - fn from(value: CancelException) -> Self { - Self::Cancel(value) - } -} - -impl From for Exception { - fn from(value: AlterException) -> Self { - Self::Alter(value) - } -} - -impl Noted for Exception { - fn get_note(&self) -> Option<&String> { - match self { - Exception::Cancel(exception) => exception.get_note(), - Exception::Alter(exception) => exception.get_note(), - } - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct CancelException { - pub note: Option, -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct AlterException { - pub date: Option, - pub note: Option, -} - -impl From for AlterException { - fn from(value: NaiveDate) -> Self { - Self { - date: Some(value), - ..Self::default() - } - } -} - -pub trait Noted { - fn get_note(&self) -> Option<&String>; -} - -macro_rules! impl_noted { - ($ty: ident) => { - impl Noted for $ty { - fn get_note(&self) -> Option<&String> { - self.note.as_ref() - } - } - }; -} - -impl_noted!(RegularSession); -impl_noted!(ExtraSession); -impl_noted!(AlterException); -impl_noted!(CancelException); - -pub trait WithNote { - fn with_note(self, note: S) -> Self - where - S: ToString; -} - -macro_rules! impl_with_node { - ($ty: ident) => { - impl WithNote for $ty { - fn with_note(self, note: S) -> Self - where - S: ToString, - { - #[allow(clippy::needless_update)] - Self { - note: Some(note.to_string()), - ..self - } - } - } - }; -} - -impl_with_node!(AlterException); -impl_with_node!(CancelException); -impl_with_node!(ExtraSession); -impl_with_node!(RegularSession); diff --git a/src/session_date_calculator.rs b/src/session_date_calculator.rs deleted file mode 100644 index 0ed3a5b..0000000 --- a/src/session_date_calculator.rs +++ /dev/null @@ -1,140 +0,0 @@ -use chrono::{Datelike, Days, Local, NaiveDate, ParseWeekdayError, Weekday}; -use serde::{Deserialize, Serialize}; -use std::error::Error; -use std::fmt::{Display, Formatter}; -use std::num::ParseIntError; -use std::str::FromStr; - -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct NthWeekday { - pub n: u8, - pub weekday: Weekday, -} - -impl NthWeekday { - pub fn new(n: u8, weekday: Weekday) -> Self { - Self { n, weekday } - } - - pub fn matches(&self, day: &Day) -> bool { - self.weekday == day.weekday && self.n == day.week_of_month - } -} - -impl FromStr for NthWeekday { - type Err = NthWeekdayParseError; - - fn from_str(s: &str) -> Result { - let (number, day) = s - .split_once(' ') - .ok_or(NthWeekdayParseError::InvalidFormat)?; - let weekday = Weekday::from_str(day) - .map_err(|e| NthWeekdayParseError::InvalidWeekday(day.to_string(), e))?; - let number = number - .trim_end_matches(|c: char| !c.is_ascii_digit()) - .parse::() - .map_err(|e| NthWeekdayParseError::InvalidN(number.to_string(), e))?; - Ok(Self::new(number, weekday)) - } -} - -#[derive(Debug)] -pub enum NthWeekdayParseError { - InvalidFormat, - InvalidWeekday(String, ParseWeekdayError), - InvalidN(String, ParseIntError), -} - -impl Display for NthWeekdayParseError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - NthWeekdayParseError::InvalidFormat => write!(f, "Invalid format, use e.g '3rd fri'"), - NthWeekdayParseError::InvalidWeekday(week_day, e) => { - write!(f, "Invalid weekday '{week_day}': {e}") - } - NthWeekdayParseError::InvalidN(number, e) => write!(f, "Invalid number '{number}': {e}"), - } - } -} - -impl Error for NthWeekdayParseError {} - -#[derive(Debug)] -pub struct DayIter { - date: Option, -} - -impl From for DayIter { - fn from(value: NaiveDate) -> Self { - Self { date: value.into() } - } -} - -impl From for DayIter { - fn from(value: Day) -> Self { - Self { - date: value.date.into(), - } - } -} - -impl Default for DayIter { - fn default() -> Self { - Self { - date: today().into(), - } - } -} - -impl Iterator for DayIter { - type Item = Day; - - fn next(&mut self) -> Option { - let date = self.date; - self.date = date.and_then(|date| date.checked_add_days(Days::new(1))); - date.map(Day::from) - } -} - -#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct Day { - pub date: NaiveDate, - pub weekday: Weekday, - pub week_of_month: u8, -} - -impl From for Day { - fn from(date: NaiveDate) -> Self { - Self { - date, - weekday: date.weekday(), - week_of_month: date.day0() as u8 / 7 + 1, - } - } -} - -impl Default for Day { - fn default() -> Self { - Self::from(today()) - } -} - -fn today() -> NaiveDate { - Local::now().date_naive() -} - -#[cfg(test)] -mod test { - use crate::session_date_calculator::DayIter; - use chrono::NaiveDate; - - #[test] - fn test_day_iter() { - let mut day_iter = DayIter::from(NaiveDate::from_ymd_opt(2025, 2, 1).expect("valid date")); - for week_of_month in 1..=4 { - for _ in 0..7 { - assert_eq!(day_iter.next().unwrap().week_of_month, week_of_month) - } - } - } -}