use super::funejson::Value; use super::dates_add_days::{add_days, days_between, parse_iso_date}; use super::dates_add_months::add_months; /// CPD minutes logged in the period that applies on on_date, against the /// requirement. Fixed period boundaries are anchor + k x period_months, each /// worked out from the anchor so month-end clamping never accumulates. /// /// # Panics /// Panics on negative minutes or requirement, a period under one month, a /// rolling period with an anchor or a fixed one without, or a malformed date. pub fn cpd_hours(entries: &[CpdEntry], requirement: &CpdRequirement, on_date: &str) -> CpdProgress { let on = parse_iso_date(on_date); let required = requirement.required_minutes; let months = requirement.period_months; if required < 0 { panic!("requiredMinutes must be a whole number of 0 or more, received {}", required); } if months < 1 { panic!("periodMonths must be a whole number of 1 or more, received {}", months); } let (period_start, period_end) = if requirement.rolling { if requirement.anchor.is_some() { panic!("a rolling period takes no anchor"); } (add_days(&add_months(on_date, -months), 1), on_date.to_string()) } else { let anchor = match &requirement.anchor { Some(a) => a.as_str(), None => panic!("a fixed period needs an anchor date"), }; let a = parse_iso_date(anchor); let months_apart = (on.year - a.year) * 12 + (on.month - a.month); let mut k = months_apart.div_euclid(months); while add_months(anchor, k * months).as_str() > on_date { k -= 1; } while add_months(anchor, (k + 1) * months).as_str() <= on_date { k += 1; } (add_months(anchor, k * months), add_days(&add_months(anchor, (k + 1) * months), -1)) }; let mut logged: i64 = 0; let mut counted: i64 = 0; for e in entries { parse_iso_date(&e.date); if e.minutes < 0 { panic!("minutes must be a whole number of 0 or more, received {} on {}", e.minutes, e.date); } if e.date >= period_start && e.date <= period_end && e.date.as_str() <= on_date { logged += e.minutes; counted += 1; } } let shortfall = (required - logged).max(0); CpdProgress { days_remaining: days_between(on_date, &period_end), period_start, period_end, logged_minutes: logged, entries_counted: counted, required_minutes: required, shortfall_minutes: shortfall, met: shortfall == 0, } } fn whole(value: &Value, message: &str) -> i64 { match value { Value::Float(f) if f.fract() != 0.0 => panic!("{}, received {}", message, f), _ => value.as_i64(), } } pub fn cpd_progress_to_value(p: &CpdProgress) -> Value { Value::obj(vec![ ("periodStart", Value::str(&p.period_start)), ("periodEnd", Value::str(&p.period_end)), ("loggedMinutes", Value::Int(p.logged_minutes)), ("entriesCounted", Value::Int(p.entries_counted)), ("requiredMinutes", Value::Int(p.required_minutes)), ("shortfallMinutes", Value::Int(p.shortfall_minutes)), ("met", Value::Bool(p.met)), ("daysRemaining", Value::Int(p.days_remaining)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let entries: Vec = args[0] .as_arr() .iter() .map(|v| CpdEntry { date: v.get("date").as_str().to_string(), minutes: whole(v.get("minutes"), "minutes must be a whole number of 0 or more"), }) .collect(); let r = &args[1]; let anchor = r.get("anchor"); let requirement = CpdRequirement { required_minutes: whole(r.get("requiredMinutes"), "requiredMinutes must be a whole number of 0 or more"), period_months: whole(r.get("periodMonths"), "periodMonths must be a whole number of 1 or more"), rolling: r.get("rolling").as_bool(), anchor: if anchor.is_null() { None } else { Some(anchor.as_str().to_string()) }, }; cpd_progress_to_value(&cpd_hours(&entries, &requirement, args[2].as_str())) }