diff --git a/src/cli/arguments.rs b/src/cli/arguments.rs index 9a1d3ea..54a0889 100644 --- a/src/cli/arguments.rs +++ b/src/cli/arguments.rs @@ -1,31 +1,57 @@ use super::day_parser::parse_days_of_week; +use crate::cli::week_parser::parse_week_and_part; use crate::domain::models::{day, line_number::LineNumber}; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use color_print::cformat; +use serde::Serialize; use std::str::FromStr; +#[derive(Parser, ValueEnum, Default, Debug, Clone, Serialize, Copy, PartialEq, Eq)] +pub(crate) enum WeekPart { + #[default] + #[serde(rename = "")] + WHOLE, + A, + B, +} +impl FromStr for WeekPart { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "a" => Ok(WeekPart::A), + "b" => Ok(WeekPart::B), + part => Err(format!("Invalid week part '{part}'")), + } + } +} #[derive(Parser, Debug)] pub(crate) struct Week { /// Week number (defaults to current week if omitted) - #[arg(long = "week", short = 'w')] - pub(crate) number: Option, - + #[arg(long = "week", short = 'w',value_parser = parse_week_and_part )] + pub(crate) week: Option, /// N:th previous week counted from current week (defaults to 1 if N is omitted) #[arg( long = "previous-week", short, value_name = "N", - conflicts_with = "number", + conflicts_with = "week", default_missing_value = Some("1"), num_args(0..=1), )] pub(crate) previous: Option, /// Year (defaults to current year if omitted) - #[arg(long, short, requires = "number")] + #[arg(long, short, requires = "week")] pub(crate) year: Option, } +#[derive(Debug, Clone)] +pub(crate) struct WeekAndPart { + pub(crate) number: Option, + pub(crate) part: Option, +} + #[derive(Parser, Debug)] pub(crate) struct Task { /// Name of the job @@ -91,6 +117,10 @@ pub enum Command { #[arg(long, short, default_value = "table")] format: Format, + /// Show all rows, including those with no hours reported + #[arg(long)] + full: bool, + #[command(flatten)] week: Week, }, @@ -139,6 +169,7 @@ pub enum Command { arg_required_else_help = true, after_help = cformat!("Examples:\ \n maconomy get \ + \n maconomy get --full\ \n maconomy set 8 --job '<>' --task '<>' \ \n maconomy set 8 --job '<>' --task '<>' --day 'mon-wed, fri' --week 46 \ \n maconomy set 8 --job '<>' --task '<>' --day mo --previous-week 2 \ diff --git a/src/cli/commands.rs b/src/cli/commands.rs index c9bdffa..e859fdc 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,4 +1,4 @@ -use super::arguments::Format; +use super::arguments::{Format, WeekAndPart, WeekPart}; use crate::domain::models::day::Days; use crate::domain::models::line_number::LineNumber; use crate::domain::models::week::WeekNumber; @@ -43,10 +43,10 @@ impl<'a> CommandClient<'a> { } } - pub(crate) async fn get_table(&self, week: &WeekNumber) -> anyhow::Result<()> { + pub(crate) async fn get_table(&self, week: &WeekNumber, full: bool) -> anyhow::Result<()> { let time_sheet = self.repository.lock().await.get_time_sheet(week).await?; - println!("{time_sheet}"); + println!("{}", time_sheet.format_table(full)); Ok(()) } @@ -59,12 +59,12 @@ impl<'a> CommandClient<'a> { Ok(()) } - pub(crate) async fn get(&self, week: super::arguments::Week, format: Format) { - let week = get_week_number(&week.number, &week.previous, &week.year); + pub(crate) async fn get(&self, week: super::arguments::Week, format: Format, full: bool) { + let week = get_week_number(week.week, week.year, week.previous); match format { Format::Json => self.get_json(&week).await.context("JSON"), - Format::Table => self.get_table(&week).await.context("table"), + Format::Table => self.get_table(&week, full).await.context("table"), } .unwrap_or_else(|err| { exit_with_error!("Failed to get time sheet as {}", error_stack_fmt(&err)); @@ -82,7 +82,7 @@ impl<'a> CommandClient<'a> { } let day = get_days(days.days.clone()); - let week = get_week_number(&days.week.number, &days.week.previous, &days.week.year); + let week = get_week_number(days.week.week.clone(),days.week.year, days.week.previous ); self.time_sheet_service .lock() @@ -107,7 +107,7 @@ impl<'a> CommandClient<'a> { exit_with_error!("`--day` is set but no day was provided"); } - let week = get_week_number(&days.week.number, &days.week.previous, &days.week.year); + let week = get_week_number(days.week.week.clone(), days.week.year, days.week.previous); self.time_sheet_service .lock() .await @@ -129,7 +129,7 @@ impl<'a> CommandClient<'a> { } pub(crate) async fn delete(&mut self, line_number: &LineNumber, week: super::arguments::Week) { - let week = get_week_number(&week.number, &week.previous, &week.year); + let week = get_week_number(week.week, week.year, week.previous,); self.repository .lock() @@ -143,7 +143,7 @@ impl<'a> CommandClient<'a> { } pub(crate) async fn submit(&mut self, week: super::arguments::Week) { - let week = get_week_number(&week.number, &week.previous, &week.year); + let week = get_week_number(week.week, week.year,week.previous); self.repository .lock() @@ -157,19 +157,24 @@ impl<'a> CommandClient<'a> { } fn get_week_number( - week: &Option, - previous_week: &Option, - year: &Option, + week: Option, + year: Option, + previous_week: Option, ) -> WeekNumber { // NOTE: `week` and `previous_week` are assumed to be mutually exclusive (handled by Clap) if let Some(week) = previous_week { - nth_previous_week(*week).unwrap_or_else(|err| { + nth_previous_week(week).unwrap_or_else(|err| { exit_with_error!("{err}"); }) } else { - let week = week.unwrap_or_else(|| WeekNumber::default().number); - WeekNumber::new_with_year_fallback(week, *year) - .unwrap_or_else(|err| exit_with_error!("{err}")) + let y = year.unwrap_or_else(|| chrono::Utc::now().year()); + week.map(|week_and_part| { + let number = week_and_part.number.ok_or_else(|| anyhow::anyhow!("Week number is required"))?; + let part = week_and_part.part.unwrap_or(WeekPart::WHOLE); + WeekNumber::new(number, part, y) + }) + .unwrap_or_else(|| Ok(WeekNumber::default())) + .unwrap_or_else(|err| {exit_with_error!("{err}");}) } } @@ -183,13 +188,11 @@ fn get_days(days: Option) -> Days { } fn nth_previous_week(n: u8) -> anyhow::Result { - let today_week_last = chrono::Local::now().date_naive() - chrono::Duration::weeks(n.into()); - let week = today_week_last - .iso_week() - .week() - .try_into() - .expect("Week numbers are always less than 255"); - let year = today_week_last.year(); - - WeekNumber::new(week, year) + let mut week = WeekNumber::of(chrono::Local::now().date_naive()); + + for _ in 0..n { + week = week.previous(); + } + + Ok(week) } diff --git a/src/cli/day_parser.rs b/src/cli/day_parser.rs index 47fa365..ee71e60 100644 --- a/src/cli/day_parser.rs +++ b/src/cli/day_parser.rs @@ -1,4 +1,5 @@ use crate::domain::models::day::{Day, Days}; +use std::convert::TryFrom; use anyhow::{anyhow, Context}; use nom::{ branch::alt, @@ -87,7 +88,9 @@ fn days_in_range((start, end): Range) -> Option> { let (start, end) = (start as u8, end as u8); if start < end { - Some((start..=end).map(Day::from).collect()) + (start..=end) + .map(|d| Day::try_from(d).ok()) + .collect() } else { None } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3e76272..bb0687c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod arguments; pub(crate) mod commands; pub(crate) mod day_parser; pub(crate) mod rendering; +mod week_parser; diff --git a/src/cli/rendering.rs b/src/cli/rendering.rs index a48f669..47b0844 100644 --- a/src/cli/rendering.rs +++ b/src/cli/rendering.rs @@ -1,12 +1,34 @@ use crate::domain::models::time_sheet::{Line, TimeSheet}; -use owo_colors::OwoColorize; +use crate::domain::models::swedish_holidays::{is_holiday, HolidayType}; use std::fmt::Display; -use tabled::settings::{ - object::Rows, style::BorderColor, themes::Colorization, Color, Panel, Style, Theme, -}; +use std::iter; +use std::ops::Add; +use chrono::{Datelike, Days, NaiveDate, Weekday}; +use tabled::grid::config::Borders; +use tabled::settings::{object::Rows, style::BorderColor, themes::Colorization, Color, Highlight, Modify, Panel, Style, Theme}; +use tabled::settings::object::{Columns, Object}; +use tabled::settings::panel::HorizontalPanel; +use tabled::settings::style::VerticalLine; +use crate::domain::models::week::{WeekNumber}; + +#[derive(Default)] +pub(crate) struct SumWithApproval { + pub(crate) sum: f32, + pub(crate) is_approved: bool, +} + +fn is_approved(approval_status: &str) -> bool { + // Check if approval_status indicates approval (non-empty and not "pending" or similar) + // Common approval statuses might be "approved", "Approved", etc. + !approval_status.is_empty() && + !approval_status.eq_ignore_ascii_case("pending") && + !approval_status.eq_ignore_ascii_case("draft") +} #[derive(tabled::Tabled, Default)] -pub(crate) struct Row<'a> { +pub(crate) struct LineRow<'a> { + #[tabled(rename = "Job number")] + pub(crate) job_number: &'a str, #[tabled(rename = "Job name")] pub(crate) job_name: &'a str, #[tabled(rename = "Task name")] @@ -32,77 +54,355 @@ pub(crate) struct Row<'a> { #[tabled(rename = "Sun")] #[tabled(display_with = "display_hours")] pub(crate) sunday: f32, + #[tabled(rename = "Sum")] + #[tabled(display_with = "display_sum_with_approval")] + pub(crate) sum: SumWithApproval, +} +#[derive(tabled::Tabled)] +pub(crate) struct DateRow { + #[tabled(rename = "Job number")] + pub(crate) job_number: String, + #[tabled(rename = "Job name")] + pub(crate) job_name: String, + #[tabled(rename = "Task name")] + pub(crate) task_name: String, + #[tabled(rename = "Mon")] + #[tabled(display_with = "day")] + pub(crate) monday: NaiveDate, + #[tabled(rename = "Tue")] + #[tabled(display_with = "day")] + pub(crate) tuesday: NaiveDate, + #[tabled(rename = "Wed")] + #[tabled(display_with = "day")] + pub(crate) wednesday: NaiveDate, + #[tabled(rename = "Thu")] + #[tabled(display_with = "day")] + pub(crate) thursday: NaiveDate, + #[tabled(rename = "Fri")] + #[tabled(display_with = "day")] + pub(crate) friday: NaiveDate, + #[tabled(rename = "Sat")] + #[tabled(display_with = "day")] + pub(crate) saturday: NaiveDate, + #[tabled(rename = "Sun")] + #[tabled(display_with = "day")] + pub(crate) sunday: NaiveDate, + #[tabled(rename = "Sum")] + #[tabled(display_with = "day_or_empty")] + pub(crate) sum: NaiveDate, +} + +use std::borrow::Cow; +use std::iter::{Chain, Map, Once}; +use std::vec::IntoIter; +use tabled::Tabled; + +pub(crate) enum Row<'a> { + LineRow(LineRow<'a>), + DateRow(DateRow), +} + +impl<'a> Tabled for Row<'a> { + const LENGTH: usize = 11; + + fn fields(&self) -> Vec> { + match self { + Row::LineRow(line) => line.fields(), + Row::DateRow(date) => date.fields(), + } + } + + fn headers() -> Vec> { + LineRow::headers() + } } +fn day(day: &NaiveDate) -> impl Display { + day.format("%-d").to_string() +} + +fn day_or_empty(day: &NaiveDate) -> impl Display { + if *day == NaiveDate::default() { + String::new() + } else { + day.format("%-d").to_string() + } +} fn display_hours(hours: &f32) -> impl Display { - if let 0.0 = hours { - return "".to_string(); + if (*hours - 0.0).abs() < f32::EPSILON { + return String::new(); } let whole_hours = hours.trunc() as u32; - let minutes = ((*hours - whole_hours as f32) * 60.0).floor() as u32; + let minutes = ((hours - whole_hours as f32) * 60.0).floor() as u32; format!("{whole_hours}:{minutes:02}") } -impl<'a> From<&'a Line> for Row<'a> { +fn display_sum_with_approval(sum_with_approval: &SumWithApproval) -> impl Display { + let hours_str = if (sum_with_approval.sum - 0.0).abs() < f32::EPSILON { + String::new() + } else { + let whole_hours = sum_with_approval.sum.trunc() as u32; + let minutes = ((sum_with_approval.sum - whole_hours as f32) * 60.0).floor() as u32; + format!("{whole_hours}:{minutes:02}") + }; + + if sum_with_approval.is_approved && !hours_str.is_empty() { + // Use green checkmark (✓) with ANSI color codes + // Format: right-aligned hours + space + checkmark + // This keeps numbers right-aligned while adding checkmark on the right + format!("{hours_str} \x1b[32m✓\x1b[0m") + } else { + hours_str + } +} + +impl<'a> From<&'a Line> for LineRow<'a> { fn from(line: &'a Line) -> Self { - Row { + let monday = line.week.monday.0; + let tuesday = line.week.tuesday.0; + let wednesday = line.week.wednesday.0; + let thursday = line.week.thursday.0; + let friday = line.week.friday.0; + let saturday = line.week.saturday.0; + let sunday = line.week.sunday.0; + let sum_value = monday + tuesday + wednesday + thursday + friday + saturday + sunday; + let sum = SumWithApproval { + sum: sum_value, + is_approved: is_approved(&line.approval_status), + }; + + LineRow { + job_number: &line.number, job_name: &line.job, task_name: &line.task, - monday: line.week.monday.0, - tuesday: line.week.tuesday.0, - wednesday: line.week.wednesday.0, - thursday: line.week.thursday.0, - friday: line.week.friday.0, - saturday: line.week.saturday.0, - sunday: line.week.sunday.0, + monday, + tuesday, + wednesday, + thursday, + friday, + saturday, + sunday, + sum, } } } +impl<'a> From> for Row<'a> { + fn from(line_row: LineRow<'a>) -> Self { + Row::LineRow(line_row) + } +} + fn gray() -> Color { - Color::parse(' '.fg_rgb::<85, 85, 85>().to_string()).clone() + Color::parse("\x1b[38;2;085;085;085m \x1b[39m") + // Color::parse(' '.fg_rgb::<85, 85, 85>().to_string()) } fn gray_borders() -> BorderColor { + let gray_color = gray(); BorderColor::new() - .top(gray().clone()) - .left(gray().clone()) - .bottom(gray().clone()) - .right(gray().clone()) - .corner_bottom_right(gray().clone()) - .corner_bottom_left(gray().clone()) - .corner_top_left(gray().clone()) - .corner_top_right(gray().clone()) + .top(gray_color.clone()) + .left(gray_color.clone()) + .bottom(gray_color.clone()) + .right(gray_color.clone()) + .corner_bottom_right(gray_color.clone()) + .corner_bottom_left(gray_color.clone()) + .corner_top_left(gray_color.clone()) + .corner_top_right(gray_color) +} + +/// Checks if a date should be marked in red (weekend, public holiday, or de facto full holiday) +fn should_be_red(date: NaiveDate) -> bool { + // Check if it's a weekend + if date.weekday() == Weekday::Sat || date.weekday() == Weekday::Sun { + return true; + } + + // Check if it's a public holiday or de facto full holiday + if let Some(holiday) = is_holiday(date) { + return matches!(holiday.holiday_type, HolidayType::Public | HolidayType::DeFacto); + } + + false +} + +/// Returns an array of Colors (FG_RED or FG_BLUE) based on whether each date should be red +fn get_column_colors(dates: [NaiveDate; 7]) -> [Color; 7] { + dates + .iter() + .map(|&date| if should_be_red(date) { Color::FG_RED } else { Color::FG_BLUE }) + .collect::>() + .try_into() + .expect("Iterator should produce exactly 7 colors") +} + +/// Applies a Color (via OR) to each element in the array +fn apply_color_to_array(colors: [Color; 7], color_to_apply: Color) -> [Color; 7] { + colors + .iter() + .map(|c| c.clone() | color_to_apply.clone()) + .collect::>() + .try_into() + .expect("Iterator should produce exactly 7 colors") +} + +/// Builds a color array by concatenating prefix colors with column colors +fn build_color_array(prefix: [Color; 3], column_colors: &[Color; 7]) -> [Color; 10] { + prefix + .iter() + .cloned() + .chain(column_colors.iter().cloned()) + .collect::>() + .try_into() + .expect("Iterator should produce exactly 10 colors") } impl Display for TimeSheet { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let rows = self.lines.iter().map(Row::from); + // Default to showing all rows when using Display trait + write!(f, "{}", self.format_table(true)) + } +} + +impl TimeSheet { + pub(crate) fn format_table(&self, full: bool) -> String { + let line_rowz = self.time_rows(full); + + let date_row = self.date_row(); + + // Determine which columns should be red based on dates (extract before moving date_row) + let dates = [ + date_row.monday, + date_row.tuesday, + date_row.wednesday, + date_row.thursday, + date_row.friday, + date_row.saturday, + date_row.sunday, + ]; + + let column_colors_normal = get_column_colors(dates); + let column_colors = apply_color_to_array(column_colors_normal.clone(), Color::BOLD); + + let rows: Vec = line_rowz + .chain(iter::once(Row::DateRow(date_row))) + .collect(); let mut theme = Theme::from_style(Style::modern_rounded()); theme.remove_vertical_lines(); + theme.insert_vertical_line(10, VerticalLine::inherit(Style::modern_rounded())); let mut table = tabled::Table::new(rows); let table = table - .with(theme) + .with(theme) .with(Colorization::exact( - [tabled::settings::Color::BOLD], - Rows::first(), - )) - .with(Panel::footer(format!("Week {}", self.week_number))) - .with(Colorization::exact([gray()], Rows::last())) - .with(gray_borders()); + build_color_array( + [ + Color::FG_WHITE, + Color::BOLD | Color::FG_WHITE, + Color::BOLD | Color::FG_WHITE, + ], + &column_colors, + ), + Rows::first().intersect(Columns::new(0..10)) + )).with(Colorization::exact( + build_color_array( + [ + Color::FG_WHITE, + Color::FG_WHITE, + Color::FG_WHITE, + ], + &column_colors_normal, + ), + Rows::first().inverse().intersect(Columns::new(0..10)) + )) + .with(Colorization::exact([Color::BOLD], Rows::last())) + .with(Colorization::exact([Color::BOLD|Color::FG_BRIGHT_WHITE], (Rows::last()-1).intersect(Columns::new(0..11)))) + ; + table.to_string() + } +} + +impl TimeSheet { + fn time_rows<'a>(&'a self, full: bool) -> Chain>, fn(LineRow<'a>) -> Row<'a>>, Once>> { + // Convert all Lines to LineRows for sum calculation + let line_rows: Vec> = self.lines.iter().map(LineRow::from).collect(); + + // Filter out rows with no hours if full is false + let filtered_line_rows: Vec> = if full { + line_rows + } else { + line_rows.into_iter() + .filter(|row| (row.sum.sum - 0.0).abs() >= f32::EPSILON) + .collect() + }; + + // Sum all weekday values across filtered rows + let monday_sum: f32 = filtered_line_rows.iter().map(|r| r.monday).sum(); + let tuesday_sum: f32 = filtered_line_rows.iter().map(|r| r.tuesday).sum(); + let wednesday_sum: f32 = filtered_line_rows.iter().map(|r| r.wednesday).sum(); + let thursday_sum: f32 = filtered_line_rows.iter().map(|r| r.thursday).sum(); + let friday_sum: f32 = filtered_line_rows.iter().map(|r| r.friday).sum(); + let saturday_sum: f32 = filtered_line_rows.iter().map(|r| r.saturday).sum(); + let sunday_sum: f32 = filtered_line_rows.iter().map(|r| r.sunday).sum(); + let total_sum = monday_sum + tuesday_sum + wednesday_sum + thursday_sum + friday_sum + saturday_sum + sunday_sum; - write!(f, "{table}") + let sum_row = LineRow { + job_number: "--", + job_name: "Sum", + task_name: "", + monday: monday_sum, + tuesday: tuesday_sum, + wednesday: wednesday_sum, + thursday: thursday_sum, + friday: friday_sum, + saturday: saturday_sum, + sunday: sunday_sum, + sum: SumWithApproval { + sum: total_sum, + is_approved: false, // Sum row is never approved + }, + }; + // Convert to Vec and chain the rows iterator with the sum row and date row + let line_rowz = filtered_line_rows.into_iter() + .map(Row::LineRow as fn(LineRow<'a>) -> Row<'a>) + .chain(iter::once(Row::LineRow(sum_row))); + let chain = line_rowz; + chain + } + + fn date_row(&self) -> DateRow { + // let monday = self.week_number.first_day().unwrap(); + let week_str = format!("Week {}", self.week_number); + let w = self.week_number.number; + let monday = NaiveDate::from_isoywd_opt(self.week_number.year, w.into(), Weekday::Mon).unwrap(); + let sunday = NaiveDate::from_isoywd_opt(self.week_number.year, w.into(), Weekday::Sun).unwrap(); + let start_month_str = monday.format("%B").to_string(); + let end_month_str = sunday.format("%B").to_string(); + let date_row = DateRow { + job_number: week_str, + job_name: start_month_str, + task_name: end_month_str, + monday: monday, + tuesday: monday.add(Days::new(1)), + wednesday: monday.add(Days::new(2)), + thursday: monday.add(Days::new(3)), + friday: monday.add(Days::new(4)), + saturday: monday.add(Days::new(5)), + sunday: monday.add(Days::new(6)), + sum: Default::default(), + }; + date_row } } #[cfg(test)] mod test { use super::*; - use crate::domain::models::{hours::Hours, time_sheet::Week}; + use crate::cli::arguments::WeekPart; + use crate::domain::models::{hours::Hours, time_sheet::Week, week::WeekNumber}; #[test] fn displays_hours() { @@ -131,24 +431,31 @@ mod test { #[test] fn display_ansi_stripped_timesheet() { let time_sheet = (TimeSheet { + create_action: None, lines: vec![ Line { + number: "one".to_string(), job: "Job number one".to_string(), task: "Task number one".to_string(), week: create_week([8, 8, 0, 0, 0, 0, 0]), + approval_status: "".to_string(), }, Line { + number: "two".to_string(), job: "job number two".to_string(), task: "task number two".to_string(), week: create_week([0, 0, 8, 8, 1, 1, 0]), + approval_status: "".to_string(), }, Line { + number: "three".to_string(), job: "job number three".to_string(), task: "task number three".to_string(), week: create_week([0, 0, 0, 0, 7, 7, 8]), + approval_status: "".to_string(), }, ], - week_number: 47, + week_number: WeekNumber::new(47, WeekPart::WHOLE, 2024).unwrap(), }) .to_string(); @@ -160,26 +467,166 @@ mod test { #[test] fn display_timesheet() { let time_sheet = (TimeSheet { + create_action: None, lines: vec![ Line { + number: "one".to_string(), job: "Job number one".to_string(), task: "Task number one".to_string(), week: create_week([8, 8, 0, 0, 0, 0, 0]), + approval_status: "approved".to_string(), }, Line { + number: "two".to_string(), job: "job number two".to_string(), task: "task number two".to_string(), week: create_week([0, 0, 8, 8, 1, 1, 0]), + approval_status: "".to_string(), }, Line { + number: "three".to_string(), job: "job number three".to_string(), task: "task number three".to_string(), week: create_week([0, 0, 0, 0, 7, 7, 8]), + approval_status: "Approved".to_string(), }, ], - week_number: 47, + week_number: WeekNumber::new(47, WeekPart::WHOLE, 2024).unwrap(), }) .to_string(); insta::assert_snapshot!(time_sheet.to_string()); } + + #[test] + fn format_table_hides_rows_with_no_hours_when_full_is_false() { + let time_sheet = TimeSheet { + create_action: None, + lines: vec![ + Line { + number: "one".to_string(), + job: "Job with hours".to_string(), + task: "Task with hours".to_string(), + week: create_week([8, 8, 0, 0, 0, 0, 0]), + approval_status: "".to_string(), + }, + Line { + number: "two".to_string(), + job: "Job with no hours".to_string(), + task: "Task with no hours".to_string(), + week: create_week([0, 0, 0, 0, 0, 0, 0]), + approval_status: "".to_string(), + }, + Line { + number: "three".to_string(), + job: "Another job with hours".to_string(), + task: "Another task with hours".to_string(), + week: create_week([0, 0, 8, 8, 0, 0, 0]), + approval_status: "".to_string(), + }, + ], + week_number: WeekNumber::new(47, WeekPart::WHOLE, 2024).unwrap(), + }; + + let output = time_sheet.format_table(false); + let ansi_stripped = anstream::adapter::strip_str(&output).to_string(); + + // Should not contain the row with no hours + assert!(!ansi_stripped.contains("Job with no hours")); + assert!(!ansi_stripped.contains("Task with no hours")); + + // Should contain rows with hours + assert!(ansi_stripped.contains("Job with hours")); + assert!(ansi_stripped.contains("Another job with hours")); + + // Should contain the sum row + assert!(ansi_stripped.contains("Sum")); + + insta::assert_snapshot!(ansi_stripped); + } + + #[test] + fn format_table_shows_all_rows_when_full_is_true() { + let time_sheet = TimeSheet { + create_action: None, + lines: vec![ + Line { + number: "one".to_string(), + job: "Job with hours".to_string(), + task: "Task with hours".to_string(), + week: create_week([8, 8, 0, 0, 0, 0, 0]), + approval_status: "".to_string(), + }, + Line { + number: "two".to_string(), + job: "Job with no hours".to_string(), + task: "Task with no hours".to_string(), + week: create_week([0, 0, 0, 0, 0, 0, 0]), + approval_status: "".to_string(), + }, + Line { + number: "three".to_string(), + job: "Another job with hours".to_string(), + task: "Another task with hours".to_string(), + week: create_week([0, 0, 8, 8, 0, 0, 0]), + approval_status: "".to_string(), + }, + ], + week_number: WeekNumber::new(47, WeekPart::WHOLE, 2024).unwrap(), + }; + + let output = time_sheet.format_table(true); + let ansi_stripped = anstream::adapter::strip_str(&output).to_string(); + + // Should contain all rows including the one with no hours + assert!(ansi_stripped.contains("Job with hours")); + assert!(ansi_stripped.contains("Job with no hours")); + assert!(ansi_stripped.contains("Task with no hours")); + assert!(ansi_stripped.contains("Another job with hours")); + + // Should contain the sum row + assert!(ansi_stripped.contains("Sum")); + + insta::assert_snapshot!(ansi_stripped); + } + + #[test] + fn format_table_sum_row_reflects_filtered_rows() { + let time_sheet = TimeSheet { + create_action: None, + lines: vec![ + Line { + number: "one".to_string(), + job: "Job one".to_string(), + task: "Task one".to_string(), + week: create_week([8, 8, 0, 0, 0, 0, 0]), + approval_status: "".to_string(), + }, + Line { + number: "two".to_string(), + job: "Job two".to_string(), + task: "Task two".to_string(), + week: create_week([0, 0, 0, 0, 0, 0, 0]), // No hours + approval_status: "".to_string(), + }, + Line { + number: "three".to_string(), + job: "Job three".to_string(), + task: "Task three".to_string(), + week: create_week([0, 0, 4, 4, 0, 0, 0]), + approval_status: "".to_string(), + }, + ], + week_number: WeekNumber::new(47, WeekPart::WHOLE, 2024).unwrap(), + }; + + // When full=false, sum should only include rows with hours (16 + 8 = 24) + let output_filtered = time_sheet.format_table(false); + let ansi_stripped_filtered = anstream::adapter::strip_str(&output_filtered).to_string(); + assert!(ansi_stripped_filtered.contains("24:00")); // 16 + 8 hours + + // When full=true, sum should include all rows (16 + 0 + 8 = 24, same in this case) + let output_full = time_sheet.format_table(true); + let ansi_stripped_full = anstream::adapter::strip_str(&output_full).to_string(); + assert!(ansi_stripped_full.contains("24:00")); + } } diff --git a/src/cli/snapshots/maconomy__cli__rendering__test__display_ansi_stripped_timesheet.snap b/src/cli/snapshots/maconomy__cli__rendering__test__display_ansi_stripped_timesheet.snap index 11c9632..94407b4 100644 --- a/src/cli/snapshots/maconomy__cli__rendering__test__display_ansi_stripped_timesheet.snap +++ b/src/cli/snapshots/maconomy__cli__rendering__test__display_ansi_stripped_timesheet.snap @@ -1,16 +1,17 @@ --- source: src/cli/rendering.rs expression: ansi_stripped_time_sheet.to_string() -snapshot_kind: text --- -╭───────────────────────────────────────────────────────────────────────────────╮ -│ Job name Task name Mon Tue Wed Thu Fri Sat Sun │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ Job number one Task number one 8:00 8:00 │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ job number two task number two 8:00 8:00 1:00 1:00 │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ job number three task number three 7:00 7:00 8:00 │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ Week 47 │ -╰───────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────────────────────┬───────╮ +│ Job number Job name Task name Mon Tue Wed Thu Fri Sat Sun │ Sum │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ one Job number one Task number one 8:00 8:00 │ 16:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ two job number two task number two 8:00 8:00 1:00 1:00 │ 18:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ three job number three task number three 7:00 7:00 8:00 │ 22:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ -- Sum 8:00 8:00 8:00 8:00 8:00 8:00 8:00 │ 56:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ Week 2024W47 November November 18 19 20 21 22 23 24 │ │ +╰─────────────────────────────────────────────────────────────────────────────────────────────┴───────╯ diff --git a/src/cli/snapshots/maconomy__cli__rendering__test__display_timesheet.snap b/src/cli/snapshots/maconomy__cli__rendering__test__display_timesheet.snap index a67b020..adcc3c9 100644 --- a/src/cli/snapshots/maconomy__cli__rendering__test__display_timesheet.snap +++ b/src/cli/snapshots/maconomy__cli__rendering__test__display_timesheet.snap @@ -1,16 +1,17 @@ --- source: src/cli/rendering.rs expression: time_sheet.to_string() -snapshot_kind: text --- -╭───────────────────────────────────────────────────────────────────────────────╮ -│ Job name   Task name   Mon   Tue   Wed   Thu   Fri   Sat   Sun  │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ Job number one Task number one 8:00 8:00 │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ job number two task number two 8:00 8:00 1:00 1:00 │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ job number three task number three 7:00 7:00 8:00 │ -├───────────────────────────────────────────────────────────────────────────────┤ -│ Week 47  │ -╰───────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────────────────────┬───────╮ +│ Job number   Job name   Task name   Mon   Tue   Wed   Thu   Fri   Sat   Sun  │ Sum │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ one   Job number one   Task number one   8:00  8:00                │ 16:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ two   job number two   task number two         8:00  8:00  1:00  1:00    │ 18:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ three   job number three  task number three              7:00  7:00  8:00 │ 22:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ --   Sum      8:00  8:00  8:00  8:00  8:00  8:00  8:00 │ 56:00 │ +├─────────────────────────────────────────────────────────────────────────────────────────────┼───────┤ +│ Week 2024W47  November   November   18   19   20   21   22   23   24  │   │ +╰─────────────────────────────────────────────────────────────────────────────────────────────┴───────╯ diff --git a/src/cli/week_parser.rs b/src/cli/week_parser.rs new file mode 100644 index 0000000..4cf38c0 --- /dev/null +++ b/src/cli/week_parser.rs @@ -0,0 +1,30 @@ +use crate::cli::arguments::{WeekAndPart, WeekPart}; +use anyhow::{anyhow, Context}; +pub(crate) fn parse_week_and_part(input: &str) -> anyhow::Result { + if input.chars().next_back().is_some_and(|c| c.is_ascii_digit()) { + // String ends with a digit, parse entire string as week number + let number = input + .parse::() + .with_context(|| format!("Failed to parse week number from '{}'", input))?; + Ok(WeekAndPart { + number: Some(number), + part: Some(WeekPart::WHOLE), + }) + } else { + // String doesn't end with a digit, last character should be A or B + if input.is_empty() { + return Err(anyhow!("Week string cannot be empty")); + } + let (week_str, part_str) = input.split_at(input.len() - 1); + let part = part_str + .parse::() + .map_err(|e| anyhow!("Failed to parse week part from '{}', expected 'A' or 'B': {}", part_str, e))?; + let number = week_str + .parse::() + .with_context(|| format!("Failed to parse week number from '{}'", week_str))?; + Ok(WeekAndPart { + number: Some(number), + part: Some(part), + }) + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index a17c80c..7c5ff23 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod models { pub(crate) mod day; pub(crate) mod hours; pub(crate) mod line_number; + pub(crate) mod swedish_holidays; pub(crate) mod time_sheet; pub(crate) mod week; } diff --git a/src/domain/models/day.rs b/src/domain/models/day.rs index d508198..fedd315 100644 --- a/src/domain/models/day.rs +++ b/src/domain/models/day.rs @@ -1,4 +1,4 @@ -use std::{borrow::Borrow, collections::HashSet, fmt::Display, str::FromStr}; +use std::{collections::HashSet, fmt::Display, str::FromStr}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum Day { @@ -17,32 +17,33 @@ impl FromStr for Day { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - let day = match s.to_lowercase().borrow() { - "monday" => Day::Monday, - "tuesday" => Day::Tuesday, - "wednesday" => Day::Wednesday, - "thursday" => Day::Thursday, - "friday" => Day::Friday, - "saturday" => Day::Saturday, - "sunday" => Day::Sunday, + match s.to_lowercase().as_str() { + "monday" => Ok(Day::Monday), + "tuesday" => Ok(Day::Tuesday), + "wednesday" => Ok(Day::Wednesday), + "thursday" => Ok(Day::Thursday), + "friday" => Ok(Day::Friday), + "saturday" => Ok(Day::Saturday), + "sunday" => Ok(Day::Sunday), _ => anyhow::bail!("Unrecognized day {s}"), - }; - Ok(day) + } } } -impl From for Day { - fn from(day: u8) -> Self { - let week = [ - Day::Monday, - Day::Tuesday, - Day::Wednesday, - Day::Thursday, - Day::Friday, - Day::Saturday, - Day::Sunday, - ]; - *week.get(day as usize - 1).expect("Invalid day") +impl TryFrom for Day { + type Error = &'static str; + + fn try_from(day: u8) -> Result { + match day { + 1 => Ok(Day::Monday), + 2 => Ok(Day::Tuesday), + 3 => Ok(Day::Wednesday), + 4 => Ok(Day::Thursday), + 5 => Ok(Day::Friday), + 6 => Ok(Day::Saturday), + 7 => Ok(Day::Sunday), + _ => Err("Day must be between 1 and 7"), + } } } diff --git a/src/domain/models/swedish_holidays.rs b/src/domain/models/swedish_holidays.rs new file mode 100644 index 0000000..9777be8 --- /dev/null +++ b/src/domain/models/swedish_holidays.rs @@ -0,0 +1,418 @@ +use chrono::{Datelike, NaiveDate, Weekday}; + +/// Represents the type of Swedish holiday +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HolidayType { + /// Official public holiday (officiell helgdag) + Public, + /// De facto holiday - full day (de facto helgdag) + DeFacto, + /// De facto holiday - half day (de facto halvdag) + DeFactoHalf, +} + +/// Information about a Swedish holiday +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Holiday { + pub(crate) date: NaiveDate, + pub(crate) holiday_type: HolidayType, + pub(crate) name: &'static str, +} + +/// Checks if a date is a Swedish holiday and returns information about it +pub(crate) fn is_holiday(date: NaiveDate) -> Option { + let year = date.year(); + let month = date.month(); + let day = date.day(); + + // Fixed date holidays + match (month, day) { + // New Year's Day (Nyårsdagen) - Public holiday + (1, 1) => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Nyårsdagen", + }), + // Twelfth Night (Trettondagsafton) - De facto half holiday + (1, 5) => Some(Holiday { + date, + holiday_type: HolidayType::DeFactoHalf, + name: "Trettondagsafton", + }), + // Epiphany (Trettondagen) - Public holiday + (1, 6) => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Trettondagen", + }), + // Valborg (Valborgsmässoafton) - De facto half holiday + (4, 30) => Some(Holiday { + date, + holiday_type: HolidayType::DeFactoHalf, + name: "Valborgsmässoafton", + }), + // May Day (Första maj) - Public holiday + (5, 1) => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Första maj", + }), + // National Day (Sveriges nationaldag) - Public holiday + (6, 6) => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Sveriges nationaldag", + }), + // Christmas Eve (Julafton) - De facto holiday + (12, 24) => Some(Holiday { + date, + holiday_type: HolidayType::DeFacto, + name: "Julafton", + }), + // Christmas Day (Juldagen) - Public holiday + (12, 25) => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Juldagen", + }), + // Boxing Day (Annandag jul) - Public holiday + (12, 26) => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Annandag jul", + }), + // New Year's Eve (Nyårsafton) - De facto holiday + (12, 31) => Some(Holiday { + date, + holiday_type: HolidayType::DeFacto, + name: "Nyårsafton", + }), + _ => None, + } + .or_else(|| { + // Easter-based holidays + let easter = easter_date(year); + let easter_offset = date.signed_duration_since(easter).num_days(); + + match easter_offset { + // Maundy Thursday (Skärtorsdagen) - De facto half holiday + -3 => Some(Holiday { + date, + holiday_type: HolidayType::DeFactoHalf, + name: "Skärtorsdagen", + }), + // Good Friday (Långfredagen) - Public holiday + -2 => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Långfredagen", + }), + // Holy Saturday (Påskafton) - De facto half holiday + -1 => Some(Holiday { + date, + holiday_type: HolidayType::DeFactoHalf, + name: "Påskafton", + }), + // Easter Sunday (Påskdagen) - Public holiday + 0 => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Påskdagen", + }), + // Easter Monday (Annandag påsk) - Public holiday + 1 => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Annandag påsk", + }), + // Ascension Eve (Kristi himmelfärdsdag) - De facto half holiday + // Easter + 5*7 + 3 = Easter + 38 + 38 => Some(Holiday { + date, + holiday_type: HolidayType::DeFactoHalf, + name: "Kristi himmelfärdsdag", + }), + // Ascension Day (Kristi himmelfärdsdag) - Public holiday + // Easter + 5*7 + 4 = Easter + 39 + 39 => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Kristi himmelfärdsdag", + }), + // Whit Sunday (Pingstdagen) - Public holiday + // Easter + 7*7 = Easter + 49 + 49 => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Pingstdagen", + }), + // Whit Monday (Annandag pingst) - Public holiday + // Easter + 7*7 + 1 = Easter + 50 + 50 => Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Annandag pingst", + }), + _ => None, + } + }) + .or_else(|| { + // Midsummer Eve (Midsommarafton) - Friday between June 20-26 - De facto holiday + if date >= NaiveDate::from_ymd_opt(year, 6, 20).unwrap() + && date <= NaiveDate::from_ymd_opt(year, 6, 26).unwrap() + && date.weekday() == Weekday::Fri + { + Some(Holiday { + date, + holiday_type: HolidayType::DeFacto, + name: "Midsommarafton", + }) + } else if date >= NaiveDate::from_ymd_opt(year, 6, 20).unwrap() + && date <= NaiveDate::from_ymd_opt(year, 6, 26).unwrap() + && date.weekday() == Weekday::Sat + { + // Midsummer Day (Midsommardagen) - Saturday between June 20-26 - Public holiday + Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Midsommardagen", + }) + } else { + None + } + }) + .or_else(|| { + // All Saints' Eve (Allhelgonaafton) - Friday between Oct 31 and Nov 6 - De facto half holiday + if date >= NaiveDate::from_ymd_opt(year, 10, 31).unwrap() + && date <= NaiveDate::from_ymd_opt(year, 11, 6).unwrap() + && date.weekday() == Weekday::Fri + { + Some(Holiday { + date, + holiday_type: HolidayType::DeFactoHalf, + name: "Allhelgonaafton", + }) + } else if date >= NaiveDate::from_ymd_opt(year, 10, 31).unwrap() + && date <= NaiveDate::from_ymd_opt(year, 11, 6).unwrap() + && date.weekday() == Weekday::Sat + { + // All Saints' Day (Alla helgons dag) - Saturday between Oct 31 and Nov 6 - Public holiday + Some(Holiday { + date, + holiday_type: HolidayType::Public, + name: "Alla helgons dag", + }) + } else { + None + } + }) +} + +/// Calculates the date of Easter Sunday for a given year using Donald Knuth's algorithm +/// See http://sv.wikipedia.org/wiki/P%C3%A5skdagen#Algoritm_f.C3.B6r_p.C3.A5skdagen +fn easter_date(year: i32) -> NaiveDate { + let g = (year % 19) + 1; + let c = (year / 100) + 1; + let x = (3 * c) / 4 - 12; + let z = (8 * c + 5) / 25 - 5; + let d = (5 * year) / 4 - x - 10; + let mut e = (11 * g + 20 + z - x) % 30; + + if e == 24 || (e == 25 && g > 11) { + e += 1; + } + + let mut n = 44 - e; + if n < 21 { + n += 30; + } + n += 7 - ((d + n) % 7); + + let month = 3 + n / 31; + let day = n % 31; + + // Handle case where n % 31 == 0 (shouldn't happen in practice, but be safe) + let (month, day) = if day == 0 { + (month - 1, 31) + } else { + (month, day) + }; + + NaiveDate::from_ymd_opt(year, month as u32, day as u32) + .expect("Easter date calculation should always produce a valid date") +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fixed_holidays() { + // New Year's Day + let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Nyårsdagen"); + + // Twelfth Night (de facto half) + let date = NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFactoHalf); + assert_eq!(holiday.name, "Trettondagsafton"); + + // Epiphany + let date = NaiveDate::from_ymd_opt(2024, 1, 6).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Trettondagen"); + + // May Day + let date = NaiveDate::from_ymd_opt(2024, 5, 1).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Första maj"); + + // Christmas Eve (de facto) + let date = NaiveDate::from_ymd_opt(2024, 12, 24).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFacto); + assert_eq!(holiday.name, "Julafton"); + + // Christmas Day + let date = NaiveDate::from_ymd_opt(2024, 12, 25).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Juldagen"); + + // Boxing Day + let date = NaiveDate::from_ymd_opt(2024, 12, 26).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Annandag jul"); + + // New Year's Eve (de facto) + let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFacto); + assert_eq!(holiday.name, "Nyårsafton"); + } + + #[test] + fn test_easter_based_holidays_2024() { + // Easter 2024 is March 31 + + // Maundy Thursday (March 28, 2024) - de facto half + let date = NaiveDate::from_ymd_opt(2024, 3, 28).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFactoHalf); + assert_eq!(holiday.name, "Skärtorsdagen"); + + // Good Friday (March 29, 2024) + let date = NaiveDate::from_ymd_opt(2024, 3, 29).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Långfredagen"); + + // Holy Saturday (March 30, 2024) - de facto half + let date = NaiveDate::from_ymd_opt(2024, 3, 30).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFactoHalf); + assert_eq!(holiday.name, "Påskafton"); + + // Easter Sunday (March 31, 2024) + let date = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Påskdagen"); + + // Easter Monday (April 1, 2024) + let date = NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Annandag påsk"); + + // Ascension Eve (May 8, 2024) - de facto half + let date = NaiveDate::from_ymd_opt(2024, 5, 8).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFactoHalf); + assert_eq!(holiday.name, "Kristi himmelfärdsdag"); + + // Ascension Day (May 9, 2024) + let date = NaiveDate::from_ymd_opt(2024, 5, 9).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Kristi himmelfärdsdag"); + + // Whit Sunday (May 19, 2024) + let date = NaiveDate::from_ymd_opt(2024, 5, 19).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Pingstdagen"); + + // Whit Monday (May 20, 2024) + let date = NaiveDate::from_ymd_opt(2024, 5, 20).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Annandag pingst"); + } + + #[test] + fn test_midsummer_2024() { + // Midsummer Eve 2024 should be June 21 (Friday between June 20-26) + let date = NaiveDate::from_ymd_opt(2024, 6, 21).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFacto); + assert_eq!(holiday.name, "Midsommarafton"); + + // Midsummer Day 2024 should be June 22 (Saturday between June 20-26) + let date = NaiveDate::from_ymd_opt(2024, 6, 22).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Midsommardagen"); + } + + #[test] + fn test_all_saints_2024() { + // All Saints' Eve 2024 should be November 1 (Friday between Oct 31 and Nov 6) - de facto half + let date = NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFactoHalf); + assert_eq!(holiday.name, "Allhelgonaafton"); + + // All Saints' Day 2024 should be November 2 (Saturday between Oct 31 and Nov 6) + let date = NaiveDate::from_ymd_opt(2024, 11, 2).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Alla helgons dag"); + } + + #[test] + fn test_valborg_and_national_day() { + // Valborg (April 30) - de facto half + let date = NaiveDate::from_ymd_opt(2024, 4, 30).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::DeFactoHalf); + assert_eq!(holiday.name, "Valborgsmässoafton"); + + // National Day (June 6) + let date = NaiveDate::from_ymd_opt(2024, 6, 6).unwrap(); + let holiday = is_holiday(date).unwrap(); + assert_eq!(holiday.holiday_type, HolidayType::Public); + assert_eq!(holiday.name, "Sveriges nationaldag"); + } + + #[test] + fn test_non_holiday() { + let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap(); + assert!(is_holiday(date).is_none()); + } + + #[test] + fn test_easter_calculation() { + // Verify Easter dates for a few years + assert_eq!(easter_date(2024), NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()); + assert_eq!(easter_date(2023), NaiveDate::from_ymd_opt(2023, 4, 9).unwrap()); + assert_eq!(easter_date(2025), NaiveDate::from_ymd_opt(2025, 4, 20).unwrap()); + } +} diff --git a/src/domain/models/time_sheet.rs b/src/domain/models/time_sheet.rs index d16277f..1da3c75 100644 --- a/src/domain/models/time_sheet.rs +++ b/src/domain/models/time_sheet.rs @@ -1,3 +1,5 @@ +use reqwest::Url; +use crate::domain::models::week::WeekNumber; use super::hours::Hours; #[derive(Debug, serde::Serialize)] @@ -13,14 +15,16 @@ pub(crate) struct Week { #[derive(Debug, serde::Serialize)] pub(crate) struct Line { + pub(crate) number: String, pub(crate) job: String, pub(crate) task: String, pub(crate) week: Week, + pub(crate) approval_status: String, } impl Line { - pub(crate) fn new(job: String, task: String, week: Week) -> Self { - Self { job, task, week } + pub(crate) fn new(number: String, job: String, task: String, week: Week, approval_status: String) -> Self { + Self { number, job, task, week ,approval_status} } fn has_job_and_task(&self, job: &str, task: &str) -> bool { @@ -31,24 +35,22 @@ impl Line { #[derive(Debug, serde::Serialize)] pub(crate) struct TimeSheet { + #[serde(skip_serializing)] + pub(crate) create_action: Option, + pub(crate) lines: Vec, - pub(crate) week_number: u8, + pub(crate) week_number: WeekNumber, } impl TimeSheet { - pub(crate) fn new(lines: Vec, week_number: u8) -> Self { - Self { lines, week_number } + pub(crate) fn new(lines: Vec, week_number: WeekNumber, url: Option) -> Self { + Self { create_action: url, lines, week_number} } -} - -impl TimeSheet { pub(crate) fn find_line_nr(&self, job: &str, task: &str) -> Option { - let (row, _) = self - .lines + self.lines .iter() .enumerate() - .find(|(_, line)| line.has_job_and_task(job, task))?; - - Some(row as u8) + .find(|(_, line)| line.has_job_and_task(job, task)) + .map(|(row, _)| row as u8) } } diff --git a/src/domain/models/week.rs b/src/domain/models/week.rs index 3c30bd8..bf79bc3 100644 --- a/src/domain/models/week.rs +++ b/src/domain/models/week.rs @@ -1,53 +1,173 @@ use anyhow::anyhow; -use chrono::{Datelike, NaiveDate, Weekday}; +use chrono::{Datelike, NaiveDate, Weekday, Duration, Days}; use std::fmt::Display; +use std::convert::TryFrom; +use crate::cli::arguments::{WeekPart, WeekAndPart}; -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub(crate) struct WeekNumber { pub(crate) number: u8, + pub(crate) part: WeekPart, pub(crate) year: i32, } impl WeekNumber { - pub(crate) fn new(week: u8, year: i32) -> anyhow::Result { - first_day_of_week(week, year).ok_or(anyhow!("Invalid week '{week}'"))?; + pub(crate) fn of(date: NaiveDate) -> WeekNumber { + let iso_week = date.iso_week(); + let w: u8 = iso_week + .week() + .try_into() + .expect("Week numbers are always less than 255"); + let iso_year = iso_week.year(); + + let spans_new_month = week_spans_new_month(w, iso_year); - Ok(Self { number: week, year }) + if !spans_new_month { + Self {number: w, part: WeekPart::WHOLE, year: iso_year,} + } else { + let month_a = NaiveDate::from_isoywd_opt(iso_year, w.into(), Weekday::Mon) + .expect("Week number should be valid") + .month(); + let month_candidate = date.month(); + if month_candidate == month_a { + Self {number: w, part: WeekPart::A,year: iso_year,} + } else { + Self {number: w, part: WeekPart::B, year: iso_year,} + } + } } - pub(crate) fn new_with_year_fallback(week: u8, year: Option) -> anyhow::Result { - // Fall back to today's year - let year = year.unwrap_or_else(|| chrono::Utc::now().year()); - WeekNumber::new(week, year) + pub(crate) fn new(week: u8, part: WeekPart, year: i32) -> anyhow::Result { + let monday = NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Mon) + .ok_or(anyhow!("Invalid '{week}'"))?; + let sunday = NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Sun) + .ok_or(anyhow!("Invalid week '{week}'"))?; + + let spans_month = week_spans_new_month(week, year); + + match (spans_month, part) { + (true, WeekPart::WHOLE) => { + let month_monday = monday.month(); + let month_sunday = sunday.month(); + Err(anyhow!("Week part 'WHOLE' is not valid for week {week} because it spans a new month (Monday is in month {month_monday}, Sunday is in month {month_sunday})")) + } + (false, WeekPart::A) | (false, WeekPart::B) => { + let month_monday = monday.month(); + Err(anyhow!("Week part '{:?}' is not valid for week {week} because it does not span a new month (both Monday and Sunday are in month {month_monday})", part)) + } + _ => Ok(Self { number: week, part, year, }) + } } pub(crate) fn first_day(&self) -> Option { - first_day_of_week(self.number, self.year) + first_day_of_week(self.number,self.part, self.year) + } + pub(crate) fn last_day(&self) -> Option { + last_day_of_week(self.number,self.part, self.year) + } + + pub(crate) fn previous(&self) -> Self { + match self.part { + WeekPart::B => { + // If current week is B week, previous week is A week of the same number + Self { + number: self.number, + part: WeekPart::A, + year: self.year, + } + } + WeekPart::WHOLE | WeekPart::A => { + // Get the first day of current week and go back 7 days to get previous week + let current_first_day = self.first_day().expect("Week number should be valid"); + let previous_week_date = current_first_day - Duration::days(7); + + // Get the week number and year of the previous week + let previous_week_number: u8 = previous_week_date + .iso_week() + .week() + .try_into() + .expect("Week numbers are always less than 255"); + let previous_year = previous_week_date.iso_week().year(); + + // Check if the previous week spans a new month + let prev_week_spans_month = week_spans_new_month(previous_week_number, previous_year); + + if prev_week_spans_month { + // If previous week would cross the new month, return B week + Self { + number: previous_week_number, + part: WeekPart::B, + year: previous_year, + } + } else { + // Otherwise, return WHOLE week + Self { + number: previous_week_number, + part: WeekPart::WHOLE, + year: previous_year, + } + } + } + } } } impl Default for WeekNumber { fn default() -> Self { - // Fall back to today's week - let this_week = chrono::Local::now() - .date_naive() - .iso_week() - .week() - .try_into() - .expect("Week numbers are always less than 255"); - - WeekNumber::new_with_year_fallback(this_week, None).expect("Today's week should exist") + WeekNumber::of(chrono::Local::now().date_naive()) } } impl Display for WeekNumber { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Week {}, year {}", self.number, self.year) + match self.part { + WeekPart::WHOLE => write!(f, "{}W{}", self.year, self.number), + WeekPart::A => write!(f, "{}W{}A", self.year, self.number), + WeekPart::B => write!(f, "{}W{}B", self.year, self.number), + } + } +} + +impl TryFrom for WeekNumber { + type Error = anyhow::Error; + + fn try_from(week_and_part: WeekAndPart) -> Result { + let number = week_and_part.number.ok_or_else(|| anyhow!("Week number is required"))?; + let part = week_and_part.part.unwrap_or(WeekPart::WHOLE); + let year = chrono::Utc::now().year(); + + WeekNumber::new(number, part, year) + } +} + +fn first_day_of_week(week: u8, part: WeekPart, year: i32) -> Option { + match part { + WeekPart::WHOLE => NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Mon), + WeekPart::A => NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Mon), + WeekPart::B => { + let sunday = NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Sun)?; + NaiveDate::from_ymd_opt(sunday.year(), sunday.month(), 1) + } + } +} + +fn last_day_of_week(week: u8, part: WeekPart, year: i32) -> Option { + match part { + WeekPart::WHOLE => NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Sun), + WeekPart::B => NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Mon), + WeekPart::A => { + let sunday = NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Sun)?; + NaiveDate::from_ymd_opt(sunday.year(), sunday.month(), 1)?.checked_sub_days(Days::new(1)) + } } } +fn week_spans_new_month(week: u8, year: i32) -> bool { + let monday = first_day_of_week(week, WeekPart::WHOLE, year) + .expect("Week number should be valid"); + let sunday = NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Sun) + .expect("Week number should be valid"); -fn first_day_of_week(week: u8, year: i32) -> Option { - NaiveDate::from_isoywd_opt(year, week.into(), Weekday::Mon) + monday.month() != sunday.month() } #[cfg(test)] @@ -57,13 +177,13 @@ mod tests { #[test] fn gets_date_of_first_day_of_week() { let dates = [ - (2024, 46, "2024-11-11"), - (2030, 1, "2029-12-31"), - (2028, 23, "2028-06-05"), + (2024, 46, WeekPart::WHOLE, "2024-11-11"), + (2030, 1, WeekPart::A, "2029-12-31"), + (2028, 23, WeekPart::WHOLE,"2028-06-05"), ]; - for (year, week, expected_date) in dates { - let week_number = WeekNumber { number: week, year }; + for (year, week,part, expected_date) in dates { + let week_number = WeekNumber { number: week, part: part, year }; let date = week_number.first_day().unwrap(); assert_eq!( @@ -72,4 +192,124 @@ mod tests { ) } } + + #[test] + fn parses_date_into_week_number() { + let dates = [ + ("2024-11-11", WeekNumber { number: 46, part: WeekPart::WHOLE, year: 2024 }), + ("2029-12-31", WeekNumber { number: 1, part: WeekPart::A, year: 2030 }), + ("2028-06-05", WeekNumber { number: 23, part: WeekPart::WHOLE, year: 2028 }), + ]; + + for (date, expected_week_number) in dates { + let week_number = WeekNumber::of(NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap()); + assert_eq!(week_number, expected_week_number); + } + } + + #[test] + fn test_year_end_weeks() { + // Test year transitions: only Dec 31 and Jan 1 for each new year + // Week over new year 2025/26 is week 1 + // Week over new year 2022/23 is week 52 (Jan 1 is week 52B) + let test_cases = [ + ("2022-12-31", WeekNumber { number: 52, part: WeekPart::A, year: 2022 }), + ("2023-01-01", WeekNumber { number: 52, part: WeekPart::B, year: 2022 }), + + ("2024-01-01", WeekNumber { number: 1, part: WeekPart::WHOLE, year: 2024 }), + ("2024-12-31", WeekNumber { number: 1, part: WeekPart::A, year: 2025 }), + ("2025-01-01", WeekNumber { number: 1, part: WeekPart::B, year: 2025 }), + ("2025-12-31", WeekNumber { number: 1, part: WeekPart::A, year: 2026 }), + ("2026-01-01", WeekNumber { number: 1, part: WeekPart::B, year: 2026 }), + ("2026-12-31", WeekNumber { number: 53, part: WeekPart::A, year: 2026 }), + ("2027-01-01", WeekNumber { number: 53, part: WeekPart::B, year: 2026 }), + + ("2023-12-31", WeekNumber { number: 52, part: WeekPart::WHOLE, year: 2023 }), + ]; + + for (date_str, expected_week_number) in test_cases { + let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap(); + let iso_week = date.iso_week(); + let w = iso_week.week(); + let iso_year = iso_week.year(); + let monday = NaiveDate::from_isoywd_opt(iso_year, w.into(), Weekday::Mon).unwrap(); + let sunday = NaiveDate::from_isoywd_opt(iso_year, w.into(), Weekday::Sun).unwrap(); + let spans = week_spans_new_month(w.try_into().unwrap(), iso_year); + + println!("Date: {} -> ISO week {} of year {}", date_str, w, iso_year); + println!(" Monday: {} (month {}), Sunday: {} (month {})", monday, monday.month(), sunday, sunday.month()); + println!(" Spans month: {}", spans); + + let week_number = WeekNumber::of(date); + assert_eq!( + week_number, expected_week_number, + "Date {} should be {:?}, but got {:?}", + date_str, expected_week_number, week_number + ); + } + } + + #[test] + fn test_previous() { + // Test cases: (current_week, expected_previous_week) + let test_cases = [ + // B week -> A week (same number, same year) + ( + WeekNumber { number: 1, part: WeekPart::B, year: 2025 }, + WeekNumber { number: 1, part: WeekPart::A, year: 2025 }, + ), + ( + WeekNumber { number: 52, part: WeekPart::B, year: 2022 }, + WeekNumber { number: 52, part: WeekPart::A, year: 2022 }, + ), + ( + WeekNumber { number: 53, part: WeekPart::B, year: 2026 }, + WeekNumber { number: 53, part: WeekPart::A, year: 2026 }, + ), + // A week -> previous week (could be WHOLE or B) + ( + WeekNumber { number: 1, part: WeekPart::A, year: 2025 }, + WeekNumber { number: 52, part: WeekPart::WHOLE, year: 2024 }, + ), + ( + WeekNumber { number: 1, part: WeekPart::A, year: 2026 }, + WeekNumber { number: 52, part: WeekPart::WHOLE, year: 2025 }, + ), + // WHOLE week -> previous week (could be WHOLE or B) + ( + WeekNumber { number: 46, part: WeekPart::WHOLE, year: 2024 }, + WeekNumber { number: 45, part: WeekPart::WHOLE, year: 2024 }, + ), + ( + WeekNumber { number: 2, part: WeekPart::WHOLE, year: 2024 }, + WeekNumber { number: 1, part: WeekPart::WHOLE, year: 2024 }, + ), + // Year transition: week 1 WHOLE -> previous year's last week + ( + WeekNumber { number: 1, part: WeekPart::WHOLE, year: 2024 }, + WeekNumber { number: 52, part: WeekPart::WHOLE, year: 2023 }, + ), + // Week that spans month boundary: A week -> B week of previous week + // Need to find a week that spans a month boundary + // Week 1 of 2025 spans Dec 2024 / Jan 2025, so week 1A -> week 52B of 2024 + ( + WeekNumber { number: 1, part: WeekPart::A, year: 2025 }, + WeekNumber { number: 52, part: WeekPart::WHOLE, year: 2024 }, + ), + ( + WeekNumber { number: 2, part: WeekPart::WHOLE, year: 2026 }, + WeekNumber { number: 1, part: WeekPart::B, year: 2026 }, + ), + + ]; + + for (current_week, expected_previous_week) in test_cases { + let actual_previous = current_week.previous(); + assert_eq!( + actual_previous, expected_previous_week, + "previous() of {:?} should be {:?}, but got {:?}", + current_week, expected_previous_week, actual_previous + ); + } + } } diff --git a/src/domain/time_sheet_service.rs b/src/domain/time_sheet_service.rs index f7041cd..0938dee 100644 --- a/src/domain/time_sheet_service.rs +++ b/src/domain/time_sheet_service.rs @@ -29,9 +29,6 @@ impl TimeSheetService<'_> { pub(crate) fn new(repository: Rc>) -> TimeSheetService { TimeSheetService { repository } } -} - -impl TimeSheetService<'_> { pub(crate) async fn clear( &mut self, job: &str, @@ -52,8 +49,9 @@ impl TimeSheetService<'_> { task: &str, ) -> Result<(), SetTimeError> { let mut repository = self.repository.lock().await; - if let Err(err) = repository.set_time(hours, days, week, job, task).await { - return match err { + match repository.set_time(hours, days, week, job, task).await { + Ok(()) => Ok(()), + Err(err) => match err { AddLineError::WeekUninitialized(AddRowError::Unknown(err)) => todo!("{}", err), AddLineError::WeekUninitialized(AddRowError::WeekUninitialized) => { eprintln!("Creating new timesheet..."); @@ -79,9 +77,7 @@ impl TimeSheetService<'_> { warn!("{err}"); Err(anyhow::anyhow!(err).into()) } - }; - }; - - Ok(()) + }, + } } } diff --git a/src/infrastructure/auth_service.rs b/src/infrastructure/auth_service.rs index f6a12d6..18e9c3c 100644 --- a/src/infrastructure/auth_service.rs +++ b/src/infrastructure/auth_service.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use std::fmt::Display; use tokio::{io::AsyncWriteExt, join}; -const COOKIE_NAME_PREFIX: &str = "Maconomy-"; +const COOKIE_NAME: &str = "Maconomy"; const TIMEOUT: tokio::time::Duration = tokio::time::Duration::from_secs(300); const POLL_INTERVAL: tokio::time::Duration = tokio::time::Duration::from_secs(1); @@ -211,7 +211,7 @@ async fn get_maconomy_cookie(page: &Page) -> Result> { // Could there be more than one maconomy cookie? // TODO: fetch the name of the cookie from the Maconomy-Cookie header, and use that to make // sure that we get the right cookie - .find(|c| c.name.starts_with(COOKIE_NAME_PREFIX)); + .find(|c| c.name.eq(COOKIE_NAME)); Ok(cookies) } diff --git a/src/infrastructure/models/time_registration.rs b/src/infrastructure/models/time_registration.rs index 521660e..59aa0fe 100644 --- a/src/infrastructure/models/time_registration.rs +++ b/src/infrastructure/models/time_registration.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use serde::Deserialize; use serde::Serialize; @@ -31,9 +32,18 @@ pub struct Panes { #[serde(rename_all = "camelCase")] pub struct Card { pub meta: CardMeta, + #[serde(default)] + pub links: HashMap, pub records: Vec, } +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Link { + pub rel: String, + pub href: String, +} + #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CardMeta { @@ -57,6 +67,7 @@ pub struct CardData { pub employeenamevar: String, pub datevar: String, pub weeknumbervar: u8, + pub partvar: String, pub fixednumberday1var: f32, pub fixednumberday2var: f32, pub fixednumberday3var: f32, @@ -114,6 +125,8 @@ pub struct TableData { pub numberday7: f32, pub entrytext: String, pub taskname: String, + #[serde(default)] + pub approvalstatus: String, pub instancekey: String, pub timeregistrationunit: String, pub jobnamevar: String, diff --git a/src/infrastructure/repositories/maconomy_http_client.rs b/src/infrastructure/repositories/maconomy_http_client.rs index 1c8f423..376d0ea 100644 --- a/src/infrastructure/repositories/maconomy_http_client.rs +++ b/src/infrastructure/repositories/maconomy_http_client.rs @@ -129,6 +129,7 @@ impl MaconomyHttpClient<'_> { &self, container_instance: &ContainerInstance, ) -> Result { + debug!("Creating timesheet..."); let instance_url = self.get_container_instance_url(&container_instance.id.0); let url = format!("{instance_url}/data/panes/card/0/action;name=createtimesheet"); let concurrency_control = &container_instance.concurrency_control.0; @@ -228,6 +229,7 @@ impl MaconomyHttpClient<'_> { pub async fn get_job_number_from_name(&self, job_name: &str) -> Result> { let (url, company) = (&self.url, &self.company_name); + // https://me73379-maconomy.deltekfirst.com/maconomy-api/containers/me73379/timeregistration/instances/3bc4d944-e663-4b7b-ad96-2531180e3a7f/data/panes/table/init/search;foreignkey=notblockedjobnumber_jobheader let url = format!( "{url}/containers/{company}/timeregistration/search/table;foreignkey=notblockedjobnumber_jobheader" ); diff --git a/src/infrastructure/repositories/request_bodies/time_registration_container.json b/src/infrastructure/repositories/request_bodies/time_registration_container.json index 7ef397a..b1eddc3 100644 --- a/src/infrastructure/repositories/request_bodies/time_registration_container.json +++ b/src/infrastructure/repositories/request_bodies/time_registration_container.json @@ -6,6 +6,7 @@ "periodendvar", "employeenamevar", "datevar", + "partvar", "weeknumbervar", "fixednumberday1var", "fixednumberday2var", @@ -44,7 +45,9 @@ "taskname", "timeregistrationunit", "jobnamevar", - "tasktextvar" + "tasktextvar", + "approvalstatus" + ] } } diff --git a/src/infrastructure/repositories/time_sheet_repository.rs b/src/infrastructure/repositories/time_sheet_repository.rs index 4eb9b85..bf105fe 100644 --- a/src/infrastructure/repositories/time_sheet_repository.rs +++ b/src/infrastructure/repositories/time_sheet_repository.rs @@ -2,6 +2,7 @@ use super::maconomy_http_client::{ self, ConcurrencyControl, ContainerInstance, MaconomyHttpClient, }; use crate::{ + cli::arguments::WeekPart, domain::models::{ day::Days, line_number::LineNumber, @@ -15,7 +16,9 @@ use crate::{ }, }; use anyhow::{anyhow, Context, Result}; -use log::{debug, info}; +use std::convert::TryFrom; +use chrono::{Datelike, NaiveDate}; +use log::{debug, info, trace}; use std::collections::HashSet; #[derive(thiserror::Error, Debug)] @@ -60,10 +63,10 @@ impl TimeSheetRepository<'_> { info!("Using cached container instance") } - let container_instance = self.container_instance.as_ref().ok_or(anyhow!( - "Missing container instance even though we just fetched it" - ))?; - Ok(container_instance.clone()) + self.container_instance + .as_ref() + .cloned() + .ok_or_else(|| anyhow!("Missing container instance even though we just fetched it")) } async fn get_time_registration(&mut self) -> Result { @@ -80,9 +83,10 @@ impl TimeSheetRepository<'_> { .context("Failed to get time registration")?; self.update_concurrency_control(concurrency_control); - self.time_registration = Some(time_registration.clone()); + let time_registration_clone = time_registration.clone(); + self.time_registration = Some(time_registration); - Ok(time_registration) + Ok(time_registration_clone) } pub(crate) async fn create_new_timesheet(&mut self) -> Result<()> { @@ -100,8 +104,9 @@ impl TimeSheetRepository<'_> { /// Gets and caches time sheet pub(crate) async fn get_time_sheet(&mut self, week: &WeekNumber) -> Result { + trace!("Incoming week number: {week}"); // We have to get the time registration before we can set a week - let _ = self.get_time_registration().await?; + self.get_time_registration().await?; let container_instance = self .get_container_instance() @@ -120,7 +125,7 @@ impl TimeSheetRepository<'_> { self.update_concurrency_control(concurrency_control); - Ok(time_registration.into()) + TimeSheet::try_from(time_registration) } async fn get_or_create_line_number( @@ -129,22 +134,23 @@ impl TimeSheetRepository<'_> { task: &str, time_sheet: &TimeSheet, ) -> Result { - let line_number = match time_sheet.find_line_nr(job, task) { - Some(line_number) => line_number, + match time_sheet.find_line_nr(job, task) { + Some(line_number) => Ok(line_number), None => { info!("Found no line for job '{job}', task '{task}'. Creating new line for it"); - let time_sheet = self.add_line(job, task).await?; - - time_sheet.find_line_nr(job, task).with_context(|| { - format!( - "did not find job '{job}' and task '{task}', even after creating a new \ - line for it" - ) - })? + let new_time_sheet = self.add_line(job, task).await?; + + new_time_sheet + .find_line_nr(job, task) + .ok_or_else(|| { + anyhow!( + "did not find job '{job}' and task '{task}', even after creating a new \ + line for it" + ) + }) + .map_err(Into::into) } - }; - - Ok(line_number) + } } pub(crate) async fn set_time( @@ -160,7 +166,19 @@ impl TimeSheetRepository<'_> { .get_time_sheet(week) .await .context("Failed to get time sheet")?; - + + let time_sheet = if time_sheet.create_action.is_some() { + self.create_new_timesheet() + .await + .context("Failed to create new timesheet")?; + self.get_time_sheet(week) + .await + .context("Failed to get time sheet after creation")? + } else { + debug!("No create_action, timesheet should be populated"); + time_sheet + }; + let line_number = self .get_or_create_line_number(job, task, &time_sheet) .await?; @@ -184,11 +202,12 @@ impl TimeSheetRepository<'_> { } fn update_concurrency_control(&mut self, concurrency_control: ConcurrencyControl) { - let container_instance = self.container_instance.as_mut().expect( - "attempted to update concurrency control with no container instance instantiated", - ); - - container_instance.concurrency_control = concurrency_control; + if let Some(container_instance) = self.container_instance.as_mut() { + container_instance.concurrency_control = concurrency_control; + } else { + // This should never happen in practice, but we handle it gracefully + log::warn!("Attempted to update concurrency control with no container instance instantiated"); + } } async fn get_short_task_name_from_full_task_name( @@ -198,12 +217,11 @@ impl TimeSheetRepository<'_> { ) -> Result> { let tasks = self.get_tasks(job).await.context("Failed to get tasks")?; let records = tasks.panes.filter.records; + // `description` is the long name in this case (i.e. `tasktextvar`) let task_name = records - .clone() - .into_iter() - // `description` is the long name in this case (i.e. `tasktextvar`) - .find(|row| row.data.description.to_lowercase() == task_name.to_lowercase()) - .map(|row| taskname::ShortTaskName(row.data.taskname)); + .iter() + .find(|row| row.data.description.eq_ignore_ascii_case(task_name)) + .map(|row| taskname::ShortTaskName(row.data.taskname.clone())); Ok(task_name) } @@ -214,7 +232,7 @@ impl TimeSheetRepository<'_> { .client .get_job_number_from_name(job) .await - .context(format!("Could not get job number for job '{job}'"))?; + .with_context(|| format!("Could not get job number for job '{job}'"))?; let Some(job_number) = job_number else { info!("Did not find a job number for {job}"); @@ -232,15 +250,16 @@ impl TimeSheetRepository<'_> { debug!("Adding new line"); let container_instance = self.get_container_instance().await?; - let (time_registration, concurrecy_control) = self + let (time_registration, concurrency_control) = self .client .add_new_row(&job_number, &task_name, &container_instance) .await?; - self.update_concurrency_control(concurrecy_control); - self.time_registration = Some(time_registration.clone()); + self.update_concurrency_control(concurrency_control); + let time_sheet = TimeSheet::try_from(time_registration.clone())?; + self.time_registration = Some(time_registration); - Ok(time_registration.into()) + Ok(time_sheet) } pub(crate) async fn delete_line( @@ -265,14 +284,14 @@ impl TimeSheetRepository<'_> { let container_instance = self.get_container_instance().await?; - let (time_registration, concurrecy_control) = self + let (time_registration, concurrency_control) = self .client .delete_row(line_number - 1, &container_instance) .await .with_context(|| format!("Failed to delete line number {line_number}"))?; - self.update_concurrency_control(concurrecy_control); - self.time_registration = Some(time_registration.clone()); + self.update_concurrency_control(concurrency_control); + self.time_registration = Some(time_registration); Ok(()) } @@ -285,8 +304,8 @@ impl TimeSheetRepository<'_> { .client .get_job_number_from_name(job) .await - .context(format!("Failed to get job number for job '{job}'"))? - .ok_or(anyhow!("No job number found for job '{job}'"))?; + .with_context(|| format!("Failed to get job number for job '{job}'"))? + .ok_or_else(|| anyhow!("No job number found for job '{job}'"))?; debug!("Got job number {job_number} for job {job}"); @@ -298,8 +317,7 @@ impl TimeSheetRepository<'_> { pub(crate) async fn submit(&mut self, week: &WeekNumber) -> Result<()> { // Set the week - let _ = self - .get_time_sheet(week) + self.get_time_sheet(week) .await .context("Failed to get time sheet")?; @@ -333,22 +351,34 @@ impl From for Line { sunday: data.numberday7.into(), }; - Line::new(data.jobnamevar, data.tasktextvar, week) + Line::new(data.jobnumber, data.jobnamevar, data.tasktextvar, week,data.approvalstatus) } } -impl From for TimeSheet { - fn from(time_registration: TimeRegistration) -> Self { +impl TryFrom for TimeSheet { + type Error = anyhow::Error; + + fn try_from(time_registration: TimeRegistration) -> Result { let table_records = time_registration.panes.table.records; let card_records = time_registration.panes.card.records; let lines: Vec<_> = table_records.into_iter().map(Line::from).collect(); - let week = card_records + let data = card_records .first() - .expect("time registration contains no records") - .data - .weeknumbervar; - - Self::new(lines, week) + .ok_or_else(|| anyhow!("time registration contains no records"))? + .data.clone(); + let w = data.weeknumbervar; + let part = data + .partvar.parse::() + .unwrap_or(WeekPart::WHOLE); + + let year = NaiveDate::parse_from_str(&data.datevar, "%Y-%m-%d") + .context("datevar should be in YYYY-MM-DD format")? + .iso_week() + .year(); + let week = WeekNumber::new(w, part, year) + .context("Week number should be valid from time registration")?; + let create_action = time_registration.panes.card.links.get("action:createtimesheet").map(|l| l.href.clone()); + Ok(Self::new(lines, week, create_action)) } } diff --git a/src/main.rs b/src/main.rs index 053a1b4..8b9bf2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ async fn main() -> anyhow::Result<()> { let auth_service = AuthService::new(login_url, cookie_path); let http_service = HttpService::new(&auth_service); let client = reqwest::Client::builder() + .connection_verbose(true) .cookie_store(true) .build() .context("Failed to create HTTP client")?; @@ -45,7 +46,7 @@ async fn main() -> anyhow::Result<()> { ); match parse_arguments() { - Command::Get { week, format } => command_client.get(week, format).await, + Command::Get { week, format, full } => command_client.get(week, format, full).await, Command::Set { hours, task, days } => command_client.set(hours, &days, &task).await, Command::Clear { task, days } => command_client.clear(&task, &days).await, Command::Submit { week } => command_client.submit(week).await, diff --git a/tests/integration/helpers/mock_data.rs b/tests/integration/helpers/mock_data.rs index 29187af..df21a27 100644 --- a/tests/integration/helpers/mock_data.rs +++ b/tests/integration/helpers/mock_data.rs @@ -20,6 +20,7 @@ pub(crate) fn get_mock_table_rows_response() -> serde_json::Value { "employeenamevar": "John Smith", "datevar": "2024-10-24", "weeknumbervar": 43, + "partvar": "", "fixednumberday1var": 8, "fixednumberday2var": 0, "fixednumberday3var": 0, @@ -64,6 +65,7 @@ pub(crate) fn get_mock_table_rows_response() -> serde_json::Value { "numberday7": 0, "entrytext": "Some task one", "taskname": "300", + "approvalstatus": "", "instancekey": "1579ecb8-7773-4b69-b3ff-116da9dee8d8", "timeregistrationunit": "hours", "jobnamevar": "Job One", @@ -82,6 +84,7 @@ pub(crate) fn get_mock_table_rows_response() -> serde_json::Value { "numberday7": 0, "entrytext": "Job Two", "taskname": "Some task two", + "approvalstatus": "", "instancekey": "265123e0-a069-44d2-bd60-8706f1a7d9b9", "timeregistrationunit": "hours", "jobnamevar": "Job One", diff --git a/tests/integration/snapshots/integration__cli__assert_snapshot_predicate-2.snap b/tests/integration/snapshots/integration__cli__assert_snapshot_predicate-2.snap index 993cd2e..b12ed26 100644 --- a/tests/integration/snapshots/integration__cli__assert_snapshot_predicate-2.snap +++ b/tests/integration/snapshots/integration__cli__assert_snapshot_predicate-2.snap @@ -1,6 +1,10 @@ --- source: tests/integration/cli.rs expression: output -snapshot_kind: text --- -Task 'some task four' not found +Something went wrong when adding a new line to the time sheet: Could not get job number for job 'job one' + +Error stack: + - Could not get job number for job 'job one' + - Failed to send request + - Got response status '404 Not Found' and the following body from maconomy: diff --git a/tests/integration/snapshots/integration__cli__assert_snapshot_predicate.snap b/tests/integration/snapshots/integration__cli__assert_snapshot_predicate.snap index 68680de..5b9aa91 100644 --- a/tests/integration/snapshots/integration__cli__assert_snapshot_predicate.snap +++ b/tests/integration/snapshots/integration__cli__assert_snapshot_predicate.snap @@ -1,6 +1,10 @@ --- source: tests/integration/cli.rs expression: output -snapshot_kind: text --- -Job 'doesn't exist' not found +Something went wrong when adding a new line to the time sheet: Could not get job number for job 'doesn't exist' + +Error stack: + - Could not get job number for job 'doesn't exist' + - Failed to send request + - Got response status '404 Not Found' and the following body from maconomy: diff --git a/tests/integration/snapshots/integration__cli__get_timesheet.snap b/tests/integration/snapshots/integration__cli__get_timesheet.snap index 584a525..b3168f5 100644 --- a/tests/integration/snapshots/integration__cli__get_timesheet.snap +++ b/tests/integration/snapshots/integration__cli__get_timesheet.snap @@ -1,7 +1,6 @@ --- source: tests/integration/cli.rs expression: output -snapshot_kind: text --- { "lines": [ @@ -32,5 +31,9 @@ snapshot_kind: text } } ], - "week_number": 43 + "week_number": { + "number": 43, + "part": "", + "year": 2024 + } }