Skip to content

Commit d747d31

Browse files
feat(slots): hide times whose host-local frequency period is already at cap
The submit-time check (would_exceed_frequency_limit) rejected bookings once a host-set per-day/week/month/year cap was hit, but the slot picker still showed every time in the capped period. Guests would pick a slot, fill out the form, and only then learn it was unbookable. compute_slots now runs apply_frequency_limit_filter after slot generation: for each configured (max, period), it counts existing confirmed+pending bookings per containing host-local period, then drops slots that fall inside any capped period. The submit-time check stays as a race backstop. Tests cover the per-day case (only the booked day is hidden, the rest of the week is intact) and the per-week case (the whole week disappears when 1/week is consumed). Closes #115 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b652dc commit d747d31

1 file changed

Lines changed: 208 additions & 0 deletions

File tree

src/web/mod.rs

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10139,6 +10139,12 @@ async fn compute_slots(
1013910139
&overrides,
1014010140
);
1014110141

10142+
// Drop slots whose host-local period already meets/exceeds a configured
10143+
// frequency cap. The submit-time check (would_exceed_frequency_limit)
10144+
// stays as a backstop for the race where two guests grab the last slot
10145+
// at the same instant.
10146+
apply_frequency_limit_filter(pool, et_id, &mut result).await;
10147+
1014210148
// If first_slot_only is enabled, keep only the earliest slot per day
1014310149
let first_only: i32 =
1014410150
sqlx::query_scalar("SELECT first_slot_only FROM event_types WHERE id = ?")
@@ -10156,6 +10162,73 @@ async fn compute_slots(
1015610162
result
1015710163
}
1015810164

