@@ -59,6 +59,18 @@ func TestRoomConflictDetection(t *testing.T) {
5959 require .NoError (t , env .DB .Create (otherRoom ).Error )
6060 testRejectsRoomFromDifferentFacility (t , env , facility , facilityAdmin , program , otherRoom .ID )
6161 })
62+
63+ t .Run ("Detects conflicts with timezone-aware RRULEs across DST" , func (t * testing.T ) {
64+ room := & models.Room {FacilityID : facility .ID , Name : "DST Test Room" }
65+ require .NoError (t , env .DB .Create (room ).Error )
66+ testDetectsConflictsWithTimezoneAwareRRules (t , env , facility , facilityAdmin , program , room .ID )
67+ })
68+
69+ t .Run ("Backwards compatible with UTC-based RRULEs" , func (t * testing.T ) {
70+ room := & models.Room {FacilityID : facility .ID , Name : "UTC Compat Room" }
71+ require .NoError (t , env .DB .Create (room ).Error )
72+ testBackwardsCompatibleUTCRules (t , env , facility , facilityAdmin , program , room .ID )
73+ })
6274}
6375
6476func testDetectsOverlappingBookings (t * testing.T , env * TestEnv , facility * models.Facility , facilityAdmin * models.User , program * models.Program , roomID uint ) {
@@ -318,3 +330,122 @@ func testRejectsRoomFromDifferentFacility(t *testing.T, env *TestEnv, facility *
318330 Do ().
319331 ExpectStatus (http .StatusBadRequest )
320332}
333+
334+ // testDetectsConflictsWithTimezoneAwareRRules tests that timezone-aware RRULEs
335+ // correctly detect conflicts across DST transitions. Two classes at "10 AM local time"
336+ // created in different DST periods should conflict when using DTSTART;TZID= format.
337+ func testDetectsConflictsWithTimezoneAwareRRules (t * testing.T , env * TestEnv , facility * models.Facility , facilityAdmin * models.User , program * models.Program , roomID uint ) {
338+ // Use America/New_York timezone for DST testing
339+ facility .Timezone = "America/New_York"
340+ require .NoError (t , env .DB .Save (facility ).Error )
341+
342+ // Class 1: Created in July (EDT) at 10 AM local time, repeating on Thursdays
343+ // Using timezone-aware RRULE format: DTSTART;TZID=America/New_York:20250701T100000
344+ classStartDate := time .Date (2025 , 7 , 1 , 0 , 0 , 0 , 0 , time .UTC )
345+ creditHours := int64 (2 )
346+
347+ class1 := models.ProgramClass {
348+ ProgramID : program .ID ,
349+ FacilityID : facility .ID ,
350+ Capacity : 10 ,
351+ Name : "Summer Class" ,
352+ InstructorName : "Instructor 1" ,
353+ Description : "Class created in summer at 10 AM" ,
354+ StartDt : classStartDate ,
355+ Status : models .Scheduled ,
356+ CreditHours : & creditHours ,
357+ }
358+
359+ class1Resp := NewRequest [* models.ProgramClass ](env .Client , t , http .MethodPost , fmt .Sprintf ("/api/programs/%d/classes" , program .ID ), class1 ).
360+ WithTestClaims (& handlers.Claims {Role : models .FacilityAdmin , UserID : facilityAdmin .ID , FacilityID : facility .ID }).
361+ Do ().
362+ ExpectStatus (http .StatusCreated )
363+
364+ createdClass1 := class1Resp .GetData ()
365+
366+ // Timezone-aware RRULE: 10 AM in America/New_York, every Thursday
367+ event1Payload := map [string ]interface {}{
368+ "duration" : "1h" ,
369+ "room_id" : roomID ,
370+ "recurrence_rule" : "DTSTART;TZID=America/New_York:20250703T100000\n RRULE:FREQ=WEEKLY;BYDAY=TH" ,
371+ }
372+
373+ NewRequest [any ](env .Client , t , http .MethodPost , fmt .Sprintf ("/api/program-classes/%d/events" , createdClass1 .ID ), event1Payload ).
374+ WithTestClaims (& handlers.Claims {Role : models .FacilityAdmin , UserID : facilityAdmin .ID , FacilityID : facility .ID }).
375+ Do ().
376+ ExpectStatus (http .StatusCreated )
377+
378+ // Now try to create Class 2 in December (EST) at 10 AM local time
379+ // With timezone-aware RRULEs, this should conflict because both are at 10 AM wall time
380+ conflicts , err := env .DB .CheckRRuleConflicts (& models.ConflictCheckRequest {
381+ FacilityID : facility .ID ,
382+ RoomID : roomID ,
383+ RecurrenceRule : "DTSTART;TZID=America/New_York:20251204T100000\n RRULE:FREQ=WEEKLY;BYDAY=TH" ,
384+ Duration : "1h" ,
385+ })
386+ require .NoError (t , err )
387+ require .NotEmpty (t , conflicts , "Should detect conflicts when both classes are at 10 AM wall time (timezone-aware RRULEs preserve wall time across DST)" )
388+ require .Equal (t , "Summer Class" , conflicts [0 ].ClassName , "Conflict should identify the Summer Class" )
389+
390+ // Verify that classes at different wall times don't conflict
391+ // 11 AM local time should NOT conflict with 10 AM class
392+ noConflicts , err := env .DB .CheckRRuleConflicts (& models.ConflictCheckRequest {
393+ FacilityID : facility .ID ,
394+ RoomID : roomID ,
395+ RecurrenceRule : "DTSTART;TZID=America/New_York:20251204T110000\n RRULE:FREQ=WEEKLY;BYDAY=TH" ,
396+ Duration : "1h" ,
397+ })
398+ require .NoError (t , err )
399+ require .Empty (t , noConflicts , "Should NOT detect conflicts when classes are at different wall times (10 AM vs 11 AM)" )
400+ }
401+
402+ // testBackwardsCompatibleUTCRules verifies that old UTC-based RRULEs still work.
403+ // Note: UTC-based RRULEs will have the DST shift behavior (this is expected for legacy data).
404+ func testBackwardsCompatibleUTCRules (t * testing.T , env * TestEnv , facility * models.Facility , facilityAdmin * models.User , program * models.Program , roomID uint ) {
405+ facility .Timezone = "America/New_York"
406+ require .NoError (t , env .DB .Save (facility ).Error )
407+
408+ classStartDate := time .Date (2026 , 8 , 1 , 0 , 0 , 0 , 0 , time .UTC )
409+ creditHours := int64 (2 )
410+
411+ class1 := models.ProgramClass {
412+ ProgramID : program .ID ,
413+ FacilityID : facility .ID ,
414+ Capacity : 10 ,
415+ Name : "Legacy UTC Class" ,
416+ InstructorName : "Instructor" ,
417+ Description : "Class using old UTC-based RRULE format" ,
418+ StartDt : classStartDate ,
419+ Status : models .Scheduled ,
420+ CreditHours : & creditHours ,
421+ }
422+
423+ class1Resp := NewRequest [* models.ProgramClass ](env .Client , t , http .MethodPost , fmt .Sprintf ("/api/programs/%d/classes" , program .ID ), class1 ).
424+ WithTestClaims (& handlers.Claims {Role : models .FacilityAdmin , UserID : facilityAdmin .ID , FacilityID : facility .ID }).
425+ Do ().
426+ ExpectStatus (http .StatusCreated )
427+
428+ createdClass1 := class1Resp .GetData ()
429+
430+ // Old UTC-based RRULE format: 14:00 UTC = 10:00 AM EDT in August
431+ event1Payload := map [string ]interface {}{
432+ "duration" : "1h" ,
433+ "room_id" : roomID ,
434+ "recurrence_rule" : "DTSTART:20260806T140000Z\n RRULE:FREQ=WEEKLY;BYDAY=TH" ,
435+ }
436+
437+ NewRequest [any ](env .Client , t , http .MethodPost , fmt .Sprintf ("/api/program-classes/%d/events" , createdClass1 .ID ), event1Payload ).
438+ WithTestClaims (& handlers.Claims {Role : models .FacilityAdmin , UserID : facilityAdmin .ID , FacilityID : facility .ID }).
439+ Do ().
440+ ExpectStatus (http .StatusCreated )
441+
442+ // Another UTC-based RRULE at the same UTC time should conflict
443+ conflicts , err := env .DB .CheckRRuleConflicts (& models.ConflictCheckRequest {
444+ FacilityID : facility .ID ,
445+ RoomID : roomID ,
446+ RecurrenceRule : "DTSTART:20260806T140000Z\n RRULE:FREQ=WEEKLY;BYDAY=TH" ,
447+ Duration : "1h" ,
448+ })
449+ require .NoError (t , err )
450+ require .NotEmpty (t , conflicts , "UTC-based RRULEs at same UTC time should still conflict (backwards compatibility)" )
451+ }
0 commit comments