From ded1081a16c099968b198b63d66a23424a7703e7 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 20 Oct 2025 20:19:31 +0100 Subject: [PATCH] fix calendar again --- backend/handlers/scheduling/default-hours.go | 202 ++++++++---------- .../handlers/scheduling/exceptional-hours.go | 10 +- 2 files changed, 99 insertions(+), 113 deletions(-) diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index df13b84..fc56276 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -86,7 +86,7 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// --- GetWorkingHours (merged default + applied exceptions) --- +// --- GetWorkingHours (merged default + applied exceptions, UK-local) --- func GetWorkingHours(w http.ResponseWriter, r *http.Request) { startStr := r.URL.Query().Get("start") endStr := r.URL.Query().Get("end") @@ -94,17 +94,23 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { http.Error(w, "start and end query params required", http.StatusBadRequest) return } - start, err := time.Parse("2006-01-02", startStr) + + ukLocation, _ := time.LoadLocation("Europe/London") + start, err := time.ParseInLocation("2006-01-02", startStr, ukLocation) if err != nil { http.Error(w, "invalid start date", http.StatusBadRequest) return } - end, err := time.Parse("2006-01-02", endStr) + end, err := time.ParseInLocation("2006-01-02", endStr, ukLocation) if err != nil { http.Error(w, "invalid end date", http.StatusBadRequest) return } + // Set to local start/end of day + start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation) + end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation) + // Load default hours defaultMap := map[int]DefaultHours{} defRows, _ := db.DB.Query(r.Context(), ` @@ -119,35 +125,31 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { } defRows.Close() - // Load exceptional applications in range + // Load exceptional applications for Mondays in range appRows, _ := db.DB.Query(r.Context(), ` - SELECT a.group_id, a.week_start - FROM exceptional_group_applications a - WHERE a.week_start <= $1 AND a.week_start >= $2 - INTERVAL '6 days' - `, end, start) // any week overlapping the range + SELECT group_id, week_start + FROM exceptional_group_applications + WHERE week_start BETWEEN $1 AND $2 + `, start, end) type appEntry struct { GroupID int WeekStart time.Time } - var apps []appEntry + apps := []appEntry{} + groupIDs := []int{} for appRows.Next() { - var e appEntry - if err := appRows.Scan(&e.GroupID, &e.WeekStart); err == nil { - apps = append(apps, e) + var a appEntry + if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil { + a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, ukLocation) + apps = append(apps, a) + groupIDs = append(groupIDs, a.GroupID) } } appRows.Close() - // Load exceptional - // Load exceptional hours for all relevant groups - groupIDs := []int{} - for _, a := range apps { - groupIDs = append(groupIDs, a.GroupID) - } - - exHoursMap := map[int]map[int]ExceptionalHours{} // groupID -> weekday -> hours + exHoursMap := map[int]map[int]ExceptionalHours{} if len(groupIDs) > 0 { - query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time::text, is_open FROM exceptional_working_hours WHERE group_id IN (?)", groupIDs) + query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time::text, is_open FROM exceptional_working_hours WHERE group_id IN (%s)", groupIDs) rows, _ := db.DB.Query(r.Context(), query, args...) for rows.Next() { var h ExceptionalHours @@ -168,14 +170,22 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) { if weekday == 0 { weekday = 6 // Go Sunday=0 -> our Sunday=6 } else { - weekday -= 1 // shift Monday=0 ... Sunday=6 + weekday -= 1 } - // find applied group for this week + // Calculate the Monday of this week + daysSinceMonday := int(d.Weekday()) - 1 + if daysSinceMonday < 0 { + daysSinceMonday = 6 // Sunday + } + weekStart := d.AddDate(0, 0, -daysSinceMonday) + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation) + var applied *ExceptionalHours - weekStart := d.AddDate(0, 0, -weekday) // Monday of current week + weekStartStr := weekStart.Format("2006-01-02") for _, a := range apps { - if a.WeekStart.Equal(weekStart) { + appWeekStartStr := a.WeekStart.Format("2006-01-02") + if appWeekStartStr == weekStartStr { if dayHours, ok := exHoursMap[a.GroupID][weekday]; ok { applied = &dayHours } @@ -240,7 +250,7 @@ type DayAvailableHours struct { Source string `json:"source"` } -// --- GetAvailableHours (working hours with gaps for lunch, bookings, etc.) --- +// --- GetAvailableHours (with bookings, UK-local) --- func GetAvailableHours(w http.ResponseWriter, r *http.Request) { startStr := r.URL.Query().Get("start") endStr := r.URL.Query().Get("end") @@ -249,35 +259,17 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { return } - // Load UK timezone - ukLocation, err := time.LoadLocation("Europe/London") - if err != nil { - http.Error(w, "failed to load timezone", http.StatusInternalServerError) - return - } + ukLocation, _ := time.LoadLocation("Europe/London") + start, _ := time.ParseInLocation("2006-01-02", startStr, ukLocation) + end, _ := time.ParseInLocation("2006-01-02", endStr, ukLocation) - // Parse dates in UK timezone - start, err := time.ParseInLocation("2006-01-02", startStr, ukLocation) - if err != nil { - http.Error(w, "invalid start date", http.StatusBadRequest) - return - } - end, err := time.ParseInLocation("2006-01-02", endStr, ukLocation) - if err != nil { - http.Error(w, "invalid end date", http.StatusBadRequest) - return - } - - // Set to start of day (00:00:00) and end of day (23:59:59) + // set start/end of day start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation) end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation) // Load default hours defaultMap := map[int]DefaultHours{} - defRows, _ := db.DB.Query(r.Context(), ` - SELECT weekday, start_time::text, end_time::text, is_open - FROM working_hours - `) + defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`) for defRows.Next() { var d DefaultHours if err := defRows.Scan(&d.Weekday, &d.StartTime, &d.EndTime, &d.IsOpen); err == nil { @@ -286,34 +278,31 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { } defRows.Close() - // Load exceptional applications in range + // Load exceptional applications for Mondays in range appRows, _ := db.DB.Query(r.Context(), ` - SELECT a.group_id, a.week_start - FROM exceptional_group_applications a - WHERE a.week_start <= $1 AND a.week_start >= $2 - INTERVAL '6 days' - `, end, start) + SELECT group_id, week_start + FROM exceptional_group_applications + WHERE week_start BETWEEN $1 AND $2 + `, start, end) type appEntry struct { GroupID int WeekStart time.Time } - var apps []appEntry + apps := []appEntry{} + groupIDs := []int{} for appRows.Next() { - var e appEntry - if err := appRows.Scan(&e.GroupID, &e.WeekStart); err == nil { - apps = append(apps, e) + var a appEntry + if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil { + a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, ukLocation) + apps = append(apps, a) + groupIDs = append(groupIDs, a.GroupID) } } appRows.Close() - // Load exceptional hours for all relevant groups - groupIDs := []int{} - for _, a := range apps { - groupIDs = append(groupIDs, a.GroupID) - } - - exHoursMap := map[int]map[int]ExceptionalHours{} // groupID -> weekday -> hours + exHoursMap := map[int]map[int]ExceptionalHours{} if len(groupIDs) > 0 { - query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time::text, is_open FROM exceptional_working_hours WHERE group_id IN (?)", groupIDs) + query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time::text, is_open FROM exceptional_working_hours WHERE group_id IN (%s)", groupIDs) rows, _ := db.DB.Query(r.Context(), query, args...) for rows.Next() { var h ExceptionalHours @@ -327,58 +316,56 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { rows.Close() } - // Load bookings with their total duration (sum of all services) - bookingRows, err := db.DB.Query(r.Context(), ` - SELECT - b.start_time, - COALESCE(SUM(s.duration_minutes), 0) as total_duration - FROM bookings b - LEFT JOIN booking_services bs ON b.id = bs.booking_id - LEFT JOIN services s ON bs.service_id = s.id - WHERE b.start_time >= $1 - AND b.start_time <= $2 - GROUP BY b.id, b.start_time - ORDER BY b.start_time -`, start, end) + // Load bookings + bookingRows, _ := db.DB.Query(r.Context(), ` + SELECT b.start_time, COALESCE(SUM(s.duration_minutes),0) as total_duration + FROM bookings b + LEFT JOIN booking_services bs ON b.id = bs.booking_id + LEFT JOIN services s ON bs.service_id = s.id + WHERE b.start_time >= $1 AND b.start_time <= $2 + GROUP BY b.id, b.start_time + ORDER BY b.start_time + `, start, end) - if err != nil { - http.Error(w, "failed to query bookings", http.StatusInternalServerError) - return - } - - bookings := map[string][]TimeSlot{} // date -> booked slots + bookings := map[string][]TimeSlot{} for bookingRows.Next() { - var startTime time.Time - var durationMinutes int - if err := bookingRows.Scan(&startTime, &durationMinutes); err == nil { - // Convert to UK timezone for date formatting - startTimeUK := startTime.In(ukLocation) - dateStr := startTimeUK.Format("2006-01-02") - endTime := startTimeUK.Add(time.Duration(durationMinutes) * time.Minute) - + var t time.Time + var dur int + if err := bookingRows.Scan(&t, &dur); err == nil { + t = t.In(ukLocation) + dateStr := t.Format("2006-01-02") + endTime := t.Add(time.Duration(dur) * time.Minute) bookings[dateStr] = append(bookings[dateStr], TimeSlot{ - StartTime: startTimeUK.Format("15:04"), + StartTime: t.Format("15:04"), EndTime: endTime.Format("15:04"), }) } } bookingRows.Close() - // Generate final result per day with gaps + // Generate available slots per day var results []DayAvailableHours for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { weekday := int(d.Weekday()) if weekday == 0 { - weekday = 6 // Go Sunday=0 -> our Sunday=6 + weekday = 6 } else { - weekday -= 1 // shift Monday=0 ... Sunday=6 + weekday -= 1 } - // Find applied group for this week + // Calculate the Monday of this week + daysSinceMonday := int(d.Weekday()) - 1 + if daysSinceMonday < 0 { + daysSinceMonday = 6 // Sunday + } + weekStart := d.AddDate(0, 0, -daysSinceMonday) + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation) + var applied *ExceptionalHours - weekStart := d.AddDate(0, 0, -weekday) // Monday of current week + weekStartStr := weekStart.Format("2006-01-02") for _, a := range apps { - if a.WeekStart.Equal(weekStart) { + appWeekStartStr := a.WeekStart.Format("2006-01-02") + if appWeekStartStr == weekStartStr { if dayHours, ok := exHoursMap[a.GroupID][weekday]; ok { applied = &dayHours } @@ -390,7 +377,6 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { day.Date = d.Format("2006-01-02") day.Weekday = weekday - // Determine base working hours and source var baseStart, baseEnd string var isOpen bool if applied != nil { @@ -411,18 +397,12 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { } day.IsOpen = isOpen - if isOpen { - // Start with the full working hours as one slot - allSlots := []TimeSlot{{StartTime: baseStart, EndTime: baseEnd}} - - // Apply gaps from bookings - dateStr := day.Date - if booked, ok := bookings[dateStr]; ok { - allSlots = subtractTimeSlots(allSlots, booked) + slots := []TimeSlot{{StartTime: baseStart, EndTime: baseEnd}} + if booked, ok := bookings[day.Date]; ok { + slots = subtractTimeSlots(slots, booked) } - - day.Slots = allSlots + day.Slots = slots } else { day.Slots = []TimeSlot{} } diff --git a/backend/handlers/scheduling/exceptional-hours.go b/backend/handlers/scheduling/exceptional-hours.go index 99f67f9..e8efb91 100644 --- a/backend/handlers/scheduling/exceptional-hours.go +++ b/backend/handlers/scheduling/exceptional-hours.go @@ -142,8 +142,9 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) { } var parsedWeeks []time.Time + ukLocation, _ := time.LoadLocation("Europe/London") for _, ws := range g.WeekStarts { - weekStart, err := time.Parse("2006-01-02", ws) + weekStart, err := time.ParseInLocation("2006-01-02", ws, ukLocation) if err != nil { http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest) return @@ -152,6 +153,8 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) { http.Error(w, "week_start must be a Monday", http.StatusBadRequest) return } + // Normalize to UK midnight + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation) parsedWeeks = append(parsedWeeks, weekStart) } @@ -262,8 +265,9 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) { // Validate and parse weeks var parsedWeeks []time.Time + ukLocation, _ := time.LoadLocation("Europe/London") for _, ws := range req.WeekStarts { - weekStart, err := time.Parse("2006-01-02", ws) + weekStart, err := time.ParseInLocation("2006-01-02", ws, ukLocation) if err != nil { http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest) return @@ -272,6 +276,8 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) { http.Error(w, "week_start must be a Monday", http.StatusBadRequest) return } + // Normalize to UK midnight + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation) parsedWeeks = append(parsedWeeks, weekStart) }