use super::funejson::Value; use super::time_minutes_between::minutes_between; /// 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 { if !(1..=480).contains(&slot_minutes) { panic!("slotMinutes must be a whole number from 1 to 480, received {}", slot_minutes); } let mut clinic: Vec = 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 = 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 = args[0] .as_arr() .iter() .map(|v| ClinicSession { date: text(v, "date"), start: text(v, "start"), end: text(v, "end") }) .collect(); let breaks: Vec = 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 = 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()) }