Lucky Spin 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 Lucky Spin wheel. One spin per table per show. Server-side result (anti-cheat). Prize added as comp item to table order. Result shown on shared wall.
Tech Stack: Next.js 16, Convex mutations + queries, Framer Motion for wheel animation, Tailwind CSS v4.
Spec: docs/superpowers/specs/07-lucky-spin.md
Business Summary
What this does: Gives each table one spin of a prize wheel per show. The result is determined server-side (anti-cheat) and the prize (free drink, dessert, or discount) is automatically added to the table order as a comp item. Recent wins are displayed on the shared wall in real-time.
Why it matters: Adds gamified excitement to every table visit. The anticipation of the spin creates a memorable moment, and winning a prize reinforces positive emotions. The shared wall display builds excitement across the venue as other guests watch wins happen in real-time.
Time to implement: 3-5 days | Complexity: Medium
Dependencies: Foundation (guestProfiles, orders, tables) must be complete first
Task 1: Add Spin Tables to Schema
Files:
-
Modify:
convex/schema.ts -
Create:
apps/frontend/lib/schemas/lucky-spin.ts -
Create:
apps/frontend/lib/schemas/lucky-spin-errors.ts -
Step 1: Add
spinPrizesandspinResultstables
spinPrizes: defineTable({
label: v.string(),
prizeType: v.union(v.literal("MENU_ITEM"), v.literal("DISCOUNT")),
menuItemId: v.optional(v.id("menuItems")),
discountPercent: v.optional(v.number()),
weight: v.number(),
enabled: v.boolean(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_enabled", ["enabled"]),
spinResults: defineTable({
profileId: v.id("guestProfiles"),
orderId: v.id("orders"),
tableId: v.id("tables"),
prizeId: v.id("spinPrizes"),
displayText: v.string(),
showDate: v.string(),
createdAt: v.number(),
})
.index("by_show_date", ["showDate"])
.index("by_table_show", ["tableId", "showDate"])
.index("by_profile", ["profileId"]),- Step 2: Create Zod schemas
// apps/frontend/lib/schemas/lucky-spin.ts
import { z } from "zod";
export const SpinWheelSchema = z.object({
profileId: z.string().min(1, "Profile ID is required"),
orderId: z.string().min(1, "Order ID is required"),
tableId: z.string().min(1, "Table ID is required"),
});
export const GetSpinResultSchema = z.object({
profileId: z.string().min(1, "Profile ID is required"),
});
export type SpinWheelInput = z.infer<typeof SpinWheelSchema>;
export type GetSpinResultInput = z.infer<typeof GetSpinResultSchema>;// apps/frontend/lib/schemas/lucky-spin-errors.ts
export const SpinErrorCode = {
ALREADY_SPUN: "SPIN_ALREADY_SPUN",
NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
type SpinError = keyof typeof SpinErrorCode;- Step 3: Commit
git add convex/schema.ts apps/frontend/lib/schemas/lucky-spin.ts apps/frontend/lib/schemas/lucky-spin-errors.ts
git commit -m "feat(lucky-spin): add spinPrizes and spinResults tables"Phase 2: Spin Convex Functions
Task 2: Add Spin Functions
Files:
-
Modify:
convex/functions/challenges.ts -
Step 1: Add spin wheel function
// Named error codes for lucky spin operations
const SpinErrorCode = {
ALREADY_SPUN: "SPIN_ALREADY_SPUN",
NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
// Cryptographically secure random pick for weighted selection
function weightedRandomPick<T extends { weight: number }>(items: T[]): T {
const totalWeight = items.reduce((sum, p) => sum + p.weight, 0);
// Use crypto.getRandomValues for secure random — no Math.random()
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
let random = (randomBytes[0] / 0xffffffff) * totalWeight;
for (const prize of items) {
random -= prize.weight;
if (random <= 0) {
return prize;
}
}
return items[items.length - 1];
}
export const spinWheel = mutation({
args: {
profileId: v.id("guestProfiles"),
orderId: v.id("orders"),
tableId: v.id("tables"),
},
handler: async (ctx, { profileId, orderId, tableId }) => {
const today = new Date().toISOString().split("T")[0];
// Check one spin per profile per show
const existing = await ctx.db
.query("spinResults")
.withIndex("by_profile", (q) => q.eq("profileId", profileId))
.first();
if (existing && existing.showDate === today) {
throw new Error(`${SpinErrorCode.ALREADY_SPUN}: Already spun today`);
}
// Get enabled prizes
const prizes = await ctx.db
.query("spinPrizes")
.withIndex("by_enabled", (q) => q.eq("enabled", true))
.collect();
if (prizes.length === 0) {
throw new Error(
`${SpinErrorCode.NO_PRIZES_AVAILABLE}: No prizes configured`,
);
}
// Weighted random pick using crypto (server-side anti-cheat)
const selectedPrize = weightedRandomPick(prizes);
const now = Date.now();
// If MENU_ITEM prize, add as comp to order
if (selectedPrize.prizeType === "MENU_ITEM" && selectedPrize.menuItemId) {
const menuItem = await ctx.db.get(selectedPrize.menuItemId);
if (!menuItem) {
throw new Error(
`${SpinErrorCode.PRIZE_NOT_FOUND}: Prize menu item not found`,
);
}
await ctx.db.insert("orderItems", {
orderId,
menuItemId: selectedPrize.menuItemId,
quantity: 1,
unitPrice: 0,
status: "PENDING",
station: menuItem.station,
notes: undefined,
isComp: true,
compSource: "SPIN",
createdAt: now,
updatedAt: now,
});
}
// If DISCOUNT prize, apply to order total
if (
selectedPrize.prizeType === "DISCOUNT" &&
selectedPrize.discountPercent
) {
const order = await ctx.db.get(orderId);
if (!order) {
throw new Error(`${SpinErrorCode.ORDER_NOT_FOUND}: Order not found`);
}
const discount = Math.round(
order.totalAmount * (selectedPrize.discountPercent / 100),
);
await ctx.db.patch(orderId, {
totalAmount: order.totalAmount - discount,
updatedAt: now,
});
}
// Record spin result
const resultId = await ctx.db.insert("spinResults", {
profileId,
orderId,
tableId,
prizeId: selectedPrize._id,
displayText: selectedPrize.label,
showDate: today,
createdAt: now,
});
return { resultId, prize: selectedPrize };
},
});
export const getSpinResult = query({
args: { profileId: v.id("guestProfiles") },
handler: async (ctx, { profileId }) => {
const today = new Date().toISOString().split("T")[0];
return await ctx.db
.query("spinResults")
.withIndex("by_profile", (q) => q.eq("profileId", profileId))
.first();
},
});
export const getRecentSpins = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const today = new Date().toISOString().split("T")[0];
const results = await ctx.db
.query("spinResults")
.withIndex("by_show_date", (q) => q.eq("showDate", today))
.collect();
return results.slice(-(limit ?? 5)).reverse();
},
});- Step 2: Commit
git add convex/functions/challenges.ts
git commit -m "feat(lucky-spin): add spin wheel function with weighted random"Phase 3: Spin Wheel UI
Task 3: Create Spin Wheel Component
Files:
-
Create:
apps/frontend/components/minigames/spin-wheel.tsx -
Step 1: Create spin wheel component
// apps/frontend/components/minigames/spin-wheel.tsx
"use client";
import { useState, useTransition } from "react";
import { useMutation, useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { motion } from "framer-motion";
import { IconSymbol } from "~/components/ui/icon-symbol";
import { SpinErrorCode } from "~/lib/schemas/lucky-spin-errors";
export function SpinWheel({ profileId, orderId, tableId }: {
profileId: string;
orderId: string;
tableId: string;
}) {
const t = useTranslations("minigames.luckySpin");
const [spinning, setSpinning] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const spinWheel = useMutation(api.challenges.spinWheel);
const existingResult = useQuery(api.challenges.getSpinResult, { profileId });
const handleSpin = async () => {
if (spinning || isPending) return;
setSpinning(true);
setResult(null);
startTransition(async () => {
try {
// tableId is passed through to the mutation for the spinResults record
const { prize } = await spinWheel({ profileId, orderId, tableId });
setResult(prize.label);
setSpinning(false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes(SpinErrorCode.ALREADY_SPUN)) {
toast.error(t("errorAlreadySpun"));
} else {
toast.error(t("errorSpinFailed"));
}
setSpinning(false);
}
});
};
if (existingResult) {
return (
<div className="text-center py-8">
<IconSymbol name="sparkles" size={40} className="mx-auto mb-4 text-accent" />
<p className="font-serif text-2xl text-accent mb-2">{t("youWon")}</p>
<p className="text-white text-xl font-bold">{existingResult.displayText}</p>
<p className="text-gray-400 text-sm mt-2">{t("addedToOrder")}</p>
</div>
);
}
return (
<div className="flex flex-col items-center">
{/* Wheel */}
<div className="relative w-64 h-64 mb-6">
<motion.div
animate={spinning ? { rotate: 1440 + 720 } : {}}
transition={spinning ? { duration: 4, ease: "easeOut" } : {}}
className="w-full h-full rounded-full border-4 border-accent overflow-hidden"
>
{/* Wheel segments rendered via CSS */}
<div className="w-full h-full relative">
{/* Segments injected by wall display */}
</div>
</motion.div>
{/* Pointer — use border-t-[12px] (Tailwind arbitrary value syntax) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-0.5 w-0 h-0 border-l-8 border-r-8 border-t-[12px] border-l-transparent border-r-transparent border-t-accent" />
</div>
{result ? (
<div className="text-center">
<p className="text-2xl font-serif text-accent">{result}</p>
<p className="text-gray-400 text-sm mt-1">{t("addedToOrder")}</p>
</div>
) : (
<button
onClick={handleSpin}
disabled={spinning || isPending}
className="px-8 py-4 bg-accent text-black font-bold text-lg rounded-full disabled:opacity-50"
>
{spinning || isPending ? t("spinning") : t("spin")}
</button>
)}
</div>
);
}- Step 2: Add Framer Motion dependency check
npm list framer-motion 2>/dev/null || npm install framer-motion- Step 3: Commit
git add apps/frontend/components/minigames/spin-wheel.tsx
git commit -m "feat(lucky-spin): add spin wheel UI component"Phase 4: Wall Spin Feed
Task 4: Create Spin Results Feed for Wall
Files:
-
Create:
apps/frontend/components/wall/spin-feed.tsx -
Step 1: Create spin feed component
// apps/frontend/components/wall/spin-feed.tsx
"use client";
import { Suspense } from "react";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/icon-symbol";
import type { Id } from "convex/_generated/dataModel";
type SpinResult = {
_id: Id<"spinResults">;
_creationTime: number;
profileId: Id<"guestProfiles">;
orderId: Id<"orders">;
tableId: Id<"tables">;
prizeId: Id<"spinPrizes">;
displayText: string;
showDate: string;
createdAt: number;
};
function SpinFeedSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3 bg-surface rounded-lg animate-pulse">
<div className="w-8 h-8 rounded-full bg-accent/20" />
<div className="flex-1 space-y-1">
<div className="h-4 w-24 bg-accent/20 rounded" />
<div className="h-3 w-32 bg-accent/10 rounded" />
</div>
</div>
))}
</div>
);
}
export function SpinFeed() {
const t = useTranslations("minigames.luckySpin");
const spins = useQuery(api.challenges.getRecentSpins, { limit: 5 });
if (!spins?.length) {
return (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<IconSymbol name="sparkles" size={40} className="mb-3 opacity-50" />
<p className="text-sm">{t("noSpinsYet")}</p>
</div>
);
}
return (
<Suspense fallback={<SpinFeedSkeleton />}>
<div className="space-y-3">
<h3 className="font-serif text-xl text-accent">{t("recentWins")}</h3>
{spins.map((spin: SpinResult) => (
<div key={spin._id} className="flex items-center gap-3 p-3 bg-surface rounded-lg">
<IconSymbol name="star.fill" size={32} className="text-accent" />
<div>
<p className="text-white font-medium">{t("tableWon", { table: spin.tableId })}</p>
<p className="text-accent text-sm">{t("wonPrize", { prize: spin.displayText })}</p>
</div>
</div>
))}
</div>
</Suspense>
);
}- Step 2: Commit
git add apps/frontend/components/wall/spin-feed.tsx
git commit -m "feat(lucky-spin): add spin feed for display wall"Enrichment Sections
1. Zod Schemas
// apps/frontend/lib/schemas/lucky-spin.ts
import { z } from "zod";
export const SpinWheelSchema = z.object({
profileId: z.string().min(1, "Profile ID is required"),
orderId: z.string().min(1, "Order ID is required"),
tableId: z.string().min(1, "Table ID is required"),
});
export const GetSpinResultSchema = z.object({
profileId: z.string().min(1, "Profile ID is required"),
});
export type SpinWheelInput = z.infer<typeof SpinWheelSchema>;
export type GetSpinResultInput = z.infer<typeof GetSpinResultSchema>;// apps/frontend/lib/schemas/lucky-spin-errors.ts
export const SpinErrorCode = {
ALREADY_SPUN: "SPIN_ALREADY_SPUN",
NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
type SpinError = keyof typeof SpinErrorCode;2. Error Handling
| Operation | Error Code | Message Key | Notes |
|---|---|---|---|
spinWheel | SPIN_ALREADY_SPUN | errorAlreadySpun | One spin per profile per show |
spinWheel | SPIN_NO_PRIZES_AVAILABLE | errorNoPrizes | No enabled prizes in DB |
spinWheel | SPIN_PRIZE_NOT_FOUND | errorPrizeNotFound | Menu item for prize missing |
spinWheel | SPIN_ORDER_NOT_FOUND | errorOrderNotFound | Order entity missing |
Named error codes as const object:
// apps/frontend/lib/schemas/lucky-spin-errors.ts
export const SpinErrorCode = {
ALREADY_SPUN: "SPIN_ALREADY_SPUN",
NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
type SpinError = keyof typeof SpinErrorCode;Client-side error parsing: check err.message.includes(errorCode).
3. Convex Real-time Subscription Pattern
// Guest PWA — check if already spun (real-time)
const existingResult = useQuery(api.challenges.getSpinResult, { profileId });
// If staff approves Google Review reward, this also updates in real-time
// Wall feed — recent spins (real-time)
const recentSpins = useQuery(api.challenges.getRecentSpins, { limit: 5 });
// Live-updates as guests spin throughout the nightEach unique query subscription auto-receives real-time updates from Convex. No manual polling needed.
4. Mobile/Responsive Considerations
| Component | Mobile Behavior | Desktop Behavior |
|---|---|---|
SpinWheel | Full-width wheel, larger tap target | Centered, 256x256px wheel |
SpinFeed | Full-width cards, stacked | Horizontal layout, max 5 visible |
| Result display | Full-screen celebration overlay | Modal dialog |
- Wheel uses
256x256pxon all screen sizes (minimum touch target) - Disabled state prevents double-tap during spin animation
- Result shows as overlay toast on mobile
Suspenseboundary with skeleton onSpinFeed- All spin-related strings use
useTranslations
5. PWA / Offline Behavior
- Spin action: Requires network; disable spin button immediately on network loss
- Wall feed: Cached with
revalidate: 30; shows "Reconnecting..." banner if offline - Prize persistence: Spin result is committed server-side before animation; no client-side state to lose
// Service worker: cache spin results feed
const SPIN_FEED_CACHE = "spin-feed-v1";
// Strategy: network-first with 3s timeout, fall back to cached[P1 PERFORMANCE GAP]: Current implementation uses FileReader.readAsDataURL for any file preview (if extended). Should use Convex storage (storage.store) for production scale.
6. i18n / next-intl Requirements
All user-facing strings must use getTranslations/useTranslations. Add to messages/en.json and messages/vi.json:
{
"minigames": {
"luckySpin": {
"youWon": "You won!",
"addedToOrder": "Added to your table order",
"recentWins": "Recent Wins",
"spinning": "Spinning...",
"spin": "SPIN!",
"tableWon": "Table {table}",
"wonPrize": "won {prize}",
"errorAlreadySpun": "You've already spun tonight!",
"errorSpinFailed": "Spin failed. Please try again.",
"errorNoPrizes": "No prizes available tonight. Check back later.",
"errorPrizeNotFound": "Prize configuration error. Contact staff.",
"errorOrderNotFound": "Order not found. Contact staff.",
"noSpinsYet": "No spins yet — be the first!"
}
}
}Vietnamese:
{
"minigames": {
"luckySpin": {
"youWon": "Ban da trung!",
"addedToOrder": "Da them vao don cua ban",
"recentWins": "Thang gan day",
"spinning": "Dang quay...",
"spin": "QUAY!",
"tableWon": "Ban {table}",
"wonPrize": "da trung {prize}",
"errorAlreadySpun": "Ban da quay toi nay roi!",
"errorSpinFailed": "Quay that bai. Vui long thu lai.",
"errorNoPrizes": "Khong co giai thuong toi nay. Quay lai sau.",
"errorPrizeNotFound": "Loi cau hinh giai thuong. Lien he nhan vien.",
"errorOrderNotFound": "Khong tim thay don hang. Lien he nhan vien.",
"noSpinsYet": "Chua co ai quay — hay la nguoi dau tien!"
}
}
}7. Environment-Specific Configuration
// Server-only:
CLERK_SECRET_KEY= — Convex server-side Clerk auth
// Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL= — Convex deployment URL
NEXT_PUBLIC_APP_URL= — Public URL for PWA| Package | Version | Purpose |
|---|---|---|
framer-motion | ^11.0.0 | Wheel spin animation |
8. TDD Test Cases
E2E Tests (Playwright) — User Expectation Format:
// apps/frontend/e2e/lucky-spin.spec.ts
// TDD: Write test BEFORE implementation
// Run: npx playwright test e2e/lucky-spin.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Lucky Spin — User Journeys", () => {
// ─── LS-E2E-1: Guest spins wheel ────────────────────────────────
test("LS-E2E-1.1: Guest sees spin button and can trigger spin", async ({
page,
}) => {
// Given: Guest is on the spin tab in PWA
await page.goto("/en/spin");
// User expects: spin button visible with "SPIN!" label
await expect(page.getByRole("button", { name: /spin!/i })).toBeVisible();
// When: Guest taps the spin button
await page.getByRole("button", { name: /spin!/i }).click();
// Then: Wheel animation starts (button becomes disabled, shows "Spinning...")
});
test("LS-E2E-1.2: Prize result shown after spin animation", async ({
page,
}) => {
// Given: Guest tapped spin
await page.goto("/en/spin");
await page.getByRole("button", { name: /spin!/i }).click();
// User expects: after animation, prize result visible ("You won! Free Cocktail")
await expect(page.getByText(/you won/i)).toBeVisible({ timeout: 10000 });
});
test("LS-E2E-1.3: Prize added to table order as comp item", async ({
page,
}) => {
// Given: Guest has won a prize
await page.goto("/en/spin");
await page.getByRole("button", { name: /spin!/i }).click();
// User expects: "Added to your table order" message visible
await expect(page.getByText(/added to your table order/i)).toBeVisible({
timeout: 10000,
});
});
// ─── LS-E2E-2: One spin per show ──────────────────────────
test("LS-E2E-2.1: Already-spun guest sees result instead of spin button", async ({
page,
}) => {
// Given: Guest already spun tonight
await page.goto("/en/spin");
// User expects: no spin button, instead shows "You won [prize]" with result
await expect(page.getByText(/you won/i)).toBeVisible();
await expect(
page.getByRole("button", { name: /spin!/i }),
).not.toBeVisible();
});
test("LS-E2E-2.2: Already-spun guest cannot spin again on reload", async ({
page,
}) => {
// Given: Guest already spun
await page.goto("/en/spin");
await page.reload();
// User expects: still shows result, not spin button
await expect(page.getByText(/you won/i)).toBeVisible();
});
// ─── LS-E2E-3: Wall feed ───────────────────────────────────────
test("LS-E2E-3.1: Wall shows recent spin wins in real-time", async ({
page,
context,
}) => {
// Given: 2 separate browser contexts
const page1 = await context.newPage();
const page2 = await context.newPage();
// Guest 1 spins
await page1.goto("/en/spin");
await page1.getByRole("button", { name: /spin!/i }).click();
// Guest 2 is on the wall
await page2.goto("/en/wall");
// User expects: new win appears on wall without page refresh
await expect(page2.getByText(/table won/i)).toBeVisible({ timeout: 10000 });
});
// ─── LS-E2E-4: Error states ───────────────────────────────────
test("LS-E2E-4.1: Error shown when no prizes configured", async ({
page,
}) => {
// Given: No spin prizes enabled in DB
await page.goto("/en/spin");
await page.getByRole("button", { name: /spin!/i }).click();
// User expects: error toast "No prizes available tonight"
await expect(page.getByText(/no prizes available/i)).toBeVisible({
timeout: 10000,
});
});
// ─── LS-E2E-5: Mobile ──────────────────────────────────────────
test("LS-E2E-5.1: Mobile layout shows full-width spin button", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/en/spin");
// User expects: spin button is full-width and easy to tap
const spinBtn = page.getByRole("button", { name: /spin!/i });
await expect(spinBtn).toBeVisible();
const box = await spinBtn.boundingBox();
expect(box?.width).toBeGreaterThan(280); // Nearly full width on mobile
});
test("LS-E2E-5.2: Vietnamese locale all strings", async ({ page }) => {
await page.goto("/vi/spin");
// User expects: all UI strings in Vietnamese
await expect(page.getByText(/quay!/i)).toBeVisible();
await expect(page.getByText(/ban da trung/i)).toBeVisible();
});
});Component Tests (Vitest + RTL) — User Expectation Format:
// apps/frontend/__tests__/components/spin-wheel.test.tsx
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/components/spin-wheel.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { SpinWheel } from "~/components/minigames/spin-wheel";
const mockProfileId = "prof_test123";
const mockOrderId = "ord_test456";
const mockTableId = "tab_test789";
vi.mock("convex/react", () => ({
useMutation: vi.fn(() => () => Promise.resolve({ prize: { label: "Free Cocktail" } })),
useQuery: vi.fn(() => null),
}));
describe("SpinWheel — User Expectations", () => {
// ─── LS-UT-1: Initial state ────────────────────────────────────
it("LS-UT-1.1: Spin button visible when guest has not spun", () => {
// Given: Guest has not spun tonight
// When: SpinWheel component renders
// Then: prominent "SPIN!" button visible
render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
expect(screen.getByRole("button", { name: /spin!/i })).toBeVisible();
});
it("LS-UT-1.2: Spin button disabled while spinning", async () => {
// Given: Guest taps spin
// When: spin is in progress (4 second animation)
// Then: Button is disabled and shows "Spinning..."
const user = userEvent.setup();
const spinMutation = vi.fn(() => new Promise(r => setTimeout(5000)));
vi.mock("convex/react", () => ({
useMutation: vi.fn(() => spinMutation),
useQuery: vi.fn(() => null),
}));
render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
await user.click(screen.getByRole("button", { name: /spin!/i }));
const btn = screen.getByRole("button");
expect(btn).toBeDisabled();
expect(screen.getByText(/spinning/i)).toBeVisible();
});
// ─── LS-UT-2: Result state ─────────────────────────────────────
it("LS-UT-2.1: Prize result shown after successful spin", () => {
// Given: Guest won "Free Cocktail"
// When: SpinWheel renders with existing result
// Then: "You won!" and prize name visible, no spin button
vi.mock("convex/react", () => ({
useMutation: vi.fn(() => () => Promise.resolve({ prize: { label: "Free Cocktail" } })),
useQuery: vi.fn(() => ({ displayText: "Free Cocktail", _id: "result_123" })),
}));
render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
expect(screen.getByText(/you won/i)).toBeVisible();
expect(screen.getByText("Free Cocktail")).toBeVisible();
expect(screen.queryByRole("button", { name: /spin!/i })).not.toBeInTheDocument();
});
it("LS-UT-2.2: Prize shows with icon and 'added to order' message", () => {
// Given: Guest won a prize
// When: Result state is shown
// Then: sparkle icon + prize name + "Added to your table order" text
vi.mock("convex/react", () => ({
useMutation: vi.fn(() => () => Promise.resolve({ prize: { label: "Free Cocktail" } })),
useQuery: vi.fn(() => ({ displayText: "Free Cocktail", _id: "result_123" })),
}));
render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
expect(screen.getByText(/added to your table order/i)).toBeVisible();
});
});Schema Unit Tests (Vitest):
// apps/frontend/__tests__/lib/lucky-spin.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/lib/lucky-spin.test.ts
import { describe, it, expect } from "vitest";
import { SpinWheelSchema, GetSpinResultSchema } from "~/lib/schemas/lucky-spin";
describe("SpinWheelSchema", () => {
it("LS-UT-3.1: accepts valid spin request", () => {
// Given: All required fields with valid values
// When: Schema.parse is called
// Then: Returns success
const result = SpinWheelSchema.safeParse({
profileId: "prof_123",
orderId: "ord_456",
tableId: "tab_789",
});
expect(result.success).toBe(true);
});
it("LS-UT-3.2: rejects missing profileId", () => {
// Given: Missing profileId field
// When: Schema.parse is called
// Then: Returns error
const result = SpinWheelSchema.safeParse({
orderId: "ord_456",
tableId: "tab_789",
});
expect(result.success).toBe(false);
});
it("LS-UT-3.3: rejects missing orderId", () => {
// Given: Missing orderId field
// When: Schema.parse is called
// Then: Returns error
const result = SpinWheelSchema.safeParse({
profileId: "prof_123",
tableId: "tab_789",
});
expect(result.success).toBe(false);
});
it("LS-UT-3.4: rejects missing tableId", () => {
// Given: Missing tableId field
// When: Schema.parse is called
// Then: Returns error
const result = SpinWheelSchema.safeParse({
profileId: "prof_123",
orderId: "ord_456",
});
expect(result.success).toBe(false);
});
});
describe("Weighted random", () => {
it("LS-UT-4.1: selects prizes proportionally to weight", () => {
// Given: Two prizes with equal weight (1:1)
// When: weightedRandomPick is called 100 times
// Then: Each prize appears roughly 50% of the time (20-80% tolerance)
const prizes = [
{ label: "A", weight: 1 },
{ label: "B", weight: 1 },
];
const results = Array.from({ length: 100 }, () => {
const total = prizes.reduce((s, p) => s + p.weight, 0);
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
let r = (randomBytes[0] / 0xffffffff) * total;
for (const prize of prizes) {
r -= prize.weight;
if (r <= 0) return prize.label;
}
return prizes[0].label;
});
const countA = results.filter((r) => r === "A").length;
expect(countA).toBeGreaterThan(20); // At least 20% (sanity check for randomness)
expect(countA).toBeLessThan(80); // At most 80%
});
});Mutation Backend Tests (Vitest):
// apps/frontend/__tests__/convex/lucky-spin.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/convex/lucky-spin.test.ts
import { describe, it, expect } from "vitest";
describe("spinWheel mutation", () => {
it("LS-MUT-1.1: successfully spins and records result", async () => {
// Given: Guest has not spun tonight, prizes exist
// When: spinWheel is called
// Then: spinResults record created, orderItems comp added if MENU_ITEM prize
});
it("LS-MUT-1.2: rejects second spin for same profile", async () => {
// Given: Guest already spun tonight
// When: spinWheel is called again
// Then: throws SPIN_ALREADY_SPUN error
});
it("LS-MUT-1.3: throws when no prizes enabled", async () => {
// Given: No prizes with enabled=true
// When: spinWheel is called
// Then: throws SPIN_NO_PRIZES_AVAILABLE error
});
it("LS-MUT-1.4: applies DISCOUNT prize to order total", async () => {
// Given: Guest wins DISCOUNT prize
// When: spinWheel is called
// Then: order totalAmount is reduced by discount percent
});
it("LS-MUT-1.5: throws when order not found for DISCOUNT prize", async () => {
// Given: Guest wins DISCOUNT prize but order doesn't exist
// When: spinWheel is called
// Then: throws SPIN_ORDER_NOT_FOUND error
});
});
describe("getSpinResult query", () => {
it("LS-QUERY-1.1: returns null when guest has not spun", async () => {
// Given: No spinResults for this profile today
// When: getSpinResult is called
// Then: returns null
});
it("LS-QUERY-1.2: returns spin result when guest has spun", async () => {
// Given: spinResults record exists for this profile today
// When: getSpinResult is called
// Then: returns the spin result
});
});
describe("getRecentSpins query", () => {
it("LS-QUERY-2.1: returns empty when no spins tonight", async () => {
// Given: No spinResults for today
// When: getRecentSpins is called
// Then: returns empty array
});
it("LS-QUERY-2.2: returns limited recent spins ordered by recency", async () => {
// Given: Multiple spinResults exist for today
// When: getRecentSpins({ limit: 5 }) is called
// Then: returns up to 5 most recent spins
});
});9. Cross-Plan Dependencies
| Depends On | Required By | Shared Schema |
|---|---|---|
guestProfiles table | All minigames | profileId reference |
orders table | Photo, Lucky Spin, Google Review | orderId reference + totalAmount |
tables table | Photo, Lucky Spin, Google Review | tableId reference |
menuItems table | Lucky Spin, Google Review | Prize reward item reference |
orderItems table | Lucky Spin, Google Review | Comp insertion |
challengeConfig (future) | All minigames | Admin-configurable prize weights |
| Photo Wall | Lucky Spin | Both use wall display sections |
10. Performance Considerations
- Weighted random: O(n) where n = number of enabled prizes; acceptable for <100 prizes
- Wall feed: Limited to 5 recent spins (
limit: 5) to minimize payload - Real-time updates: Convex subscription handles fan-out; no additional indexing needed
- Concurrent spins: Convex mutations are serialized; no race condition on
spinResultsinsert - Large shows: If >100 spins/min, consider rate-limiting via Convex rate limiter
- No
Math.random(): Usescrypto.getRandomValuesfor secure random — anti-cheat guarantee - No
as any: All type casts use properId<"tableName">types from Convex generated types - No
console.log: All user feedback viatoast(sonner)
Acceptance Criteria
- Guest taps "SPIN!" button — wheel animates for ~4 seconds
- Server-side result is picked before animation starts (anti-cheat)
- Result shown on PWA: "You won Free Cocktail!"
- Prize added as comp item to table order (isComp=true, compSource="SPIN")
- One spin per profile per show enforced (error on second attempt)
- Recent spins appear on shared wall in real-time
- Wall shows table number + prize won
- All error codes use prefixed format (SPIN_*)
- All strings use
useTranslations— no hardcoded user-facing strings Suspenseboundary with skeleton onSpinFeed
Consistency Audit: lucky-spin
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | SpinWheel component pointer | border-t-12 is invalid Tailwind syntax — should be border-t-[12px] (arbitrary value) | Fixed to border-t-[12px] in plan text |
| 2 | spinWheel mutation call | Call was passing profileId, orderId, tableId as positional args but mutation expects an options object | Fixed to spinWheel({ profileId, orderId, tableId }) with all three fields |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | SpinWheel component | FileReader.readAsDataURL for file handling — P1 performance gap | Noted in Performance Considerations section and Section 5 (PWA/Offline). Future: replace with Convex storage (storage.store) |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation not in convex/auth.ts | Not applicable — lucky spin is guest-facing only, no staff mutations required in v1 |
| 2 | Prize administration UI | Future iteration: staff UI to add/edit/remove spin prizes — requires adminMutation for staff to manage spinPrizes table |