diff --git a/FRONTEND_ISSUES.md b/FRONTEND_ISSUES.md index 5341793..7f4cb98 100644 --- a/FRONTEND_ISSUES.md +++ b/FRONTEND_ISSUES.md @@ -39,3 +39,10 @@ - Demo bootstrap slug aligned with backend demo tenant (`studio-atelier`). - Paddle mentions replaced by Stripe in legal + i18n copy. - `@neondatabase/neon-js` bumped to 0.7.0-beta — `npm audit` now reports 0 vulnerabilities. + +## Fixed This Session (class roster + waitlist promotion) + +- Class sessions in the zones classes tab now show a `N waiting` badge and expand into a two-column roster panel — confirmed attendees vs waitlist with `#N` queue positions, names, emails, and booking references. +- Waitlisted roster entries have a **Confirm seat** action; when the session is full the backend returns 409 and the button re-arms into an explicit "confirm anyway" over-capacity step rather than silently overriding. +- The booking detail modal now shows the waitlist position for waitlisted bookings and offers the same promote action (accent button above cancel); the bookings list badges waitlisted rows with their `#N` position. +- Demo mode seeds a class session with a populated roster; mutation attempts surface the demo notice instead of failing. Verified in-browser in both Czech and English with a clean console. diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 6efc855..33463bc 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -740,10 +740,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) }) - - // ============================================ - // CATALOG API - Blocked Days - // ============================================ + protected.POST("/catalog/bookings/:bookingID/promote", func(c *gin.Context) { + var request domain.PromoteBookingRequest + if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) + return + } + err := catalogService.PromoteBooking(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("bookingID"), request) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrBookingNotFound): + status = http.StatusNotFound + case errors.Is(err, bookings.ErrNotWaitlisted): + status = http.StatusBadRequest + case errors.Is(err, bookings.ErrSessionFull): + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "confirmed"}) + }) protected.GET("/catalog/blocked-days", func(c *gin.Context) { from := time.Now() to := from.AddDate(0, 3, 0) // 3 months ahead @@ -1063,6 +1081,19 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { } c.JSON(http.StatusCreated, response) }) + protected.GET("/catalog/class-sessions/:sessionID/roster", func(c *gin.Context) { + response, err := catalogService.ListSessionRoster(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID")) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound): + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, response) + }) protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) { err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID")) if err != nil { diff --git a/apps/backend/internal/bookings/lifecycle.go b/apps/backend/internal/bookings/lifecycle.go index c3d65b5..1f244cb 100644 --- a/apps/backend/internal/bookings/lifecycle.go +++ b/apps/backend/internal/bookings/lifecycle.go @@ -23,7 +23,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI return nil, err } - bookings, err := repo.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second)) + bookings, err := repo.ListBookingsBySession(ctx, sessionID) if err != nil { return nil, err } @@ -31,9 +31,6 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI confirmed := 0 var waitlisted []db.BookingRecord for _, booking := range bookings { - if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID { - continue - } switch booking.Status { case "confirmed": confirmed++ @@ -53,20 +50,72 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI if session.Capacity > 0 && confirmed >= int(session.Capacity) { break } - if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { + if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil { return promoted, err } - _ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail) - // A booking promoted after its reminder already dispatched-as-skipped - // would otherwise go unreminded; re-arm whatever the horizon allows. - _ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID) - _ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone) confirmed++ promoted = append(promoted, booking) } return promoted, nil } +// PromoteBooking manually confirms one waitlisted class booking. Unlike the +// automatic FIFO promotion it targets a specific booking; when the session is +// full it fails with ErrSessionFull unless allowOverCapacity is set — a +// deliberate business override, not a silent overbooking. +func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID string, allowOverCapacity bool) (db.BookingRecord, error) { + booking, err := repo.GetBookingByID(ctx, bookingID) + if err != nil { + return db.BookingRecord{}, err + } + if booking.TenantID != tenantID { + return db.BookingRecord{}, pgx.ErrNoRows + } + if booking.Status != "waitlisted" || booking.ClassSessionID == nil { + return db.BookingRecord{}, ErrNotWaitlisted + } + sessionID := *booking.ClassSessionID + + if !allowOverCapacity { + session, err := repo.GetClassSessionByID(ctx, sessionID) + if err != nil { + return db.BookingRecord{}, err + } + sessionBookings, err := repo.ListBookingsBySession(ctx, sessionID) + if err != nil { + return db.BookingRecord{}, err + } + confirmed := 0 + for _, other := range sessionBookings { + if other.Status == "confirmed" { + confirmed++ + } + } + if session.Capacity > 0 && confirmed >= int(session.Capacity) { + return db.BookingRecord{}, ErrSessionFull + } + } + + if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil { + return db.BookingRecord{}, err + } + booking.Status = "confirmed" + return booking, nil +} + +// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves +// the ledger entry, and re-arms reminder jobs — a booking promoted after its +// reminder already dispatched-as-skipped would otherwise go unreminded. +func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error { + if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil { + return err + } + _ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail) + _ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID) + _ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone) + return nil +} + // armReminderJobs schedules the email reminder — and the SMS reminder when the // tenant opted into SMS and a phone number is on file — for a booking whose // start still falls inside a reminder horizon. A start too close or too far diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index a53cad1..61d2dae 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -25,6 +25,8 @@ var ( ErrBookingConflict = errors.New("booking conflict") ErrTenantMembership = errors.New("tenant membership not found") ErrBookingNotFound = errors.New("booking not found") + ErrNotWaitlisted = errors.New("booking is not on a waitlist") + ErrSessionFull = errors.New("class session is at capacity") ) type Service struct { @@ -538,14 +540,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip } toDomain := func(booking db.BookingRecord) domain.UpcomingBooking { out := domain.UpcomingBooking{ - ID: booking.ID, - Reference: booking.Reference, - CustomerName: booking.CustomerName, - CustomerEmail: booking.CustomerEmail, - Notes: booking.Notes, - StartsAt: booking.StartsAt, - EndsAt: booking.EndsAt, - Status: booking.Status, + ID: booking.ID, + Reference: booking.Reference, + CustomerName: booking.CustomerName, + CustomerEmail: booking.CustomerEmail, + Notes: booking.Notes, + StartsAt: booking.StartsAt, + EndsAt: booking.EndsAt, + Status: booking.Status, + ClassSessionID: booking.ClassSessionID, } if booking.ServiceID != nil { out.Service = serviceNames[*booking.ServiceID] @@ -559,6 +562,24 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip return out } + // Waitlist positions are FIFO per session, ordered by booking creation — + // computed in memory to avoid one position query per queued booking. + waitlistPositions := func(records []db.BookingRecord) map[string]int { + positions := map[string]int{} + perSession := map[string]int{} + sorted := make([]db.BookingRecord, len(records)) + copy(sorted, records) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].CreatedAt.Before(sorted[j].CreatedAt) }) + for _, booking := range sorted { + if booking.Status != "waitlisted" || booking.ClassSessionID == nil { + continue + } + perSession[*booking.ClassSessionID]++ + positions[booking.ID] = perSession[*booking.ClassSessionID] + } + return positions + } + now := time.Now().UTC() weekEnd := now.AddDate(0, 0, 7) metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd) @@ -577,12 +598,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip if err != nil { return domain.DashboardSummary{}, err } + upcomingPositions := waitlistPositions(upcomingRecords) upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords)) for _, booking := range upcomingRecords { if booking.Status == "cancelled" { continue } - upcoming = append(upcoming, toDomain(booking)) + entry := toDomain(booking) + entry.WaitlistPosition = upcomingPositions[booking.ID] + upcoming = append(upcoming, entry) } if len(upcoming) > 5 { upcoming = upcoming[:5] @@ -595,9 +619,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip if err != nil { return domain.DashboardSummary{}, err } + allPositions := waitlistPositions(allRecords) allBookings := make([]domain.UpcomingBooking, 0, len(allRecords)) for _, booking := range allRecords { - allBookings = append(allBookings, toDomain(booking)) + entry := toDomain(booking) + entry.WaitlistPosition = allPositions[booking.ID] + allBookings = append(allBookings, entry) } brandName := membership.Tenant.Name diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index d7b0962..df3f2b7 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -1128,9 +1128,16 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi out := make([]domain.ClassSession, len(sessions)) for i, session := range sessions { booked := 0 + waitlisted := 0 for _, booking := range bookings { - if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID && - booking.Status != "cancelled" && booking.Status != "waitlisted" { + if booking.ClassSessionID == nil || *booking.ClassSessionID != session.ID { + continue + } + switch booking.Status { + case "waitlisted": + waitlisted++ + case "cancelled": + default: booked++ } } @@ -1145,11 +1152,73 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi EndsAt: session.EndsAt, Capacity: int(session.Capacity), BookedCount: booked, + WaitlistCount: waitlisted, } } return out, nil } +// ListSessionRoster returns the confirmed attendees and the FIFO waitlist of a +// tenant-owned class session for the dashboard roster view. +func (s *Service) ListSessionRoster(ctx context.Context, principal domain.Principal, sessionID string) (domain.SessionRoster, error) { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return domain.SessionRoster{}, ErrTenantMembership + } + session, err := s.repo.GetClassSessionByID(ctx, sessionID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.SessionRoster{}, ErrClassSessionNotFound + } + return domain.SessionRoster{}, err + } + if session.TenantID != membership.Tenant.ID { + return domain.SessionRoster{}, ErrClassSessionNotFound + } + + sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID) + if err != nil { + return domain.SessionRoster{}, err + } + + toEntry := func(booking db.BookingRecord) domain.SessionRosterEntry { + return domain.SessionRosterEntry{ + BookingID: booking.ID, + Reference: booking.Reference, + CustomerName: booking.CustomerName, + CustomerEmail: booking.CustomerEmail, + CustomerPhone: booking.CustomerPhone, + Status: booking.Status, + CreatedAt: booking.CreatedAt, + } + } + roster := domain.SessionRoster{ + SessionID: session.ID, + TemplateTitle: session.Title, + StartsAt: session.StartsAt, + EndsAt: session.EndsAt, + Capacity: int(session.Capacity), + Confirmed: []domain.SessionRosterEntry{}, + Waitlist: []domain.SessionRosterEntry{}, + } + if session.LocationID != nil { + if location, err := s.repo.GetLocationByID(ctx, *session.LocationID); err == nil { + roster.LocationName = location.Name + } + } + for _, booking := range sessionBookings { + switch booking.Status { + case "confirmed": + roster.Confirmed = append(roster.Confirmed, toEntry(booking)) + case "waitlisted": + entry := toEntry(booking) + entry.WaitlistPosition = len(roster.Waitlist) + 1 + roster.Waitlist = append(roster.Waitlist, entry) + } + } + return roster, nil +} + func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { @@ -1311,6 +1380,25 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal, return nil } +// PromoteBooking confirms one waitlisted class booking on behalf of the +// business. Sessions at capacity require the explicit allowOverCapacity flag; +// the promoted customer receives the standard confirmation email. +func (s *Service) PromoteBooking(ctx context.Context, principal domain.Principal, bookingID string, req domain.PromoteBookingRequest) error { + membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) + if err != nil { + return ErrTenantMembership + } + promoted, err := bookings.PromoteBooking(ctx, s.repo, membership.Tenant.ID, bookingID, req.AllowOverCapacity) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrBookingNotFound + } + return err + } + go s.sendBookingEmail(promoted, true) + return nil +} + // sendBookingEmail builds the shared payload and fires a confirmation // (promoted=true) or cancellation notification. Failures are logged-and-done: // email delivery must not fail the cancellation itself. diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go index 5f5b45a..c07cd04 100644 --- a/apps/backend/internal/catalog/service_test.go +++ b/apps/backend/internal/catalog/service_test.go @@ -641,6 +641,123 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) { } } +func TestListSessionRoster(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + for i := 0; i < 4; i++ { + bookClass(t, repo, fmt.Sprintf("seat%d@example.com", i)) + } + bookClass(t, repo, "first-waiter@example.com") + bookClass(t, repo, "second-waiter@example.com") + + roster, err := svc.ListSessionRoster(ctx, owner, demoSessionID) + if err != nil { + t.Fatalf("roster: %v", err) + } + if roster.Capacity != 4 || len(roster.Confirmed) != 4 || len(roster.Waitlist) != 2 { + t.Fatalf("unexpected roster %+v", roster) + } + if roster.Waitlist[0].CustomerEmail != "first-waiter@example.com" || roster.Waitlist[0].WaitlistPosition != 1 { + t.Fatalf("expected first waiter at position 1, got %+v", roster.Waitlist[0]) + } + if roster.Waitlist[1].WaitlistPosition != 2 { + t.Fatalf("expected second waiter at position 2, got %+v", roster.Waitlist[1]) + } + if roster.TemplateTitle != "Small group mobility class" || roster.LocationName != "Main Studio" { + t.Fatalf("unexpected roster header %+v", roster) + } + + sessions, _ := svc.ListClassSessions(ctx, owner) + if sessions[0].WaitlistCount != 2 { + t.Fatalf("expected waitlistCount 2, got %d", sessions[0].WaitlistCount) + } + + stranger := domain.Principal{Subject: "someone-else", Role: "staff"} + if _, err := svc.ListSessionRoster(ctx, stranger, demoSessionID); !errors.Is(err, ErrTenantMembership) { + t.Fatalf("expected ErrTenantMembership, got %v", err) + } + if _, err := svc.ListSessionRoster(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrClassSessionNotFound) { + t.Fatalf("expected ErrClassSessionNotFound, got %v", err) + } +} + +func TestPromoteBooking(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + for i := 0; i < 4; i++ { + bookClass(t, repo, fmt.Sprintf("seat%d@example.com", i)) + } + first := bookClass(t, repo, "first-waiter@example.com") + second := bookClass(t, repo, "second-waiter@example.com") + + // Full session: strict promote is rejected with ErrSessionFull. + if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrSessionFull) { + t.Fatalf("expected ErrSessionFull, got %v", err) + } + + // The explicit override confirms anyway — deliberate out-of-order + // overbooking, skipping the first waiter. + if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{AllowOverCapacity: true}); err != nil { + t.Fatalf("over-capacity promote: %v", err) + } + booking, _ := repo.GetBookingByID(ctx, second.BookingID) + if booking.Status != "confirmed" { + t.Fatalf("expected confirmed after override, got %q", booking.Status) + } + + // Confirmed bookings, unknown IDs, and foreign principals are rejected. + if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrNotWaitlisted) { + t.Fatalf("expected ErrNotWaitlisted for confirmed booking, got %v", err) + } + if err := svc.PromoteBooking(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.PromoteBookingRequest{}); !errors.Is(err, ErrBookingNotFound) { + t.Fatalf("expected ErrBookingNotFound, got %v", err) + } + stranger := domain.Principal{Subject: "someone-else", Role: "staff"} + if err := svc.PromoteBooking(ctx, stranger, first.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, ErrTenantMembership) { + t.Fatalf("expected ErrTenantMembership, got %v", err) + } +} + +func TestPromoteBookingWithFreeSeat(t *testing.T) { + repo := db.NewMemoryRepository() + svc := newCatalogService(repo) + ctx := context.Background() + + // A waitlisted booking on a session with free capacity — an edge state the + // auto-promoter cannot reach, e.g. a queue entry left after manual edits. + session, err := repo.GetClassSessionByID(ctx, demoSessionID) + if err != nil { + t.Fatalf("get session: %v", err) + } + created, err := repo.CreateBooking(ctx, db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + ClassSessionID: &[]string{demoSessionID}[0], + BookingMode: "class", + CustomerName: "Queued Customer", + CustomerEmail: "queued@example.com", + StartsAt: session.StartsAt, + EndsAt: session.EndsAt, + Status: "waitlisted", + Reference: "BOK-QUEUED", + ManageToken: "queued-token", + }) + if err != nil { + t.Fatalf("seed waitlisted booking: %v", err) + } + + if err := svc.PromoteBooking(ctx, owner, created.ID, domain.PromoteBookingRequest{}); err != nil { + t.Fatalf("promote with free seat: %v", err) + } + booking, _ := repo.GetBookingByID(ctx, created.ID) + if booking.Status != "confirmed" { + t.Fatalf("expected confirmed, got %q", booking.Status) + } +} + func TestUpdateClassTemplate(t *testing.T) { repo := db.NewMemoryRepository() svc := newCatalogService(repo) diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 88549a0..73061f4 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -50,6 +50,50 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID return records, rows.Err() } +// ListBookingsBySession returns every booking on a class session in +// creation order — the ordering that defines the waitlist queue. +func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id, + customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference, + COALESCE(manage_token, ''), COALESCE(notes, ''), created_at + FROM bookings + WHERE class_session_id = $1 + ORDER BY created_at ASC + `, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []BookingRecord + for rows.Next() { + var record BookingRecord + if err := rows.Scan( + &record.ID, + &record.TenantID, + &record.ServiceID, + &record.ClassSessionID, + &record.StaffID, + &record.LocationID, + &record.CustomerName, + &record.CustomerEmail, + &record.CustomerPhone, + &record.StartsAt, + &record.EndsAt, + &record.Status, + &record.Reference, + &record.ManageToken, + &record.Notes, + &record.CreatedAt, + ); err != nil { + return nil, err + } + records = append(records, record) + } + return records, rows.Err() +} + func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) { var created CreatedBooking err := r.pool.QueryRow(ctx, ` diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index d14e089..3ad7d99 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -35,6 +35,7 @@ type Repository interface { UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error) DeleteClassTemplate(ctx context.Context, templateID string) error ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error) + ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error @@ -1356,6 +1357,17 @@ func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenant return out, nil } +func (r *MemoryRepository) ListBookingsBySession(_ context.Context, sessionID string) ([]BookingRecord, error) { + var out []BookingRecord + for _, booking := range r.bookings { + if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID { + out = append(out, booking) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) + return out, nil +} + func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) { created := CreatedBooking{ ID: uuid.NewString(), diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 91a9b5d..87aeff4 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -18,18 +18,20 @@ type DashboardKPI struct { } type UpcomingBooking struct { - ID string `json:"id"` - Reference string `json:"reference"` - CustomerName string `json:"customerName"` - CustomerEmail string `json:"customerEmail"` - Service string `json:"service,omitempty"` - Location string `json:"location,omitempty"` - StaffName string `json:"staffName,omitempty"` - Notes string `json:"notes,omitempty"` - StartsAt time.Time `json:"startsAt"` - EndsAt time.Time `json:"endsAt"` - Status string `json:"status"` - Label string `json:"label,omitempty"` + ID string `json:"id"` + Reference string `json:"reference"` + CustomerName string `json:"customerName"` + CustomerEmail string `json:"customerEmail"` + Service string `json:"service,omitempty"` + Location string `json:"location,omitempty"` + StaffName string `json:"staffName,omitempty"` + Notes string `json:"notes,omitempty"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Status string `json:"status"` + Label string `json:"label,omitempty"` + ClassSessionID *string `json:"classSessionId,omitempty"` + WaitlistPosition int `json:"waitlistPosition,omitempty"` } type WidgetSnippet struct { @@ -401,6 +403,41 @@ type ClassSession struct { EndsAt time.Time `json:"endsAt"` Capacity int `json:"capacity"` BookedCount int `json:"bookedCount"` + // WaitlistCount is the number of customers queued for a free seat. + WaitlistCount int `json:"waitlistCount"` +} + +// SessionRosterEntry is one booking on a class session roster. WaitlistPosition +// is the 1-based queue spot for waitlisted entries; zero for confirmed ones. +type SessionRosterEntry struct { + BookingID string `json:"bookingId"` + Reference string `json:"reference"` + CustomerName string `json:"customerName"` + CustomerEmail string `json:"customerEmail"` + CustomerPhone string `json:"customerPhone,omitempty"` + Status string `json:"status"` + WaitlistPosition int `json:"waitlistPosition,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// SessionRoster is the dashboard attendee view of a class session: confirmed +// seats plus the waitlist queue in FIFO order. +type SessionRoster struct { + SessionID string `json:"sessionId"` + TemplateTitle string `json:"templateTitle"` + LocationName string `json:"locationName,omitempty"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + Capacity int `json:"capacity"` + Confirmed []SessionRosterEntry `json:"confirmed"` + Waitlist []SessionRosterEntry `json:"waitlist"` +} + +// PromoteBookingRequest controls manual waitlist promotion. AllowOverCapacity +// lets a business deliberately overbook a session; without it the request fails +// when no free seat remains. +type PromoteBookingRequest struct { + AllowOverCapacity bool `json:"allowOverCapacity,omitempty"` } type CreateClassSessionRequest struct { diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 462f4ba..4e0ad4d 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -820,6 +820,34 @@ paths: description: Booking already cancelled "404": description: Booking not found + /v1/catalog/bookings/{bookingID}/promote: + post: + tags: [Catalog] + operationId: promoteDashboardBooking + security: + - bearerAuth: [] + parameters: + - in: path + name: bookingID + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/PromoteBookingRequest" + responses: + "200": + description: Waitlisted booking confirmed; customer notified + "400": + description: Booking is not on a waitlist + "404": + description: Booking not found + "409": + description: Session at capacity; retry with allowOverCapacity to override /v1/catalog/blocked-days: get: tags: [Catalog] @@ -1196,6 +1224,28 @@ paths: description: Class session deleted "404": description: Class session not found + /v1/catalog/class-sessions/{sessionID}/roster: + get: + tags: [Catalog] + operationId: getSessionRoster + security: + - bearerAuth: [] + parameters: + - in: path + name: sessionID + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Confirmed attendees and FIFO waitlist for the session + content: + application/json: + schema: + $ref: "#/components/schemas/SessionRoster" + "404": + description: Class session not found # ---------------- Billing ---------------- /v1/billing/subscription: get: @@ -1714,6 +1764,12 @@ components: type: string notes: type: string + classSessionId: + type: string + format: uuid + waitlistPosition: + type: integer + description: 1-based queue spot for waitlisted class bookings; absent otherwise DashboardBookingRequest: type: object required: [customerName, customerEmail, startsAt] @@ -2126,7 +2182,7 @@ components: type: integer ClassSession: type: object - required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount] + required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount, waitlistCount] properties: id: type: string @@ -2154,6 +2210,63 @@ components: type: integer bookedCount: type: integer + waitlistCount: + type: integer + SessionRosterEntry: + type: object + required: [bookingId, reference, customerName, customerEmail, status, createdAt] + properties: + bookingId: + type: string + format: uuid + reference: + type: string + customerName: + type: string + customerEmail: + type: string + customerPhone: + type: string + status: + type: string + waitlistPosition: + type: integer + createdAt: + type: string + format: date-time + SessionRoster: + type: object + required: [sessionId, templateTitle, startsAt, endsAt, capacity, confirmed, waitlist] + properties: + sessionId: + type: string + format: uuid + templateTitle: + type: string + locationName: + type: string + startsAt: + type: string + format: date-time + endsAt: + type: string + format: date-time + capacity: + type: integer + confirmed: + type: array + items: + $ref: "#/components/schemas/SessionRosterEntry" + waitlist: + type: array + items: + $ref: "#/components/schemas/SessionRosterEntry" + PromoteBookingRequest: + type: object + properties: + allowOverCapacity: + type: boolean + description: Confirm even when the session is full (deliberate overbooking) CreateClassSessionRequest: type: object required: [templateId, startsAt] diff --git a/apps/frontend/src/components/dashboard/icons.tsx b/apps/frontend/src/components/dashboard/icons.tsx index d05fd56..89eb2f5 100644 --- a/apps/frontend/src/components/dashboard/icons.tsx +++ b/apps/frontend/src/components/dashboard/icons.tsx @@ -96,6 +96,12 @@ export const ChevronRightIcon = () => ( ); +export const ChevronDownIcon = () => ( + + + +); + export const SparklesIcon = () => ( diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 314b64d..7431a4b 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -424,6 +424,23 @@ const dictionaries = { "dashboard.class.noTemplates": "Zatím žádné šablony lekcí.", "dashboard.class.noSessions": "Zatím žádné naplánované lekce.", "dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.", + "dashboard.class.roster": "Prezenční listina", + "dashboard.class.attendees": "Potvrzení účastníci", + "dashboard.class.waitlist": "Čekací listina", + "dashboard.class.waiting": "čeká", + "dashboard.class.noAttendees": "Zatím žádní účastníci.", + "dashboard.class.noWaitlist": "Čekací listina je prázdná.", + "dashboard.class.promote": "Potvrdit místo", + "dashboard.class.promoteFull": "Lekce je plná — přesto potvrdit?", + "dashboard.class.promoteConfirm": "Přesto potvrdit", + "dashboard.class.promoted": "Rezervace byla potvrzena.", + "dashboard.class.promoteFailed": "Potvrzení rezervace selhalo.", + "dashboard.class.queuePosition": "v pořadí", + "dashboard.class.showRoster": "Účastníci", + "dashboard.class.hideRoster": "Skrýt", + "dashboard.waitlistPosition": "Pořadí v čekací listině", + "dashboard.booking.promote": "Potvrdit z čekací listiny", + "dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?", "dashboard.services": "Služby", "dashboard.services.subtitle": "služeb v nabídce", "dashboard.service.add": "Přidat službu", @@ -1002,6 +1019,23 @@ const dictionaries = { "dashboard.class.noTemplates": "No class templates yet.", "dashboard.class.noSessions": "No scheduled sessions yet.", "dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.", + "dashboard.class.roster": "Session roster", + "dashboard.class.attendees": "Confirmed attendees", + "dashboard.class.waitlist": "Waitlist", + "dashboard.class.waiting": "waiting", + "dashboard.class.noAttendees": "No attendees yet.", + "dashboard.class.noWaitlist": "The waitlist is empty.", + "dashboard.class.promote": "Confirm seat", + "dashboard.class.promoteFull": "Session is full — confirm anyway?", + "dashboard.class.promoteConfirm": "Confirm anyway", + "dashboard.class.promoted": "Booking confirmed.", + "dashboard.class.promoteFailed": "Failed to confirm the booking.", + "dashboard.class.queuePosition": "in queue", + "dashboard.class.showRoster": "Roster", + "dashboard.class.hideRoster": "Hide", + "dashboard.waitlistPosition": "Waitlist position", + "dashboard.booking.promote": "Promote from waitlist", + "dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?", "dashboard.services": "Services", "dashboard.services.subtitle": "services offered", "dashboard.service.add": "Add service", diff --git a/apps/frontend/src/routes/dashboard/bookings-page.tsx b/apps/frontend/src/routes/dashboard/bookings-page.tsx index a59133d..b2478c9 100644 --- a/apps/frontend/src/routes/dashboard/bookings-page.tsx +++ b/apps/frontend/src/routes/dashboard/bookings-page.tsx @@ -226,6 +226,7 @@ function BookingsPage() { : "bg-canvas-subtle text-ink-subtle" }`}> {statusLabels[booking.status]} + {booking.status === "waitlisted" && booking.waitlistPosition ? ` #${booking.waitlistPosition}` : ""}
+

