plans
2026-05-03
2026 05 03 Live Viewers Plan

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 liveViewers table 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.ts functions
// 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

OperationError CodeMessage KeyNotes
trackViewLIVE_VIEWERS_INVALID_SESSIONSession 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

ComponentMobile BehaviorDesktop Behavior
BadgeInline, same sizingSame

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.getCount responses with 60s stale-while-revalidate
  • Privacy: Session ID stored in sessionStorage (not localStorage), 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 URL

No 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 OnRequired ByShared Schema
showOccurrences tableLive 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 sessionStorage to prevent double-counting on page refresh (within same tab)
  • Debounce: 2-second debounce on trackView mutation prevents rapid-fire calls
  • Hydration pattern: useState + isHydrated flag prevents SSR mismatch with useQuery returning undefined on 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.randomUUID is used for session ID generation
  • No console.log: Uses consola.debug for non-critical failures, consola.error for unexpected errors
  • No as any: All types are properly defined

Acceptance Criteria

  1. Homepage shows live viewer count badge (green dot + count) when count > 0
  2. Badge not rendered when count is 0 (not just hidden)
  3. Count is real-time via Convex subscription
  4. Session debounced to avoid inflating count on refresh
  5. Session ID uses crypto.randomUUID (no insecure fallback)
  6. No console.log/console.warn calls — use consola
  7. Suspense boundary with skeleton on homepage badge
  8. SSR-safe: no hydration mismatch between server (null) and client (badge or null)

Consistency Audit: live-viewers

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1LiveViewerCount componentFallback path for crypto.randomUUID silently returned empty string — security issueAdded 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.
2LiveViewerCount componentMissing hydration tracking — useQuery returns undefined on server, causing hydration mismatchAdded useState for local count and isHydrated flag. Component returns null until isHydrated is true.

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
NoneNo P1 violations foundN/A

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

#IssueAction Required
1TTL cleanup for stale countsAdd scheduled Convex mutation to reset counts at show end (3 AM local time)