diff --git a/src/app/catalog.rs b/src/app/catalog.rs index 52f26cb..0901d1a 100644 --- a/src/app/catalog.rs +++ b/src/app/catalog.rs @@ -5,7 +5,7 @@ use sqlx::SqlitePool; use askama_axum::{IntoResponse, Response}; use crate::db::inventory_item::{inventory_item_get_all, inventory_item_get_search, DbInventoryItem}; use crate::error::{AppError, QueryExtractor}; -use crate::app::SearchQueryArgs; +use crate::app::common::query_args::search::SearchQueryArgs; #[derive(Template)] #[template(path = "catalog.html")] diff --git a/src/app/common/mod.rs b/src/app/common/mod.rs new file mode 100644 index 0000000..7a62313 --- /dev/null +++ b/src/app/common/mod.rs @@ -0,0 +1 @@ +pub mod query_args; \ No newline at end of file diff --git a/src/app/common/query_args/datetime_range.rs b/src/app/common/query_args/datetime_range.rs new file mode 100644 index 0000000..a25a5c5 --- /dev/null +++ b/src/app/common/query_args/datetime_range.rs @@ -0,0 +1,68 @@ +use std::str::FromStr; +use serde::Deserialize; +use anyhow::Error; +use chrono::{FixedOffset, NaiveDate, NaiveTime}; +use crate::util::parsing; +use crate::util::time::{LocalTimestampRange, DEFAULT_TIMEZONE_OFFSET}; + +/// Common query args for datetime ranges. Times assumed to be +/// in local time. This usually means a user configured time, +/// but can be specified as an offset of seconds +#[derive(Debug, Deserialize)] +pub struct DatetimeRangeQueryArgs { + #[serde(rename = "start-date", alias = "sd")] + pub start_date: Option, + #[serde(rename = "start-time", alias = "st")] + pub start_time: Option, + #[serde(rename = "end-date", alias = "ed")] + pub end_date: Option, + #[serde(rename = "end-time", alias = "et")] + pub end_time: Option, + #[serde(rename = "timezone-offset", alias = "tz")] + pub time_zone_offset: Option, +} + + +impl TryInto for DatetimeRangeQueryArgs { + type Error = Error; + fn try_into(self) -> Result { + let start_date = self.start_date + .map(|x| x.parse::()).transpose()? + .or_else(|| NaiveDate::from_ymd_opt(2000, 1, 1)) + .ok_or_else(|| anyhow::anyhow!("Invalid start"))?; + + let end_date = parsing::parse_or(self.end_date, + || NaiveDate::from_ymd_opt(3000, 1, 1))?; + + let start_time = parsing::parse_or(self.start_time, + || NaiveTime::from_hms_opt(0, 0, 0))?; + + let end_time = parsing::parse_or(self.end_time, + || NaiveTime::from_hms_opt(23, 59, 59))?; + + let timezone = self.time_zone_offset + .or(Some(DEFAULT_TIMEZONE_OFFSET)) + .map(|tz_offset| FixedOffset::east_opt(tz_offset)) + .flatten() + .ok_or_else(|| anyhow::anyhow!("Invalid timezone"))?; + + let start = start_date + .and_time(start_time) + .and_local_timezone(timezone) + .earliest() + .ok_or(anyhow::anyhow!("Invalid start"))?; + + let end = end_date + .and_time(end_time) + .and_local_timezone(timezone) + .latest() + .ok_or(anyhow::anyhow!("Invalid end"))?; + + Ok( + LocalTimestampRange { + start, + end + } + ) + } +} \ No newline at end of file diff --git a/src/app/common/query_args/mod.rs b/src/app/common/query_args/mod.rs new file mode 100644 index 0000000..885891e --- /dev/null +++ b/src/app/common/query_args/mod.rs @@ -0,0 +1,4 @@ +pub mod datetime_range; +pub mod search; + + diff --git a/src/app/common/query_args/search.rs b/src/app/common/query_args/search.rs new file mode 100644 index 0000000..f95c7b4 --- /dev/null +++ b/src/app/common/query_args/search.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; + +/// Common query args for text search +#[derive(Debug, Deserialize)] +pub struct SearchQueryArgs { + #[serde(rename = "q")] + pub search: Option, + #[serde(alias = "p")] + pub page: Option, + #[serde(rename = "size")] + pub page_size: Option, +} \ No newline at end of file diff --git a/src/app/history.rs b/src/app/history.rs index f003570..aa0347f 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,7 +1,8 @@ +use crate::app::common::query_args::datetime_range::DatetimeRangeQueryArgs; use crate::db::adjustment::{get_adjustments_target_date_range, DbAdjustment, DbAdjustmentWithUserAndItem}; use crate::error::{AppError, QueryExtractor}; use crate::session::SessionUser; -use crate::util::time::tz_offset_to_string; +use crate::util::time::{tz_offset_to_string, LocalTimestampRange, UtcTimestampRange}; use anyhow::Result; use askama::Template; use askama_axum::{IntoResponse, Response}; @@ -11,6 +12,7 @@ use chrono::prelude::*; use serde::Deserialize; use sqlx::SqlitePool; use tracing::info; +use crate::util::currency; #[derive(Template)] #[template(path = "history.html")] @@ -46,18 +48,11 @@ struct PositiveAdjustmentDisplayItem { pub unit_value: String, } -pub fn int_cents_to_currency_string(i: i64) -> String { - let whole = i / 100; - let cents = i % 100; - - format!("${}.{}", whole, cents) -} - impl From for PositiveAdjustmentDisplayItem { fn from(adjustment: DbAdjustment) -> Self { Self { amount: format!("{}", adjustment.amount), - unit_value: int_cents_to_currency_string(adjustment.unit_price.unwrap_or_default()), + unit_value: currency::int_cents_to_dollars_string(adjustment.unit_price.unwrap_or_default()), } } } @@ -114,23 +109,8 @@ impl HistoryDisplayItem { } -/// Common query args for datetime ranges -#[derive(Debug, Deserialize)] -pub struct DatetimeRangeQueryArgs { - #[serde(rename = "start-date", alias = "sd")] - pub start_date: Option, - #[serde(rename = "start-time", alias = "st")] - pub start_time: Option, - #[serde(rename = "end-date", alias = "ed")] - pub end_date: Option, - #[serde(rename = "end-time", alias = "et")] - pub end_time: Option, - #[serde(rename = "timezone-offset", alias = "tz")] - pub time_zone_offset: Option, -} - pub async fn history_log_handler( - QueryExtractor(query): QueryExtractor, + QueryExtractor(mut query): QueryExtractor, HxRequest(hx_request): HxRequest, State(db): State, user: SessionUser @@ -140,45 +120,17 @@ pub async fn history_log_handler( let today = Local::now().naive_local().date(); - let start_date = query.start_date.unwrap_or("2000-01-01".to_string()); - let start_time = query.start_time.unwrap_or("00:00:00".to_string()); - let end_date = query.end_date.unwrap_or(today.to_string()); - let end_time = query.end_time.unwrap_or("23:59:59".to_string()); - let tz_offset = query.time_zone_offset.unwrap_or(user.tz_offset); - let timezone = FixedOffset::east_opt(tz_offset) - .ok_or(anyhow::anyhow!("Invalid timezone"))?; - - let naive_start_date = start_date.parse::()?; - let naive_start_time = start_time.parse::()?; - let naive_end_date = end_date.parse::()?; - let naive_end_time = end_time.parse::()?; + let _ = query.time_zone_offset.get_or_insert(user.tz_offset); + let _ = query.end_date.get_or_insert(today.to_string()); + let local_range: LocalTimestampRange = query.try_into()?; + let utc_range: UtcTimestampRange = local_range.into(); + let tz_offset = local_range.start.timezone().local_minus_utc(); - let local_start = naive_start_date - .and_time(naive_start_time) - .and_local_timezone(timezone) - .earliest() - .ok_or(anyhow::anyhow!("Invalid start"))?; - - let combined_start = local_start.to_utc(); - - let local_end = naive_end_date - .and_time(naive_end_time) - .and_local_timezone(timezone) - .latest() - .ok_or(anyhow::anyhow!("Invalid start"))?; - - let combined_end = local_end.to_utc(); - - let start_date = local_start.naive_local().date().to_string(); - let start_time = local_start.naive_local().time().to_string(); - let end_date = local_end.naive_local().date().to_string(); - let end_time = local_end.naive_local().time().to_string(); - - info!("Get items from: {} to {}", combined_start, combined_end); + info!("Get items from: {} to {}", utc_range.start, utc_range.end); let items = DbAdjustmentWithUserAndItem::query_by_date_range( - &db, combined_start, combined_end, 1000, 0) + &db, utc_range.start, utc_range.end, 1000, 0) .await? .into_iter() .map(|x| { @@ -193,6 +145,11 @@ pub async fn history_log_handler( } else { let time_zone = tz_offset_to_string(tz_offset); + let start_date = local_range.start.naive_local().date().to_string(); + let start_time = local_range.start.naive_local().time().to_string(); + let end_date = local_range.end.naive_local().date().to_string(); + let end_time = local_range.end.naive_local().time().to_string(); + Ok(HistoryLogTemplate { items, start_date, diff --git a/src/app/home.rs b/src/app/home.rs index 99e56c7..f7c023f 100644 --- a/src/app/home.rs +++ b/src/app/home.rs @@ -5,7 +5,7 @@ use askama_axum::{IntoResponse, Response}; use axum_htmx::HxRequest; use crate::db::inventory_item::{inventory_item_get_search, DbInventoryItem}; use crate::error::{AppError, QueryExtractor}; -use crate::app::SearchQueryArgs; +use crate::app::common::query_args::search::SearchQueryArgs; #[derive(Template)] #[template(path = "home.html")] diff --git a/src/app/mod.rs b/src/app/mod.rs index 2c38255..f681e6c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -15,6 +15,7 @@ mod home; mod overview; mod reports; mod history; +pub mod common; pub fn routes() -> Router { Router::new() @@ -62,13 +63,3 @@ impl FromRef for BasicClient { } } -/// Common query args for text search -#[derive(Debug, Deserialize)] -pub struct SearchQueryArgs { - #[serde(rename = "q")] - pub search: Option, - #[serde(alias = "p")] - pub page: Option, - #[serde(rename = "size")] - pub page_size: Option, -} \ No newline at end of file diff --git a/src/util/currency.rs b/src/util/currency.rs new file mode 100644 index 0000000..54f51bd --- /dev/null +++ b/src/util/currency.rs @@ -0,0 +1,6 @@ +pub fn int_cents_to_dollars_string(i: i64) -> String { + let whole = i / 100; + let cents = i % 100; + + format!("${}.{}", whole, cents) +} \ No newline at end of file diff --git a/src/util/mod.rs b/src/util/mod.rs index c25ca52..53e2faf 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1 +1,3 @@ -pub mod time; \ No newline at end of file +pub mod time; +pub mod currency; +pub mod parsing; \ No newline at end of file diff --git a/src/util/parsing.rs b/src/util/parsing.rs new file mode 100644 index 0000000..13b74bf --- /dev/null +++ b/src/util/parsing.rs @@ -0,0 +1,15 @@ +use anyhow::Error; +use std::str::FromStr; + + +pub fn parse_or(val: Option, f: F) -> Result +where + T: FromStr, + ::Err: core::error::Error + Send + Sync + 'static, + F: FnOnce() -> Option +{ + val.map(|x| x.parse::()) + .transpose()? + .or_else(f) + .ok_or_else(|| anyhow::anyhow!("Invalid start")) +} \ No newline at end of file diff --git a/src/util/time.rs b/src/util/time.rs index 6c4a27e..bd80155 100644 --- a/src/util/time.rs +++ b/src/util/time.rs @@ -1,3 +1,28 @@ +use chrono::{DateTime, FixedOffset, Utc}; + +pub const DEFAULT_TIMEZONE_OFFSET: i32 = -6 * 3600; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct LocalTimestampRange { + pub start: DateTime, + pub end: DateTime, +} + + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct UtcTimestampRange { + pub start: DateTime, + pub end: DateTime, +} + +impl From for UtcTimestampRange { + fn from(item: LocalTimestampRange) -> Self { + Self { + start: item.start.to_utc(), + end: item.end.to_utc(), + } + } +} /// Super naive implementation. Just a quick and dirty to get a string