{data.i18n.t("dashboard.waitlistPosition")}

+

#{data.selectedBooking()?.waitlistPosition}

+
+

{data.i18n.t("dashboard.bookingModal.notes")}

@@ -283,7 +331,24 @@ function DashboardShell(props: { children: JSX.Element }) {
-
+
+ + + + +
+
+ +
+
+ …
+ }> +
+
+

+ {data.i18n.t("dashboard.class.attendees")} ({(roster()?.confirmed ?? []).length}/{roster()?.capacity ?? session.capacity}) +

+ 0} fallback={ +

{data.i18n.t("dashboard.class.noAttendees")}

+ }> +
+ + {(entry: any) => ( +
+
+ {getInitials(entry.customerName)} +
+
+

{entry.customerName}

+

{entry.customerEmail} • {entry.reference}

+
+
+ )} +
+
+
+
+
+

+ {data.i18n.t("dashboard.class.waitlist")} ({(roster()?.waitlist ?? []).length}) +

+ 0} fallback={ +

{data.i18n.t("dashboard.class.noWaitlist")}

+ }> +
+ + {(entry: any) => ( +
+
+ #{entry.waitlistPosition} +
+
+

{entry.customerName}

+

{entry.customerEmail} • {entry.reference}

+
+ void handlePromote(entry.bookingId, true)} + disabled={promoteBusy() === entry.bookingId} + title={data.i18n.t("dashboard.class.promoteFull")} + class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-warning text-canvas hover:opacity-90 transition-opacity disabled:opacity-60" + > + {promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promoteConfirm")} + + } + > + + +
+ )} +
+
+
+
+
+ +
+ +
- - - )} + ); + }}
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 0f6b11b..7645620 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -303,6 +303,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/bookings/{bookingID}/promote": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["promoteDashboardBooking"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/class-sessions": { parameters: { query?: never; @@ -335,6 +351,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/catalog/class-sessions/{sessionID}/roster": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSessionRoster"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/catalog/class-templates": { parameters: { query?: never; @@ -1024,6 +1056,7 @@ export interface components { templateTitle: string; /** Format: uuid */ tenantId: string; + waitlistCount: number; }; ClassTemplate: { capacity: number; @@ -1196,6 +1229,10 @@ export interface components { /** Format: uri */ url: string; }; + PromoteBookingRequest: { + /** @description Confirm even when the session is full (deliberate overbooking) */ + allowOverCapacity?: boolean; + }; PublicAvailabilityResponse: { /** @enum {string} */ locale: "cs" | "en"; @@ -1244,6 +1281,31 @@ export interface components { /** Format: uuid */ tenantId: string; }; + SessionRoster: { + capacity: number; + confirmed: components["schemas"]["SessionRosterEntry"][]; + /** Format: date-time */ + endsAt: string; + locationName?: string; + /** Format: uuid */ + sessionId: string; + /** Format: date-time */ + startsAt: string; + templateTitle: string; + waitlist: components["schemas"]["SessionRosterEntry"][]; + }; + SessionRosterEntry: { + /** Format: uuid */ + bookingId: string; + /** Format: date-time */ + createdAt: string; + customerEmail: string; + customerName: string; + customerPhone?: string; + reference: string; + status: string; + waitlistPosition?: number; + }; StaffMember: { active: boolean; /** Format: date-time */ @@ -1334,6 +1396,8 @@ export interface components { siteId?: string; }; UpcomingBooking: { + /** Format: uuid */ + classSessionId?: string; customerEmail?: string; customerName?: string; /** Format: date-time */ @@ -1348,6 +1412,8 @@ export interface components { /** Format: date-time */ startsAt?: string; status?: string; + /** @description 1-based queue spot for waitlisted class bookings; absent otherwise */ + waitlistPosition?: number; }; UpdateBrandRequest: { logoUrl?: string; @@ -2084,6 +2150,51 @@ export interface operations { }; }; }; + promoteDashboardBooking: { + parameters: { + query?: never; + header?: never; + path: { + bookingID: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PromoteBookingRequest"]; + }; + }; + responses: { + /** @description Waitlisted booking confirmed; customer notified */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Booking is not on a waitlist */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Booking not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Session at capacity; retry with allowOverCapacity to override */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listClassSessions: { parameters: { query?: never; @@ -2169,6 +2280,35 @@ export interface operations { }; }; }; + getSessionRoster: { + parameters: { + query?: never; + header?: never; + path: { + sessionID: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Confirmed attendees and FIFO waitlist for the session */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionRoster"]; + }; + }; + /** @description Class session not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; listClassTemplates: { parameters: { query?: never; diff --git a/project.md b/project.md index b7a5e84..f253a84 100644 --- a/project.md +++ b/project.md @@ -81,7 +81,8 @@ - shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion - bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically - `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface - - remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed + - waitlist position display landed in Milestone 16; manual promotion controls landed in Milestone 20 + - remaining: live staging validation - `[done/partial]` Milestone 12: Real dashboard business metrics - `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories - `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points) @@ -111,7 +112,8 @@ - `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1 - `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN - fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do) - - remaining: live staging validation; manual waitlist promotion controls are still not exposed + - manual promotion controls landed in Milestone 20 + - remaining: live staging validation - `[done/partial]` Milestone 17: Class template editing - `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client - classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance @@ -127,6 +129,15 @@ - validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean - local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it - remaining: live staging validation on Neon +- `[done/partial]` Milestone 20: Class roster + manual waitlist promotion + - `GET /v1/catalog/class-sessions/{id}/roster` returns session metadata plus `confirmed` and `waitlist` entry lists (booking id, reference, customer name/email, status, `waitlistPosition`, `createdAt`); `ClassSession` now carries `waitlistCount` so session rows can badge queue length without an extra request + - `POST /v1/catalog/bookings/{id}/promote` manually confirms a waitlisted booking: `409` when the session is full unless the caller passes `allowOverCapacity`, `400` for non-waitlisted bookings, `404` for missing or foreign bookings; promotion reuses the shared confirm path so waitlist ledger rows resolve and reminders re-arm automatically + - `ListBookingsBySession` repository method (pgx + memory) backs the roster and simplifies `PromoteWaitlist`; `UpcomingBooking` carries `classSessionId` + `waitlistPosition`, computed per session in the dashboard summary + - classes tab session rows expand into a two-column roster panel (attendees vs waitlist with `#N` position badges) with per-entry promote buttons; a full session arms an explicit "confirm anyway" over-capacity step instead of silently overriding + - the booking detail modal shows the waitlist position and offers the same promote action for waitlisted bookings; the bookings list badges waitlisted rows with `#N` + - demo mode ships a seeded session with a populated roster; mutation attempts surface the demo notice instead of failing + - verified live in demo mode: seeded a full session via the public booking API, roster listed 4 confirmed + 1 waitlisted, promote rejected with 409, `allowOverCapacity` promoted and drained the queue; browser-checked CS/EN rendering with a clean console + - remaining: live staging validation on Neon + pgx repository path ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. @@ -361,7 +372,7 @@ Key entities: ## Current Gaps - Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment. - Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants. -- Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, and customers; class/session template management and per-staff availability rules still have no management UI. +- Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, customers, and class templates/sessions with rosters; per-staff availability rules still have no management UI. - Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures. - Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops. - The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup. @@ -371,7 +382,19 @@ Key entities: - sync Neon Auth identities into tenant membership records and onboarding bootstrap - validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure - replace noop notification providers with the first production email transport and a gated SMS transport -- add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding +- add management UI for per-staff availability rules so staff schedules are editable after onboarding + +## Future Phases +Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle: + +- **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability. +- **Customer self-service rebooking**: let customers move to a different free slot from the manage page instead of cancel-and-rebook; the reschedule path already validates targets, so this is mostly UI plus a picker. +- **Calendar integrations**: ICS feed per tenant/staff and optional Google Calendar sync; high demand for salons, moderate complexity. Start with a read-only `.ics` subscription URL — zero third-party auth needed. +- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios. +- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light. +- **Persistent notification center**: the dropdown currently derives notifications from summary data; a `notifications` table would enable read/unread state and delivery history in-app. +- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer. +- **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses. ## Product Principles - calm, premium, structured UX