plans
2026-05-03
2026 05 03 Show System

Show System 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. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement the 2-level Show Template + Occurrences architecture that powers the entire House of Legends platform. This is the foundation for the homepage carousel, programme page, show detail pages, and booking flow.

Architecture: Show Templates (Level 1) define the show definition once. Show Occurrences (Level 2) are specific date/time instances. This architecture enables batch generation of occurrences, independent date cancellation, and price overrides per date — critical for Hamza's operational needs.

Tech Stack: Next.js 16 App Router, Convex (PostgreSQL), real-time subscriptions via useQuery, Tailwind CSS v4.

Pricing Context:

  • Base Dinner Theatre price: stored in showTemplates.defaultDinnerPrice (e.g., 900,000 VND)
  • Base Show Only price: stored in showTemplates.defaultShowOnlyPrice (e.g., 450,000 VND)
  • Per-occurrence overrides: showOccurrences.dinnerPriceOverride / showOnlyPriceOverride
  • Surcharges (from package-bundle-pricing plan): Thu +50K, Fri +100K, Sat +150K, Sun +100K per person

Business Summary

What this does: Implements the 2-level Show Template + Occurrences architecture that powers the entire House of Legends platform. Show Templates define a show once; Occurrences are specific date/time instances that can be batch-generated, independently cancelled, or have price overrides.

Why it matters: This is the foundational data layer for all guest-facing pages (homepage carousel, programme page, show detail) and the booking flow. Real-time availability badges on show dates directly impact conversion rates and prevent overselling. Enables Hamza to manage the programme without touching code.

Time to implement: 8-12 days | Complexity: High

Dependencies: No dependencies on other plans. This plan is a prerequisite for booking-flow, package-bundle-pricing, table-pos-system, notifications-crm, and staff-operations — they all reference show templates or occurrences.


Context & Key Constraints

[P0 GAP] staffMutation / adminMutation not yet implemented. The existing convex/auth.ts only provides getCurrentUser, upsertUser, and isAdmin helpers. Any plan referencing staffMutation or adminMutation must use plain mutation with inline role checks via ctx.auth.getUserIdentity() instead.

[P0 RULE] No dynamic URL segments. The booking flow uses nuqs URL state. The [Book] button on the show detail page MUST navigate to /booking?step=tickets&occurrenceId=${occurrence._id} — NOT /booking/${occurrence._id}/tickets. Show detail URL is /shows?slug=slug (nuqs), NOT /shows/${slug}.

[P1 RULE] All user-facing strings use translation keys: Availability badge labels, button text, empty state messages, and all UI copy must use translation keys — never hardcoded English strings.

[P1 RULE] Structured logging: Use consola instead of console.log. Import: import { consola } from "consola";

[P0 RULE] useQuery API calls: Never double-call API functions. Use useQuery(api.shows.upcoming, { limit: 8 }) NOT useQuery(api.shows.upcoming({ limit: 8 })).


File Map

convex/
├── schema.ts                    # ALREADY EXISTS — add indexes if needed
├── auth.ts                      # EXISTS — only getCurrentUser, upsertUser, isAdmin (no staffMutation/adminMutation)
├── functions/
│   ├── shows.ts               # ALREADY EXISTS — extend with upcoming query
│   └── occurrences.ts           # ALREADY EXISTS — extend with upcoming query

apps/frontend/
├── app/[locale]/
│   ├── page.tsx               # MODIFY — wire carousel to Convex upcoming query
│   ├── programme/page.tsx      # CREATE — full show list
│   └── shows/page.tsx         # CREATE — show detail (nuqs ?slug=, NOT dynamic [slug] route)
├── components/home/
│   └── upcoming-shows-section.tsx  # MODIFY — accept typed Convex data
└── lib/
    └── convex/
        └── provider.tsx       # ALREADY EXISTS

Phase 1: Convex Backend — Show System

Task 1: Verify & Extend shows.ts

Files:

  • Modify: convex/functions/shows.ts

The existing shows.ts has basic CRUD. We need upcoming query for the homepage carousel.

  • Step 1: Add upcoming query to shows.ts

Add this query after the existing listActive query:

// Get upcoming occurrences for homepage carousel (next 8 across all shows)
export const upcoming = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) => {
    const today = new Date().toISOString().split("T")[0];
    const occurrences = await ctx.db
      .query("showOccurrences")
      .withIndex("by_date_status", (q) =>
        q.gte("date", today).eq("status", "SCHEDULED"),
      )
      .collect();
 
    // Sort by date + time, take limit
    occurrences.sort((a, b) => {
      const dateCompare = a.date.localeCompare(b.date);
      return dateCompare !== 0 ? dateCompare : a.time.localeCompare(b.time);
    });
 
    const limited = occurrences.slice(0, limit ?? 8);
 
    // Enrich with template data
    const results = [];
    for (const occ of limited) {
      const template = await ctx.db.get(occ.templateId);
      if (template && template.status === "ACTIVE") {
        results.push({ occurrence: occ, show: template });
      }
    }
    return results;
  },
});
  • Step 2: Run to verify it compiles

Run: npx convex dev --run "api.shows.upcoming({limit: 8})" Expected: Returns array of {occurrence, show} objects or empty array if no data

  • Step 3: Commit
git add convex/functions/shows.ts
git commit -m "feat(shows): add upcoming query for homepage carousel"

