plans
2026-05-11
2026 05 11 Realtime Slot Locking

Real-Time Slot Locking Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

Goal: Real-time "being processed" indicator on show listings + 5-minute slot lock with fast expiry

Architecture:

  • User clicks "Book Now" → PENDING reservation created → slot locked for 5 min
  • All show listings subscribe to availability → see real-time "X being processed" count
  • Cron runs every 30 seconds to release expired holds

Tech Stack: Convex queries + subscriptions, cron jobs, existing reservation schema


File Map

packages/backend/convex/
├── domains/
│   ├── reservations.ts      # Change RESERVATION_EXPIRY_MINUTES to 5
│   └── events.ts            # Add getEventAvailabilityWithPending query
├── crons.ts                  # Change interval to 30 seconds
└── functions/
    └── scheduled.ts          # expireStaleReservations (from previous plan)

apps/frontend/
├── components/booking/
│   └── step-tickets.tsx      # Already wired (from previous fix)
├── components/home/
│   └── experience-schedule-preview.tsx  # Real-time availability display
└── hooks/
    └── booking/
        └── use-ticket-selection.ts   # Local state (no changes needed)

Task 1: Change Expiry to 5 Minutes

Files:

  • Modify: packages/backend/convex/domains/reservations.ts:214

  • Step 1: Find and change RESERVATION_EXPIRY_MINUTES

// Line ~214: Change from 10 to 5
const RESERVATION_EXPIRY_MINUTES = 5;
  • Step 2: Verify change

Run: grep -n "RESERVATION_EXPIRY_MINUTES = " packages/backend/convex/domains/reservations.ts Expected: RESERVATION_EXPIRY_MINUTES = 5


Task 2: Change Cron Interval to 30 Seconds

Files:

  • Modify: packages/backend/convex/crons.ts:11-20

  • Step 1: Read crons.ts

Run: cat packages/backend/convex/crons.ts

  • Step 2: Change interval from 1 minute to 30 seconds
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
 
const crons = cronJobs();
 
// D-1 low occupancy check: daily at 09:00 Vietnam time = 02:00 UTC
crons.daily(
  "d1LowOccupancyCheck",
  { hourUTC: 2, minuteUTC: 0 },
  internal.functions.scheduled.d1LowOccupancyCheck,
);
 
// Payment expiry: every 30 seconds to release stale reservations
crons.interval(
  "expireStaleReservations",
  { seconds: 30 },
  internal.functions.scheduled.expireStaleReservations,
);
 
export default crons;
  • Step 3: Verify

Run: grep -A3 "expireStaleReservations" packages/backend/convex/crons.ts Expected: seconds: 30


Task 3: Add expireStaleReservations Mutation (from Previous Plan)

Files:

  • Modify: packages/backend/convex/functions/scheduled.ts (append after existing content)

  • Step 1: Read current line count

Run: wc -l packages/backend/convex/functions/scheduled.ts

  • Step 2: Append expireStaleReservations mutation
/**
 * expireStaleReservations — release seats for expired pending reservations
 *
 * Runs every 30 seconds via Convex cron. Finds all PENDING reservations where
 * bookingExpiresAt < now and:
 * - Releases seats back to event capacity (decrements bookedCount)
 * - Marks reservation as FAILED
 * - Clears payment-related fields
 *
 * Idempotent: only processes PENDING reservations, skips PAID/REFUNDED
 */
export const expireStaleReservations = internalMutation({
  args: v.object({}),
  handler: async (ctx) => {
    const now = Date.now();
    const expired = await ctx.db
      .query("reservations")
      .withIndex("by_expires", (q) => q.lt("bookingExpiresAt", now))
      .collect();
 
    let released = 0;
    for (const reservation of expired) {
      // Only process PENDING reservations
      if (reservation.paymentStatus !== "PENDING") continue;
 
      // Release seats back to event (decrement bookedCount)
      const event = await ctx.db.get(reservation.eventId);
      if (event) {
        await ctx.db.patch(event._id, {
          bookedCount: Math.max(0, event.bookedCount - reservation.quantity),
          updatedAt: now,
        });
      }
 
      // Mark reservation as failed and clear payment fields
      await ctx.db.patch(reservation._id, {
        paymentStatus: "FAILED",
        bookingExpiresAt: undefined,
        paymentExpiresAt: undefined,
        onePayOrderId: undefined,
        vaNumber: undefined,
        qrCode: undefined,
        qrCodeUrl: undefined,
        updatedAt: now,
      });
 
      released++;
    }
 
    return { released, checked: expired.length };
  },
});
  • Step 3: Verify

