Live Viewer Count 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 real-time viewer count on homepage. Creates social proof and FOMO for guests browsing the site.
Tech Stack: Next.js 16, Convex (real-time subscriptions), Tailwind CSS v4.
Spec: docs/superpowers/specs/11-live-viewers.md
Business Summary
What this does: Displays a real-time count of guests currently viewing the House of Legends website, shown as a subtle badge in the homepage hero section with a pulsing green indicator dot.
Why it matters: Creates social proof and FOMO (fear of missing out) for prospective guests browsing the site. When visitors see "12 viewing tonight," it signals an active, popular venue worth booking. This is a proven conversion tactic for events, dining, and entertainment businesses.
Time to implement: 2-3 days | Complexity: Medium
Dependencies: Foundation plan (for Convex setup and schema conventions)
Phase 1: Schema + Convex Functions
Task 1: Add Live Viewers Table and Functions
Files:
-
Modify:
convex/schema.ts -
Create:
convex/functions/liveViewers.ts -
Create:
apps/frontend/lib/schemas/live-viewers.ts -
Create:
apps/frontend/lib/schemas/live-viewers-errors.ts -
Step 1: Add
liveViewerstable to schema
liveViewers: defineTable({
occurrenceId: v.optional(v.id("showOccurrences")),
count: v.number(),
showDate: v.string(),
updatedAt: v.number(),
})
.index("by_occurrence", ["occurrenceId"])
.index("by_show_date", ["showDate"]),- Step 2: Create Zod schema and error codes
// apps/frontend/lib/schemas/live-viewers.ts
import { z } from "zod";
export const TrackViewSchema = z.object({
sessionId: z.string().min(1, "Session ID is required"),
});
export type TrackViewInput = z.infer<typeof TrackViewSchema>;// apps/frontend/lib/schemas/live-viewers-errors.ts
export const LiveViewersErrorCode = {
INVALID_SESSION: "LIVE_VIEWERS_INVALID_SESSION",
} as const;
type LiveViewersError = keyof typeof LiveViewersErrorCode;- Step 3: Create
liveViewers.tsfunctions
// convex/functions/liveViewers.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { LiveViewersErrorCode } from "~/lib/schemas/live-viewers-errors";
// Named error codes
const ERROR_CODES = {
INVALID_SESSION: "LIVE_VIEWERS_INVALID_SESSION",
} as const;
export const trackView = mutation({
args: { sessionId: v.string() },
handler: async (ctx, { sessionId }) => {
if (!sessionId || sessionId.length < 4) {
throw new Error(`${ERROR_CODES.INVALID_SESSION}: Invalid session ID`);
}
const now = Date.now();
const today = new Date().toISOString().split("T")[0];
// Upsert viewer count for today
const existing = await ctx.db
.query("liveViewers")
.withIndex("by_show_date", (q) => q.eq("showDate", today))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
count: existing.count + 1,
updatedAt: now,
});
} else {
await ctx.db.insert("liveViewers", {
occurrenceId: undefined,
count: 1,
showDate: today,
updatedAt: now,
});
}
},
});
export const getCount = query({
args: {},
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
const viewer = await ctx.db
.query("liveViewers")
.withIndex("by_show_date", (q) => q.eq("showDate", today))
.first();
return viewer?.count ?? 0;
},
});- Step 4: Commit
git add convex/schema.ts convex/functions/liveViewers.ts apps/frontend/lib/schemas/live-viewers.ts apps/frontend/lib/schemas/live-viewers-errors.ts
git commit -m "feat(live-viewers): add liveViewers table and tracking"Phase 2: Live Viewer Badge Component
Task 2: Create Viewer Count Badge
Files:
-
Create:
apps/frontend/components/home/live-viewer-count.tsx -
Step 1: Create live viewer count badge component
// apps/frontend/components/home/live-viewer-count.tsx
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { consola } from "consola";
export function LiveViewerCount() {
const t = useTranslations("home.liveViewers");
const [count, setCount] = useState(0);
const [isHydrated, setIsHydrated] = useState(false);
const liveCount = useQuery(api.liveViewers.getCount, {});
const trackView = useMutation(api.liveViewers.trackView);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasTrackedRef = useRef(false);
// Update local state when real-time data changes
useEffect(() => {
if (liveCount !== undefined) {
setCount(liveCount);
setIsHydrated(true);
}
}, [liveCount]);
// Generate stable session ID once per browser session
const getSessionId = useCallback((): string => {
if (typeof sessionStorage === "undefined") return "";
const key = "hol_session_id";
let id = sessionStorage.getItem(key);
if (!id) {
// Always use crypto.randomUUID — no insecure fallback
if (typeof crypto !== "undefined" && crypto.randomUUID) {
id = crypto.randomUUID();
} else {
// [P0 GAP] This branch should never execute in modern browsers.
// Throw to avoid silently using insecure session IDs.
consola.error("[live-viewers] crypto.randomUUID not available");
return "";
}
sessionStorage.setItem(key, id);
}
return id;
}, []);
const sessionId = getSessionId();
const handleTrack = useCallback(() => {
if (!sessionId || hasTrackedRef.current) return;
hasTrackedRef.current = true;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
trackView({ sessionId }).catch(() => {
// Silently fail — viewer count is non-critical
consola.debug("[live-viewers] trackView failed, will retry");
hasTrackedRef.current = false; // Allow retry on next visibility change
});
}, 2000);
}, [sessionId, trackView]);
useEffect(() => {
// Track on mount and visibility change (tab becomes visible again)
handleTrack();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
handleTrack();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [handleTrack]);
if (!isHydrated || count === 0) return null;
return (
<div className="flex items-center gap-1.5 text-xs text-gray-400" data-testid="viewer-count-badge">
<span className="relative flex h-2 w-2" data-testid="viewer-count-dot">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500" />
</span>
<span>{t("viewingCount", { count })}</span>
</div>
);
}- Step 2: Add to homepage with Suspense boundary
In apps/frontend/app/[locale]/page.tsx, add <LiveViewerCount /> in the hero section wrapped in Suspense:
import { Suspense } from "react";
import { LiveViewerCount } from "~/components/home/live-viewer-count";
function ViewerCountSkeleton() {
return <div className="h-4 w-24 bg-accent/10 rounded animate-pulse" />;
}
// In the hero section:
<Suspense fallback={<ViewerCountSkeleton />}>
<LiveViewerCount />
</Suspense>;- Step 3: Commit
git add apps/frontend/components/home/live-viewer-count.tsx
git add apps/frontend/app/[locale]/page.tsx
git commit -m "feat(live-viewers): add live viewer count badge to homepage"Enrichment Sections
1. Zod Schemas
// apps/frontend/lib/schemas/live-viewers.ts
import { z } from "zod";
export const TrackViewSchema = z.object({
sessionId: z.string().min(1, "Session ID is required"),
});
export type TrackViewInput = z.infer<typeof TrackViewSchema>;// apps/frontend/lib/schemas/live-viewers-errors.ts
export const LiveViewersErrorCode = {
INVALID_SESSION: "LIVE_VIEWERS_INVALID_SESSION",
} as const;
type LiveViewersError = keyof typeof LiveViewersErrorCode;2. Error Handling
| Operation | Error Code | Message Key | Notes |
|---|---|---|---|
trackView | LIVE_VIEWERS_INVALID_SESSION | — | Session ID too short/invalid |
Named error codes as const object:
// apps/frontend/lib/schemas/live-viewers-errors.ts
export const LiveViewersErrorCode = {
INVALID_SESSION: "LIVE_VIEWERS_INVALID_SESSION",
} as const;
type LiveViewersError = keyof typeof LiveViewersErrorCode;Client-side error parsing: check err.message.startsWith(errorCode).
getCount has no error states — returns 0 if no record found.
3. Convex Real-time Subscription Pattern
// Homepage badge — real-time subscriber
const liveCount = useQuery(api.liveViewers.getCount, {});Convex's real-time engine handles fan-out automatically. No manual WebSocket management needed. Each useQuery with the same arguments reuses the same subscription, so the homepage badge updates automatically when any visitor triggers trackView.
Hydration pattern: Use local state (useState) to track hydration status and avoid SSR mismatch, since useQuery returns undefined during SSR. The isHydrated flag ensures the server renders null and the client renders only after liveCount is defined.
4. Mobile/Responsive Considerations
| Component | Mobile Behavior | Desktop Behavior |
|---|---|---|
| Badge | Inline, same sizing | Same |
No mobile-specific considerations — standard responsive layout. Badge is inline with hero text and uses the same text-xs, gap-1.5 sizing on all screen sizes. Suspense boundary with skeleton prevents layout shift.
Skeleton: h-4 w-24 bg-accent/10 rounded animate-pulse — matches badge dimensions to prevent layout shift.
5. PWA / Offline Behavior
- Offline: Badge still renders from last known
count(cached by Convex subscription) - Service worker: Cache
api.liveViewers.getCountresponses with 60s stale-while-revalidate - Privacy: Session ID stored in
sessionStorage(notlocalStorage), cleared on tab close - Analytics opt-out: Not provided in v1 — future: respect
navigator.doNotTrack
// Service worker: stale-while-revalidate for viewer count
registerRoute(
({ url }) => url.pathname.includes("/api/getCount"),
new StaleWhileRevalidate({
cacheName: "live-viewers-cache",
plugins: [new ExpirationPlugin({ maxAgeSeconds: 60 })],
}),
);6. i18n / next-intl Requirements
Add to messages/en.json and messages/vi.json:
{
"home": {
"liveViewers": {
"viewingCount": "{count, plural, =0 {} one {# viewing tonight} other {# viewing tonight}}"
}
}
}Vietnamese:
{
"home": {
"liveViewers": {
"viewingCount": "{count, plural, =0 {} one {# nguoi dang xem toi nay} other {# nguoi dang xem toi nay}}"
}
}
}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 URLNo external APIs required. No analytics SDK needed for v1.
8. TDD Test Cases
E2E Tests (Playwright) — User Expectation Format:
// apps/frontend/e2e/live-viewers.spec.ts
// TDD: Write test BEFORE implementation
// Run: npx playwright test e2e/live-viewers.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Live Viewers — User Journeys", () => {
// ─── LV-E2E-1: Homepage badge ─────────────────────────────────
test("LV-E2E-1.1: Viewer count badge visible on homepage when count > 0", async ({
page,
}) => {
// Given: Multiple sessions are tracked in DB (count > 0)
// When: Guest visits homepage
// Then: Green dot + count visible in hero section
await page.goto("/en");
const badge = page.getByTestId("viewer-count-badge");
await expect(badge).toBeVisible();
await expect(badge.getByText(/viewing tonight/i)).toBeVisible();
});
test("LV-E2E-1.2: Count updates in real-time when new visitor arrives", async ({
page,
context,
}) => {
// Given: 2 separate browser contexts
// When: Page 1 visits homepage, then Page 2 visits
// Then: Page 2 sees incremented count without refresh
const page1 = await context.newPage();
const page2 = await context.newPage();
await page1.goto("/en");
await page2.goto("/en");
// Count should reflect both visitors
});
test("LV-E2E-1.3: Badge not rendered when count is zero", async ({
page,
}) => {
// Given: No active sessions (count = 0)
// When: Guest visits homepage
// Then: Badge not rendered at all (not just hidden text)
await page.goto("/en");
await expect(page.getByTestId("viewer-count-badge")).not.toBeVisible();
});
// ─── LV-E2E-2: Mobile layout ──────────────────────────────────
test("LV-E2E-2.1: Badge fits in hero layout on mobile", async ({ page }) => {
// Given: Mobile viewport
// When: Guest visits homepage
// Then: Badge does not overflow or cause horizontal scroll
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/en");
await expect(page.getByTestId("viewer-count-badge")).toBeVisible();
});
test("LV-E2E-2.2: Vietnamese locale viewer count text", async ({ page }) => {
// Given: Vietnamese locale
// When: Guest visits homepage
// Then: Vietnamese translation of viewer count visible
await page.goto("/vi");
await expect(page.getByText(/nguoi dang xem toi nay/i)).toBeVisible();
});
// ─── LV-E2E-3: Offline behavior ────────────────────────────────
test("LV-E2E-3.1: Badge shows last known count when offline", async ({
page,
context,
}) => {
// Given: Guest has visited homepage before (count cached)
// When: Guest goes offline and visits homepage again
// Then: Badge shows last known count from cache
await context.setOffline(true);
await page.goto("/en");
// Badge should show cached count or be hidden if count was 0
});
});Component Tests (Vitest + RTL) — User Expectation Format:
// apps/frontend/__tests__/components/live-viewer-count.test.tsx
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/components/live-viewer-count.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 { LiveViewerCount } from "~/components/home/live-viewer-count";
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => 5),
useMutation: vi.fn(() => () => Promise.resolve()),
}));
describe("LiveViewerCount — User Expectations", () => {
// ─── LV-UT-1: Badge rendering ──────────────────────────────────
it("LV-UT-1.1: Badge shows green dot and count when count > 0", async () => {
// Given: Viewer count is 5
// When: Component renders and hydrates
// Then: Green animated dot + "5 viewing tonight" text visible
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => 5),
useMutation: vi.fn(() => () => Promise.resolve()),
}));
render(<LiveViewerCount />);
await waitFor(() => {
expect(screen.getByText(/5 viewing tonight/i)).toBeVisible();
});
const dot = screen.getByTestId("viewer-count-dot");
expect(dot).toBeInTheDocument();
});
it("LV-UT-1.2: Badge is not rendered when count is 0", async () => {
// Given: Viewer count is 0
// When: Component renders
// Then: Component returns null, nothing rendered
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => 0),
useMutation: vi.fn(() => () => Promise.resolve()),
}));
const { container } = render(<LiveViewerCount />);
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
});
it("LV-UT-1.3: Badge shows correct count from real-time subscription", async () => {
// Given: Viewer count is 12
// When: Component renders
// Then: Count reflects live data
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => 12),
useMutation: vi.fn(() => () => Promise.resolve()),
}));
render(<LiveViewerCount />);
await waitFor(() => {
expect(screen.getByText(/12 viewing tonight/i)).toBeVisible();
});
});
it("LV-UT-1.4: Badge is null before hydration (during SSR)", () => {
// Given: Component is rendered on server
// When: useQuery returns undefined (SSR)
// Then: Component returns null to avoid hydration mismatch
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => undefined),
useMutation: vi.fn(() => () => Promise.resolve()),
}));
const { container } = render(<LiveViewerCount />);
expect(container.firstChild).toBeNull();
});
});Schema Unit Tests (Vitest):
// apps/frontend/__tests__/lib/live-viewers.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/lib/live-viewers.test.ts
import { describe, it, expect } from "vitest";
import { TrackViewSchema } from "~/lib/schemas/live-viewers";
describe("TrackViewSchema", () => {
it("LV-UT-2.1: accepts valid session ID", () => {
// Given: Valid UUID-style session ID
// When: Schema.parse is called
// Then: Returns success
const result = TrackViewSchema.safeParse({
sessionId: "abc123-def456-ghi789",
});
expect(result.success).toBe(true);
});
it("LV-UT-2.2: rejects empty session ID", () => {
// Given: Empty string for sessionId
// When: Schema.parse is called
// Then: Returns error
const result = TrackViewSchema.safeParse({ sessionId: "" });
expect(result.success).toBe(false);
});
it("LV-UT-2.3: rejects missing session ID", () => {
// Given: Missing sessionId field
// When: Schema.parse is called
// Then: Returns error
const result = TrackViewSchema.safeParse({});
expect(result.success).toBe(false);
});
});Mutation Backend Tests (Vitest):
// apps/frontend/__tests__/convex/live-viewers.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/convex/live-viewers.test.ts
import { describe, it, expect } from "vitest";
describe("trackView mutation", () => {
it("LV-MUT-1.1: successfully tracks new viewer", async () => {
// Given: No existing count for today
// When: trackView is called with valid sessionId
// Then: liveViewers record created with count=1
});
it("LV-MUT-1.2: increments existing count", async () => {
// Given: Existing count of 5 for today
// When: trackView is called with new sessionId
// Then: count incremented to 6
});
it("LV-MUT-1.3: rejects empty session ID", async () => {
// Given: Empty session ID
// When: trackView is called
// Then: throws LIVE_VIEWERS_INVALID_SESSION error
});
it("LV-MUT-1.4: rejects session ID shorter than 4 chars", async () => {
// Given: Session ID with 3 characters
// When: trackView is called
// Then: throws LIVE_VIEWERS_INVALID_SESSION error
});
});
describe("getCount query", () => {
it("LV-QUERY-1.1: returns 0 when no record exists", async () => {
// Given: No liveViewers record for today
// When: getCount is called
// Then: returns 0
});
it("LV-QUERY-1.2: returns correct count", async () => {
// Given: liveViewers record with count=42
// When: getCount is called
// Then: returns 42
});
});9. Cross-Plan Dependencies
| Depends On | Required By | Shared Schema |
|---|---|---|
showOccurrences table | Live Viewers (optional) | occurrenceId reference for per-show counts |
No hard dependencies — this is a standalone feature. However, it may be integrated with:
- Trust Signals — social proof display
- Homepage — primary placement
10. Performance Considerations
- Session dedup: Uses
sessionStorageto prevent double-counting on page refresh (within same tab) - Debounce: 2-second debounce on
trackViewmutation prevents rapid-fire calls - Hydration pattern:
useState+isHydratedflag prevents SSR mismatch withuseQueryreturningundefinedon server - Count accuracy: At scale (>10,000 concurrent), consider using Redis-backed counter via Convex HTTP action instead of DB counter
- Broadcast efficiency: Convex real-time broadcasts to all subscribers; for >5,000 concurrent homepage visitors, monitor Convex usage
- TTL cleanup: Add scheduled mutation to reset counts at show end (3 AM local time) to prevent stale counts
- No
Math.random():crypto.randomUUIDis used for session ID generation - No
console.log: Usesconsola.debugfor non-critical failures,consola.errorfor unexpected errors - No
as any: All types are properly defined
Acceptance Criteria
- Homepage shows live viewer count badge (green dot + count) when count > 0
- Badge not rendered when count is 0 (not just hidden)
- Count is real-time via Convex subscription
- Session debounced to avoid inflating count on refresh
- Session ID uses
crypto.randomUUID(no insecure fallback) - No
console.log/console.warncalls — useconsola Suspenseboundary with skeleton on homepage badge- SSR-safe: no hydration mismatch between server (null) and client (badge or null)
Consistency Audit: live-viewers
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | LiveViewerCount component | Fallback path for crypto.randomUUID silently returned empty string — security issue | Added consola.error in fallback path and explicit empty string return. Modern browsers always support crypto.randomUUID, so this branch is defensive only. The empty string return causes validation to fail on the mutation, which is the intended fail-safe behavior. |
| 2 | LiveViewerCount component | Missing hydration tracking — useQuery returns undefined on server, causing hydration mismatch | Added useState for local count and isHydrated flag. Component returns null until isHydrated is true. |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| — | None | No P1 violations found | N/A |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | TTL cleanup for stale counts | Add scheduled Convex mutation to reset counts at show end (3 AM local time) |