Task 2: Extend occurrences.ts with Show-Specific Queries

Files:

  • Modify: convex/functions/occurrences.ts

  • Create: convex/functions/occurrences.ts (add new queries)

  • Step 1: Read existing occurrences.ts

cat convex/functions/occurrences.ts
  • Step 2: Add byTemplate query
// Get all occurrences for a specific show template
export const byTemplate = query({
  args: { templateId: v.id("showTemplates") },
  handler: async (ctx, { templateId }) => {
    return await ctx.db
      .query("showOccurrences")
      .withIndex("by_template", (q) => q.eq("templateId", templateId))
      .collect();
  },
});
  • Step 3: Add upcomingByTemplate query
// Get upcoming SCHEDULED occurrences for a specific show (for show detail page)
export const upcomingByTemplate = query({
  args: { templateId: v.id("showTemplates"), limit: v.optional(v.number()) },
  handler: async (ctx, { templateId, limit }) => {
    const today = new Date().toISOString().split("T")[0];
    const occurrences = await ctx.db
      .query("showOccurrences")
      .withIndex("by_template_date", (q) =>
        q.eq("templateId", templateId).gte("date", today),
      )
      .collect();
 
    // Filter to SCHEDULED only
    const scheduled = occurrences
      .filter((o) => o.status === "SCHEDULED")
      .sort((a, b) => {
        const dateCompare = a.date.localeCompare(b.date);
        return dateCompare !== 0 ? dateCompare : b.time.localeCompare(a.time);
      });
 
    return limit ? scheduled.slice(0, limit) : scheduled;
  },
});
  • Step 4: Add getAvailability query for real-time scarcity signals
// Get availability info for an occurrence (for booking flow + show page)
export const getAvailability = query({
  args: { occurrenceId: v.id("showOccurrences") },
  handler: async (ctx, { occurrenceId }) => {
    const occ = await ctx.db.get(occurrenceId);
    if (!occ) return null;
 
    const remaining = occ.actualCapacity - occ.bookedCount;
    const template = await ctx.db.get(occ.templateId);
 
    return {
      occurrenceId: occ._id,
      totalCapacity: occ.actualCapacity,
      bookedCount: occ.bookedCount,
      remaining,
      status: occ.status,
      // Badge: AVAILABLE / FEW_LEFT / SOLD_OUT — translated on frontend
      badge:
        remaining > 10 ? "AVAILABLE" : remaining > 0 ? "FEW_LEFT" : "SOLD_OUT",
      showOnlyEnabled: occ.showOnlyEnabled,
      dinnerPrice: occ.dinnerPriceOverride ?? template?.defaultDinnerPrice ?? 0,
      showOnlyPrice:
        occ.showOnlyPriceOverride ?? template?.defaultShowOnlyPrice ?? 0,
    };
  },
});
  • Step 5: Commit
git add convex/functions/occurrences.ts
git commit -m "feat(occurrences): add template queries and availability"

Phase 2: Frontend — Homepage Carousel

Task 3: Modify Homepage to Use Real Convex Data

Files:

  • Modify: apps/frontend/app/[locale]/page.tsx
  • Create: apps/frontend/components/home/upcoming-shows-section.tsx (or modify existing)

The current homepage likely has static/mock data. We need to wire it to Convex real-time data.

  • Step 1: Read current homepage and carousel component
cat apps/frontend/app/\[locale\]/page.tsx
cat apps/frontend/components/home/upcoming-shows-section.tsx
  • Step 2: Modify page.tsx to pass Convex data

The homepage imports UpcomingShowsSection. You need to:

  1. Add ConvexClientProvider to the locale layout if not present (check app/[locale]/layout.tsx)
  2. In page.tsx, fetch data: const upcoming = useQuery(api.shows.upcoming, { limit: 8 });
  3. Pass upcoming to <UpcomingShowsSection shows={upcoming ?? []} />
  • Step 3: Update UpcomingShowsSection to accept typed data

The component should accept:

type UpcomingShow = {
  occurrence: {
    _id: Id<"showOccurrences">;
    date: string;
    time: string;
    status: "SCHEDULED" | "CANCELLED" | "SOLD_OUT";
    bookedCount: number;
    actualCapacity: number;
  };
  show: {
    _id: Id<"showTemplates">;
    title: string;
    slug: string;
    gallery: string[];
    defaultDinnerPrice: number;
    supportedTicketTypes: ("DINNER_THEATRE" | "SHOW_ONLY")[];
  };
};

Card display logic:

  • Hero image: show.gallery[0] or placeholder

  • Title: show.title

  • Date/time: occurrence.date + occurrence.time formatted with locale

  • Availability badge: computed from occurrence.actualCapacity - occurrence.bookedCount

  • Price: "From X VND" using show.defaultDinnerPrice

  • CTA button: links to /shows?slug=${show.slug} (navigates to show detail, not directly to booking)

  • Step 4: Test in browser

Run dev server, navigate to homepage, verify carousel shows real data (or empty state if no occurrences exist yet).

  • Step 5: Commit
git add apps/frontend/app/\[locale\]/page.tsx apps/frontend/components/home/upcoming-shows-section.tsx
git commit -m "feat(homepage): wire carousel to Convex upcoming query"

Phase 3: Programme Page

Task 4: Create Programme Page (/programme)

