use crate::{
commands::{Config, not_available},
entities::{
BillingPeriod, DateTime, Duration, Error, Money, Rate, Result, RoundMode, Tag, WorkPeriod,
WorkTime, split_value_unit,
},
jobber::{Deleted, JobberGet, JobberIter},
};
use clap::ValueEnum;
use color_print::cformat;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use strum_macros::Display;
#[derive(Copy, Clone)]
pub(crate) enum Limit {
None,
Day,
Week,
Month,
}
#[derive(Debug, PartialEq, ValueEnum, Display, Copy, Clone, Serialize, Deserialize)]
#[clap(rename_all = "kebab-case")]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum Base {
#[serde(alias = "m", alias = "min", alias = "minute")]
Minutes,
#[serde(alias = "h", alias = "hrs", alias = "hour")]
Hours,
#[serde(alias = "d", alias = "day")]
Days,
#[serde(alias = "w", alias = "week")]
Weeks,
}
impl FromStr for Base {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"m" | "min" | "minute" | "minutes" => Ok(Base::Minutes),
"h" | "hrs" | "hour" | "hours" => Ok(Base::Hours),
"d" | "day" | "days" => Ok(Base::Days),
"w" | "week" | "weeks" => Ok(Base::Weeks),
other => Err(Error::UnknownInterval(other.to_string())),
}
}
}
#[derive(Debug, PartialEq, Copy, Clone, Serialize, Deserialize)]
pub(crate) struct WorkingInterval {
value: u32,
base: Base,
}
impl std::fmt::Display for WorkingInterval {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.value, self.base)
}
}
impl Default for WorkingInterval {
fn default() -> Self {
Self {
value: 15,
base: Base::Minutes,
}
}
}
impl WorkingInterval {
pub(crate) fn new(value: u32, base: Base) -> WorkingInterval {
Self { value, base }
}
pub(crate) fn num_minutes(&self) -> u32 {
match self.base {
Base::Minutes => self.value,
Base::Hours => self.value * 60,
Base::Days => self.value * 60 * 24,
Base::Weeks => self.value * 60 * 24 * 7,
}
}
pub(crate) fn duration(&self) -> chrono::TimeDelta {
Duration::minutes(self.num_minutes() as i64)
}
}
impl FromStr for WorkingInterval {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let (value, unit) = split_value_unit(s)?;
Ok(WorkingInterval::new(value, unit.parse()?))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct Scheme {
pub(crate) rate: Option<Rate>,
pub(crate) billing_period: Option<BillingPeriod>,
pub(crate) min_work_interval: WorkingInterval,
}
fn merge_work_times(mut work_times: Vec<&WorkTime>) -> Vec<WorkTime> {
work_times.sort_by_key(|wt| wt.start());
work_times.into_iter().fold(Vec::new(), |mut merged, wt| {
match merged.last_mut() {
Some(last) if wt.start() <= last.end() => {
last.set(last.start(), last.end().max(wt.end()))
}
_ => merged.push(wt.clone()),
}
merged
})
}
impl Default for Scheme {
fn default() -> Self {
Self::new(None, None, Default::default()).expect("invalid default working scheme")
}
}
impl Scheme {
pub(crate) fn new(
rate: Option<Rate>,
billing: Option<BillingPeriod>,
interval: WorkingInterval,
) -> Result<Scheme> {
if 1440 % interval.num_minutes() != 0 {
return Err(Error::InvalidWorkingInterval(interval.to_string()));
}
Ok(Scheme {
rate,
billing_period: billing,
min_work_interval: interval,
})
}
pub(crate) fn measure_work<'a>(
&self,
work_times: impl Iterator<Item = &'a WorkTime>,
) -> Duration {
let packed = self.pack(work_times);
Duration::minutes(packed.iter().map(|wt| (wt.duration()).num_minutes()).sum())
}
pub(crate) fn measure_revenue(&self, worked: Duration) -> Result<Money> {
if let Some(rate) = &self.rate {
let units = match rate.work_period {
WorkPeriod::Minutes(min) => worked.num_minutes() as u32 / min,
WorkPeriod::Hours(hours) => worked.num_hours() as u32 / hours,
WorkPeriod::Days(days) => worked.num_days() as u32 / days,
WorkPeriod::Weeks(weeks) => worked.num_weeks() as u32 / weeks,
};
Ok(rate.amount.mul(units)?)
} else {
Err(Error::NoRate)
}
}
fn pack<'a>(&self, work_times: impl Iterator<Item = &'a WorkTime>) -> Vec<WorkTime> {
merge_work_times(work_times.collect())
}
pub(crate) fn round(&self, date_time: &DateTime, mode: RoundMode) -> DateTime {
date_time.round_to_interval(self.min_work_interval, mode)
}
fn limit(&self, limit: Limit) -> Duration {
self.limit_ex(limit, 1.0)
}
fn max_limit(&self, limit: Limit) -> Duration {
self.limit_ex(limit, 1.25)
}
fn limit_ex(&self, limit: Limit, factor: f64) -> Duration {
match limit {
Limit::None => Duration::MAX,
Limit::Day => Duration::hours((8.0 * factor) as i64),
Limit::Week => Duration::hours((40.0 * factor) as i64),
Limit::Month => Duration::hours((160.0 * factor) as i64),
}
}
pub(crate) fn format_duration(&self, duration: Duration, limit: Limit, ansi: bool) -> String {
let minutes = duration.num_minutes();
let output = if minutes < 60 {
format!("{minutes} min")
} else {
let hours = minutes / 60;
format!("{hours}:{:02} hrs", minutes - hours * 60)
};
if ansi && duration > self.max_limit(limit) {
cformat!("<s><r!>{output}</>")
} else if ansi && duration > self.limit(limit) {
cformat!("<s><y!>{output}</>")
} else if ansi && duration.is_zero() {
cformat!("<dim>-</>")
} else {
output
}
}
pub(crate) fn format_hours(&self, hours: f32, limit: Limit, ansi: bool) -> String {
let output = if hours == 0.0 {
not_available(ansi)
} else {
format!("{hours:.2}")
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
};
let duration = Duration::minutes((hours * 60.0) as i64);
if ansi && duration > self.max_limit(limit) {
cformat!("<s><r!>{output}</>")
} else if ansi && duration > self.limit(limit) {
cformat!("<s><y!>{output}</>")
} else if ansi && duration.is_zero() {
cformat!("<dim>{output}</>")
} else if ansi {
cformat!("<s>{output}</>")
} else {
output
}
}
pub(crate) fn format_date_time(&self, date_time: DateTime) -> String {
date_time.format("%Y %a %b %-d %H:%M").to_string()
}
pub(crate) fn format_time(&self, date_time: DateTime) -> String {
date_time.format("%H:%M").to_string()
}
pub(crate) fn format_revenue(&self, worked: Duration, ansi: bool) -> String {
if let Some(rate) = &self.rate {
let units = match rate.work_period {
WorkPeriod::Minutes(min) => worked.num_minutes() as u32 / min,
WorkPeriod::Hours(hours) => worked.num_hours() as u32 / hours,
WorkPeriod::Days(days) => worked.num_days() as u32 / days,
WorkPeriod::Weeks(weeks) => worked.num_weeks() as u32 / weeks,
};
format_revenue(rate.amount.mul(units).expect("money error"), ansi)
} else if ansi {
cformat!("<dim>n/a</>")
} else {
"n/a".to_string()
}
}
}
pub(crate) fn format_opt<T: ToString + Clone>(t: &Option<T>, ansi: bool) -> String {
t.clone().map_or(not_available(ansi), |p| p.to_string())
}
pub(crate) fn format_duration(duration: Duration, limit: Limit, ansi: bool) -> String {
Scheme::default().format_duration(duration, limit, ansi)
}
pub(crate) fn format_revenue(revenue: Money, ansi: bool) -> String {
if ansi {
if revenue.is_zero() {
cformat!("<dim>-</>")
} else {
cformat!("<s><u>{revenue}</>",)
}
} else {
revenue.to_string()
}
}
pub(crate) fn format_hours(hours: f32, limit: Limit, ansi: bool) -> String {
Scheme::default().format_hours(hours, limit, ansi)
}
pub(crate) fn format_tag(tag_id: &str, tag: &Tag, ansi: bool) -> String {
let tag_id = if ansi {
match *tag.color % 14 {
0 => cformat!("<#000000><bg:#00FFFF> {tag_id} </>"),
1 => cformat!("<#000000><bg:#FF00FF> {tag_id} </>"),
2 => cformat!("<#000000><bg:#FFFF00> {tag_id} </>"),
3 => cformat!("<#000000><bg:#0000FF> {tag_id} </>"),
4 => cformat!("<#000000><bg:#00FF00> {tag_id} </>"),
5 => cformat!("<#000000><bg:#FF0000> {tag_id} </>"),
6 => cformat!("<#000000><bg:#FFFFFF> {tag_id} </>"),
7 => cformat!("<#000000><bg:#007F7F> {tag_id} </>"),
8 => cformat!("<#000000><bg:#7F007F> {tag_id} </>"),
9 => cformat!("<#000000><bg:#7F7F00> {tag_id} </>"),
10 => cformat!("<#000000><bg:#00007F> {tag_id} </>"),
11 => cformat!("<#000000><bg:#007F00> {tag_id} </>"),
12 => cformat!("<#000000><bg:#7F0000> {tag_id} </>"),
13 => cformat!("<#000000><bg:#7F7F7F> {tag_id} </>"),
_ => unreachable!(),
}
} else {
tag_id.to_string()
};
if tag.deleted {
if ansi {
cformat!("❌ {tag_id}")
} else {
format!("(deleted) {tag_id}")
}
} else {
tag_id
}
}
pub(crate) fn format_date(date: DateTime) -> String {
date.format("%x").to_string()
}
pub(crate) fn format_invoice_no(
no: usize,
deleted: bool,
ansi: bool,
) -> crate::database::Result<String> {
format_id(&no, false, false, deleted, ansi)
}
pub(crate) fn format_client_id(
client_id: &str,
database: &(impl JobberGet + JobberIter),
config: &Config,
ansi: bool,
) -> crate::database::Result<String> {
let client = database.get_client(client_id)?;
let deleted = client.deleted;
let mut current = false;
let running = database
.iter_jobs(Deleted::No)
.filter(|(_, job)| job.client == client_id)
.inspect(|(job_id, _)| {
if Some(*job_id) == config.current_job {
current = true;
}
})
.any(|(_, job)| job.running.is_some());
format_id(&client_id, current, running, deleted, ansi)
}
pub(crate) fn format_job_id(
job_id: usize,
database: &impl JobberGet,
config: &Config,
ansi: bool,
) -> crate::database::Result<String> {
let job = database.get_job(job_id)?;
let deleted = job.deleted;
let running = job.running.is_some();
let current = Some(job_id) == config.current_job;
format_id(&job_id, current, running, deleted, ansi)
}
pub(crate) fn format_worker_id(
worker_id: &str,
database: &(impl JobberGet + JobberIter),
config: &Config,
ansi: bool,
) -> crate::database::Result<String> {
let worker = database.get_worker(worker_id)?;
let deleted = worker.deleted;
let mut current = false;
let running = database
.iter_jobs(Deleted::No)
.filter(|(_, job)| job.worker == worker_id)
.inspect(|(job_id, _)| {
if Some(*job_id) == config.current_job {
current = true;
}
})
.any(|(_, job)| job.running.is_some());
format_id(&worker_id, current, running, deleted, ansi)
}
fn format_id<T: std::fmt::Display>(
id: &T,
current: bool,
running: bool,
deleted: bool,
ansi: bool,
) -> crate::database::Result<String> {
if ansi {
Ok(match (current, running, deleted) {
(true, true, false) => cformat!("👷 <g!>▶</> {id}"),
(true, false, false) => cformat!("<g!>▶</> {id}"),
(false, true, false) => cformat!("👷 {id}"),
(false, false, false) => id.to_string(),
(.., true) => cformat!("❌ {id}"),
})
} else {
Ok(match (current, running, deleted) {
(true, true, false) => format!("(working) > {id}"),
(true, false, false) => format!("(working) {id}"),
(false, true, false) => format!("> {id}"),
(false, false, false) => id.to_string(),
(.., true) => format!("(deleted) {id}"),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::{from_end, from_start};
fn wt(start: u32, end: u32) -> WorkTime {
let start = from_start(&format!("2026-1-1 {start}:00")).unwrap();
WorkTime::new(
start,
from_end(&format!("2026-1-1 {end}:00"), start).unwrap(),
)
}
#[test]
fn empty_input() {
assert_eq!(merge_work_times(vec![]), vec![]);
}
#[test]
fn single_entry() {
assert_eq!(merge_work_times(vec![&wt(8, 10)]), vec![wt(8, 10)]);
}
#[test]
fn non_overlapping() {
let input = [wt(8, 10), wt(12, 14)];
assert_eq!(
merge_work_times(input.iter().collect()),
vec![wt(8, 10), wt(12, 14)]
);
}
#[test]
fn overlapping() {
let input = [wt(8, 12), wt(10, 14)];
assert_eq!(merge_work_times(input.iter().collect()), vec![wt(8, 14)]);
}
#[test]
fn adjacent() {
let input = [wt(8, 10), wt(10, 12)];
assert_eq!(merge_work_times(input.iter().collect()), vec![wt(8, 12)]);
}
#[test]
fn contained() {
let input = [wt(8, 16), wt(10, 12)];
assert_eq!(merge_work_times(input.iter().collect()), vec![wt(8, 16)]);
}
#[test]
fn unsorted_input() {
let input = [wt(12, 14), wt(8, 10)];
assert_eq!(
merge_work_times(input.iter().collect()),
vec![wt(8, 10), wt(12, 14)]
);
}
#[test]
fn multiple_merges() {
let input = [wt(6, 9), wt(8, 11), wt(10, 14), wt(16, 18)];
assert_eq!(
merge_work_times(input.iter().collect()),
vec![wt(6, 14), wt(16, 18)]
);
}
}