use std::collections::BTreeSet;
use time::OffsetDateTime;
use time_tz::{OffsetDateTimeExt, Tz};
use crate::model::{Node, TimeRange};
pub const DEFAULT_BOOK_BUFFER_MINS: u64 = 0;
pub const DEFAULT_BOOK_LEAD_MINS: u64 = 120;
pub const DEFAULT_BOOK_HORIZON_DAYS: u64 = 14;
pub const DEFAULT_BOOK_MAX_OFFERS: u64 = 3;
pub const DEFAULT_BOOK_RETRIES: u64 = 1;
pub const DEFAULT_BOOK_TIMEOUT_SECS: u64 = 5;
pub const MIN_BOOK_DURATION_MINS: u64 = 5;
pub const MAX_BOOK_DURATION_MINS: u64 = 480;
pub const MAX_BOOK_BUFFER_MINS: u64 = 240;
pub const MAX_BOOK_LEAD_MINS: u64 = 60 * 24 * 30;
pub const MAX_BOOK_HORIZON_DAYS: u64 = 31;
pub const MAX_BOOK_OFFERS: u64 = 5;
pub const BOOK_GRANULARITY_MINS: u64 = 30;
const PREFIX: &str = "bk";
pub const BOOK_DAY_KEYS: &[&str] = &[
"today", "tomorrow", "mon", "tue", "wed", "thu", "fri", "sat", "sun",
];
pub fn day_ref(day: &str) -> String {
format!("{PREFIX}day_{day}")
}
pub fn day_key(days_ahead: i64, weekday_from_monday: usize) -> &'static str {
match days_ahead {
0 => "today",
1 => "tomorrow",
_ => BOOK_DAY_KEYS[2 + (weekday_from_monday % 7)],
}
}
pub fn time_ref(minutes_of_day: u64) -> String {
format!(
"{PREFIX}time_{:02}{:02}",
minutes_of_day / 60,
minutes_of_day % 60
)
}
pub fn press_ref(digit: u64) -> String {
format!("{PREFIX}press_{digit}")
}
pub fn taken_ref() -> String {
format!("{PREFIX}taken")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VocabularyRef {
Day { day: String },
Time { hour: u64, minute: u64 },
Press { digit: u64 },
Taken,
}
pub fn parse_vocabulary_ref(reference: &str) -> Option<VocabularyRef> {
if reference == taken_ref() {
return Some(VocabularyRef::Taken);
}
if let Some(day) = reference.strip_prefix(&format!("{PREFIX}day_")) {
return BOOK_DAY_KEYS.contains(&day).then(|| VocabularyRef::Day {
day: day.to_string(),
});
}
if let Some(digits) = reference.strip_prefix(&format!("{PREFIX}time_")) {
if digits.len() != 4 || !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let hour: u64 = digits[..2].parse().ok()?;
let minute: u64 = digits[2..].parse().ok()?;
return (hour <= 23 && minute <= 59).then_some(VocabularyRef::Time { hour, minute });
}
if let Some(digits) = reference.strip_prefix(&format!("{PREFIX}press_")) {
let digit: u64 = digits.parse().ok()?;
return (1..=MAX_BOOK_OFFERS)
.contains(&digit)
.then_some(VocabularyRef::Press { digit });
}
None
}
fn minutes_of(hhmm: &str) -> Option<u64> {
let (h, m) = hhmm.trim().split_once(':')?;
if m.len() != 2 || h.is_empty() || h.len() > 2 {
return None;
}
let hours: u64 = h.parse().ok()?;
let mins: u64 = m.parse().ok()?;
(hours <= 23 && mins <= 59).then_some(hours * 60 + mins)
}
fn starts_in_range(range: &TimeRange, duration_mins: u64) -> Vec<u64> {
let (Some(open), Some(close)) = (minutes_of(&range.open), minutes_of(&range.close)) else {
return Vec::new();
};
if close <= open {
return Vec::new();
}
let first = open.div_ceil(BOOK_GRANULARITY_MINS) * BOOK_GRANULARITY_MINS;
let mut starts = Vec::new();
let mut mins = first;
while mins < close {
if mins + duration_mins <= close {
starts.push(mins);
}
mins += BOOK_GRANULARITY_MINS;
}
starts
}
pub fn vocabulary_refs(node: &Node) -> Vec<String> {
let Node::Book {
schedule,
exceptions,
duration_mins,
max_offers,
..
} = node
else {
return Vec::new();
};
let mut refs: BTreeSet<String> = BTreeSet::new();
for day in BOOK_DAY_KEYS {
refs.insert(day_ref(day));
}
refs.insert(taken_ref());
for digit in 1..=(*max_offers).min(MAX_BOOK_OFFERS) {
refs.insert(press_ref(digit));
}
let duration = duration_mins.clamp(&MIN_BOOK_DURATION_MINS, &MAX_BOOK_DURATION_MINS);
let weekly = [
&schedule.mon,
&schedule.tue,
&schedule.wed,
&schedule.thu,
&schedule.fri,
&schedule.sat,
&schedule.sun,
];
let mut ranges: Vec<&TimeRange> = weekly.iter().flat_map(|day| day.iter()).collect();
for exception in exceptions {
if exception.closed {
continue;
}
ranges.extend(exception.ranges.iter());
}
for range in ranges {
for start in starts_in_range(range, *duration) {
refs.insert(time_ref(start));
}
}
refs.into_iter().collect()
}
pub fn time_refs(start: OffsetDateTime, now: OffsetDateTime, tz: &Tz) -> Vec<String> {
let local = start.to_timezone(tz);
let today = now.to_timezone(tz).date();
let days_ahead = (local.date() - today).whole_days();
let minutes = u64::from(local.hour()) * 60 + u64::from(local.minute());
vec![
day_ref(day_key(
days_ahead,
local.date().weekday().number_days_from_monday() as usize,
)),
time_ref(minutes),
]
}
pub fn offer_refs(start: OffsetDateTime, now: OffsetDateTime, tz: &Tz, digit: u64) -> Vec<String> {
let mut refs = time_refs(start, now, tz);
refs.push(press_ref(digit));
refs
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
#[test]
fn grid_starts_leave_room_for_the_whole_appointment() {
let range = TimeRange {
open: "09:10".into(),
close: "10:30".into(),
};
assert_eq!(starts_in_range(&range, 30), vec![570, 600]);
assert!(starts_in_range(&range, 120).is_empty());
}
#[test]
fn unreadable_ranges_offer_nothing_rather_than_failing() {
let backwards = TimeRange {
open: "17:00".into(),
close: "09:00".into(),
};
assert!(starts_in_range(&backwards, 30).is_empty());
let nonsense = TimeRange {
open: "nine".into(),
close: "five".into(),
};
assert!(starts_in_range(&nonsense, 30).is_empty());
}
#[test]
fn refs_round_trip_through_their_meaning() {
assert_eq!(
parse_vocabulary_ref(&time_ref(9 * 60 + 30)),
Some(VocabularyRef::Time {
hour: 9,
minute: 30
})
);
assert_eq!(
parse_vocabulary_ref(&day_ref("tue")),
Some(VocabularyRef::Day { day: "tue".into() })
);
assert_eq!(
parse_vocabulary_ref(&press_ref(2)),
Some(VocabularyRef::Press { digit: 2 })
);
assert_eq!(
parse_vocabulary_ref(&taken_ref()),
Some(VocabularyRef::Taken)
);
assert_eq!(parse_vocabulary_ref("vprompt_ab12cd34"), None);
assert_eq!(parse_vocabulary_ref("bkday_funday"), None);
assert_eq!(parse_vocabulary_ref("bktime_2599"), None);
assert_eq!(parse_vocabulary_ref("bkpress_9"), None);
}
#[test]
fn day_key_prefers_today_and_tomorrow_over_the_weekday_name() {
assert_eq!(day_key(0, 3), "today");
assert_eq!(day_key(1, 4), "tomorrow");
assert_eq!(day_key(2, 0), "mon");
assert_eq!(day_key(6, 6), "sun");
}
#[test]
fn a_time_is_said_in_the_businesss_zone_not_the_servers() {
let tz = crate::hours::resolve_tz("America/New_York").unwrap();
let start = datetime!(2026-07-07 14:30 UTC);
let now = datetime!(2026-07-06 12:00 UTC); assert_eq!(
time_refs(start, now, tz),
vec!["bkday_tomorrow".to_string(), "bktime_1030".to_string()],
);
let earlier = datetime!(2026-07-01 12:00 UTC);
assert_eq!(
time_refs(start, earlier, tz),
vec!["bkday_tue".to_string(), "bktime_1030".to_string()],
);
}
#[test]
fn a_late_utc_instant_can_be_the_previous_local_day() {
let tz = crate::hours::resolve_tz("America/New_York").unwrap();
let start = datetime!(2026-07-08 01:00 UTC);
let now = datetime!(2026-07-07 13:00 UTC); assert_eq!(
time_refs(start, now, tz),
vec!["bkday_today".to_string(), "bktime_2100".to_string()],
);
}
#[test]
fn an_offer_ends_with_the_key_that_takes_it() {
let tz = crate::hours::resolve_tz("UTC").unwrap();
let start = datetime!(2026-07-07 09:30 UTC);
let now = datetime!(2026-07-07 08:00 UTC);
assert_eq!(
offer_refs(start, now, tz, 2),
vec![
"bkday_today".to_string(),
"bktime_0930".to_string(),
"bkpress_2".to_string()
],
);
}
}