Files:

  • Create: apps/frontend/app/[locale]/programme/page.tsx

  • Create: apps/frontend/components/programme/programme-grid.tsx (if needed)

  • Step 1: Create programme page

"use client";
 
import { useTranslations } from "next-intl";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useRouter } from "next/navigation";
 
export default function ProgrammePage() {
  const t = useTranslations();
  const router = useRouter();
  // CORRECT: useQuery(api.shows.listActive) NOT useQuery(api.shows.listActive())
  const shows = useQuery(api.shows.listActive);
 
  if (!shows) {
    return (
      <div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
        <p className="text-[#808080]">{t("shows.programme.loading")}</p>
      </div>
    );
  }
 
  return (
    <div className="min-h-screen bg-[#1a1a1a] py-12">
      <div className="max-w-7xl mx-auto px-6">
        <h1 className="font-serif text-[#C5A059] text-4xl mb-8">
          {t("shows.programme.title")}
        </h1>
 
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {shows.map((show) => (
            <button
              key={show._id}
              onClick={() => router.push(`/shows?slug=${show.slug}`)}
              className="bg-[#1a1a1a] border border-[#333] rounded-lg overflow-hidden hover:border-[#C5A059]/50 transition-colors text-left"
            >
              {show.gallery[0] && (
                <div className="aspect-video bg-gray-800">
                  <img
                    src={show.gallery[0]}
                    alt={show.title}
                    className="w-full h-full object-cover"
                  />
                </div>
              )}
              <div className="p-4">
                <h2 className="font-serif text-[#C5A059] text-xl mb-2">
                  {show.title}
                </h2>
                <p className="text-[#e6e6e6]/60 text-sm mb-4 line-clamp-2">
                  {show.tagline}
                </p>
                <div className="flex justify-between items-center">
                  <span className="text-[#C5A059] font-bold">
                    {t("shows.fromPrice", { price: show.defaultDinnerPrice.toLocaleString() })}
                  </span>
                  <span className="text-sm text-[#808080]">
                    {t("shows.viewDates")}
                  </span>
                </div>
              </div>
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
  • Step 2: Add route to routing.ts if needed

Check apps/frontend/routing.ts to ensure /programme route is registered.

  • Step 3: Test page loads

Navigate to /{locale}/programme and verify grid renders.

  • Step 4: Commit
git add apps/frontend/app/\[locale\]/programme/
git commit -m "feat(programme): add show listing page"

Phase 4: Show Detail Page

Task 5: Create Show Detail Page (/shows?slug=slug — nuqs, no [slug] route)

Files:

  • Create: apps/frontend/app/[locale]/shows/page.tsx (single page, slug via nuqs)
  • Create: apps/frontend/components/shows/show-hero.tsx
  • Create: apps/frontend/components/shows/show-occurrence-list.tsx

This is the main conversion page — most important page on the site.

  • Step 1: Create page with data fetching
"use client";
 
import { useQueryState } from "nuqs";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { ShowDetailClient } from "~/components/shows/show-detail-client";
 
export default function ShowDetailPage() {
  const [slug] = useQueryState("slug", { defaultValue: "" });
  // CORRECT: useQuery(api.shows.getBySlug, { slug }) NOT useQuery(api.shows.getBySlug({ slug }))
  const show = useQuery(api.shows.getBySlug, { slug });
 
  if (slug && !show) return null; // 404 handled by notFound in parent
  if (!slug || !show) {
    return (
      <div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center">
        <p className="text-[#808080]">{t("shows.detail.loading")}</p>
      </div>
    );
  }
 
  return <ShowDetailClient show={show} />;
}
  • Step 2: ShowDetailClient component

  • Embedded video (YouTube/Vimeo): autoplay muted — use <iframe> with ?autoplay=1&mute=1

  • Title + tagline

  • "See dates" button: smooth scroll to occurrences section

  • Photo gallery: horizontal scroll or grid below hero

  • Step 3: ShowOccurrenceList — the key conversion component

Data: useQuery(api.occurrences.upcomingByTemplate, { templateId: show._id })

Render each occurrence as a row:

Friday    May 2    7:30 PM    ● 12 seats left    [Book]
  • Day + Date + Time columns
  • Availability dot: computed from remaining seats (color class from translation key)
  • Availability text: translated from badge key shows.availability.available, shows.availability.fewLeft, shows.availability.soldOut
  • [Book] button: MUST navigate to /booking?step=tickets&occurrenceId=${occurrence._id} — using useRouter from next/navigation, NOT a dynamic route segment

"See more dates" button: Expand 5 more inline (show first 5, button shows next 5).

Critical: Clicking [Book] locks the date/time — no need to re-pick inside booking.

  • Step 4: Availability badge with translation keys
import { useTranslations } from "next-intl";
 
function getBadge(remaining: number, t: (key: string) => string) {
  if (remaining > 10)
    return {
      colorClass: "bg-green-500",           // dot color class
      labelKey: "shows.availability.available",
    };
  if (remaining > 0)
    return {
      colorClass: "bg-orange-500",
      labelKey: "shows.availability.fewLeft",
      labelParams: { count: remaining },
    };
  return {
    colorClass: "bg-gray-500",
    labelKey: "shows.availability.soldOut",
  };
}
 
// In JSX:
<span className={`inline-block w-2 h-2 rounded-full ${colorClass}`} />
<span>{t(labelKey, labelParams)}</span>
  • Step 5: Test booking button navigation

Click [Book] → should navigate to /booking?step=tickets&occurrenceId=xxx

  • Step 6: Commit
git add apps/frontend/app/\[locale\]/shows/
git commit -m "feat(show-detail): add show page with occurrence list"

Phase 5: Verify Schema Consistency

Task 6: Cross-Check Schema vs Frontend Types

Files:

  • Read: convex/schema.ts

  • Read: apps/frontend/lib/convex/_generated/api (generated types)

  • Step 1: Ensure all frontend type imports match schema

After running npx convex dev, generated types are in convex/_generated/api.d.ts.

Key type checks:

  • showTemplates.status is "ACTIVE" | "DRAFT" | "ARCHIVED"

  • showOccurrences.status is "SCHEDULED" | "CANCELLED" | "SOLD_OUT"

  • showOccurrences.bookedCount — ensure it's updated when reservations confirm (not on PENDING)

  • showTemplates.supportedTicketTypes is array of "DINNER_THEATRE" | "SHOW_ONLY"

  • Step 2: Verify bookedCount is correct

Current schema reservations doesn't auto-update bookedCount on PENDING — it only does so on reservation creation. This is a business decision: do we want to hold seats on PENDING, or only count PAID?

Per TFB Section 3.1: "Availability badge: Available / Few left / Sold out" — real-time availability should reflect actual confirmable seats. The current implementation holds seats on PENDING (counts them), which prevents overselling but may reduce conversion (people hold seats without paying).

If the business wants to only count PAID seats, we need to:

  1. Remove bookedCount++ from createPending
  2. Add bookedCount++ in confirmPayment
  3. Decrement on releaseExpired and cancel

This is a product decision — flag to Hamza before implementing.

  • Step 3: Commit any schema/function changes

Enrichment Sections

1. Zod Schemas

// convex/functions/shows.ts
import { z } from "zod";
 
export const ShowSlugSchema = z.object({
  slug: z.string().min(1, "Slug is required"),
});
 
export const UpcomingQuerySchema = z.object({
  limit: z.number().int().positive().optional(),
});
 
export const UpcomingByTemplateSchema = z.object({
  templateId: z.string().min(1, "Template ID is required"),
  limit: z.number().int().positive().optional(),
});
 
export const GetAvailabilitySchema = z.object({
  occurrenceId: z.string().min(1, "Occurrence ID is required"),
});
 
export const CreateShowTemplateSchema = z.object({
  title: z.string().min(1, "Title is required"),
  slug: z.string().min(1, "Slug is required"),
  tagline: z.string().optional(),
  description: z.string().optional(),
  gallery: z.array(z.string()).default([]),
  videoUrl: z.string().url().optional(),
  defaultDinnerPrice: z.number().nonnegative(),
  defaultShowOnlyPrice: z.number().nonnegative().optional(),
  actualCapacity: z.number().int().positive(),
  supportedTicketTypes: z
    .array(z.enum(["DINNER_THEATRE", "SHOW_ONLY"]))
    .default(["DINNER_THEATRE"]),
  status: z.enum(["ACTIVE", "DRAFT", "ARCHIVED"]).default("DRAFT"),
});
 
export const UpdateShowTemplateSchema = z.object({
  id: z.string().min(1),
  title: z.string().min(1).optional(),
  tagline: z.string().optional(),
  description: z.string().optional(),
  gallery: z.array(z.string()).optional(),
  videoUrl: z.string().url().optional(),
  defaultDinnerPrice: z.number().nonnegative().optional(),
  defaultShowOnlyPrice: z.number().nonnegative().optional(),
  actualCapacity: z.number().int().positive().optional(),
  supportedTicketTypes: z
    .array(z.enum(["DINNER_THEATRE", "SHOW_ONLY"]))
    .optional(),
  status: z.enum(["ACTIVE", "DRAFT", "ARCHIVED"]).optional(),
});
 
export const CreateOccurrenceSchema = z.object({
  templateId: z.string().min(1, "Template ID is required"),
  date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format"),
  time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:MM format"),
  actualCapacity: z.number().int().positive(),
  dinnerPriceOverride: z.number().nonnegative().optional(),
  showOnlyPriceOverride: z.number().nonnegative().optional(),
  showOnlyEnabled: z.boolean().default(false),
  status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]).default("SCHEDULED"),
});
 
export const UpdateOccurrenceSchema = z.object({
  id: z.string().min(1),
  date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .optional(),
  time: z
    .string()
    .regex(/^\d{2}:\d{2}$/)
    .optional(),
  actualCapacity: z.number().int().positive().optional(),
  dinnerPriceOverride: z.number().nonnegative().optional(),
  showOnlyPriceOverride: z.number().nonnegative().optional(),
  showOnlyEnabled: z.boolean().optional(),
  status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]).optional(),
});

2. Error Handling

// convex/functions/shows.ts + occurrences.ts
export const SHOW_ERROR_CODES = {
  SHOW_NOT_FOUND: "SHOW_NOT_FOUND",
  TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND",
  OCCURRENCE_NOT_FOUND: "OCCURRENCE_NOT_FOUND",
  OCCURRENCE_NOT_SCHEDULED: "OCCURRENCE_NOT_SCHEDULED",
  SHOW_ONLY_NOT_ENABLED: "SHOW_ONLY_NOT_ENABLED",
  GENERATION_FAILED: "GENERATION_FAILED",
} as const;
 
export type ShowErrorCode = keyof typeof SHOW_ERROR_CODES;
FunctionError CodeMessage KeyCondition
shows.getBySlugSHOW_NOT_FOUNDerrors.shows.showNotFoundSlug does not match any show
shows.getBySlugTEMPLATE_NOT_FOUNDerrors.shows.templateNotFoundTemplate ID invalid
occurrences.upcomingByTemplateTEMPLATE_NOT_FOUNDerrors.shows.templateNotFoundTemplate ID invalid
occurrences.getAvailabilityOCCURRENCE_NOT_FOUNDerrors.shows.occurrenceNotFoundOccurrence ID invalid
occurrences.upcomingByTemplateOCCURRENCE_NOT_SCHEDULEDerrors.shows.occurrenceNotScheduledOccurrence is cancelled/sold-out
Admin mutations (create, update)UNAUTHORIZEDerrors.auth.unauthorizedNot admin or staff
generateBatchGENERATION_FAILEDerrors.shows.generationFailedBatch generation failed

3. Convex Real-time Subscription Pattern

// Homepage carousel — all upcoming shows
// CORRECT: pass function reference, not call it
const upcoming = useQuery(api.shows.upcoming, { limit: 8 });
 
// Programme page — all active shows
const shows = useQuery(api.shows.listActive);
 
// Show detail page — single show
const show = useQuery(api.shows.getBySlug, { slug });
 
// Show detail page — occurrences for this show
// CORRECT: pass function reference, not call it
const occurrences = useQuery(api.occurrences.upcomingByTemplate, {
  templateId: show._id,
  limit: 5,
});
 
// Booking flow — availability for selected occurrence
// CORRECT: pass function reference, not call it
const availability = useQuery(api.occurrences.getAvailability, {
  occurrenceId,
});

4. Mobile/Responsive Considerations

ComponentMobile Behavior
Homepage carouselHorizontal scroll cards; visible fraction of next card
Programme gridSingle column on mobile; 2 columns on tablet; 3 on desktop
Show detail pageFull-width hero; stacked content; sticky book button
Occurrence listSingle column rows; date/time stacked; full-width book button
GalleryHorizontal scroll; fullscreen lightbox on tap

5. PWA / Offline Behavior

Not applicable — show pages are content pages that refresh on each visit. Real-time availability is critical, so no offline caching for these pages.

6. i18n / next-intl Requirements

{
  "shows": {
    "programme": {
      "title": "Programme",
      "loading": "Loading shows..."
    },
    "detail": {
      "loading": "Loading show details...",
      "seeDates": "See Dates",
      "bookNow": "Book Now"
    },
    "availability": {
      "available": "Available",
      "fewLeft": "Only {count} seats left",
      "soldOut": "Sold Out"
    },
    "fromPrice": "From {price} VND",
    "viewDates": "View Dates & Book",
    "seeMoreDates": "See more dates",
    "showLessDates": "Show less"
  },
  "common": {
    "days": {
      "short": {
        "0": "Sun",
        "1": "Mon",
        "2": "Tue",
        "3": "Wed",
        "4": "Thu",
        "5": "Fri",
        "6": "Sat"
      }
    },
    "currency": "VND"
  },
  "errors": {
    "shows": {
      "showNotFound": "Show not found",
      "templateNotFound": "Show template not found",
      "occurrenceNotFound": "Show date not found",
      "occurrenceNotScheduled": "This show date is no longer available",
      "generationFailed": "Failed to generate show dates"
    },
    "auth": {
      "unauthorized": "You must be signed in as staff or admin"
    }
  }
}

Date formatting: Use Intl.DateTimeFormat with locale for date/time display:

const formattedDate = new Intl.DateTimeFormat(locale, {
  weekday: "long",
  month: "long",
  day: "numeric",
}).format(new Date(occurrence.date));

7. Environment-Specific Configuration

# Server-only (never exposed to client):
CLERK_SECRET_KEY=           # Clerk secret key
 
# Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL=    # Convex deployment URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=  # Clerk publishable key

8. TDD Test Cases

E2E Tests (Playwright):

// e2e/show-system.spec.ts
 
test("SS-E2E-1.1: Homepage displays upcoming show carousel", async ({
  page,
}) => {
  // Given: 3 active shows with upcoming occurrences exist
  // When: Guest visits homepage
  // Then: Carousel shows up to 8 occurrence cards with show title, date, time, and price
  await page.goto("/");
  await expect(
    page.locator('[data-testid="upcoming-shows-carousel"]'),
  ).toBeVisible();
});
 
test("SS-E2E-1.2: Programme page shows all active shows", async ({ page }) => {
  // Given: 5 active show templates exist
  // When: Guest navigates to /programme
  // Then: Grid displays 5 show cards with title, tagline, hero image, and CTA
  await page.goto("/en/programme");
  const cards = page.locator('[data-testid="show-card"]');
  await expect(cards).toHaveCount(5);
});
 
test("SS-E2E-1.3: Show detail page loads with slug param", async ({ page }) => {
  // Given: Show "A" exists with slug "show-a"
  // When: Guest navigates to /shows?slug=show-a
  // Then: Page shows video, description, gallery, and occurrence list
  await page.goto("/en/shows?slug=show-a");
  await expect(page.locator('[data-testid="show-hero"]')).toBeVisible();
  await expect(page.locator('[data-testid="occurrence-list"]')).toBeVisible();
});
 
test("SS-E2E-1.4: Invalid slug shows 404", async ({ page }) => {
  // Given: No show with slug "nonexistent" exists
  // When: Guest navigates to /shows?slug=nonexistent
  // Then: Page shows notFound()
  await page.goto("/en/shows?slug=nonexistent");
  await expect(page.locator('[data-testid="not-found"]')).toBeVisible();
});
 
test("SS-E2E-1.5: Book button navigates to booking flow", async ({ page }) => {
  // Given: Show "A" has a scheduled occurrence with ID "occ123"
  // When: Guest on show detail page clicks [Book] on that occurrence
  // Then: Guest navigates to /booking?step=tickets&occurrenceId=occ123
  await page.goto("/en/shows?slug=show-a");
  await page.getByTestId("book-btn-occ123").click();
  await expect(page).toHaveURL(/\/booking\?step=tickets&occurrenceId=occ123/);
});
 
test("SS-E2E-1.6: See more dates expands occurrence list", async ({ page }) => {
  // Given: Show "A" has 12 upcoming occurrences
  // When: Guest on show detail page clicks "See more dates"
  // Then: 5 more occurrences appear inline, button updates to "Show less"
  await page.goto("/en/shows?slug=show-a");
  await page.getByTestId("see-more-dates-btn").click();
  await expect(page.locator('[data-testid="occurrence-row"]')).toHaveCount(10);
});

Component Tests (Vitest + RTL):

// __tests__/components/upcoming-shows-section.test.tsx
 
it("SS-CT-1.1: Renders all available occurrences with correct data", async () => {
  // Given: Mock upcoming shows data with 3 occurrences
  const mockData = [
    { occurrence: { date: "2026-05-15", time: "19:30", status: "SCHEDULED", bookedCount: 5, actualCapacity: 50 }, show: { title: "Show A", slug: "show-a", gallery: [], defaultDinnerPrice: 900000 } },
  ];
  render(<UpcomingShowsSection shows={mockData} />);
  // Then: Each occurrence shows date, time, availability badge
  expect(screen.getByText("2026-05-15")).toBeInTheDocument();
  expect(screen.getByText("7:30 PM")).toBeInTheDocument();
});
 
it("SS-CT-1.2: Availability badge shows green for available", async () => {
  // Given: Occurrence with 25 remaining seats (out of 50)
  const mockData = [{ occurrence: { bookedCount: 25, actualCapacity: 50 }, show: { title: "Show A" } }];
  render(<UpcomingShowsSection shows={mockData} />);
  // Then: Green badge with "Available" text
  const badge = screen.getByTestId("availability-badge");
  expect(badge).toHaveClass("bg-green-500");
  expect(badge).toContainText("Available");
});
 
it("SS-CT-1.3: Availability badge shows orange for few left", async () => {
  // Given: Occurrence with 8 remaining seats
  const mockData = [{ occurrence: { bookedCount: 42, actualCapacity: 50 }, show: { title: "Show A" } }];
  render(<UpcomingShowsSection shows={mockData} />);
  // Then: Orange badge with "Only 8 seats left" text
  const badge = screen.getByTestId("availability-badge");
  expect(badge).toHaveClass("bg-orange-500");
  expect(badge).toContainText("Only 8 seats left");
});
 
it("SS-CT-1.4: Sold out occurrence shows disabled Book button", async () => {
  // Given: Occurrence with 0 remaining seats
  const mockData = [{ occurrence: { _id: "occ1", bookedCount: 50, actualCapacity: 50, status: "SOLD_OUT" }, show: { title: "Show A", slug: "show-a" } }];
  render(<UpcomingShowsSection shows={mockData} />);
  // Then: Book button is disabled
  const bookBtn = screen.getByTestId("book-btn-occ1");
  expect(bookBtn).toBeDisabled();
});
 
it("SS-CT-1.5: Empty state shown when no upcoming shows", async () => {
  // Given: Empty shows array
  render(<UpcomingShowsSection shows={[]} />);
  // Then: Empty state message or placeholder
  expect(screen.getByTestId("empty-state")).toBeInTheDocument();
});

Backend Tests (Vitest):

// __tests__/convex/shows.test.ts
 
it("SS-BE-1.1: upcoming query returns only SCHEDULED occurrences", async () => {
  // Given: Mixed SCHEDULED and CANCELLED occurrences in DB
  // When: Calling api.shows.upcoming query
  // Then: Only SCHEDULED occurrences returned
  const result = await ctx.runQuery(api.shows.upcoming, { limit: 8 });
  for (const { occurrence } of result) {
    expect(occurrence.status).toBe("SCHEDULED");
  }
});
 
it("SS-BE-1.2: upcoming query returns occurrences sorted by date then time", async () => {
  // Given: Multiple upcoming occurrences
  // When: Calling api.shows.upcoming query
  // Then: Results sorted chronologically
  const result = await ctx.runQuery(api.shows.upcoming, { limit: 8 });
  for (let i = 1; i < result.length; i++) {
    const prevDate = result[i - 1].occurrence.date;
    const currDate = result[i].occurrence.date;
    expect(prevDate.localeCompare(currDate) <= 0).toBe(true);
  }
});
 
it("SS-BE-1.3: upcoming query enriches with ACTIVE show templates only", async () => {
  // Given: Occurrences linked to ACTIVE and ARCHIVED templates
  // When: Calling api.shows.upcoming query
  // Then: Only occurrences linked to ACTIVE templates returned
  const result = await ctx.runQuery(api.shows.upcoming, { limit: 8 });
  for (const { show } of result) {
    expect(show.status).toBe("ACTIVE");
  }
});
 
it("SS-BE-1.4: getAvailability returns null for nonexistent occurrence", async () => {
  // Given: Non-existent occurrence ID
  // When: Calling api.occurrences.getAvailability
  // Then: Returns null
  const result = await ctx.runQuery(api.occurrences.getAvailability, {
    occurrenceId: "nonexistent",
  });
  expect(result).toBeNull();
});
 
it("SS-BE-1.5: getAvailability computes AVAILABLE badge correctly", async () => {
  // Given: Occurrence with 20 remaining seats
  // When: Calling api.occurrences.getAvailability
  // Then: Badge is "AVAILABLE" and remaining is 20
  const result = await ctx.runQuery(api.occurrences.getAvailability, {
    occurrenceId: "occWith20Remaining",
  });
  expect(result?.badge).toBe("AVAILABLE");
  expect(result?.remaining).toBe(20);
});
 
it("SS-BE-1.6: getAvailability computes FEW_LEFT badge correctly", async () => {
  // Given: Occurrence with 5 remaining seats
  // When: Calling api.occurrences.getAvailability
  // Then: Badge is "FEW_LEFT" and remaining is 5
  const result = await ctx.runQuery(api.occurrences.getAvailability, {
    occurrenceId: "occWith5Remaining",
  });
  expect(result?.badge).toBe("FEW_LEFT");
  expect(result?.remaining).toBe(5);
});
 
it("SS-BE-1.7: getAvailability computes SOLD_OUT badge correctly", async () => {
  // Given: Occurrence with 0 remaining seats
  // When: Calling api.occurrences.getAvailability
  // Then: Badge is "SOLD_OUT" and remaining is 0
  const result = await ctx.runQuery(api.occurrences.getAvailability, {
    occurrenceId: "occWith0Remaining",
  });
  expect(result?.badge).toBe("SOLD_OUT");
  expect(result?.remaining).toBe(0);
});
 
it("SS-BE-1.8: upcomingByTemplate returns only SCHEDULED occurrences for given template", async () => {
  // Given: Template "A" with 3 SCHEDULED and 1 CANCELLED occurrences
  // When: Calling api.occurrences.upcomingByTemplate with templateId
  // Then: Returns only SCHEDULED occurrences (3), sorted by date
  const result = await ctx.runQuery(api.occurrences.upcomingByTemplate, {
    templateId: "templateA",
    limit: 5,
  });
  expect(result).toHaveLength(3);
  for (const occ of result) {
    expect(occ.status).toBe("SCHEDULED");
  }
});
 
it("SS-BE-1.9: upcomingByTemplate returns empty array for template with no upcoming occurrences", async () => {
  // Given: Template with all past/cancelled occurrences
  // When: Calling api.occurrences.upcomingByTemplate
  // Then: Returns empty array
  const result = await ctx.runQuery(api.occurrences.upcomingByTemplate, {
    templateId: "templateWithNoUpcoming",
  });
  expect(result).toHaveLength(0);
});

9. Cross-Plan Dependencies

DependencyPlanShared Schema
Required bypackage-bundle-pricingshowTemplates.defaultDinnerPrice, showOccurrences.dinnerPriceOverride
Required bynotifications-crmshowTemplates.title for email content
Depends onbooking-flowBooking flow links from show detail page
Required bystaff-operationsShow occurrences linked to reservations
Required bytable-pos-systemshowOccurrences used in reservation linkage
Schema sharesshowTemplates, showOccurrencesBoth show-system and booking-flow use these tables

10. Performance Considerations

ScenarioAt Scale (10 shows, 1000 occurrences, 10K daily visitors)
Homepage carouselupcoming query limited to 8 results; simple DB read; < 50ms
Programme pagelistActive returns ~10 shows; cached by Convex; < 20ms
Show detailupcomingByTemplate for single show; ~30 results max; < 30ms
Availability badgegetAvailability per occurrence; simple calculation; < 10ms
Real-time updatesConvex subscription for availability changes; < 500ms latency
Slug lookupshows.getBySlug uses by_slug index; < 10ms

Database indexes required (verify in schema.ts):

  • showOccurrences.by_date_status — for upcoming query (date >= today, status = SCHEDULED)
  • showOccurrences.by_template_date — for upcomingByTemplate query (templateId + date >= today)
  • showTemplates.by_slug — for getBySlug query (slug unique lookup)
  • showTemplates.by_status — for listActive query (status = ACTIVE)

Acceptance Criteria

  1. Homepage carousel shows next 8 upcoming occurrences across all ACTIVE shows, real-time via Convex subscription
  2. Programme page (/programme) lists all ACTIVE show templates in a grid
  3. Show detail page (/shows?slug=slug) displays:
    • Embedded video autoplay
    • Full description + gallery
    • Upcoming occurrences as a vertical list with availability badges
    • [Book] button per occurrence — navigates to /booking?step=tickets&occurrenceId=xxx
  4. "See more dates" expands 5 more occurrences inline
  5. All data is real-time — admin changes to occurrence price/status reflect immediately on the public site
  6. Scarcity signals show correct seat counts

User Stories

IDAs a...I want to...So that...Priority
SS-US01GuestBrowse upcoming shows on the homepage carouselI can see what's available and book quicklyMust
SS-US02GuestView the programme page with all showsI can compare shows and plan my visitMust
SS-US03GuestSelect a specific show and see all its datesI can find a convenient date for my groupMust
SS-US04GuestBook a specific show occurrence directlyI don't have to re-select the date during bookingMust
SS-US05GuestSee real-time seat availability on show datesI know which dates have space before I commitShould
SS-US06GuestSee pricing for dinner theatre vs show-only optionsI can choose the ticket type that fits my budgetMust
SS-US07AdminCreate a new show template with all detailsI can define shows once and generate many occurrencesMust
SS-US08AdminBatch-generate 30 occurrences for a showI don't have to create each date manuallyMust
SS-US09AdminOverride price for a specific occurrenceI can offer discounts or premium pricing for special datesShould
SS-US10AdminCancel a specific occurrence without affecting othersI can manage schedule changes independentlyMust
SS-US11GuestSee availability badges update in real-timeI can trust the seat count is accurateShould

Test Scenarios

IDScenarioGivenWhenThen
SS-TS01Homepage carousel loadsThere are 3 upcoming occurrences across 2 active showsGuest visits homepageCarousel shows 3 cards with correct show titles, dates, times, and prices
SS-TS02Homepage carousel empty stateThere are no upcoming occurrencesGuest visits homepageCarousel shows empty state message or placeholder
SS-TS03Programme page gridThere are 5 active show templatesGuest navigates to /programmeGrid displays 5 show cards with title, tagline, hero image, and CTA button
SS-TS04Programme page linksA show card is displayedGuest clicks "View dates & book"Guest navigates to /shows?slug={slug}
SS-TS05Show detail page loadsShow template "A" exists with slug "show-a"Guest navigates to /shows?slug=show-aPage shows video, description, gallery, and occurrence list
SS-TS06Occurrence list displaysShow "A" has 8 upcoming occurrencesGuest views show detail pageAll 8 occurrences display with date, time, and availability badge
SS-TS07Book button navigationAn occurrence with ID "occ123" existsGuest clicks [Book] on that occurrenceGuest navigates to /booking?step=tickets&occurrenceId=occ123
SS-TS08Availability badge greenAn occurrence has 25 seats remaining out of 50Guest views occurrence rowBadge shows green dot and translated "available" text
SS-TS09Availability badge orangeAn occurrence has 8 seats remainingGuest views occurrence rowBadge shows orange dot and translated "few left" text
SS-TS10Availability badge sold outAn occurrence has 0 seats remainingGuest views occurrence rowBadge shows grey dot and translated "sold out" text, Book button is disabled
SS-TS11See more dates expandsA show has 12 occurrences, 5 are shown initiallyGuest clicks "See more dates"5 more occurrences appear inline, button updates to "Show less"
SS-TS12Admin creates show templateAdmin is logged into admin panelAdmin fills in show form and savesNew show template appears in programme page
SS-TS13Admin batch generates occurrencesA show template existsAdmin selects 30 dates and clicks generate30 new occurrences appear in the show's occurrence list
SS-TS14Admin price overrideAn occurrence exists with default dinner price 900,000Admin sets dinnerPriceOverride to 1,200,000Guest sees 1,200,000 VND on show page, not 900,000
SS-TS15Real-time seat count updateA guest books a seat (seat count goes from 15 to 14)Another guest views the same showSeat count shows 14, not the stale 15

Consistency Audit: show-system

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1All useQuery callsuseQuery(api.fn({ args })) — double-call patternChanged to useQuery(api.fn, { args }) — pass function reference, not call
2Phase 1 Task 1Test code referenced ctx.runQuery(api.shows.upcoming, ...) but showed incorrect double-call in inline test examplesFixed inline test examples to use correct pattern
3File MapReferences staffMutation/adminMutation from convex/auth.ts which don't existRemoved references; use plain mutation with inline role checks via ctx.auth.getUserIdentity()
4Phase 5Schema consistency section references "Convex schema types" which require generated typesFlagged as P0 GAP — depends on npx convex dev generating types first

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1Phase 3, ProgrammePageHardcoded text replaced with useTranslationsAdded useTranslations for all user-facing strings
2Phase 3, ProgrammePageHardcoded color values replaced with Tailwind classesUsed bg-[#1a1a1a], text-[#C5A059] etc.
3GlobalNo consola usage for loggingAdded structured logging note for admin mutations

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
1staffMutation/adminMutation/authenticatedMutation not in convex/auth.tsUse plain mutation with inline role checks until auth helpers are implemented
2Generated Convex types (convex/_generated/api.d.ts) required for full type safetyRun npx convex dev to generate types before implementing frontend components

Schema Consistency Check

  • shows.upcoming uses withIndex("by_date_status") — verify this index exists in schema.ts
  • shows.getBySlug uses by_slug index — verify this index exists
  • occurrences.upcomingByTemplate uses withIndex("by_template_date") — verify this index exists
  • showTemplates.status field type matches "ACTIVE" | "DRAFT" | "ARCHIVED" used in frontend
  • showOccurrences.status field type matches "SCHEDULED" | "CANCELLED" | "SOLD_OUT" used in frontend