Functional Weave
Code in Rust

education.cpd-hours@1.0.0

impl/typescript.ts

2,491 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { addDays, daysBetween, parseIsoDate } from "./dates_add_days.ts";  ← from dates.add-days ^1.0.0 · built alongside by fune
import { addMonths } from "./dates_add_months.ts";  ← from dates.add-months ^1.0.0 · built alongside by fune
import { type CpdEntry, type CpdProgress, type CpdRequirement } from "./education_cpd_hours_types.ts";

/**
 * CPD minutes logged in the period that applies on onDate, against the
 * requirement. Fixed period boundaries are anchor + k x periodMonths, each
 * worked out from the anchor so month-end clamping never accumulates.
 */
export function cpdHours(entries: readonly CpdEntry[], requirement: CpdRequirement, onDate: string): CpdProgress {
  const on = parseIsoDate(onDate);
  const { requiredMinutes, periodMonths, rolling, anchor } = requirement;
  if (!Number.isInteger(requiredMinutes) || requiredMinutes < 0) {
    throw new RangeError(`requiredMinutes must be a whole number of 0 or more, received ${requiredMinutes}`);
  }
  if (!Number.isInteger(periodMonths) || periodMonths < 1) {
    throw new RangeError(`periodMonths must be a whole number of 1 or more, received ${periodMonths}`);
  }
  let periodStart: string;
  let periodEnd: string;
  if (rolling) {
    if (anchor !== null) throw new RangeError("a rolling period takes no anchor");
    periodEnd = onDate;
    periodStart = addDays(addMonths(onDate, -periodMonths), 1);
  } else {
    if (anchor === null) throw new RangeError("a fixed period needs an anchor date");
    const a = parseIsoDate(anchor);
    const monthsApart = (on.year - a.year) * 12 + (on.month - a.month);
    let k = Math.floor(monthsApart / periodMonths);
    while (addMonths(anchor, k * periodMonths) > onDate) k -= 1;
    while (addMonths(anchor, (k + 1) * periodMonths) <= onDate) k += 1;
    periodStart = addMonths(anchor, k * periodMonths);
    periodEnd = addDays(addMonths(anchor, (k + 1) * periodMonths), -1);
  }

  let logged = 0;
  let counted = 0;
  for (const e of entries) {
    parseIsoDate(e.date);
    if (!Number.isInteger(e.minutes) || e.minutes < 0) {
      throw new RangeError(`minutes must be a whole number of 0 or more, received ${e.minutes} on ${e.date}`);
    }
    if (e.date >= periodStart && e.date <= periodEnd && e.date <= onDate) {
      logged += e.minutes;
      counted += 1;
    }
  }
  const shortfall = Math.max(0, requiredMinutes - logged);
  return {
    periodStart,
    periodEnd,
    loggedMinutes: logged,
    entriesCounted: counted,
    requiredMinutes,
    shortfallMinutes: shortfall,
    met: shortfall === 0,
    daysRemaining: daysBetween(onDate, periodEnd),
  };
}