Functional Weave
Code in TypeScript

health.appointment-slots@1.0.1

impl/rust.rs

5,132 bytes · the Rust 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.

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::time_minutes_between::minutes_between;  ← from time.minutes-between ^1.0.0 · built alongside by fune

/// A break without a date still needs a real date for the time check.
const ANY_DATE: &str = "2000-01-01";

struct Interval {
    date: String,
    start: i64,
    end: i64,
}

/// Minute of the day, 0 to 1439; panics on a malformed date or time.
fn minute_of_day(date: &str, time: &str) -> i64 {
    minutes_between(date, "00:00", date, time)
}

fn interval(kind: &str, date: Option<&str>, start: &str, end: &str) -> Interval {
    let on = date.unwrap_or(ANY_DATE);
    let from = minute_of_day(on, start);
    let to = minute_of_day(on, end);
    if to <= from {
        panic!("{} on {} must end after it starts ({} to {})", kind, date.unwrap_or("every day"), start, end);
    }
    Interval { date: on.to_string(), start: from, end: to }
}

fn clock(minute: i64) -> String {
    format!("{:02}:{:02}", minute / 60, minute % 60)
}

/// Free appointment slots. Breaks split a session into segments and the slot
/// grid restarts at the start of each segment, so a 12:30-13:30 lunch gives
/// slots from 13:30. Bookings only take slots away: they never move the grid,
/// because they are booked into slots. Intervals are half-open, so a booking
/// ending at 09:15 does not touch the 09:15 slot.
///
/// # Panics
/// Panics on a bad date or time, an interval that does not end after it
/// starts, overlapping sessions, or slot_minutes outside 1..=480.
pub fn appointment_slots(
    sessions: &[ClinicSession],
    slot_minutes: i64,
    breaks: &[ClinicBreak],
    bookings: &[Booking],
) -> Vec<AppointmentSlot> {
    if !(1..=480).contains(&slot_minutes) {
        panic!("slotMinutes must be a whole number from 1 to 480, received {}", slot_minutes);
    }
    let mut clinic: Vec<Interval> =
        sessions.iter().map(|s| interval("session", Some(&s.date), &s.start, &s.end)).collect();
    let closed: Vec<(bool, Interval)> = breaks
        .iter()
        .map(|b| (b.date.is_none(), interval("break", b.date.as_deref(), &b.start, &b.end)))
        .collect();
    let booked: Vec<Interval> =
        bookings.iter().map(|b| interval("booking", Some(&b.date), &b.start, &b.end)).collect();

    clinic.sort_by(|a, b| a.date.cmp(&b.date).then(a.start.cmp(&b.start)));
    for pair in clinic.windows(2) {
        if pair[1].date == pair[0].date && pair[1].start < pair[0].end {
            panic!("sessions overlap on {}", pair[1].date);
        }
    }

    let mut slots = Vec::new();
    for session in &clinic {
        let mut pauses: Vec<(i64, i64)> = closed
            .iter()
            .filter(|(always, b)| *always || b.date == session.date)
            .map(|(_, b)| (b.start, b.end))
            .collect();
        pauses.sort();
        let mut segments: Vec<(i64, i64)> = Vec::new();
        let mut cursor = session.start;
        for (p_start, p_end) in pauses {
            if p_end <= cursor || p_start >= session.end {
                continue;
            }
            if p_start > cursor {
                segments.push((cursor, p_start));
            }
            cursor = cursor.max(p_end);
        }
        if cursor < session.end {
            segments.push((cursor, session.end));
        }

        for (from, to) in segments {
            let mut t = from;
            while t + slot_minutes <= to {
                let end = t + slot_minutes;
                let taken = booked.iter().any(|b| b.date == session.date && b.start < end && b.end > t);
                if !taken {
                    slots.push(AppointmentSlot { date: session.date.clone(), start: clock(t), end: clock(end) });
                }
                t += slot_minutes;
            }
        }
    }
    slots
}

pub fn appointment_slot_to_value(slot: &AppointmentSlot) -> Value {
    Value::obj(vec![
        ("date", Value::str(&slot.date)),
        ("start", Value::str(&slot.start)),
        ("end", Value::str(&slot.end)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what an i64 cannot hold with the wording TypeScript and Python use.
    let slot_minutes = match &args[1] {
        Value::Int(v) => *v,
        other => panic!("slotMinutes must be a whole number from 1 to 480, received {:?}", other),
    };
    let text = |v: &Value, key: &str| v.get(key).as_str().to_string();
    let sessions: Vec<ClinicSession> = args[0]
        .as_arr()
        .iter()
        .map(|v| ClinicSession { date: text(v, "date"), start: text(v, "start"), end: text(v, "end") })
        .collect();
    let breaks: Vec<ClinicBreak> = args[2]
        .as_arr()
        .iter()
        .map(|v| ClinicBreak {
            date: if v.get("date").is_null() { None } else { Some(text(v, "date")) },
            start: text(v, "start"),
            end: text(v, "end"),
        })
        .collect();
    let bookings: Vec<Booking> = args[3]
        .as_arr()
        .iter()
        .map(|v| Booking { date: text(v, "date"), start: text(v, "start"), end: text(v, "end") })
        .collect();
    Value::Arr(appointment_slots(&sessions, slot_minutes, &breaks, &bookings).iter().map(appointment_slot_to_value).collect())
}