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)
position := 0
if booking.Status == "waitlisted" && booking.ClassSessionID != nil {
position, _ = s.repo.GetWaitlistPosition(ctx, *booking.ClassSessionID, booking.ID)
}
return domain.CustomerBookingView{
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: locationName,
Status: booking.Status,
Notes: booking.Notes,
Reference: booking.Reference,
CustomerName: booking.CustomerName,
CustomerEmail: booking.CustomerEmail,
Service: serviceName,
BusinessName: tenant.Name,
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Location: locationName,
Status: booking.Status,
Notes: booking.Notes,
WaitlistPosition: position,
}, nil
}
@@ -703,3 +703,66 @@ func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) {
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
}
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 {
_, err := r.pool.Exec(ctx, `
UPDATE reminder_jobs
+22
View File
@@ -107,6 +107,7 @@ type Repository interface {
// Customer Booking Management
GetBookingByReference(ctx context.Context, reference 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
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
}
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 {
for i, b := range r.bookings {
if b.ID == bookingID {
+3
View File
@@ -458,6 +458,9 @@ type CustomerBookingView struct {
Location string `json:"location"`
Status string `json:"status"`
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 {
@@ -195,18 +195,48 @@ export function BookingManageRoute() {
<CardDescription class="mt-1">{b().businessName}</CardDescription>
</div>
<span class={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${
b().status === 'confirmed'
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
: 'bg-[hsl(var(--warning-soft))] text-[hsl(var(--warning))]'
b().status === 'confirmed'
? 'bg-[hsl(var(--success-soft))] text-[hsl(var(--success))]'
: 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')
: b().status === 'waitlisted'
? (i18n.locale() === 'cs' ? 'Čekací listina' : 'Waitlisted')
: b().status === 'cancelled'
? (i18n.locale() === 'cs' ? 'Zrušeno' : 'Cancelled')
: (i18n.locale() === 'cs' ? 'Čeká se' : 'Pending')
}
</span>
</div>
</CardHeader>
<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 */}
<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))]">