refactor(scheduling): split multi-day blockers and normalize time strings

Split multi-day time blockers into per-day segments for subtractTimeSlots. Add normalizeTime helper to strip seconds from HH:MM:SS for consistent comparison. Subtract blockers for all users (not just non-admin) to prevent 409 errors on reserve. Move late-night lock logic to separate block.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-22 17:05:58 +01:00
co-authored by Sisyphus
parent 9d70d42bd4
commit edc7d833ea
+52 -21
View File
@@ -492,18 +492,33 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
blockerMap := make(map[string][]TimeSlot)
for _, blocker := range blockers {
blockStart := blocker.StartTime
blockEnd := blockStart.Add(time.Duration(blocker.DurationMinutes) * time.Minute)
dateStr := blocker.StartTime.Format("2006-01-02")
// Split multi-day blockers into per-day segments so subtractTimeSlots
// only compares times within the same calendar day. Each segment's end
// time uses "24:00" for day boundaries (midnight of the next day) since
// "00:00" as an end time would incorrectly appear before all slot times.
cur := blockStart
for cur.Before(blockEnd) {
dayEnd := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, cur.Location()).AddDate(0, 0, 1)
segEnd := blockEnd
if segEnd.After(dayEnd) {
segEnd = dayEnd
}
endTime := blocker.StartTime.Add(time.Duration(blocker.DurationMinutes) * time.Minute)
blockerMap[dateStr] = append(blockerMap[dateStr], TimeSlot{
StartTime: blocker.StartTime.Format("15:04"),
EndTime: endTime.Format("15:04"),
})
dateStr := cur.Format("2006-01-02")
endStr := segEnd.Format("15:04")
if segEnd.Equal(dayEnd) {
endStr = "24:00"
}
blockerMap[dateStr] = append(blockerMap[dateStr], TimeSlot{
StartTime: cur.Format("15:04"),
EndTime: endStr,
})
cur = dayEnd
}
}
// Check if user is admin
@@ -575,21 +590,28 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
day.IsOpen = isOpen
if isOpen {
// Normalize to HH:MM to match blocker and booking time formats
baseStart = normalizeTime(baseStart)
baseEnd = normalizeTime(baseEnd)
slots := []TimeSlot{{StartTime: baseStart, EndTime: baseEnd}}
if booked, ok := bookings[day.Date]; ok {
slots = subtractTimeSlots(slots, booked)
}
day.Slots = slots
// Handle time blockers
if !isAdmin {
// Regular users: subtract blockers from available slots
if dayBlockers, ok := blockerMap[day.Date]; ok {
day.Slots = subtractTimeSlots(day.Slots, dayBlockers)
// Subtract time blockers from available slots for ALL users
// (prevents showing blocked slots that would fail on reserve)
if dayBlockers, ok := blockerMap[day.Date]; ok {
day.Slots = subtractTimeSlots(day.Slots, dayBlockers)
// Store blockers separately for admin warning display
if isAdmin {
day.Blockers = dayBlockers
}
}
// Late night lock: after 22:00, block next morning 00:00-11:00 for non-admin users
// Late night lock: after 22:00, block next morning 00:00-11:00 for non-admin users
if !isAdmin {
now := time.Now()
if !isAdmin && now.Hour() >= 22 {
if now.Hour() >= 22 {
// Check if this is tomorrow's date
tomorrow := now.AddDate(0, 0, 1)
tomorrowStr := tomorrow.Format("2006-01-02")
@@ -602,11 +624,6 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
day.Slots = subtractTimeSlots(day.Slots, []TimeSlot{lateNightBlock})
}
}
} else {
// Admins: keep blockers visible for warning display
if dayBlockers, ok := blockerMap[day.Date]; ok {
day.Blockers = dayBlockers
}
}
} else {
day.Slots = []TimeSlot{}
@@ -619,6 +636,20 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(results)
}
// normalizeTime strips seconds from HH:MM:SS to HH:MM for consistent string
// comparison with blocker and booking time formats in subtractTimeSlots.
func normalizeTime(t string) string {
// Strip trailing :SS (seconds) from HH:MM:SS format while leaving
// bare HH:MM untouched and handling single-digit hours (e.g. 9:00:00).
if len(t) > 5 && t[len(t)-3] == ':' {
prefix := t[:len(t)-3]
if strings.Contains(prefix, ":") {
return prefix
}
}
return t
}
// subtractTimeSlots removes gaps from available slots
// Returns the remaining available time slots after removing the gaps
func subtractTimeSlots(available []TimeSlot, gaps []TimeSlot) []TimeSlot {