10165+
/// Remove slots that fall inside a host-local period already at/over a
10166+
/// configured booking-frequency cap, so the picker doesn't surface times
10167+
/// that the submit-time check would reject.
10168+
async fn apply_frequency_limit_filter(pool: &SqlitePool, et_id: &str, days: &mut [SlotDay]) {
10169+
let limits: Vec<(i32, String)> = sqlx::query_as(
10170+
"SELECT max_bookings, period FROM booking_frequency_limits WHERE event_type_id = ?",
10171+
)
10172+
.bind(et_id)
10173+
.fetch_all(pool)
10174+
.await
10175+
.unwrap_or_default();
10176+
10177+
if limits.is_empty() {
10178+
return;
10179+
}
10180+
10181+
// Gather every (limit, period_start) pair that any visible slot belongs to,
10182+
// then resolve each unique (period, period_start) to a count. The same
10183+
// period covers many slots, so we collapse before querying.
10184+
let mut needed: HashMap<(String, NaiveDateTime), i64> = HashMap::new();
10185+
for day in days.iter() {
10186+
for slot in &day.slots {
10187+
let Ok(d) = NaiveDate::parse_from_str(&slot.host_date, "%Y-%m-%d") else {
10188+
continue;
10189+
};
10190+
let dt = d.and_hms_opt(12, 0, 0).unwrap();
10191+
for (_, period) in &limits {
10192+
let (ps, _) = frequency_period_range(dt, period);
10193+
needed.entry((period.clone(), ps)).or_insert(0);
10194+
}
10195+
}
10196+
}
10197+
10198+
for ((period, ps), count) in needed.iter_mut() {
10199+
let (rs, re) = frequency_period_range(*ps, period);
10200+
let rs_str = rs.format("%Y-%m-%dT%H:%M:%S").to_string();
10201+
let re_str = re.format("%Y-%m-%dT%H:%M:%S").to_string();
10202+
let c: (i64,) = sqlx::query_as(
10203+
"SELECT COUNT(*) FROM bookings WHERE event_type_id = ? AND status IN ('confirmed', 'pending') AND start_at >= ? AND start_at < ?",
10204+
)
10205+
.bind(et_id)
10206+
.bind(&rs_str)
10207+
.bind(&re_str)
10208+
.fetch_one(pool)
10209+
.await
10210+
.unwrap_or((0,));
10211+
*count = c.0;
10212+
}
10213+
10214+
for day in days.iter_mut() {
10215+
day.slots.retain(|slot| {
10216+
let Ok(d) = NaiveDate::parse_from_str(&slot.host_date, "%Y-%m-%d") else {
10217+
return true;
10218+
};
10219+
let dt = d.and_hms_opt(12, 0, 0).unwrap();
10220+
for (max, period) in &limits {
10221+
let (ps, _) = frequency_period_range(dt, period);
10222+
let count = needed.get(&(period.clone(), ps)).copied().unwrap_or(0);
10223+
if count >= *max as i64 {
10224+
return false;
10225+
}
10226+
}
10227+
true
10228+
});
10229+
}
10230+
}
10231+
1015910232
/// Save booking frequency limits from the serialized form field ("1:day,5:week").
1016010233
async fn save_frequency_limits(pool: &SqlitePool, event_type_id: &str, limits_str: &str) {
1016110234
if limits_str.is_empty() {
@@ -15756,6 +15829,141 @@ mod tests {
1575615829
);
1575715830
}
1575815831

15832+
#[tokio::test]
15833+
async fn compute_slots_frequency_limit_per_day_drops_capped_day() {
15834+
let pool = setup_test_db().await;
15835+
let (_, _, et_id) = seed_test_data(&pool).await;
15836+
15837+
let now = Utc::now().with_timezone(&Tz::UTC).naive_local();
15838+
let mut next_monday = now.date() + Duration::days(1);
15839+
while next_monday.weekday() != chrono::Weekday::Mon {
15840+
next_monday += Duration::days(1);
15841+
}
15842+
let days_to_monday = (next_monday - now.date()).num_days() as i32;
15843+
15844+
sqlx::query("INSERT INTO booking_frequency_limits (id, event_type_id, max_bookings, period) VALUES (?, ?, 1, 'day')")
15845+
.bind(uuid::Uuid::new_v4().to_string())
15846+
.bind(&et_id)
15847+
.execute(&pool).await.unwrap();
15848+
15849+
let start_at = next_monday
15850+
.and_hms_opt(10, 0, 0)
15851+
.unwrap()
15852+
.format("%Y-%m-%dT%H:%M:%S")
15853+
.to_string();
15854+
let end_at = next_monday
15855+
.and_hms_opt(10, 30, 0)
15856+
.unwrap()
15857+
.format("%Y-%m-%dT%H:%M:%S")
15858+
.to_string();
15859+
sqlx::query("INSERT INTO bookings (id, event_type_id, uid, guest_name, guest_email, guest_timezone, start_at, end_at, status, cancel_token, reschedule_token) VALUES (?, ?, 'uid1', 'Guest', 'g@e.com', 'UTC', ?, ?, 'confirmed', ?, ?)")
15860+
.bind(uuid::Uuid::new_v4().to_string())
15861+
.bind(&et_id)
15862+
.bind(&start_at)
15863+
.bind(&end_at)
15864+
.bind(uuid::Uuid::new_v4().to_string())
15865+
.bind(uuid::Uuid::new_v4().to_string())
15866+
.execute(&pool).await.unwrap();
15867+
15868+
let slot_days = compute_slots(
15869+
&pool,
15870+
&et_id,
15871+
30,
15872+
0,
15873+
0,
15874+
0,
15875+
days_to_monday,
15876+
5,
15877+
Tz::UTC,
15878+
Tz::UTC,
15879+
BusySource::Individual(vec![]),
15880+
)
15881+
.await;
15882+
15883+
let monday_date = next_monday.format("%Y-%m-%d").to_string();
15884+
let monday = slot_days.iter().find(|d| d.date == monday_date);
15885+
assert!(
15886+
monday.map(|d| d.slots.is_empty()).unwrap_or(true),
15887+
"1/day cap reached → Monday should expose no slots, got {:?}",
15888+
monday.map(|d| d.slots.len())
15889+
);
15890+
15891+
let tuesday_date = (next_monday + Duration::days(1))
15892+
.format("%Y-%m-%d")
15893+
.to_string();
15894+
let tuesday = slot_days
15895+
.iter()
15896+
.find(|d| d.date == tuesday_date)
15897+
.expect("Tuesday should be present");
15898+
assert_eq!(
15899+
tuesday.slots.len(),
15900+
16,
15901+
"Tuesday is unaffected by Monday's per-day cap"
15902+
);
15903+
}
15904+
15905+
#[tokio::test]
15906+
async fn compute_slots_frequency_limit_per_week_drops_whole_week() {
15907+
let pool = setup_test_db().await;
15908+
let (_, _, et_id) = seed_test_data(&pool).await;
15909+
15910+
let now = Utc::now().with_timezone(&Tz::UTC).naive_local();
15911+
let mut next_monday = now.date() + Duration::days(1);
15912+
while next_monday.weekday() != chrono::Weekday::Mon {
15913+
next_monday += Duration::days(1);
15914+
}
15915+
let days_to_monday = (next_monday - now.date()).num_days() as i32;
15916+
15917+
sqlx::query("INSERT INTO booking_frequency_limits (id, event_type_id, max_bookings, period) VALUES (?, ?, 1, 'week')")
15918+
.bind(uuid::Uuid::new_v4().to_string())
15919+
.bind(&et_id)
15920+
.execute(&pool).await.unwrap();
15921+
15922+
// One booking on Monday consumes the week's only slot.
15923+
let start_at = next_monday
15924+
.and_hms_opt(10, 0, 0)
15925+
.unwrap()
15926+
.format("%Y-%m-%dT%H:%M:%S")
15927+
.to_string();
15928+
let end_at = next_monday
15929+
.and_hms_opt(10, 30, 0)
15930+
.unwrap()
15931+
.format("%Y-%m-%dT%H:%M:%S")
15932+
.to_string();
15933+
sqlx::query("INSERT INTO bookings (id, event_type_id, uid, guest_name, guest_email, guest_timezone, start_at, end_at, status, cancel_token, reschedule_token) VALUES (?, ?, 'uid1', 'Guest', 'g@e.com', 'UTC', ?, ?, 'confirmed', ?, ?)")
15934+
.bind(uuid::Uuid::new_v4().to_string())
15935+
.bind(&et_id)
15936+
.bind(&start_at)
15937+
.bind(&end_at)
15938+
.bind(uuid::Uuid::new_v4().to_string())
15939+
.bind(uuid::Uuid::new_v4().to_string())
15940+
.execute(&pool).await.unwrap();
15941+
15942+
let slot_days = compute_slots(
15943+
&pool,
15944+
&et_id,
15945+
30,
15946+
0,
15947+
0,
15948+
0,
15949+
days_to_monday,
15950+
5,
15951+
Tz::UTC,
15952+
Tz::UTC,
15953+
BusySource::Individual(vec![]),
15954+
)
15955+
.await;
15956+
15957+
for day in &slot_days {
15958+
assert!(
15959+
day.slots.is_empty(),
15960+
"Per-week cap of 1 reached → {} should have no slots, got {}",
15961+
day.date,
15962+
day.slots.len()
15963+
);
15964+
}
15965+
}
15966+
1575915967
#[tokio::test]
1576015968
async fn compute_slots_no_weekend_slots() {
1576115969
let pool = setup_test_db().await;

0 commit comments

Comments
 (0)