@@ -239,6 +239,40 @@ fn format_time_from_dt(dt_str: &str) -> String {
239239 }
240240}
241241
242+ /// Parse availability windows from the form.
243+ /// Supports new `avail_windows` format ("09:00-12:00,13:00-17:00") with fallback to
244+ /// legacy single `avail_start`/`avail_end` pair. Returns at least one window.
245+ fn parse_avail_windows (
246+ windows_str : Option < & str > ,
247+ legacy_start : Option < & str > ,
248+ legacy_end : Option < & str > ,
249+ ) -> Vec < ( String , String ) > {
250+ if let Some ( ws) = windows_str. filter ( |s| !s. trim ( ) . is_empty ( ) ) {
251+ let parsed: Vec < ( String , String ) > = ws
252+ . split ( ',' )
253+ . filter_map ( |w| {
254+ let parts: Vec < & str > = w. trim ( ) . splitn ( 2 , '-' ) . collect ( ) ;
255+ if parts. len ( ) == 2
256+ && NaiveTime :: parse_from_str ( parts[ 0 ] , "%H:%M" ) . is_ok ( )
257+ && NaiveTime :: parse_from_str ( parts[ 1 ] , "%H:%M" ) . is_ok ( )
258+ {
259+ Some ( ( parts[ 0 ] . to_string ( ) , parts[ 1 ] . to_string ( ) ) )
260+ } else {
261+ None
262+ }
263+ } )
264+ . collect ( ) ;
265+ if !parsed. is_empty ( ) {
266+ return parsed;
267+ }
268+ }
269+ // Fallback to legacy single window
270+ vec ! [ (
271+ legacy_start. unwrap_or( "09:00" ) . to_string( ) ,
272+ legacy_end. unwrap_or( "17:00" ) . to_string( ) ,
273+ ) ]
274+ }
275+
242276pub fn create_router ( pool : SqlitePool , data_dir : PathBuf , secret_key : [ u8 ; 32 ] ) -> Router {
243277 let mut env = Environment :: new ( ) ;
244278 env. set_undefined_behavior ( minijinja:: UndefinedBehavior :: Lenient ) ;
@@ -1263,9 +1297,10 @@ struct EventTypeForm {
12631297 location_type : Option < String > , // "link", "phone", "in_person", "custom"
12641298 location_value : Option < String > ,
12651299 // Availability schedule
1266- avail_days : Option < String > , // comma-separated: "1,2,3,4,5"
1267- avail_start : Option < String > , // "09:00"
1268- avail_end : Option < String > , // "17:00"
1300+ avail_days : Option < String > , // comma-separated: "1,2,3,4,5"
1301+ avail_start : Option < String > , // legacy: "09:00"
1302+ avail_end : Option < String > , // legacy: "17:00"
1303+ avail_windows : Option < String > , // "09:00-12:00,13:00-17:00"
12691304 // Group (optional)
12701305 group_id : Option < String > ,
12711306 // Calendar selection (comma-separated IDs)
@@ -1435,23 +1470,28 @@ async fn create_event_type(
14351470
14361471 // Create availability rules
14371472 let avail_days = form. avail_days . as_deref ( ) . unwrap_or ( "1,2,3,4,5" ) ;
1438- let avail_start = form. avail_start . as_deref ( ) . unwrap_or ( "09:00" ) ;
1439- let avail_end = form. avail_end . as_deref ( ) . unwrap_or ( "17:00" ) ;
1473+ let windows = parse_avail_windows (
1474+ form. avail_windows . as_deref ( ) ,
1475+ form. avail_start . as_deref ( ) ,
1476+ form. avail_end . as_deref ( ) ,
1477+ ) ;
14401478
14411479 for day_str in avail_days. split ( ',' ) {
14421480 if let Ok ( day) = day_str. trim ( ) . parse :: < i32 > ( ) {
14431481 if ( 0 ..=6 ) . contains ( & day) {
1444- let rule_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
1445- let _ = sqlx:: query (
1446- "INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)" ,
1447- )
1448- . bind ( & rule_id)
1449- . bind ( & et_id)
1450- . bind ( day)
1451- . bind ( avail_start)
1452- . bind ( avail_end)
1453- . execute ( & state. pool )
1454- . await ;
1482+ for ( ws, we) in & windows {
1483+ let rule_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
1484+ let _ = sqlx:: query (
1485+ "INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)" ,
1486+ )
1487+ . bind ( & rule_id)
1488+ . bind ( & et_id)
1489+ . bind ( day)
1490+ . bind ( ws)
1491+ . bind ( we)
1492+ . execute ( & state. pool )
1493+ . await ;
1494+ }
14551495 }
14561496 }
14571497 }
@@ -1513,30 +1553,41 @@ async fn edit_event_type_form(
15131553 } ;
15141554
15151555 // Get current availability rules
1516- let rules : Vec < ( i32 , String , String ) > = sqlx:: query_as (
1517- "SELECT day_of_week, start_time, end_time FROM availability_rules WHERE event_type_id = ? ORDER BY day_of_week LIMIT 1 " ,
1556+ let all_rules : Vec < ( i32 , String , String ) > = sqlx:: query_as (
1557+ "SELECT day_of_week, start_time, end_time FROM availability_rules WHERE event_type_id = ? ORDER BY day_of_week, start_time " ,
15181558 )
15191559 . bind ( & et_id)
15201560 . fetch_all ( & state. pool )
15211561 . await
15221562 . unwrap_or_default ( ) ;
15231563
1524- let all_rules: Vec < ( i32 , ) > = sqlx:: query_as (
1525- "SELECT DISTINCT day_of_week FROM availability_rules WHERE event_type_id = ? ORDER BY day_of_week" ,
1526- )
1527- . bind ( & et_id)
1528- . fetch_all ( & state. pool )
1529- . await
1530- . unwrap_or_default ( ) ;
1564+ let avail_days: String = {
1565+ let mut days: Vec < i32 > = all_rules. iter ( ) . map ( |( d, _, _) | * d) . collect ( ) ;
1566+ days. sort ( ) ;
1567+ days. dedup ( ) ;
1568+ days. iter ( )
1569+ . map ( |d| d. to_string ( ) )
1570+ . collect :: < Vec < _ > > ( )
1571+ . join ( "," )
1572+ } ;
15311573
1532- let avail_days: String = all_rules
1574+ // Collect distinct time windows (preserving order)
1575+ let mut windows: Vec < ( String , String ) > = Vec :: new ( ) ;
1576+ for ( _, s, e) in & all_rules {
1577+ let pair = ( s. clone ( ) , e. clone ( ) ) ;
1578+ if !windows. contains ( & pair) {
1579+ windows. push ( pair) ;
1580+ }
1581+ }
1582+ let avail_windows: String = windows
15331583 . iter ( )
1534- . map ( |( d , ) | d . to_string ( ) )
1584+ . map ( |( s , e ) | format ! ( "{}-{}" , s , e ) )
15351585 . collect :: < Vec < _ > > ( )
15361586 . join ( "," ) ;
1537- let ( avail_start, avail_end) = rules
1587+ // Legacy fields for backward compat
1588+ let ( avail_start, avail_end) = windows
15381589 . first ( )
1539- . map ( | ( _ , s , e ) | ( s . clone ( ) , e . clone ( ) ) )
1590+ . cloned ( )
15401591 . unwrap_or_else ( || ( "09:00" . to_string ( ) , "17:00" . to_string ( ) ) ) ;
15411592
15421593 // Get user's calendars (is_busy=1) for calendar selection
@@ -1599,6 +1650,7 @@ async fn edit_event_type_form(
15991650 form_avail_days => avail_days,
16001651 form_avail_start => avail_start,
16011652 form_avail_end => avail_end,
1653+ form_avail_windows => avail_windows,
16021654 form_reminder_minutes => reminder_min. unwrap_or( 0 ) ,
16031655 error => "" ,
16041656 sidebar => sidebar_context( & auth_user, "event-types" ) ,
@@ -1692,23 +1744,28 @@ async fn update_event_type(
16921744 . await ;
16931745
16941746 let avail_days = form. avail_days . as_deref ( ) . unwrap_or ( "1,2,3,4,5" ) ;
1695- let avail_start = form. avail_start . as_deref ( ) . unwrap_or ( "09:00" ) ;
1696- let avail_end = form. avail_end . as_deref ( ) . unwrap_or ( "17:00" ) ;
1747+ let windows = parse_avail_windows (
1748+ form. avail_windows . as_deref ( ) ,
1749+ form. avail_start . as_deref ( ) ,
1750+ form. avail_end . as_deref ( ) ,
1751+ ) ;
16971752
16981753 for day_str in avail_days. split ( ',' ) {
16991754 if let Ok ( day) = day_str. trim ( ) . parse :: < i32 > ( ) {
17001755 if ( 0 ..=6 ) . contains ( & day) {
1701- let rule_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
1702- let _ = sqlx:: query (
1703- "INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)" ,
1704- )
1705- . bind ( & rule_id)
1706- . bind ( & et_id)
1707- . bind ( day)
1708- . bind ( avail_start)
1709- . bind ( avail_end)
1710- . execute ( & state. pool )
1711- . await ;
1756+ for ( ws, we) in & windows {
1757+ let rule_id = uuid:: Uuid :: new_v4 ( ) . to_string ( ) ;
1758+ let _ = sqlx:: query (
1759+ "INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)" ,
1760+ )
1761+ . bind ( & rule_id)
1762+ . bind ( & et_id)
1763+ . bind ( day)
1764+ . bind ( ws)
1765+ . bind ( we)
1766+ . execute ( & state. pool )
1767+ . await ;
1768+ }
17121769 }
17131770 }
17141771 }
@@ -3110,6 +3167,7 @@ fn render_event_type_form_error(
31103167 form_avail_days => form. avail_days. as_deref( ) . unwrap_or( "1,2,3,4,5" ) ,
31113168 form_avail_start => form. avail_start. as_deref( ) . unwrap_or( "09:00" ) ,
31123169 form_avail_end => form. avail_end. as_deref( ) . unwrap_or( "17:00" ) ,
3170+ form_avail_windows => form. avail_windows. as_deref( ) . unwrap_or( "" ) ,
31133171 error => error,
31143172 sidebar => sidebar_context( auth_user, "event-types" ) ,
31153173 impersonating => impersonating,
@@ -7590,4 +7648,54 @@ mod tests {
75907648 let tz = parse_guest_tz ( None ) ;
75917649 let _ = tz;
75927650 }
7651+
7652+ // --- parse_avail_windows tests ---
7653+
7654+ #[ test]
7655+ fn parse_avail_windows_single_window ( ) {
7656+ let w = parse_avail_windows ( Some ( "09:00-17:00" ) , None , None ) ;
7657+ assert_eq ! ( w, vec![ ( "09:00" . to_string( ) , "17:00" . to_string( ) ) ] ) ;
7658+ }
7659+
7660+ #[ test]
7661+ fn parse_avail_windows_multiple_windows ( ) {
7662+ let w = parse_avail_windows ( Some ( "09:00-12:00,13:00-17:00" ) , None , None ) ;
7663+ assert_eq ! (
7664+ w,
7665+ vec![
7666+ ( "09:00" . to_string( ) , "12:00" . to_string( ) ) ,
7667+ ( "13:00" . to_string( ) , "17:00" . to_string( ) ) ,
7668+ ]
7669+ ) ;
7670+ }
7671+
7672+ #[ test]
7673+ fn parse_avail_windows_legacy_fallback ( ) {
7674+ let w = parse_avail_windows ( None , Some ( "08:00" ) , Some ( "16:00" ) ) ;
7675+ assert_eq ! ( w, vec![ ( "08:00" . to_string( ) , "16:00" . to_string( ) ) ] ) ;
7676+ }
7677+
7678+ #[ test]
7679+ fn parse_avail_windows_empty_string_uses_legacy ( ) {
7680+ let w = parse_avail_windows ( Some ( "" ) , Some ( "10:00" ) , Some ( "18:00" ) ) ;
7681+ assert_eq ! ( w, vec![ ( "10:00" . to_string( ) , "18:00" . to_string( ) ) ] ) ;
7682+ }
7683+
7684+ #[ test]
7685+ fn parse_avail_windows_invalid_times_ignored ( ) {
7686+ let w = parse_avail_windows ( Some ( "09:00-12:00,bad-data,13:00-17:00" ) , None , None ) ;
7687+ assert_eq ! (
7688+ w,
7689+ vec![
7690+ ( "09:00" . to_string( ) , "12:00" . to_string( ) ) ,
7691+ ( "13:00" . to_string( ) , "17:00" . to_string( ) ) ,
7692+ ]
7693+ ) ;
7694+ }
7695+
7696+ #[ test]
7697+ fn parse_avail_windows_defaults_when_none ( ) {
7698+ let w = parse_avail_windows ( None , None , None ) ;
7699+ assert_eq ! ( w, vec![ ( "09:00" . to_string( ) , "17:00" . to_string( ) ) ] ) ;
7700+ }
75937701}
0 commit comments