feat(waitlist): queue position on the customer manage page

A waitlisted customer saw only a bare status — worse, the badge fell
back to "Pending" for both waitlisted and cancelled states. The view
now carries a 1-based waitlistPosition (earlier-created waitlisted
bookings on the session + 1) and the page renders a warning-tone
notice with the queue spot and auto-promotion explanation, in CS/EN.
Also fixed the badge's nonexistent --danger-soft/--warning-soft tokens.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 13:07:06 +02:00
1 parent 7509e82261
commit 3892e9ce8c
7 files changed
+155 -14

No files matched your search

@@ -108,17 +108,23 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
serviceName, locationName := s.descriptors(ctx, booking) serviceName, locationName := s.descriptors(ctx, booking)
position := 0
if booking.Status == "waitlisted" && booking.ClassSessionID != nil {
position, _ = s.repo.GetWaitlistPosition(ctx, *booking.ClassSessionID, booking.ID)
}
return domain.CustomerBookingView{ return domain.CustomerBookingView{
Reference: booking.Reference, Reference: booking.Reference,
CustomerName: booking.CustomerName, CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail, CustomerEmail: booking.CustomerEmail,
Service: serviceName, Service: serviceName,
BusinessName: tenant.Name, BusinessName: tenant.Name,
StartsAt: booking.StartsAt, StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt, EndsAt: booking.EndsAt,
Location: locationName, Location: locationName,
Status: booking.Status, Status: booking.Status,
Notes: booking.Notes, Notes: booking.Notes,
WaitlistPosition: position,
}, nil }, nil
} }
@@ -703,3 +703,66 @@ func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) {
t.Fatalf("expected email+sms channels, got %v", channels) t.Fatalf("expected email+sms channels, got %v", channels)
} }
} }
func TestWaitlistPositionReflectsQueueOrder(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
if err != nil {
t.Fatalf("get session: %v", err)
}
sessionID := session.ID
book := func(email string) domain.CreateBookingResponse {
resp, err := service.Create(ctx, domain.CreateBookingRequest{
TenantSlug: "studio-atelier",
BookingMode: "class",
ClassSessionID: &sessionID,
CustomerName: "Queue Test",
CustomerEmail: email,
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
})
if err != nil {
t.Fatalf("book %s: %v", email, err)
}
return resp
}
var seats []domain.CreateBookingResponse
for i := 0; i < 4; i++ {
seats = append(seats, book(fmt.Sprintf("qseat%[email protected]", i)))
}
first := book("[email protected]")
second := book("[email protected]")
positionOf := func(resp domain.CreateBookingResponse) int {
manageURL, _ := url.Parse(resp.ManageURL)
view, err := customerService.GetBookingByReference(ctx, resp.Reference, manageURL.Query().Get("token"))
if err != nil {
t.Fatalf("get booking view: %v", err)
}
return view.WaitlistPosition
}
if got := positionOf(first); got != 1 {
t.Fatalf("expected first waiter at position 1, got %d", got)
}
if got := positionOf(second); got != 2 {
t.Fatalf("expected second waiter at position 2, got %d", got)
}
// Free a seat: first waiter is promoted, second moves to the front.
manageURL, _ := url.Parse(seats[0].ManageURL)
if err := customerService.CancelBooking(ctx, seats[0].Reference, manageURL.Query().Get("token")); err != nil {
t.Fatalf("cancel: %v", err)
}
if got := positionOf(second); got != 1 {
t.Fatalf("expected second waiter at position 1 after promotion, got %d", got)
}
if got := positionOf(first); got != 0 {
t.Fatalf("expected promoted booking to report no position, got %d", got)
}
}
+12
View File
@@ -204,6 +204,18 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
return metrics, err return metrics, err
} }
func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) {
var position int
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) + 1
FROM bookings
WHERE class_session_id = $1
AND status = 'waitlisted'
AND created_at < (SELECT created_at FROM bookings WHERE id = $2)
`, sessionID, bookingID).Scan(&position)
return position, err
}
func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error { func (r *PGRepository) CancelPendingReminderJobsForBooking(ctx context.Context, bookingID string) error {
_, err := r.pool.Exec(ctx, ` _, err := r.pool.Exec(ctx, `
UPDATE reminder_jobs UPDATE reminder_jobs
+22
View File
@@ -107,6 +107,7 @@ type Repository interface {
// Customer Booking Management // Customer Booking Management
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error) GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error)
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
@@ -1844,6 +1845,27 @@ func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (
return BookingRecord{}, pgx.ErrNoRows return BookingRecord{}, pgx.ErrNoRows
} }
func (r *MemoryRepository) GetWaitlistPosition(_ context.Context, sessionID, bookingID string) (int, error) {
var self *BookingRecord
for index := range r.bookings {
if r.bookings[index].ID == bookingID {
self = &r.bookings[index]
break
}
}
if self == nil {
return 0, pgx.ErrNoRows
}
position := 1
for _, booking := range r.bookings {
if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID &&
booking.Status == "waitlisted" && booking.CreatedAt.Before(self.CreatedAt) {
position++
}
}
return position, nil
}
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error { func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
for i, b := range r.bookings { for i, b := range r.bookings {
if b.ID == bookingID { if b.ID == bookingID {
+3
View File
@@ -458,6 +458,9 @@ type CustomerBookingView struct {
Location string `json:"location"` Location string `json:"location"`
Status string `json:"status"` Status string `json:"status"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
// WaitlistPosition is the 1-based queue spot for waitlisted class
// bookings; zero for any other status.
WaitlistPosition int `json:"waitlistPosition,omitempty"`
} }
type RescheduleBookingRequest struct { type RescheduleBookingRequest struct {
@@ -195,18 +195,48 @@ export function BookingManageRoute() {
<CardDescription class="mt-1">{b().businessName}</CardDescription> <CardDescription class="mt-1">{b().businessName}</CardDescription>
</div> </div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${ <span class={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
b().status === 'confirmed' b().status === 'confirmed'
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]' ? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
: 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]' : b().status === 'cancelled'
? 'bg-[hsl(var(--error-subtle))] text-[hsl(var(--error))]'
: 'bg-[hsl(var(--warning-subtle))] text-[hsl(var(--warning))]'
}`}> }`}>
{b().status === 'confirmed' {b().status === 'confirmed'
? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed') ? (i18n.locale() === 'cs' ? 'Potvrzeno' : 'Confirmed')
: b().status === 'waitlisted'
? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlisted')
: b().status === 'cancelled'
? (i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled')
: (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending') : (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending')
} }
</span> </span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-6">
{/* Waitlist position */}
<Show when={b().status === 'waitlisted'}>
<div class="flex items-center gap-3 p-4 bg-[hsl(var(--warning-subtle))] rounded-xl border border-[hsl(var(--warning))]/20">
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--warning))]/10 flex items-center justify-center text-[hsl(var(--warning))]">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<div>
<p class="font-medium text-ink">
{b().waitlistPosition > 0
? (i18n.locale() === 'cs' ? `Jste ${b().waitlistPosition}. v pořadí` : `You are #${b().waitlistPosition} in the queue`)
: (i18n.locale() === 'cs' ? 'Jste na čekací listině' : 'You are on the waitlist')}
</p>
<p class="text-sm text-ink-muted">
{i18n.locale() === 'cs'
? 'Po uvolnění místa vám rezervaci automaticky potvrdíme e-mailem.'
: 'When a seat frees up we will confirm your booking by email automatically.'}
</p>
</div>
</div>
</Show>
{/* Reference */} {/* Reference */}
<div class="flex items-center gap-3 p-3 bg-canvas-subtle rounded-xl"> <div class="flex items-center gap-3 p-3 bg-canvas-subtle rounded-xl">
<div class="w-10 h-10 rounded-lg bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]"> <div class="w-10 h-10 rounded-lg bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
+5
View File
@@ -107,6 +107,11 @@
- `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason - `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason
- reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels - reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels
- remaining: live staging validation against a real SMS Manager key - remaining: live staging validation against a real SMS Manager key
- `[done/partial]` Milestone 16: Waitlist position display
- `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
## Overview ## 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. 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.