From 0937dfbe339c82c729cae64be5787082b880ff12 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 19:26:56 +0000 Subject: [PATCH 1/2] [#209] Add view counts for storyline and plot pages - Migration: page_views table with session dedup, view_count column on storylines, atomic increment_view_count RPC function - API: POST /api/views (insert + dedup per session/hour), GET /api/views (returns denormalized count) - ViewCount component: SVG eye icon + compact number formatting (1.2k, 1M) - ViewTracker component: fire-and-forget POST on page mount with sessionStorage-based session ID - Display: home page story cards, story detail header, writer dashboard - No performance impact on page load (tracking is async/non-blocking) Fixes #209 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/supabase.ts | 29 ++++++ src/app/api/views/route.ts | 111 ++++++++++++++++++++ src/app/dashboard/writer/page.tsx | 15 ++- src/app/story/[storylineId]/page.tsx | 3 + src/components/StoryCard.tsx | 2 + src/components/ViewCount.tsx | 126 +++++++++++++++++++++++ supabase/migrations/00007_page_views.sql | 31 ++++++ 7 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/app/api/views/route.ts create mode 100644 src/components/ViewCount.tsx create mode 100644 supabase/migrations/00007_page_views.sql diff --git a/lib/supabase.ts b/lib/supabase.ts index 293f405f..16a8f34a 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -60,6 +60,7 @@ export interface Database { log_index: number; block_timestamp: string | null; indexed_at: string; + view_count: number; }; Insert: { id?: never; @@ -77,6 +78,7 @@ export interface Database { log_index: number; block_timestamp?: string | null; indexed_at?: string; + view_count?: number; }; Update: { id?: never; @@ -94,6 +96,33 @@ export interface Database { log_index?: number; block_timestamp?: string | null; indexed_at?: string; + view_count?: number; + }; + }; + page_views: { + Row: { + id: number; + storyline_id: number; + plot_index: number | null; + viewer_address: string | null; + session_id: string; + viewed_at: string; + }; + Insert: { + id?: never; + storyline_id: number; + plot_index?: number | null; + viewer_address?: string | null; + session_id: string; + viewed_at?: string; + }; + Update: { + id?: never; + storyline_id?: number; + plot_index?: number | null; + viewer_address?: string | null; + session_id?: string; + viewed_at?: string; }; }; plots: { diff --git a/src/app/api/views/route.ts b/src/app/api/views/route.ts new file mode 100644 index 00000000..075a938e --- /dev/null +++ b/src/app/api/views/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServerClient, supabase } from "../../../../lib/supabase"; + +function error(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +// --------------------------------------------------------------------------- +// GET /api/views?storylineId=N +// --------------------------------------------------------------------------- + +export async function GET(req: NextRequest) { + const storylineId = req.nextUrl.searchParams.get("storylineId"); + if (!storylineId) return error("Missing storylineId"); + + const db = supabase; + if (!db) return error("Supabase not configured", 500); + + const sid = Number(storylineId); + if (isNaN(sid) || sid <= 0) return error("Invalid storylineId"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error: dbError } = await (db.from("storylines") as any) + .select("view_count") + .eq("storyline_id", sid) + .single(); + + if (dbError) return error(`Database error: ${dbError.message}`, 500); + if (!data) return error("Storyline not found", 404); + + return NextResponse.json({ storylineId: sid, viewCount: data.view_count ?? 0 }); +} + +// --------------------------------------------------------------------------- +// POST /api/views +// Body: { storylineId, plotIndex?, sessionId, viewerAddress? } +// Dedup: max 1 view per session per page per hour +// --------------------------------------------------------------------------- + +interface ViewBody { + storylineId: number; + plotIndex?: number | null; + sessionId: string; + viewerAddress?: string | null; +} + +export async function POST(req: NextRequest) { + let body: ViewBody; + try { + body = await req.json(); + } catch { + return error("Invalid JSON body"); + } + + const { storylineId, plotIndex, sessionId, viewerAddress } = body; + + if (!storylineId || typeof storylineId !== "number" || storylineId <= 0) { + return error("Missing or invalid storylineId"); + } + if (!sessionId || typeof sessionId !== "string" || sessionId.length > 128) { + return error("Missing or invalid sessionId"); + } + + const serverClient = createServerClient(); + if (!serverClient) return error("Supabase not configured", 500); + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const plotVal = plotIndex ?? null; + + // Dedup: check if this session already viewed this page in the last hour + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let dedupQuery = (serverClient.from("page_views") as any) + .select("id") + .eq("storyline_id", storylineId) + .eq("session_id", sessionId) + .gte("viewed_at", oneHourAgo) + .limit(1); + + if (plotVal === null) { + dedupQuery = dedupQuery.is("plot_index", null); + } else { + dedupQuery = dedupQuery.eq("plot_index", plotVal); + } + + const { data: existing } = await dedupQuery; + + if (existing && existing.length > 0) { + return NextResponse.json({ success: true, deduplicated: true }); + } + + // Insert page view record + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: insertError } = await (serverClient.from("page_views") as any).insert({ + storyline_id: storylineId, + plot_index: plotVal, + viewer_address: viewerAddress?.toLowerCase() ?? null, + session_id: sessionId, + }); + + if (insertError) return error(`Database error: ${insertError.message}`, 500); + + // Increment denormalized counter (storyline-level views only) + if (plotVal === null) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (serverClient.rpc as any)("increment_view_count", { sid: storylineId }).catch(() => { + // Ignore — counter will be slightly behind but page_views table is authoritative + }); + } + + return NextResponse.json({ success: true, deduplicated: false }); +} diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index dd624c40..63443d9d 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -15,6 +15,13 @@ import Link from "next/link"; import { ConnectWallet } from "../../../components/ConnectWallet"; import { type Address } from "viem"; +function formatViewCountDashboard(n: number): string { + if (n < 1000) return String(n); + if (n < 10000) return `${(n / 1000).toFixed(1)}k`; + if (n < 1000000) return `${Math.round(n / 1000)}k`; + return `${(n / 1000000).toFixed(1)}M`; +} + async function fetchWriterStorylines( address: string, ): Promise { @@ -101,13 +108,19 @@ function StorylineDetail({ storyline, writerAddress }: { storyline: Storyline; w )} -
+
Plots {storyline.plot_count}
+
+ + Views + + {formatViewCountDashboard(storyline.view_count)} +
Created diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 97867298..9853fb4a 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -14,6 +14,7 @@ import { type Address } from "viem"; import { truncateAddress } from "../../../../lib/utils"; import { AgentBadge } from "../../../components/AgentBadge"; import { WriterIdentity } from "../../../components/WriterIdentity"; +import { ViewCount, ViewTracker } from "../../../components/ViewCount"; type Params = Promise<{ storylineId: string }>; @@ -123,6 +124,7 @@ export default async function StoryPage({ params }: { params: Params }) { return (
+
@@ -186,6 +188,7 @@ function StoryHeader({ {storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} + {storyline.writer_type === 1 && }
diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 40e02a2f..29a95fc7 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -4,6 +4,7 @@ import { truncateAddress } from "../../lib/utils"; import { AgentBadge } from "./AgentBadge"; import { RatingSummary } from "./RatingSummary"; import { StoryCardStats } from "./StoryCardStats"; +import { ViewCount } from "./ViewCount"; export function StoryCard({ storyline, @@ -44,6 +45,7 @@ export function StoryCard({ {storyline.plot_count} {storyline.plot_count === 1 ? "plot" : "plots"} + {dateStr && {dateStr}} {genre && ( diff --git a/src/components/ViewCount.tsx b/src/components/ViewCount.tsx new file mode 100644 index 00000000..8ae1d7b5 --- /dev/null +++ b/src/components/ViewCount.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAccount } from "wagmi"; + +// --------------------------------------------------------------------------- +// SVG eye icon matching the design system +// --------------------------------------------------------------------------- + +function EyeIcon({ className }: { className?: string }) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Compact number formatting: 1234 → "1.2k", 1000000 → "1M" +// --------------------------------------------------------------------------- + +function formatViewCount(n: number): string { + if (n < 1000) return String(n); + if (n < 10000) return `${(n / 1000).toFixed(1)}k`; + if (n < 1000000) return `${Math.round(n / 1000)}k`; + if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`; + return `${Math.round(n / 1000000)}M`; +} + +// --------------------------------------------------------------------------- +// ViewCount — displays eye icon + count (fetches from server) +// --------------------------------------------------------------------------- + +export function ViewCount({ + storylineId, + initialCount, +}: { + storylineId: number; + initialCount?: number; +}) { + const { data } = useQuery({ + queryKey: ["view-count", storylineId], + queryFn: async () => { + const res = await fetch(`/api/views?storylineId=${storylineId}`); + if (!res.ok) return initialCount ?? 0; + const json = await res.json(); + return json.viewCount as number; + }, + initialData: initialCount, + staleTime: 120000, + }); + + const count = data ?? initialCount ?? 0; + + return ( + + + {formatViewCount(count)} + + ); +} + +// --------------------------------------------------------------------------- +// ViewTracker — fire-and-forget POST on mount to record a view +// --------------------------------------------------------------------------- + +function getSessionId(): string { + if (typeof window === "undefined") return ""; + const key = "plotlink-session-id"; + let id = sessionStorage.getItem(key); + if (!id) { + id = crypto.randomUUID(); + sessionStorage.setItem(key, id); + } + return id; +} + +export function ViewTracker({ + storylineId, + plotIndex, +}: { + storylineId: number; + plotIndex?: number | null; +}) { + const { address } = useAccount(); + const queryClient = useQueryClient(); + + useEffect(() => { + const sessionId = getSessionId(); + if (!sessionId) return; + + fetch("/api/views", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + storylineId, + plotIndex: plotIndex ?? null, + sessionId, + viewerAddress: address ?? null, + }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.success && !data.deduplicated) { + // Invalidate the cached count so it refetches + queryClient.invalidateQueries({ queryKey: ["view-count", storylineId] }); + } + }) + .catch(() => { + // Silently ignore — view tracking is best-effort + }); + }, [storylineId, plotIndex, address, queryClient]); + + return null; +} diff --git a/supabase/migrations/00007_page_views.sql b/supabase/migrations/00007_page_views.sql new file mode 100644 index 00000000..7680409e --- /dev/null +++ b/supabase/migrations/00007_page_views.sql @@ -0,0 +1,31 @@ +-- Add denormalized view_count to storylines +ALTER TABLE storylines ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0; + +-- page_views table for granular tracking +CREATE TABLE page_views ( + id SERIAL PRIMARY KEY, + storyline_id INTEGER NOT NULL REFERENCES storylines(storyline_id), + plot_index INTEGER, -- NULL = storyline page, 0 = genesis, 1+ = chapter + viewer_address TEXT, -- NULL for anonymous views + session_id TEXT NOT NULL, -- fingerprint for session dedup + viewed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_page_views_storyline ON page_views(storyline_id); +CREATE INDEX idx_page_views_plot ON page_views(storyline_id, plot_index); +CREATE INDEX idx_page_views_dedup ON page_views(storyline_id, plot_index, session_id, viewed_at); + +-- RLS: public read, service-role insert (API route uses service role) +ALTER TABLE page_views ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public read page_views" + ON page_views FOR SELECT + USING (true); + +-- Atomic increment function for view_count +CREATE OR REPLACE FUNCTION increment_view_count(sid INTEGER) +RETURNS void AS $$ +BEGIN + UPDATE storylines SET view_count = view_count + 1 WHERE storyline_id = sid; +END; +$$ LANGUAGE plpgsql; From 281db3f397b77e88052ad3c124cf60601be08066 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Mon, 16 Mar 2026 19:30:20 +0000 Subject: [PATCH 2/2] [#209] Wire per-plot view tracking on story page Each PlotEntry now mounts a ViewTracker with plotIndex, so individual plot views are recorded in page_views (not just storyline-level). Addresses T2a review feedback on PR #217. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/story/[storylineId]/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 9853fb4a..2b5fd116 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -230,6 +230,7 @@ function StoryHeader({ function PlotEntry({ plot }: { plot: Plot }) { return (
+
{plot.plot_index === 0 ? "Genesis" : `Plot #${plot.plot_index}`}