Run: grep -n "expireStaleReservations" packages/backend/convex/functions/scheduled.ts Expected: Line number where function was added


Task 4: Add getEventAvailabilityWithPending Query

Files:

  • Modify: packages/backend/convex/domains/events.ts (append after getAvailability)

  • Step 1: Read end of events.ts to find insertion point

Run: tail -20 packages/backend/convex/domains/events.ts

  • Step 2: Append new query after getAvailability
/**
 * getEventAvailabilityWithPending — real-time availability with pending breakdown
 *
 * Returns availability info including separate counts for paid vs pending (in-checkout)
 * reservations. This enables the "X being processed" display on show listings.
 *
 * Usage: Frontend subscribes to this query for real-time updates via Convex subscriptions.
 */
export const getEventAvailabilityWithPending = zQuery({
  args: { eventId: zid("experienceEvents") },
  returns: z
    .object({
      eventId: zid("experienceEvents"),
      code: z.string(),
      experienceTitle: z.string(),
      date: z.string(),
      time: z.string(),
      totalCapacity: z.number(),
      paidCount: z.number(), // Confirmed paid reservations
      pendingCount: z.number(), // In-checkout (PENDING status)
      available: z.number(), // total - paid - pending
      status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]),
      experienceOnlyEnabled: z.boolean(),
      dinnerPrice: z.number(),
      experienceOnlyPrice: z.number(),
      badge: z.enum(["AVAILABLE", "FEW_LEFT", "ALMOST_FULL", "SOLD_OUT"]),
    })
    .nullable(),
  handler: async (ctx, { eventId }) => {
    const evt = await ctx.db.get(eventId);
    if (!evt) return null;
 
    const experience = await ctx.db.get(evt.experienceId);
 
    // Get all non-failed reservations for this event
    const allReservations = await ctx.db
      .query("reservations")
      .withIndex("by_event", (q) => q.eq("eventId", eventId))
      .collect();
 
    // Separate paid vs pending
    const paidReservations = allReservations.filter(
      (r) => r.paymentStatus === "PAID",
    );
    const pendingReservations = allReservations.filter(
      (r) => r.paymentStatus === "PENDING",
    );
 
    const paidCount = paidReservations.reduce((sum, r) => sum + r.quantity, 0);
    const pendingCount = pendingReservations.reduce(
      (sum, r) => sum + r.quantity,
      0,
    );
    const available = Math.max(
      0,
      evt.actualCapacity - paidCount - pendingCount,
    );
 
    // Badge logic with "being processed" urgency
    const hasPending = pendingCount > 0;
    let badge: "AVAILABLE" | "FEW_LEFT" | "ALMOST_FULL" | "SOLD_OUT";
 
    if (available === 0) {
      badge = "SOLD_OUT";
    } else if (available <= 5) {
      badge = hasPending ? "ALMOST_FULL" : "FEW_LEFT";
    } else if (available <= Math.ceil(evt.actualCapacity * 0.2)) {
      // Within 20% capacity remaining
      badge = hasPending ? "ALMOST_FULL" : "FEW_LEFT";
    } else {
      badge = "AVAILABLE";
    }
 
    return {
      eventId: evt._id,
      code: evt.code,
      experienceTitle: experience?.title ?? "Unknown Experience",
      date: evt.date,
      time: evt.time,
      totalCapacity: evt.actualCapacity,
      paidCount,
      pendingCount,
      available,
      status: evt.status,
      experienceOnlyEnabled: evt.experienceOnlyEnabled,
      dinnerPrice: evt.dinnerPrice,
      experienceOnlyPrice: evt.experienceOnlyPrice,
      badge,
    };
  },
});
  • Step 3: Verify

Run: grep -n "getEventAvailabilityWithPending" packages/backend/convex/domains/events.ts Expected: Line number where query was added

  • Step 4: Add index to schema if needed

Check if reservations table has by_event index:

