Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::{add_days, days_between, parse_iso_date}; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_add_months::add_months; ← from dates.add-months ^1.0.0 · built alongside by fune
/// 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<CpdEntry> = 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()))
}