wavekat_flow/book.rs
1//! The `book` component (schema_version 2): its bounds, and the
2//! vocabulary that lets a caller be told a time.
3//!
4//! # Why a vocabulary exists at all
5//!
6//! Every sound a flow makes is a clip. The daemon runs no text-to-speech:
7//! prompts are rendered when the flow is published and frozen as version
8//! assets, and playback is a file read. That is what makes a flow work
9//! with no network, and it is why `book` cannot simply "say the time" —
10//! the time is not known until a caller is on the line, hours or days
11//! after the last thing was rendered.
12//!
13//! So the times a flow can *ever* offer are enumerated at publish and
14//! rendered then, exactly like its prompts. Two things make that a small
15//! finite set rather than an impossible one: starts snap to a quarter
16//! hour ([`BOOK_GRANULARITY_MINS`]), and the node already declares the
17//! only hours it books in. A business open 9–5 on weekdays therefore
18//! needs 32 time clips, not 1440 — and needs them regardless of which
19//! week the caller rings in, because "nine thirty" is the same two words
20//! on every one of those days.
21//!
22//! # Why it is split day + time
23//!
24//! An utterance is two clips: a day phrase, then a time phrase
25//! ("Tuesday", then "ten thirty a.m."). One clip per (day, time) pair
26//! multiplies the render count by nine for no gain. Going finer the
27//! other way — composing "ten", "thirty" and "a.m." separately — is what
28//! a naive reading suggests and is wrong: word order and grammar differ across
29//! the languages this platform ships, and an engine concatenating
30//! fragments in English order produces something between odd and
31//! unintelligible elsewhere. Whole phrases keep each language's grammar
32//! inside the phrase, where a translator can see it; day-before-time is
33//! the one ordering assumption left, and it holds across all of them.
34//!
35//! # The contract
36//!
37//! These refs are the contract between the two sides: the platform
38//! renders exactly this set at publish and stores it with the version's
39//! frozen assets; the daemon plays them and never asks what they mean.
40//! Both sides compute the set from the same node config with
41//! [`vocabulary_refs`], so neither can render one thing and expect
42//! another. The refs join [`crate::model_ext`]'s `required_assets`,
43//! which puts them behind the daemon's existing "don't arm a flow whose
44//! audio hasn't synced" gate with no new machinery.
45//!
46//! Twin: `packages/flow-schema/src/book.ts`. Keep the two in lockstep —
47//! the conformance corpus pins the ref sets they produce.
48
49use std::collections::BTreeSet;
50
51use time::OffsetDateTime;
52use time_tz::{OffsetDateTimeExt, Tz};
53
54use crate::model::{Node, TimeRange};
55
56// ── Defaults (mirroring the schema's own, which typify reads) ───────────
57
58pub const DEFAULT_BOOK_BUFFER_MINS: u64 = 0;
59pub const DEFAULT_BOOK_LEAD_MINS: u64 = 120;
60pub const DEFAULT_BOOK_HORIZON_DAYS: u64 = 14;
61pub const DEFAULT_BOOK_MAX_OFFERS: u64 = 3;
62pub const DEFAULT_BOOK_RETRIES: u64 = 1;
63pub const DEFAULT_BOOK_TIMEOUT_SECS: u64 = 5;
64
65// ── Bounds (the validator's, per "schema owns shape, validators own
66// semantics" — see CLAUDE.md) ───────────────────────────────────────
67
68/// Shortest and longest one appointment may run.
69pub const MIN_BOOK_DURATION_MINS: u64 = 5;
70pub const MAX_BOOK_DURATION_MINS: u64 = 480;
71/// Widest clear time that may be kept on each side of an appointment.
72pub const MAX_BOOK_BUFFER_MINS: u64 = 240;
73/// Furthest ahead a caller may be pushed before the first offer (30 days).
74pub const MAX_BOOK_LEAD_MINS: u64 = 60 * 24 * 30;
75/// Furthest ahead a `book` node may look.
76pub const MAX_BOOK_HORIZON_DAYS: u64 = 31;
77/// How many times one caller may be offered — bounded by the keypad
78/// digits the vocabulary carries.
79pub const MAX_BOOK_OFFERS: u64 = 5;
80
81/// Every candidate appointment start lands on a quarter hour.
82///
83/// Load-bearing, not cosmetic: this is what bounds the vocabulary.
84/// Twin: the platform's `SLOT_GRANULARITY_MINS`, which must agree — if
85/// the server offers a time the vocabulary has no clip for, the caller
86/// hears silence where the time should be.
87pub const BOOK_GRANULARITY_MINS: u64 = 15;
88
89// ── Vocabulary refs ────────────────────────────────────────────────────
90
91/// Prefix every vocabulary ref carries, so nothing collides with a
92/// platform-generated clip ref (`vprompt_…`) or an author's own file.
93const PREFIX: &str = "bk";
94
95/// The day phrases, always required. `today`/`tomorrow` are how a person
96/// says a date this close to now, and a caller told "Monday" on a Monday
97/// has to work out which Monday.
98pub const BOOK_DAY_KEYS: &[&str] = &[
99 "today", "tomorrow", "mon", "tue", "wed", "thu", "fri", "sat", "sun",
100];
101
102/// `bkday_tue` — the day half of an utterance.
103pub fn day_ref(day: &str) -> String {
104 format!("{PREFIX}day_{day}")
105}
106
107/// The day key for a slot, given how many civil days away it falls and
108/// which weekday it lands on (0 = Monday, matching [`BOOK_DAY_KEYS`]'s
109/// weekday order). Today and tomorrow win over the weekday name.
110pub fn day_key(days_ahead: i64, weekday_from_monday: usize) -> &'static str {
111 match days_ahead {
112 0 => "today",
113 1 => "tomorrow",
114 _ => BOOK_DAY_KEYS[2 + (weekday_from_monday % 7)],
115 }
116}
117
118/// `bktime_0930` — the time half, in the flow's own timezone.
119pub fn time_ref(minutes_of_day: u64) -> String {
120 format!(
121 "{PREFIX}time_{:02}{:02}",
122 minutes_of_day / 60,
123 minutes_of_day % 60
124 )
125}
126
127/// `bkpress_2` — "press two", the digit that takes the offer.
128pub fn press_ref(digit: u64) -> String {
129 format!("{PREFIX}press_{digit}")
130}
131
132/// The one fixed line the component speaks on its own account: the slot
133/// the caller chose was taken between hearing it and pressing the key.
134/// Everything else a caller hears is either the author's prompt or a time.
135pub fn taken_ref() -> String {
136 format!("{PREFIX}taken")
137}
138
139/// What a vocabulary ref means — so the side that *renders* it can look
140/// up the words without re-deriving the ref format.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum VocabularyRef {
143 Day { day: String },
144 Time { hour: u64, minute: u64 },
145 Press { digit: u64 },
146 Taken,
147}
148
149/// Read a ref back into what it says. `None` for anything that is not a
150/// vocabulary ref (an author's clip, a filename), so a caller can filter
151/// a mixed asset list with it.
152pub fn parse_vocabulary_ref(reference: &str) -> Option<VocabularyRef> {
153 if reference == taken_ref() {
154 return Some(VocabularyRef::Taken);
155 }
156 if let Some(day) = reference.strip_prefix(&format!("{PREFIX}day_")) {
157 return BOOK_DAY_KEYS.contains(&day).then(|| VocabularyRef::Day {
158 day: day.to_string(),
159 });
160 }
161 if let Some(digits) = reference.strip_prefix(&format!("{PREFIX}time_")) {
162 if digits.len() != 4 || !digits.chars().all(|c| c.is_ascii_digit()) {
163 return None;
164 }
165 let hour: u64 = digits[..2].parse().ok()?;
166 let minute: u64 = digits[2..].parse().ok()?;
167 return (hour <= 23 && minute <= 59).then_some(VocabularyRef::Time { hour, minute });
168 }
169 if let Some(digits) = reference.strip_prefix(&format!("{PREFIX}press_")) {
170 let digit: u64 = digits.parse().ok()?;
171 return (1..=MAX_BOOK_OFFERS)
172 .contains(&digit)
173 .then_some(VocabularyRef::Press { digit });
174 }
175 None
176}
177
178// ── Enumerating a node's vocabulary ────────────────────────────────────
179
180/// `"HH:MM"` → minutes since midnight, or `None` if it isn't one.
181/// Lenient in the same direction as the runtime: a range this can't read
182/// is a range that offers nothing, not a document that fails to load.
183fn minutes_of(hhmm: &str) -> Option<u64> {
184 let (h, m) = hhmm.trim().split_once(':')?;
185 if m.len() != 2 || h.is_empty() || h.len() > 2 {
186 return None;
187 }
188 let hours: u64 = h.parse().ok()?;
189 let mins: u64 = m.parse().ok()?;
190 (hours <= 23 && mins <= 59).then_some(hours * 60 + mins)
191}
192
193/// The starts a single open range can produce: on the quarter-hour grid,
194/// from the first grid point at or after `open`, while the whole
195/// appointment still finishes by `close`.
196///
197/// Mirrors the platform's slot walk, including that the walk uses real
198/// instants and so may drop a candidate this keeps on a daylight-saving
199/// boundary. That direction is safe: the vocabulary may be a superset (a
200/// clip nobody plays), never a subset (a time nobody can say).
201fn starts_in_range(range: &TimeRange, duration_mins: u64) -> Vec<u64> {
202 let (Some(open), Some(close)) = (minutes_of(&range.open), minutes_of(&range.close)) else {
203 return Vec::new();
204 };
205 if close <= open {
206 return Vec::new();
207 }
208 let first = open.div_ceil(BOOK_GRANULARITY_MINS) * BOOK_GRANULARITY_MINS;
209 let mut starts = Vec::new();
210 let mut mins = first;
211 while mins < close {
212 if mins + duration_mins <= close {
213 starts.push(mins);
214 }
215 mins += BOOK_GRANULARITY_MINS;
216 }
217 starts
218}
219
220/// Every asset ref a `book` node needs in order to speak: the nine day
221/// phrases, one clip per bookable time of day, one "press N" per offer
222/// it may make, and the taken line. Sorted and unique, so two callers of
223/// this compare equal.
224///
225/// Reads only the node's own config, never a clock — the answer must be
226/// the same at publish (when it is rendered) and on a call months later
227/// (when it is played). Empty for every other kind of node, so a caller
228/// can map it over a whole flow.
229pub fn vocabulary_refs(node: &Node) -> Vec<String> {
230 let Node::Book {
231 schedule,
232 exceptions,
233 duration_mins,
234 max_offers,
235 ..
236 } = node
237 else {
238 return Vec::new();
239 };
240
241 let mut refs: BTreeSet<String> = BTreeSet::new();
242
243 for day in BOOK_DAY_KEYS {
244 refs.insert(day_ref(day));
245 }
246 refs.insert(taken_ref());
247
248 for digit in 1..=(*max_offers).min(MAX_BOOK_OFFERS) {
249 refs.insert(press_ref(digit));
250 }
251
252 // A duration outside the bounds is a document the validator rejects;
253 // clamping keeps a bad draft from asking for an unbounded render
254 // while the editor is still showing the error.
255 let duration = duration_mins.clamp(&MIN_BOOK_DURATION_MINS, &MAX_BOOK_DURATION_MINS);
256
257 let weekly = [
258 &schedule.mon,
259 &schedule.tue,
260 &schedule.wed,
261 &schedule.thu,
262 &schedule.fri,
263 &schedule.sat,
264 &schedule.sun,
265 ];
266 let mut ranges: Vec<&TimeRange> = weekly.iter().flat_map(|day| day.iter()).collect();
267 for exception in exceptions {
268 // A closed day offers nothing; its `ranges`, if any, are ignored
269 // by the runtime too.
270 if exception.closed {
271 continue;
272 }
273 ranges.extend(exception.ranges.iter());
274 }
275
276 for range in ranges {
277 for start in starts_in_range(range, *duration) {
278 refs.insert(time_ref(start));
279 }
280 }
281
282 refs.into_iter().collect()
283}
284
285// ── Saying one time ────────────────────────────────────────────────────
286
287/// The clips that say one appointment time, in order: the day phrase
288/// then the time phrase ("Tuesday" → "ten thirty a.m.").
289///
290/// `start` is an absolute instant — the server answers in UTC — so the
291/// business's own zone is what turns it back into the words a caller
292/// expects; a 9 a.m. appointment must not be announced as 21:00 because
293/// the platform happens to run in UTC. `now` decides only whether the
294/// day is worth naming: "Tuesday" is a poor way to say tomorrow.
295pub fn time_refs(start: OffsetDateTime, now: OffsetDateTime, tz: &Tz) -> Vec<String> {
296 let local = start.to_timezone(tz);
297 let today = now.to_timezone(tz).date();
298 let days_ahead = (local.date() - today).whole_days();
299 let minutes = u64::from(local.hour()) * 60 + u64::from(local.minute());
300 vec![
301 day_ref(day_key(
302 days_ahead,
303 local.date().weekday().number_days_from_monday() as usize,
304 )),
305 time_ref(minutes),
306 ]
307}
308
309/// The clips that offer one time: what it is, then which key takes it.
310pub fn offer_refs(start: OffsetDateTime, now: OffsetDateTime, tz: &Tz, digit: u64) -> Vec<String> {
311 let mut refs = time_refs(start, now, tz);
312 refs.push(press_ref(digit));
313 refs
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use time::macros::datetime;
320
321 #[test]
322 fn grid_starts_leave_room_for_the_whole_appointment() {
323 let range = TimeRange {
324 open: "09:10".into(),
325 close: "10:30".into(),
326 };
327 // First grid point at or after 09:10 is 09:15; the last start that
328 // still finishes by 10:30 with a 30-minute appointment is 10:00.
329 assert_eq!(starts_in_range(&range, 30), vec![555, 570, 585, 600]);
330 // An appointment longer than the window produces nothing at all.
331 assert!(starts_in_range(&range, 120).is_empty());
332 }
333
334 #[test]
335 fn unreadable_ranges_offer_nothing_rather_than_failing() {
336 let backwards = TimeRange {
337 open: "17:00".into(),
338 close: "09:00".into(),
339 };
340 assert!(starts_in_range(&backwards, 30).is_empty());
341 let nonsense = TimeRange {
342 open: "nine".into(),
343 close: "five".into(),
344 };
345 assert!(starts_in_range(&nonsense, 30).is_empty());
346 }
347
348 #[test]
349 fn refs_round_trip_through_their_meaning() {
350 assert_eq!(
351 parse_vocabulary_ref(&time_ref(9 * 60 + 30)),
352 Some(VocabularyRef::Time {
353 hour: 9,
354 minute: 30
355 })
356 );
357 assert_eq!(
358 parse_vocabulary_ref(&day_ref("tue")),
359 Some(VocabularyRef::Day { day: "tue".into() })
360 );
361 assert_eq!(
362 parse_vocabulary_ref(&press_ref(2)),
363 Some(VocabularyRef::Press { digit: 2 })
364 );
365 assert_eq!(
366 parse_vocabulary_ref(&taken_ref()),
367 Some(VocabularyRef::Taken)
368 );
369
370 // Not vocabulary: an author's clip, and refs that look like one
371 // but say nothing sayable.
372 assert_eq!(parse_vocabulary_ref("vprompt_ab12cd34"), None);
373 assert_eq!(parse_vocabulary_ref("bkday_funday"), None);
374 assert_eq!(parse_vocabulary_ref("bktime_2599"), None);
375 assert_eq!(parse_vocabulary_ref("bkpress_9"), None);
376 }
377
378 #[test]
379 fn day_key_prefers_today_and_tomorrow_over_the_weekday_name() {
380 assert_eq!(day_key(0, 3), "today");
381 assert_eq!(day_key(1, 4), "tomorrow");
382 assert_eq!(day_key(2, 0), "mon");
383 assert_eq!(day_key(6, 6), "sun");
384 }
385
386 #[test]
387 fn a_time_is_said_in_the_businesss_zone_not_the_servers() {
388 let tz = crate::hours::resolve_tz("America/New_York").unwrap();
389 // 14:30 UTC on Tuesday 7 July 2026 is 10:30 EDT the same day.
390 let start = datetime!(2026-07-07 14:30 UTC);
391 let now = datetime!(2026-07-06 12:00 UTC); // Monday: the slot is tomorrow
392 assert_eq!(
393 time_refs(start, now, tz),
394 vec!["bkday_tomorrow".to_string(), "bktime_1030".to_string()],
395 );
396
397 // Same instant, a week earlier in the caller's life: now it needs
398 // its weekday name.
399 let earlier = datetime!(2026-07-01 12:00 UTC);
400 assert_eq!(
401 time_refs(start, earlier, tz),
402 vec!["bkday_tue".to_string(), "bktime_1030".to_string()],
403 );
404 }
405
406 #[test]
407 fn a_late_utc_instant_can_be_the_previous_local_day() {
408 let tz = crate::hours::resolve_tz("America/New_York").unwrap();
409 // 01:00 UTC Wednesday is 21:00 EDT Tuesday — the local date, and
410 // therefore the day word, belongs to Tuesday.
411 let start = datetime!(2026-07-08 01:00 UTC);
412 let now = datetime!(2026-07-07 13:00 UTC); // Tuesday morning, local
413 assert_eq!(
414 time_refs(start, now, tz),
415 vec!["bkday_today".to_string(), "bktime_2100".to_string()],
416 );
417 }
418
419 #[test]
420 fn an_offer_ends_with_the_key_that_takes_it() {
421 let tz = crate::hours::resolve_tz("UTC").unwrap();
422 let start = datetime!(2026-07-07 09:15 UTC);
423 let now = datetime!(2026-07-07 08:00 UTC);
424 assert_eq!(
425 offer_refs(start, now, tz, 2),
426 vec![
427 "bkday_today".to_string(),
428 "bktime_0915".to_string(),
429 "bkpress_2".to_string()
430 ],
431 );
432 }
433}