Run: grep -A5 "by_event" packages/backend/convex/schema.ts Expected: Shows index definition. If missing, add:

reservations: defineTable({
  // ... existing fields
}).index("by_event", ["eventId"]),

Task 5: Update Frontend Display — "Being Processed"

Files:

  • Modify: apps/frontend/components/home/experience-schedule-preview.tsx

  • Step 1: Read the component to understand current availability display

Run: grep -n "remaining\|badge\|available" apps/frontend/components/home/experience-schedule-preview.tsx | head -20

  • Step 2: Update to use getEventAvailabilityWithPending and show pending count

Find where availability is displayed and update to show:

// In the availability display section, replace existing display with:
<div className="flex items-center gap-2 text-sm">
  <span>{paidCount} paid</span>
  {pendingCount > 0 && (
    <span className="text-orange-500 font-medium">
      • {pendingCount} being processed
    </span>
  )}
  <span>• {available} available</span>
</div>
  • Step 3: Update badge colors for ALMOST_FULL

Find badge styling and add orange color for "ALMOST_FULL":

// In badge styling:
"ALMOST_FULL": "bg-orange-500/10 text-orange-500 border-orange-500/30"
// This creates urgency: "almost full AND someone is paying right now"
  • Step 4: Verify TypeScript passes

Run: cd apps/frontend && npx tsc --noEmit 2>&1 | grep -E "(experience-schedule|events.ts)" | head -10


Task 6: Add Schema Index if Missing

Files:

  • Modify: packages/backend/convex/schema.ts

  • Step 1: Check if by_event index exists

Run: grep "by_event" packages/backend/convex/schema.ts

If not found, add index to reservations table:

reservations: defineTable({
  // ... existing fields ...
  eventId: v.id("experienceEvents"),
  // ...
}).index("by_event", ["eventId"]),

Task 7: Test the Full Flow

  • Step 1: Start Convex dev server

Run: cd packages/backend/convex && npx convex dev 2>&1 | head -30

  • Step 2: Open browser to show listing, open two tabs

In Tab A: Click "Book Now" on a show (creates PENDING reservation) In Tab B: Observe the show listing updates in real-time to show "X being processed"

  • Step 3: Wait 5 minutes, verify cron releases the hold

Run manually to test:

npx convex run internal.functions.scheduled.expireStaleReservations --json

Expected: Released count increments, seats return to available


Task 8: Commit

git add packages/backend/convex/domains/reservations.ts \
      packages/backend/convex/domains/events.ts \
      packages/backend/convex/crons.ts \
      packages/backend/convex/functions/scheduled.ts \
      packages/backend/convex/schema.ts \
      apps/frontend/components/home/experience-schedule-preview.tsx
 
git commit -m "feat(booking): real-time slot locking with 'being processed' indicator
 
- 5-minute slot lock on Book Now click (was 10 min)
- 30-second cron to release expired holds (was 1 min)
- New getEventAvailabilityWithPending query for real-time breakdown:
  - paidCount: confirmed reservations
  - pendingCount: in-checkout (being processed)
  - available: remaining bookable slots
- Frontend shows 'X being processed' badge with urgency
- ALMOST_FULL badge when pending > 0 and available is low
 
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"

Verification Checklist

  • RESERVATION_EXPIRY_MINUTES = 5 in reservations.ts
  • Cron interval is { seconds: 30 }
  • expireStaleReservations mutation exists in scheduled.ts
  • getEventAvailabilityWithPending query exists in events.ts
  • Schema has by_event index on reservations
  • Frontend shows "being processed" badge
  • ALMOST_FULL badge shows for pending + low availability
  • TypeScript passes
  • Real-time subscription updates when pending changes

Self-Review

  1. Spec coverage: Real-time "being processed" indicator? Yes. 5-min lock? Yes. 30-sec expiry? Yes.

  2. Placeholder scan: All code complete with actual values.

  3. Type consistency: getEventAvailabilityWithPending returns paidCount, pendingCount, available — frontend uses these exact field names.

  4. Convex subscription: Query returns real-time updates to all subscribed clients when any reservation status changes.


What's Next (Not in This Plan)

  • Admin dashboard showing per-event pending count
  • WhatsApp reminder before expiry (2 min warning)
  • Waitlist for sold-out events