health.appointment-slots Unreviewed
Bookable appointment slots from clinic sessions, a slot length, breaks and existing bookings.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 20 tests, run in TypeScript, Python and Rust.
Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified clinician has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
Not professional advice. This capability calculates health figures from published rules. It is a software component for developers, not medical advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a clinician review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
Not a medical device. It is not intended to diagnose, treat or support clinical decisions about any individual. Anyone building it into clinical software is responsible for that software’s regulatory status, and must validate it under their own clinical governance.
What it does
Lists the appointment slots that can still be booked. It takes clinic sessions (a date with a start and end time), a slot length, breaks and the bookings already made. It is pure scheduling arithmetic on wall-clock times: no time zones, no clock reads, and the same input always gives the same list.
## How the slots are laid out
For example
appointment_slots(sessions ×1, 15, , )→ ×12 a 09:00-12:00 morning in 15-minute slots is twelve slotsappointment_slots(sessions ×1, 20, breaks ×1, )→ ×20 a daily 12:30-13:30 lunch splits 09:00-17:00; 20-minute slots restart at 13:30 and the 10 minutes left before lunch and at the end are droppedappointment_slots(sessions ×2, 30, breaks ×1, )→ ×3 a break with a date applies to that day only
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn appointment_slots(sessions: &[ClinicSession], slot_minutes: i64, breaks: &[ClinicBreak], bookings: &[Booking]) -> Vec<AppointmentSlot>
| sessions | ClinicSession[] | clinic sessions in any order; sessions on one date must not overlap |
| slot_minutes | int | the length of every slot, 1 to 480 |
| breaks | ClinicBreak[] | times the clinic is closed inside a session; a null date means every day |
| bookings | Booking[] | appointments already booked; a slot they overlap is not offered |
| returns | AppointmentSlot[] | the free slots, by date then start time |
The types it declares, generated into your project
/// One clinic session on one day; it cannot cross midnight.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClinicSession {
pub date: String,
/// HH:MM, 24-hour
pub start: String,
/// HH:MM, after start
pub end: String,
}
/// A break inside sessions, such as lunch; slots restart after it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClinicBreak {
/// null for a break on every session day
pub date: Option<String>,
/// HH:MM
pub start: String,
/// HH:MM, after start
pub end: String,
}
/// An existing appointment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Booking {
pub date: String,
/// HH:MM
pub start: String,
/// HH:MM, after start
pub end: String,
}
/// One bookable slot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppointmentSlot {
pub date: String,
/// HH:MM
pub start: String,
/// HH:MM
pub end: String,
}
Your code names it in one line, in the file that uses it
fune!(health.appointment-slots@^1); // then call appointment_slots(…)
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())
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add health.appointment-slots
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./health.appointment-slots-1.0.1-rust.fune, or fetch it from a terminal with fune pull health.appointment-slots@1.0.1:rust.
The whole function, every language, is one file too: health.appointment-slots-1.0.1.fune, 28,395 bytes, sha256 9b81b0c2ed4032adce8b962d58a67032ce0817f7dcf79e05cf4ec899e66e00a1. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before health.appointment-slots
after — your function gets the result and the arguments, and returns the final result.
// fune: after health.appointment-slots
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace time.minutes-between in health.appointment-slots
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show health.appointment-slots --steps.
// fune: step health.appointment-slots after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a 09:00-12:00 morning in 15-minute slots is twelve slots | sessions ×1, 15, , | → | ×12 |
| a daily 12:30-13:30 lunch splits 09:00-17:00; 20-minute slots restart at 13:30 and the 10 minutes left before lunch and at the end are dropped | sessions ×1, 20, breaks ×1, | → | ×20 |
| a break with a date applies to that day only | sessions ×2, 30, breaks ×1, | → | ×3 |
| a booking straddling two slots removes both | sessions ×1, 15, , bookings ×1 | → | ×2 |
| bookings that only touch a slot's edge do not remove it | sessions ×1, 15, , bookings ×2 | → | ×3 |
| a booking that does not fill its slot does not shift the grid | sessions ×1, 20, , bookings ×1 | → | ×2 |
| bookings on other dates are ignored | sessions ×1, 15, , bookings ×1 | → | ×4 |
| sessions given out of order come back by date and time | sessions ×3, 30, , | → | ×5 |
| a break overlapping the session start moves the first slot to the break's end | sessions ×1, 30, breaks ×1, | → | ×3 |
| a break covering the whole session leaves no slots | sessions ×1, 15, breaks ×1, | → |
Show the other 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a slot longer than the session gives no slots | sessions ×1, 45, , | → | |
| no sessions, no slots | , 15, , | → | |
| sessions that overlap on one day are an error | sessions ×2, 15, , | → | error: sessions overlap on 2026-10-05 |
| a time without a leading zero is an error | sessions ×1, 15, , | → | error: is not a time of day |
| 24:00 is not a time of day | sessions ×1, 15, , | → | error: is not a time of day |
| a session cannot cross midnight: 22:00-00:00 ends before it starts | sessions ×1, 15, , | → | error: session on 2026-10-05 must end after it starts |
| a booking that ends before it starts is an error | sessions ×1, 15, , bookings ×1 | → | error: booking on 2026-10-05 must end after it starts |
| an impossible date is an error | sessions ×1, 15, , | → | error: is not a real calendar date |
| a zero slot length is an error | sessions ×1, 0, , | → | error: slotMinutes must be a whole number from 1 to 480 |
| a fractional slot length is an error | sessions ×1, 7.5, , | → | error: slotMinutes must be a whole number from 1 to 480 |
More from the author
- **Breaks reshape a session.** A break (lunch, a team meeting) splits the session into free segments. Slots are laid back to back from the start of each segment, so after a 12:30-13:30 lunch the first slot is at 13:30, not wherever the morning's grid would have landed. A break with a null date applies to every session day. A break with a date applies to that day only. - **Leftovers are dropped.** A slot must fit wholly inside its segment. With 20-minute slots, 09:00-12:30 gives ten slots ending at 12:20, and the last 10 minutes are not offered. - **Bookings only take slots away.** A slot that overlaps any booking on the same date is removed, but the grid does not move. Bookings are made into slots, so a 5-minute booking at 09:05 removes the 09:00 slot and leaves 09:20 where it was. A booking across two slots removes both. - **Touching is not overlapping.** Intervals are half-open (start included, end excluded), so a booking ending at 09:15 leaves the 09:15 slot free.
Sessions may be given in any order. The result is sorted by date, then start time.
## Errors
The function refuses, rather than guessing, when:
- a time is not `HH:MM` from 00:00 to 23:59 (`9:00` and `24:00` are refused) - a date is not a real ISO date - a session, break or booking does not end after it starts - two sessions on the same date overlap - `slotMinutes` is not a whole number from 1 to 480
A session cannot cross midnight. Split an overnight clinic into two sessions, one on each date.
## What it does not do
It does not handle clinicians, rooms or appointment types. Call it once per clinician or resource. It does not handle double-booking capacity (more than one patient per slot) or daylight-saving changes. It does not keep slots in the past out of the list, because it never reads the clock: pass only the sessions still to come.
## Before you rely on this
**Not professional advice.** This capability calculates health figures from published rules. It is a software component for developers, not medical advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a clinician review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.
**Not a medical device.** It is not intended to diagnose, treat or support clinical decisions about any individual. Anyone building it into clinical software is responsible for that software's regulatory status, and must validate it under their own clinical governance.
**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified clinician has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
1.0.1 marks it unreviewed. The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| README.md | 3,561 |
| impl/python.py | 3,310 |
| impl/rust.rs | 5,132 |
| impl/typescript.ts | 3,510 |
| vectors.json | 7,400 |