From 3c0c4dd96267815f2551c9207c20cac81391f379 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Thu, 30 Jul 2026 10:48:08 +0100 Subject: [PATCH] feat: sort price list by booking popularity over last 6 months Adds GET /api/services/popular endpoint that returns services sorted by booking count (desc) then price (desc) for ties. Prices page now fetches from this endpoint instead of the default alphabetical sort. --- backend/handlers/services/services.go | 67 +++++++++++++++++++++++++ backend/main.go | 1 + frontend/src/routes/prices/+page.svelte | 2 +- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/backend/handlers/services/services.go b/backend/handlers/services/services.go index 57489e5..ba1ed39 100644 --- a/backend/handlers/services/services.go +++ b/backend/handlers/services/services.go @@ -262,6 +262,73 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) { } } +// PopularServicesHandler returns services sorted by booking popularity (most bookings +// in the last 6 months first), with ties broken by price (highest first). +func PopularServicesHandler(w http.ResponseWriter, r *http.Request) { + query := ` + SELECT s.id, s.name, s.description, s.price, s.duration_minutes, + s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0), + COALESCE(booking_counts.cnt, 0) + FROM services s + LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) + LEFT JOIN ( + SELECT bsvc.service_id, COUNT(*) AS cnt + FROM booking_services bsvc + JOIN bookings b ON b.id = bsvc.booking_id + WHERE b.start_time >= NOW() - INTERVAL '6 months' + GROUP BY bsvc.service_id + ) booking_counts ON s.id = booking_counts.service_id + WHERE s.is_active = TRUE + ORDER BY booking_counts.cnt DESC, s.price DESC, s.name + ` + + rows, err := db.Conn.Query(r.Context(), query) + if err != nil { + http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var services []ServiceResponse + + for rows.Next() { + var service ServiceResponse + var bookingCount int + + err := rows.Scan( + &service.ID, + &service.Name, + &service.Description, + &service.Price, + &service.DurationMinutes, + &service.MinimumAgeRequired, + &service.PatchTestDurationHours, + &bookingCount, + ) + if err != nil { + http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) + return + } + + services = append(services, service) + } + + if err = rows.Err(); err != nil { + http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + + if services == nil { + services = []ServiceResponse{} + } + + if err := json.NewEncoder(w).Encode(services); err != nil { + http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + } +} + // ServicesHandler returns all services from the database // For non-admin logged-in users, filters based on age and patch test eligibility func ServicesHandler(w http.ResponseWriter, r *http.Request) { diff --git a/backend/main.go b/backend/main.go index c9fb2d9..5ce063b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -262,6 +262,7 @@ func main() { r.Use(mw.RateLimit(120, time.Minute)) r.Use(mw.OptionalAuth) r.Get("/services", services.ServicesHandler) + r.Get("/services/popular", services.PopularServicesHandler) r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler) }) diff --git a/frontend/src/routes/prices/+page.svelte b/frontend/src/routes/prices/+page.svelte index 4049982..e26453c 100644 --- a/frontend/src/routes/prices/+page.svelte +++ b/frontend/src/routes/prices/+page.svelte @@ -23,7 +23,7 @@ async function fetchServices() { servicesLoading = true; try { - const response = await apiFetch('/api/services', { + const response = await apiFetch('/api/services/popular', { method: 'GET', headers: { 'Content-Type': 'application/json' } });