From 83c62ffb97799a5b060b86ddcc129bf22c7f4a79 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 10 May 2026 16:53:17 +0100 Subject: [PATCH] feat: edit request time blockers, today closing time, UI polish, and test fixes - Add time blocker management for booking edit requests - Add closing_time field to admin today/current-next endpoint - Update UserBookingModal and CurrentAppointment UI components - Fix fmt import in bookings_test.go (was missing) - Fix created_by FK in TestAdminApproveEditRequest_TimeBlockerOverlap - Update test coverage for edit request time blocker overlap - Update gap backlog documentation --- .sisyphus/plans/test-db-optimization.md | 206 ++++++++ backend/handlers/admin/today_test.go | 46 ++ backend/handlers/bookings/bookings_test.go | 443 ++++++++++++++++++ backend/handlers/bookings/manage.go | 66 +++ backend/handlers/scheduling/time-blockers.go | 4 +- .../handlers/scheduling/time_blockers_test.go | 58 +++ backend/handlers/today/today.go | 17 +- .../account/UserBookingModal.svelte | 64 +++ .../today/CurrentAppointment.svelte | 80 ++-- .../Crussell/Future Work - Gap Backlog.md | 3 +- 10 files changed, 950 insertions(+), 37 deletions(-) create mode 100644 .sisyphus/plans/test-db-optimization.md diff --git a/.sisyphus/plans/test-db-optimization.md b/.sisyphus/plans/test-db-optimization.md new file mode 100644 index 0000000..9ea6376 --- /dev/null +++ b/.sisyphus/plans/test-db-optimization.md @@ -0,0 +1,206 @@ +# Test DB Reset Optimization — Deep Analysis & Updated Plan + +## Current State: The Problem + +Every test (288 total) does a **full schema DROP + CREATE + TRUNCATE**: + +``` +setupTestDB(t) +├── testdb.Pool(t) → New pgxpool connection (expensive) +├── testdb.Migrate(t, pool) → Full schema DROP + CREATE +│ ├── Check typeCount → ALWAYS > 0 (shared crussell_test DB) +│ ├── DROP 14 types CASCADE +│ ├── DROP 24 tables CASCADE +│ ├── DROP 4 sequences +│ └── Run init-script.sql → CREATE all tables, types, indexes, stored procs +├── testdb.TruncateTables() → TRUNCATE 21 tables CASCADE +├── db.DB = pool → Replace global +├── jwt.Init() → Re-init JWT +└── defer: pool.Close() → Close connection +``` + +**227 tests** call `setupTestDB` directly. The remaining **61 tests** use custom setup functions (`setupTest`, `setupReserveTestDB`, `setupTimeBlockersTestDB`) that do the exact same thing — Pool + Migrate + Truncate. + +**All 288 tests do full schema destruction and rebuild.** + +--- + +## Deep Analysis: Cross-Test Safety Verification + +### ✅ VERIFIED SAFE: No Schema Creation by Tests + +Grep for `CREATE TABLE|DROP TABLE|ALTER TABLE|CREATE TYPE|DROP TYPE|CREATE INDEX|CREATE FUNCTION` in all `_test.go` files: **zero matches**. No test creates, alters, or drops any schema objects. All tests only INSERT/UPDATE/DELETE row data. + +### ✅ VERIFIED SAFE: No Cross-Test Dependencies + +Every test is self-contained. Each creates its own data via fixtures or direct SQL, and either: +- Uses `defer fixtures.Delete...` for cleanup, OR +- Relies on `TruncateTables` to clean up before the next test + +No test reads data created by a previous test. No test depends on sequential ordering. + +### ✅ VERIFIED SAFE: Empty-State Tests + +5 tests explicitly check for empty results: +- `TestPortfolio_ListImages_Empty` — expects 0 images +- `TestPortfolio_ListTags_Empty` — expects 0 tags +- `TestPortfolio_ListFilters_Empty` — expects 0 filters +- `TestNotifications_ListEmpty` — expects 0 notifications +- `TestCustomerRelationship_NoBookings` — expects user with no bookings + +All are safe with TRUNCATE — truncation produces the empty state these tests expect. + +### ✅ VERIFIED SAFE: `seedDefaultWorkingHours` + +Used by 38 tests across 4 files. Uses `ON CONFLICT (weekday) DO UPDATE` — fully idempotent. Safe to call multiple times. + +### ⚠️ FINDING: `TruncateTables` is missing 3 tables + +The truncate list has 21 tables, but the schema has 24. Missing: +- `booking_edit_requests` — used by 11 tests in bookings/admin +- `exceptional_group_applications` — used by 3 tests in bookings/scheduling +- `business_settings` — 1 row, never modified by tests + +**Why this hasn't broken things**: CASCADE foreign keys handle cleanup: +- `booking_edit_requests.booking_id → bookings.id` (CASCADE) → cleaned when `bookings` truncated +- `exceptional_group_applications.group_id → exceptional_working_hours_groups.id` (CASCADE) → cleaned when groups truncated +- `business_settings` — static seed data, never modified + +**Action needed**: Add these 3 tables to `TruncateTables` for correctness. Currently relying on implicit CASCADE behavior. + +### ⚠️ FINDING: `handlers/handlers_test.go` has no setup wrapper + +`TestIntegration_UserFlow` uses `testdb.Pool(t)` directly (no Migrate, no Truncate) with `defer fixtures.DeleteUser` for cleanup. The other 3 tests in this file don't use the DB at all. This package needs a TestMain that runs Migrate once. + +### ⚠️ FINDING: `discount_test.go` has dead code + +`truncateDiscountTables()` helper is defined but never called. The file uses `setupTestDB(t)` which already calls the full `TruncateTables`. Safe to delete. + +### ✅ VERIFIED SAFE: Global state + +- `auth.TokenAuth` — set by `jwt.Init()`, called per-test currently, will be once-per-package in TestMain. No test modifies it. +- `loginInProgress` / `loginAttempts` — package-level maps in `local.go`. Handler cleans up via defer/delete. No test modifies them directly. +- `dav.Service` — set to `&dav.BaseService{}` in auth_test.go's setup. Persists across tests but is a read-only mock. Safe. +- `fixtures.testEmailCounter` — increments for unique emails. Monotonically increasing, never resets. Safe (designed for this). + +--- + +## Updated Plan + +### Phase 1: Add TestMain to Each Package + Fix TruncateTables + +**Step 1A: Fix `TruncateTables` to include all 24 tables** + +Add to `testutils/testdb/testdb.go`: +```go +tables := []string{ + // ... existing 21 tables ... + "booking_edit_requests", // NEW + "exceptional_group_applications", // NEW + "business_settings", // NEW +} +``` + +**Step 1B: Add TestMain to each package** + +Each package gets ONE `TestMain` (not per file — per package). In Go, if multiple files in the same package define `TestMain`, it's a compile error. So we need ONE file per package with TestMain. + +| Package | TestMain goes in | Tests covered | +|---------|-----------------|---------------| +| `handlers/bookings` | New file `bookings_testmain_test.go` | 98 (4 files) | +| `handlers/admin` | New file `admin_testmain_test.go` | 65 (4 files) | +| `handlers/auth` | `auth_test.go` (add to existing) | 25 | +| `handlers/scheduling` | New file `scheduling_testmain_test.go` | 34 (2 files) | +| `handlers/portfolio` | `images_test.go` (add to existing) | 16 | +| `handlers/notifications` | `notifications_test.go` (add to existing) | 12 | +| `handlers/services` | `services_test.go` (add to existing) | 5 | +| `handlers/user` | New file `user_testmain_test.go` | 21 (3 files) | +| `handlers` | `handlers_test.go` (add to existing) | 4 | +| `main` | `main_test.go` (add to existing) | 2 | + +**TestMain template** (varies slightly per package): +```go +func TestMain(m *testing.M) { + pool := testdb.NewPool("") + testdb.Migrate(&testing.T{}, pool) + db.DB = pool + jwt.Init() + // auth_test.go also needs: dav.Service = &dav.BaseService{} + code := m.Run() + pool.Close() + os.Exit(code) +} +``` + +**Step 1C: Convert `setupTestDB(t)` to `resetTestData(t)`** + +Each package's setup function becomes: +```go +func resetTestData(t *testing.T) { + t.Helper() + testdb.TruncateTables(t, db.DB) + // For packages that need working hours: + // seedDefaultWorkingHours(t) +} +``` + +**Step 1D: Remove redundant per-test operations** + +- Remove `db.DB = pool` swap (pool is now global) +- Remove `jwt.Init()` from per-test setup (done once in TestMain) +- Remove `pool.Close()` from defer (pool is shared, closed in TestMain) +- Remove `testdb.Pool(t)` calls (use global `db.DB`) +- Remove `testdb.Migrate(t, pool)` calls (done once in TestMain) + +### Phase 2: Clean Up + +- Delete dead `truncateDiscountTables()` in discount_test.go +- Consolidate `seedDefaultWorkingHours` into one shared function (currently duplicated in 4 files with slight variations) +- Add `dav.Service` mock to TestMain for auth package only + +### Phase 3: Smart Truncate (Optional, Later) + +Identify which tables each test actually touches and truncate only those. Low priority — Phase 1 gives 95% of the benefit. + +--- + +## Risk Assessment (Updated) + +| Risk | Likelihood | Severity | Mitigation | +|------|-----------|----------|------------| +| Test pollution (data leaking) | Low | High | `TRUNCATE CASCADE` is reliable; verify with `-count=2` | +| Missing tables in TruncateTables | **Confirmed** | Medium | **Fix in Phase 1A** — add 3 missing tables | +| `TestMain` compile conflict | Low | High | ONE TestMain per package, not per file | +| `db.DB` global race | Low | High | Tests run sequentially (`-p 1`) | +| `jwt.Init()` called once vs per-test | Low | Medium | JWT state is idempotent; no test modifies TokenAuth | +| `dav.Service` mock persistence | Low | Low | Only auth tests use it; mock is stateless | + +### Verification Strategy: +1. Run `go test -tags test -v -p 1 -count=1 ./...` — record baseline count/timing +2. Apply Phase 1A (fix TruncateTables) +3. Run tests — should still pass (no logic change) +4. Apply Phase 1B-1D (TestMain + refactor) +5. Run `go test -tags test -v -p 1 -count=2 ./...` — double-run catches state leakage +6. Compare: 288 tests, same pass/fail, faster execution + +--- + +## Expected Impact + +| Metric | Before | After Phase 1 | After Phase 3 | +|--------|--------|---------------|---------------| +| Schema DROP+CREATE | 288 | 10 (one per package) | 10 | +| TRUNCATE per test | 21 tables | 21 tables | ~3-5 tables | +| Connection pools created | 288 | 10 | 10 | +| **Estimated total time** | **~144s** | **~58s** | **~30s** | + +> Estimates based on: DROP+CREATE ~400ms, TRUNCATE 21 tables ~100ms, pool connect ~50ms. Actual timing depends on PostgreSQL container performance. + +--- + +## What NOT to Change + +- **Don't add `t.Parallel()`** — requires per-test transactions or separate databases +- **Don't use transaction rollback** — some tests verify side effects needing committed data +- **Don't change `init-script.sql`** — schema is correct, we're just running it too many times +- **Don't touch `fixtures.testEmailCounter`** — it's designed to be monotonic diff --git a/backend/handlers/admin/today_test.go b/backend/handlers/admin/today_test.go index e936a72..054e454 100644 --- a/backend/handlers/admin/today_test.go +++ b/backend/handlers/admin/today_test.go @@ -19,6 +19,7 @@ import ( "encoding/json" "net/http" "testing" + "time" "crussell/db" "crussell/handlers/notifications" @@ -102,6 +103,51 @@ func TestAdminToday_CurrentNext(t *testing.T) { } } +// TestAdminToday_CurrentNext_ClosingTime verifies that the current-next endpoint +// returns the closing time for today. +func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + // Seed working hours for today (query uses current weekday) + todayWeekday := int(time.Now().Weekday()) + if todayWeekday == 0 { + todayWeekday = 7 + } + _, err := db.DB.Exec(context.Background(), ` + INSERT INTO working_hours (weekday, start_time, end_time, is_open) + VALUES ($1, '09:00', '18:00', true) + ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '18:00', is_open = true + `, todayWeekday) + if err != nil { + t.Fatalf("failed to seed working hours: %v", err) + } + + handler := http.HandlerFunc(today.GetCurrentAndNextHandler) + w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) + } + + var response today.CurrentNextResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.ClosingTime == nil { + t.Errorf("expected closing_time in response, got nil") + } + + if response.ClosingTime != nil && *response.ClosingTime == "" { + t.Error("expected closing_time to be non-empty string") + } + + if response.ClosingTime != nil && *response.ClosingTime != "18:00:00" && *response.ClosingTime != "18:00" { + t.Logf("got closing_time: %s", *response.ClosingTime) + } +} + // TestAdminToday_Appointments tests that an admin can get a list of all // bookings scheduled for today with their details. func TestAdminToday_Appointments(t *testing.T) { diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index cac2ded..2a7d554 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -20,6 +20,7 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -2709,6 +2710,94 @@ func TestCreateEditRequest(t *testing.T) { } } +// TestCreateEditRequest_WithTimeChange verifies that a user can request an edit to +// change the booking time, and a time_blocker is created to reserve the new slot. +func TestCreateEditRequest_WithTimeChange(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + if err != nil { + t.Fatalf("failed to create test booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + token := jwt.GenerateUserToken(userID) + + newStartTime := time.Now().Add(24 * time.Hour).Truncate(time.Second) + newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) + + handler := http.HandlerFunc(RequestEditHandler) + reqBody := map[string]interface{}{ + "new_start_time": newStartTime.Format(time.RFC3339), + } + w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token) + + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Errorf("expected status 200/201, got %d. body: %s", w.Code, w.Body.String()) + } + + var erNewTime time.Time + err = db.DB.QueryRow(context.Background(), + "SELECT new_start_time FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erNewTime) + if err != nil { + t.Fatalf("failed to query edit request: %v", err) + } + if !erNewTime.Truncate(time.Second).Equal(newStartTime) { + t.Errorf("expected new_start_time %v, got %v", newStartTime, erNewTime) + } + + var blockerCount int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount) + if err != nil { + t.Fatalf("failed to query time_blockers: %v", err) + } + if blockerCount != 1 { + t.Errorf("expected 1 time_blocker for edit request, got %d", blockerCount) + } + + var blockerStart time.Time + var blockerDuration int + err = db.DB.QueryRow(context.Background(), + "SELECT start_time, duration_minutes FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerStart, &blockerDuration) + if err != nil { + t.Fatalf("failed to query time_blocker details: %v", err) + } + if !blockerStart.Truncate(time.Second).Equal(newStartTime) { + t.Errorf("expected blocker start_time %v, got %v", newStartTime, blockerStart) + } + if blockerDuration < 15 { + t.Errorf("expected blocker duration >= 15, got %d", blockerDuration) + } +} + // TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification func TestDeleteEditRequest(t *testing.T) { cleanup := setupTestDB(t) @@ -3029,6 +3118,360 @@ func TestAdminRejectEditRequest(t *testing.T) { } } +// TestAdminApproveEditRequest_DeletesTimeBlocker verifies that when admin approves +// an edit request, the associated time_blocker reservation is deleted. +func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + if err != nil { + t.Fatalf("failed to create test booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + userToken := jwt.GenerateUserToken(userID) + + newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) + newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) + + createReq := http.HandlerFunc(RequestEditHandler) + createBody := map[string]interface{}{ + "new_start_time": newStartTime.Format(time.RFC3339), + } + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) + } + + var blockerCountBefore int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) + if err != nil { + t.Fatalf("failed to query blockers: %v", err) + } + if blockerCountBefore != 1 { + t.Fatalf("expected 1 blocker before approval, got %d", blockerCountBefore) + } + + var editRequestID string + err = db.DB.QueryRow(context.Background(), + "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) + if err != nil { + t.Fatalf("failed to get edit request ID: %v", err) + } + + r := chi.NewRouter() + r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) + req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) + ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", bookingID) + rctx.URLParams.Add("request_id", editRequestID) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + req = req.WithContext(ctx) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent && w.Code != http.StatusOK { + t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) + } + + var blockerCountAfter int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) + if err != nil { + t.Fatalf("failed to query blockers after approval: %v", err) + } + if blockerCountAfter != 0 { + t.Errorf("expected 0 blockers after approval, got %d", blockerCountAfter) + } +} + +// TestAdminRejectEditRequest_DeletesTimeBlocker verifies that when admin rejects +// an edit request, the associated time_blocker reservation is deleted. +func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + if err != nil { + t.Fatalf("failed to create test booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + userToken := jwt.GenerateUserToken(userID) + + newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) + newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) + + createReq := http.HandlerFunc(RequestEditHandler) + createBody := map[string]interface{}{ + "new_start_time": newStartTime.Format(time.RFC3339), + } + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) + } + + var blockerCountBefore int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) + if err != nil { + t.Fatalf("failed to query blockers: %v", err) + } + if blockerCountBefore != 1 { + t.Fatalf("expected 1 blocker before rejection, got %d", blockerCountBefore) + } + + var editRequestID string + err = db.DB.QueryRow(context.Background(), + "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) + if err != nil { + t.Fatalf("failed to get edit request ID: %v", err) + } + + r := chi.NewRouter() + r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/deny", AdminRejectEditRequestHandler) + req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil) + ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", bookingID) + rctx.URLParams.Add("request_id", editRequestID) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + req = req.WithContext(ctx) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent && w.Code != http.StatusOK { + t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) + } + + var blockerCountAfter int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) + if err != nil { + t.Fatalf("failed to query blockers after rejection: %v", err) + } + if blockerCountAfter != 0 { + t.Errorf("expected 0 blockers after rejection, got %d", blockerCountAfter) + } +} + +// TestDeleteEditRequest_DeletesTimeBlocker verifies that when user cancels their +// own edit request, the associated time_blocker reservation is deleted. +func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + if err != nil { + t.Fatalf("failed to create test booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + userToken := jwt.GenerateUserToken(userID) + + newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) + newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) + + createReq := http.HandlerFunc(RequestEditHandler) + createBody := map[string]interface{}{ + "new_start_time": newStartTime.Format(time.RFC3339), + } + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) + } + + var blockerCountBefore int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore) + if err != nil { + t.Fatalf("failed to query blockers: %v", err) + } + if blockerCountBefore != 1 { + t.Fatalf("expected 1 blocker before delete, got %d", blockerCountBefore) + } + + delHandler := http.HandlerFunc(DeleteEditRequestHandler) + w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken) + + if w.Code != http.StatusOK && w.Code != http.StatusNoContent { + t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String()) + } + + var blockerCountAfter int + err = db.DB.QueryRow(context.Background(), + "SELECT COUNT(*) FROM time_blockers WHERE description = $1", + fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter) + if err != nil { + t.Fatalf("failed to query blockers after delete: %v", err) + } + if blockerCountAfter != 0 { + t.Errorf("expected 0 blockers after user delete, got %d", blockerCountAfter) + } +} + +// TestAdminApproveEditRequest_TimeBlockerOverlap tests that approving an edit +// request fails when the new time conflicts with an existing time_blocker. +func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) { + cleanup := setupTestDB(t) + defer cleanup() + + seedDefaultWorkingHours(t) + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create test user: %v", err) + } + defer fixtures.DeleteUser(db.DB, userID) + + _, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID) + if err != nil { + t.Fatalf("failed to set deposits_required: %v", err) + } + + serviceID, err := fixtures.CreateTestService(db.DB) + if err != nil { + t.Fatalf("failed to create test service: %v", err) + } + defer fixtures.DeleteService(db.DB, serviceID) + + bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID) + if err != nil { + t.Fatalf("failed to create test booking: %v", err) + } + defer fixtures.DeleteBooking(db.DB, bookingID) + + _, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID) + if err != nil { + t.Fatalf("failed to confirm booking: %v", err) + } + + userToken := jwt.GenerateUserToken(userID) + + newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second) + newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location()) + + createReq := http.HandlerFunc(RequestEditHandler) + createBody := map[string]interface{}{ + "new_start_time": newStartTime.Format(time.RFC3339), + } + w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken) + if w.Code != http.StatusCreated && w.Code != http.StatusOK { + t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String()) + } + + _, err = db.DB.Exec(context.Background(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, 60, 'Existing blocker', $2) + `, newStartTime, userID) + if err != nil { + t.Fatalf("failed to create blocking time_blocker: %v", err) + } + + var editRequestID string + err = db.DB.QueryRow(context.Background(), + "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID) + if err != nil { + t.Fatalf("failed to get edit request ID: %v", err) + } + + r := chi.NewRouter() + r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler) + req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil) + ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", bookingID) + rctx.URLParams.Add("request_id", editRequestID) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + req = req.WithContext(ctx) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected status 409 Conflict due to time_blocker overlap, got %d. body: %s", w.Code, w.Body.String()) + } +} + // TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404 func TestBookings_RequestEdit_BookingNotFound(t *testing.T) { cleanup := setupTestDB(t) diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 49b39e4..739c4db 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -909,6 +909,11 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } + _, _ = tx.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE description = $1 + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + // Delete the admin notification for this edit request _, err = tx.Exec(r.Context(), ` DELETE FROM admin_notifications @@ -1048,6 +1053,39 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) { return } + if req.NewStartTime != nil { + _, _ = tx.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE description = $1 + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + + var durationMinutes int + if len(req.NewServices) > 0 { + _ = tx.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(s.duration_minutes), 60) + FROM services s + WHERE s.id = ANY($1) + `, req.NewServices).Scan(&durationMinutes) + } else { + _ = tx.QueryRow(r.Context(), ` + SELECT COALESCE(SUM(s.duration_minutes), 60) + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + `, bookingID).Scan(&durationMinutes) + } + + _, err = tx.Exec(r.Context(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) + VALUES ($1, $2, $3, $4) + `, *req.NewStartTime, durationMinutes, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), userID) + if err != nil { + log.Printf("Failed to create time_blocker reservation for edit request %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + // Delete existing admin notification for edit_request before creating new one (refreshes timestamp) _, err = tx.Exec(r.Context(), ` DELETE FROM admin_notifications @@ -1290,6 +1328,15 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict) return } + + blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime) + if err != nil { + log.Printf("Failed to check time blocker overlap: %v", err) + } + if blockerOverlap { + http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict) + return + } } // Build update query for bookings table @@ -1355,6 +1402,11 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } + _, _ = tx.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE description = $1 + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + // Acknowledge the admin notification for this edit request _, err = tx.Exec(r.Context(), ` UPDATE admin_notifications @@ -1366,6 +1418,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + + // TODO: Notify user that their edit request was approved. + // Options: (a) INSERT into user_notifications table (needs schema), (b) send email via SMTP provider. + // The user_notification_preferences table exists but no delivery mechanism is wired yet. + // See: obsidian/Crussell/Future Work - Gap Backlog.md → E5 (Email/SMS notification system). + if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1418,6 +1476,11 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } + _, _ = tx.Exec(r.Context(), ` + DELETE FROM time_blockers + WHERE description = $1 + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)) + // Acknowledge the admin notification for this edit request _, err = tx.Exec(r.Context(), ` UPDATE admin_notifications @@ -1430,6 +1493,9 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) { return } + // TODO: Notify user that their edit request was denied. + // Same as approve TODO above — needs user_notifications table or email delivery (E5). + if err := tx.Commit(r.Context()); err != nil { log.Printf("Failed to commit reject edit request: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index ac40e57..ce516f5 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -343,6 +343,7 @@ func CleanupOldReservations(ctx context.Context) error { oneHourAgo := time.Now().Add(-1 * time.Hour) tenMinutesAgo := time.Now().Add(-10 * time.Minute) fifteenMinutesAgo := time.Now().Add(-15 * time.Minute) + twentyFourHoursAgo := time.Now().Add(-24 * time.Hour) _, err := db.DB.Exec(ctx, ` DELETE FROM time_blockers @@ -350,7 +351,8 @@ func CleanupOldReservations(ctx context.Context) error { OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2) OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3) OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3) - `, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo) + OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4) + `, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo) return err } diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 22ce5e7..2ddfbca 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -1232,3 +1232,61 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) { t.Errorf("expected guest 1 email to start with 'anon-', got '%s'", g1Email) } } + +// --- Tests for CleanupOldReservations (Edit Request) --- + +// TestCleanupOldReservations_EditRequest verifies that edit request reservations +// older than 24 hours are deleted, while recent ones are preserved. +func TestCleanupOldReservations_EditRequest(t *testing.T) { + cleanup := setupTimeBlockersTestDB(t) + defer cleanup() + + ctx := context.Background() + ukLocation, _ := time.LoadLocation("Europe/London") + + // Create old edit_request reservation (>24 hours old) + oldTime := time.Now().Add(-25 * time.Hour).In(ukLocation) + _, err := db.DB.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) + VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2) + `, oldTime, time.Now().Add(-25*time.Hour)) + if err != nil { + t.Fatalf("failed to create old edit_request reservation: %v", err) + } + + // Create recent edit_request reservation (<24 hours old) + recentTime := time.Now().Add(-12 * time.Hour).In(ukLocation) + _, err = db.DB.Exec(ctx, ` + INSERT INTO time_blockers (start_time, duration_minutes, description, created_at) + VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2) + `, recentTime, time.Now().Add(-12*time.Hour)) + if err != nil { + t.Fatalf("failed to create recent edit_request reservation: %v", err) + } + + // Run cleanup + err = CleanupOldReservations(ctx) + if err != nil { + t.Fatalf("CleanupOldReservations failed: %v", err) + } + + // Verify old reservation was deleted + var oldCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk123'").Scan(&oldCount) + if err != nil { + t.Fatalf("failed to check old reservation: %v", err) + } + if oldCount != 0 { + t.Error("expected old edit_request reservation (25h) to be deleted") + } + + // Verify recent reservation still exists + var recentCount int + err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk456'").Scan(&recentCount) + if err != nil { + t.Fatalf("failed to check recent reservation: %v", err) + } + if recentCount != 1 { + t.Error("expected recent edit_request reservation (12h) to be preserved") + } +} diff --git a/backend/handlers/today/today.go b/backend/handlers/today/today.go index b071648..578a081 100644 --- a/backend/handlers/today/today.go +++ b/backend/handlers/today/today.go @@ -37,8 +37,9 @@ type AppointmentInfo struct { } type CurrentNextResponse struct { - Current *AppointmentInfo `json:"current"` - Next *AppointmentInfo `json:"next"` + Current *AppointmentInfo `json:"current"` + Next *AppointmentInfo `json:"next"` + ClosingTime *string `json:"closing_time,omitempty"` // "HH:MM" format } // GET /api/admin/today/current-next @@ -158,6 +159,18 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) { Next: next, } + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + var closingTime sql.NullString + _ = db.DB.QueryRow(r.Context(), ` + SELECT end_time::text FROM working_hours WHERE weekday = $1 AND is_open = true + `, weekday).Scan(&closingTime) + if closingTime.Valid { + response.ClosingTime = &closingTime.String + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(response); err != nil { diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 21db644..5314646 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -351,6 +351,70 @@ async function submitReschedule() { if (!selectedBooking || !rescheduleDate || !rescheduleTime) return; + + // Re-fetch available hours to confirm slot is still open + try { + const dateStr = rescheduleDate.toString(); + const monthKey = `${rescheduleDate.year}-${String(rescheduleDate.month).padStart(2, '0')}`; + const startOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, 1); + const endOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, rescheduleDate.calendar.getDaysInMonth(rescheduleDate)); + + const [whRes, ahRes] = await Promise.all([ + fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`), + fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`) + ]); + + if (whRes.ok && ahRes.ok) { + const whData: WorkingHoursDay[] = await whRes.json(); + const ahData: AvailableHoursDay[] = await ahRes.json(); + + const freshWH: Record = {}; + whData.forEach((d) => { freshWH[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; }); + const freshAH: Record }> = {}; + ahData.forEach((d) => { freshAH[d.date] = { isOpen: d.isOpen, slots: d.slots }; }); + + const dayWH = freshWH[dateStr]; + const dayAH = freshAH[dateStr]; + + if (!dayWH?.isOpen || !dayAH?.slots) { + toast.error('This date is no longer available. Please select a different date.'); + rescheduleDate = undefined; + rescheduleTime = ''; + return; + } + + // Check if the selected time is still available + const freshSlots: string[] = []; + for (const slot of dayAH.slots) { + const [sh, sm] = slot.startTime.split(':').map(Number); + const [eh, em] = slot.endTime.split(':').map(Number); + for (let m = sh * 60 + sm; m < eh * 60 + em; m += 15) { + if (m + totalDuration <= eh * 60 + em) { + freshSlots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`); + } + } + } + + if (!freshSlots.includes(rescheduleTime)) { + toast.error('This time slot is no longer available. Please choose a different time.'); + rescheduleTime = ''; + return; + } + + // Check lunch protection + const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots); + const freshLunch = getLunchProtectionForSlots(dayWH.startTime, dayWH.endTime, existingBookings, totalDuration, 15, false); + if (freshLunch.get(rescheduleTime)?.isBlocked) { + toast.error('This time slot is no longer available. Please choose a different time.'); + rescheduleTime = ''; + return; + } + } + } catch { + toast.error('Could not verify slot availability. Please try again.'); + return; + } + rescheduleSubmitting = true; try { const [hours, minutes] = rescheduleTime.split(':').map(Number); diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index 9920f9b..0c19561 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -37,7 +37,9 @@ let currentAppointment = $state(null); let nextAppointment = $state(null); + let closingTime = $state(null); // "HH:MM" format let freeTimeAfter = $state(0); // minutes of free time after current/next appointment + let freeTimeCapped = $state(false); // true if free time extends to/past closing let loading = $state(true); let timeRemaining = $state(0); // minutes remaining in current appointment let isInProgress = $state(false); @@ -52,48 +54,53 @@ isInProgress = currentAppointment.status === 'in_progress'; const startTime = new SvelteDate(currentAppointment.start_time); - if (isInProgress) { - // Appointment is in progress - show time remaining until end - const endTime = new SvelteDate( - startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000 - ); + const endTime = new SvelteDate( + startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000 + ); - // If current time is before start time, show time until start + if (isInProgress) { if (now.getTime() < startTime.getTime()) { const timeUntilMs = startTime.getTime() - now.getTime(); timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000)); } else { - // Otherwise show time until end const remainingMs = endTime.getTime() - now.getTime(); timeRemaining = Math.max(0, Math.floor(remainingMs / 60000)); } - - // Calculate free time until next appointment - if (nextAppointment) { - const endTime = new SvelteDate( - startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000 - ); - const nextStart = new SvelteDate(nextAppointment.start_time); - const gapMs = nextStart.getTime() - endTime.getTime(); - freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000)); - } else { - freeTimeAfter = 0; - } } else { - // Appointment is upcoming - show time until start const timeUntilMs = startTime.getTime() - now.getTime(); timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000)); - freeTimeAfter = 0; + } - // If there's a next appointment, calculate free time after this one ends - if (nextAppointment) { - const endTime = new SvelteDate( - startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000 - ); - const nextStart = new SvelteDate(nextAppointment.start_time); - const gapMs = nextStart.getTime() - endTime.getTime(); - freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000)); + // Calculate free time after this appointment + let rawFreeMinutes = 0; + if (nextAppointment) { + const nextStart = new SvelteDate(nextAppointment.start_time); + const gapMs = nextStart.getTime() - endTime.getTime(); + rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000)); + } + + // Cap at closing time + if (closingTime) { + const [ch, cm] = closingTime.split(':').map(Number); + const today = new SvelteDate(); + const closing = new SvelteDate(today.getFullYear(), today.getMonth() + 1, today.getDate(), ch, cm, 0); + const minutesToClosing = Math.max(0, Math.floor((closing.getTime() - endTime.getTime()) / 60000)); + + if (!nextAppointment) { + // No next appointment — show time until closing + freeTimeAfter = minutesToClosing; + freeTimeCapped = true; + } else if (rawFreeMinutes > minutesToClosing) { + // Next appointment is after closing — cap at closing + freeTimeAfter = minutesToClosing; + freeTimeCapped = true; + } else { + freeTimeAfter = rawFreeMinutes; + freeTimeCapped = false; } + } else { + freeTimeAfter = nextAppointment ? rawFreeMinutes : 0; + freeTimeCapped = false; } } } @@ -113,6 +120,7 @@ const data = await response.json(); currentAppointment = data.current || null; nextAppointment = data.next || null; + closingTime = data.closing_time || null; calculateTimes(); } else { toast.error('Failed to load current appointment'); @@ -203,10 +211,16 @@ In Progress • {timeRemaining} min remaining - {#if freeTimeAfter > 0 && timeRemaining > 29} - - {freeTimeAfter} min free afterwards - + {#if timeRemaining > 29} + {#if freeTimeCapped && freeTimeAfter === 0} + + closing after + + {:else if freeTimeAfter > 0} + + {freeTimeAfter} min free afterwards{#if freeTimeCapped} (closing after){/if} + + {/if} {/if} {:else} diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 27846f8..81ac068 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -28,7 +28,8 @@ No external dependencies. No paid services. No API keys needed. | 10 | **Password reset flow not wired to frontend** | S (2-3h) | Frontend | Backend has `/api/verify/generate` and `/api/verify/check` endpoints. Login page has no "forgot password" link or form. | | 11 | **Email verification flow not wired to frontend** | S (2-3h) | Frontend | Users register with `unverified_email` role. No UI to enter verification code or resend code. `+layout.svelte` has alert-based prototype. | | 12 | **Booking cancellation from user account** | S (2-3h) | Frontend | UserBookingModal shows booking details but no cancel button. Users must call/email to cancel. Backend endpoint exists (`DELETE /api/bookings/{id}`). | -| 13 | **Booking rescheduling for users** | M (1-2d) | Full-stack | Users can't reschedule their own bookings. `booking_edit_requests` table exists but frontend flow is incomplete — ApprovalModal only handles approve, not decline. | +| 13 | ~~**Booking rescheduling for users**~~ ⚠️ | M (1-2d) | Full-stack | Backend fully wired (request/approve/deny + time_blocker reservation). Frontend re-validation on submit implemented. **Remaining:** Admin UI to view/approve/deny edit requests — consider extending existing Pending Approvals panel. User notification on approve/deny tracked as TODO (blocked on E5). | +| 50 | **Admin UI for edit request approval/denial** | M (1-2d) | Frontend | Backend endpoints exist: `GET /api/admin/bookings/{id}/edit-requests`, `POST .../approve`, `POST .../deny`. Needs UI to display pending requests with proposed vs original time, approve/deny buttons. **Consider:** Extend existing Pending Approvals panel on Today page rather than building separate page. | ## P2 — Medium