Skip to content

Commit 70cd14b

Browse files
committed
fix: timezone awareness
1 parent c0abc61 commit 70cd14b

File tree

5 files changed

+145
-19
lines changed

5 files changed

+145
-19
lines changed

backend/src/database/class_events.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ func (db *DB) GetFacilityCalendar(args *models.QueryContext, dtRng *models.DateR
500500

501501
facilityEvents := make([]models.FacilityProgramClassEvent, 0, 10)
502502
for _, event := range events {
503-
rRule, err := event.GetRRule()
503+
rRule, err := event.GetRRuleWithTimezone(args.Timezone)
504504
if err != nil {
505505
return nil, err
506506
}
@@ -851,7 +851,7 @@ func (db *DB) GetClassEventInstancesWithAttendanceForRecurrence(classId int, qry
851851
return nil, newGetRecordsDBError(err, "program_class_events")
852852
}
853853

854-
rRule, err := event.GetRRule()
854+
rRule, err := event.GetRRuleWithTimezone(qryCtx.Timezone)
855855
if err != nil {
856856
logrus.Errorf("event has invalid rule, event: %v", event)
857857
}
@@ -1067,7 +1067,7 @@ func (db *DB) GetClassEventDatesForRecurrence(classID int, timezone string, mont
10671067
}
10681068

10691069
loc, _ := time.LoadLocation(timezone)
1070-
rule, err := event.GetRRule()
1070+
rule, err := event.GetRRuleWithTimezone(timezone)
10711071
if err != nil {
10721072
return nil, err
10731073
}

backend/src/models/class_event.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ func (e *ProgramClassEvent) GetRRuleWithTimezone(facilityTimezone string) (*rrul
9090
if err != nil {
9191
return nil, fmt.Errorf("failed to parse event recurrence rule: %w", err)
9292
}
93-
rruleOptions.Dtstart = rruleOptions.Dtstart.In(time.UTC)
93+
// NOTE: We no longer convert Dtstart to UTC here.
94+
// For timezone-aware RRULEs (DTSTART;TZID=...), this preserves wall-clock time across DST.
95+
// For legacy UTC-based RRULEs (DTSTART:...Z), the DTSTART is already in UTC.
9496
if !rruleOptions.Until.IsZero() {
9597
loc := rruleOptions.Until.Location()
9698
endOfDay := time.Date(

backend/tests/integration/room_conflict_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

6476
func 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\nRRULE: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\nRRULE: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\nRRULE: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\nRRULE: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\nRRULE: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+
}

frontend/src/Components/inputs/RRuleControl.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,13 @@ export const RRuleControl = forwardRef<RRuleFormHandle, RRuleControlProp>(
117117

118118
const duration = formatDuration(startTime, endTime);
119119
const rule = new RRule(options);
120-
const utcDtstart = zonedDateTime
121-
.toISOString()
122-
.replace(/[-:]/g, '')
123-
.split('.')[0];
120+
const localDtstart = `${(startDate as string).replace(/-/g, '')}T${startTime.replace(':', '')}00`;
124121
const ruleString = rule
125122
.toString()
126-
.replace(/DTSTART[^:]*:[^\n]+/, `DTSTART:${utcDtstart}Z`);
123+
.replace(
124+
/DTSTART[^:]*:[^\n]+/,
125+
`DTSTART;TZID=${user.timezone}:${localDtstart}`
126+
);
127127
returnValue = {
128128
rule: ruleString,
129129
duration: duration

frontend/src/Components/modals/RescheduleClassEventModal.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,9 @@ export const RescheduleClassEventModal = forwardRef(function (
8484
}
8585

8686
//reschedule logic
87-
const timeZoneStartDateTime = fromZonedTime(
88-
`${data.date}T${data.start_time}`,
89-
user.timezone
90-
);
91-
92-
const rescheduledRule = new RRule({
93-
freq: RRule.DAILY,
94-
count: 1,
95-
dtstart: timeZoneStartDateTime
96-
}).toString();
87+
const dateStr = (data.date as string).replace(/-/g, '');
88+
const timeStr = (data.start_time as string).replace(':', '') + '00';
89+
const rescheduledRule = `DTSTART;TZID=${user.timezone}:${dateStr}T${timeStr}\nRRULE:FREQ=DAILY;COUNT=1`;
9790
const duration = formatDuration(
9891
data.start_time as string,
9992
data.end_time as string

0 commit comments

Comments
 (0)