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 --jsonExpected: 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 = 5in reservations.ts - Cron interval is
{ seconds: 30 } -
expireStaleReservationsmutation exists in scheduled.ts -
getEventAvailabilityWithPendingquery exists in events.ts - Schema has
by_eventindex 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
-
Spec coverage: Real-time "being processed" indicator? Yes. 5-min lock? Yes. 30-sec expiry? Yes.
-
Placeholder scan: All code complete with actual values.
-
Type consistency:
getEventAvailabilityWithPendingreturnspaidCount,pendingCount,available— frontend uses these exact field names. -
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