From de42486bfcbf658b49bd95c1835d4c90d17d3049 Mon Sep 17 00:00:00 2001 From: Alexander Mayes Date: Thu, 16 Jul 2026 15:36:17 -0700 Subject: [PATCH 1/2] SSR public report summaries + make them indexable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The biggest SEO lever from the funnel analysis: ~1,500 analyses/mo generated zero organic search value because analyze pages were noindex + fully client-rendered, while Google is already the #1 acquisition channel. - lib/report-meta.ts: cached (React cache) server fetch returning a discriminated result (ok/private/not_found/error); shares mapReportMeta with the API route so the mapping lives in one place. - page.tsx: real per-report /description/OG from the actual report (was "Report ABC123"); index ONLY when the report loads publicly; canonical without query params folds ?fight=&source= variants; deleted reports return a real 404; private/errored stay noindex. - ReportSummary.tsx: server-rendered, crawlable summary (headline, zone, encounter list, roster) below the unchanged interactive app. Safeguards keep the ephemeral-content risk contained: private → noindex, deleted → 404, transient WCL errors → noindex + client retry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TG7Bgzws6wZi3hA1P8Gs2i --- app/analyze/[reportCode]/ReportSummary.tsx | 90 ++++++++++++++++++++++ app/analyze/[reportCode]/page.tsx | 79 ++++++++++++++++--- app/api/report/[code]/route.ts | 32 +------- lib/report-meta.ts | 75 ++++++++++++++++++ 4 files changed, 236 insertions(+), 40 deletions(-) create mode 100644 app/analyze/[reportCode]/ReportSummary.tsx create mode 100644 lib/report-meta.ts diff --git a/app/analyze/[reportCode]/ReportSummary.tsx b/app/analyze/[reportCode]/ReportSummary.tsx new file mode 100644 index 0000000..04b17a1 --- /dev/null +++ b/app/analyze/[reportCode]/ReportSummary.tsx @@ -0,0 +1,90 @@ +import { CLASS_COLORS } from "@/lib/constants"; +import type { ReportMeta } from "@/lib/wcl-types"; + +// Server-rendered summary of a public report. This is the crawlable content +// that makes analyze pages indexable — real boss/player/zone entities in the +// HTML rather than a fully client-rendered shell. Rendered below the +// interactive app (see page.tsx); the client experience is unchanged. +export default function ReportSummary({ + meta, + reportCode, +}: { + meta: ReportMeta; + reportCode: string; +}) { + const bosses = meta.fights; + const kills = bosses.filter((f) => f.kill).length; + const rawTitle = meta.title?.trim(); + const hasTitle = !!rawTitle && !/^[?\s.]+$/.test(rawTitle); + const headline = hasTitle ? rawTitle! : meta.zone; + + return ( + <section + aria-label="Report summary" + className="mt-12 border-t border-white/[0.06] pt-8 space-y-6 text-sm" + > + <div className="space-y-2"> + <h2 className="text-heading-sm text-foreground"> + {headline} — WoW Classic raid analysis + </h2> + <p className="text-muted-foreground max-w-2xl"> + Performance analysis of{" "} + <span className="text-foreground">{headline}</span> in {meta.zone}, + logged by {meta.owner}. This report covers {bosses.length}{" "} + {bosses.length === 1 ? "encounter" : "encounters"} + {bosses.length > 0 && <> ({kills} killed)</>} and {meta.players.length}{" "} + {meta.players.length === 1 ? "raider" : "raiders"}. ParseForge compares + each player's DPS, HPS, gear, enchants, and consumable usage + against the top-ranked parses and generates specific improvement + suggestions. + </p> + </div> + + {bosses.length > 0 && ( + <div className="space-y-2"> + <h3 className="text-heading-sm text-muted-foreground">Encounters</h3> + <ul className="flex flex-wrap gap-2"> + {bosses.map((f) => ( + <li + key={f.id} + className="surface-card rounded-lg px-3 py-1.5 text-xs" + > + <span className="text-foreground">{f.name}</span> + <span + className={`ml-2 ${f.kill ? "text-status-good" : "text-muted-foreground"}`} + > + {f.kill ? "Kill" : "Wipe"} + </span> + </li> + ))} + </ul> + </div> + )} + + {meta.players.length > 0 && ( + <div className="space-y-2"> + <h3 className="text-heading-sm text-muted-foreground">Roster</h3> + <ul className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> + {meta.players.map((p) => ( + <li key={p.id} style={{ color: CLASS_COLORS[p.type] ?? undefined }}> + {p.name} + </li> + ))} + </ul> + </div> + )} + + <p className="text-xs text-muted-foreground"> + Source:{" "} + <a + href={`https://classic.warcraftlogs.com/reports/${reportCode}`} + target="_blank" + rel="noopener noreferrer" + className="text-gold-from hover:underline" + > + Warcraft Logs report {reportCode} + </a> + </p> + </section> + ); +} diff --git a/app/analyze/[reportCode]/page.tsx b/app/analyze/[reportCode]/page.tsx index 7829df1..63df682 100644 --- a/app/analyze/[reportCode]/page.tsx +++ b/app/analyze/[reportCode]/page.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import AnalyzeClient from "./AnalyzeClient"; +import ReportSummary from "./ReportSummary"; +import { getReportMeta } from "@/lib/report-meta"; type Params = Promise<{ reportCode: string }>; type SearchParams = Promise<Record<string, string | string[] | undefined>>; @@ -8,10 +11,17 @@ function first(v: string | string[] | undefined): string | undefined { return Array.isArray(v) ? v[0] : v; } -// NOTE: generateMetadata runs on every server render of this page, so it must -// stay cheap — no data fetching here. It only builds the /og image URL from the -// route + search params; the (cached) analysis fetch happens inside /og, which -// is only requested by link unfurlers. +// WCL report titles are user-supplied and often junk ("??", blank) — fall back +// to the zone so titles/headings never show noise. +function headlineFor(title: string, zone: string): string { + const t = title?.trim(); + return t && !/^[?\s.]+$/.test(t) ? t : zone; +} + +// generateMetadata fetches report meta via the request-cached getReportMeta, so +// it shares a single WCL call with the page render below. Indexing is enabled +// only when the report loads publicly; private/errored/missing reports stay +// noindex (and missing ones 404 in the page component). export async function generateMetadata({ params, searchParams, @@ -29,24 +39,56 @@ export async function generateMetadata({ if (source) ogParams.set("source", source); const ogUrl = `/og?${ogParams.toString()}`; - const title = `Report ${reportCode}`; - const description = + const result = await getReportMeta(reportCode); + const canonical = `https://parseforge.gg/analyze/${reportCode}`; + + const generic = "WoW Classic raid performance analysis — DPS percentiles, gear audits, buff tracking, and improvement suggestions."; + if (result.status !== "ok") { + return { + title: `Report ${reportCode}`, + description: generic, + robots: { index: false }, + openGraph: { + type: "website", + siteName: "ParseForge", + title: "ParseForge raid analysis", + description: generic, + images: [{ url: ogUrl, width: 1200, height: 630, alt: "ParseForge analysis" }], + }, + twitter: { + card: "summary_large_image", + title: "ParseForge raid analysis", + description: generic, + images: [ogUrl], + }, + }; + } + + const { meta } = result; + const headline = headlineFor(meta.title, meta.zone); + const title = `${headline} — WoW Classic Raid Analysis`; + const description = `Player-by-player analysis of ${headline} in ${meta.zone} — DPS/HPS percentiles, gear and enchant audits, buff uptime, and improvement tips for ${meta.players.length} raiders.`; + return { title, description, - robots: { index: false }, + // Canonical without query params folds every ?fight=&source= permutation + // into one indexable URL. + alternates: { canonical }, + robots: { index: true, follow: true }, openGraph: { type: "website", siteName: "ParseForge", - title: "ParseForge raid analysis", + url: canonical, + title, description, - images: [{ url: ogUrl, width: 1200, height: 630, alt: "ParseForge analysis" }], + images: [{ url: ogUrl, width: 1200, height: 630, alt: `${headline} raid analysis` }], }, twitter: { card: "summary_large_image", - title: "ParseForge raid analysis", + title, description, images: [ogUrl], }, @@ -55,5 +97,20 @@ export async function generateMetadata({ export default async function AnalyzePage({ params }: { params: Params }) { const { reportCode } = await params; - return <AnalyzeClient reportCode={reportCode} />; + const result = await getReportMeta(reportCode); + + // A report that genuinely doesn't exist (usually deleted) returns a real 404 + // so search engines drop it cleanly instead of indexing an error shell. + if (result.status === "not_found") { + notFound(); + } + + return ( + <> + <AnalyzeClient reportCode={reportCode} /> + {result.status === "ok" && ( + <ReportSummary meta={result.meta} reportCode={reportCode} /> + )} + </> + ); } diff --git a/app/api/report/[code]/route.ts b/app/api/report/[code]/route.ts index accf42a..448ae79 100644 --- a/app/api/report/[code]/route.ts +++ b/app/api/report/[code]/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { wclQuery, WCLError } from "@/lib/wcl-client"; import { REPORT_META_QUERY } from "@/lib/wcl-queries"; import { errorResponse } from "@/lib/api-utils"; -import { WCLReportData, ReportMeta } from "@/lib/wcl-types"; +import { mapReportMeta } from "@/lib/report-meta"; +import { WCLReportData } from "@/lib/wcl-types"; export async function GET( _request: NextRequest, @@ -27,34 +28,7 @@ export async function GET( throw new WCLError("not_found"); } - const meta: ReportMeta = { - title: report.title, - owner: report.owner?.name ?? "Unknown", - zone: report.zone?.name ?? "Unknown", - zoneExpansionId: report.zone?.expansion?.id, - fights: report.fights - .filter((f) => f.encounterID > 0) - .map((f) => ({ - id: f.id, - name: f.name, - encounterID: f.encounterID, - kill: f.kill, - difficulty: f.difficulty, - bossPercentage: f.bossPercentage, - duration: f.endTime - f.startTime, - })), - players: report.masterData.actors - .filter((a) => a.type === "Player") - .map((a) => ({ - id: a.id, - name: a.name, - type: a.subType, - subType: a.subType, - icon: a.icon ?? a.subType, - })), - }; - - return NextResponse.json(meta); + return NextResponse.json(mapReportMeta(report)); } catch (error) { return errorResponse(error, `report-${code}`); } diff --git a/lib/report-meta.ts b/lib/report-meta.ts new file mode 100644 index 0000000..5d6dc2c --- /dev/null +++ b/lib/report-meta.ts @@ -0,0 +1,75 @@ +import { cache } from "react"; +import { wclQuery, WCLError } from "@/lib/wcl-client"; +import { REPORT_META_QUERY } from "@/lib/wcl-queries"; +import type { WCLReportData, ReportMeta } from "@/lib/wcl-types"; + +/** + * Map a raw WCL report payload into our ReportMeta shape. Shared by the + * `/api/report/[code]` route and server-side rendering (see `getReportMeta`) + * so the mapping lives in exactly one place. + */ +export function mapReportMeta(report: WCLReportData): ReportMeta { + return { + title: report.title, + owner: report.owner?.name ?? "Unknown", + zone: report.zone?.name ?? "Unknown", + zoneExpansionId: report.zone?.expansion?.id, + fights: report.fights + .filter((f) => f.encounterID > 0) + .map((f) => ({ + id: f.id, + name: f.name, + encounterID: f.encounterID, + kill: f.kill, + difficulty: f.difficulty, + bossPercentage: f.bossPercentage, + duration: f.endTime - f.startTime, + })), + players: report.masterData.actors + .filter((a) => a.type === "Player") + .map((a) => ({ + id: a.id, + name: a.name, + type: a.subType, + subType: a.subType, + icon: a.icon ?? a.subType, + })), + }; +} + +export type ReportMetaResult = + | { status: "ok"; meta: ReportMeta } + | { status: "not_found" } + | { status: "private" } + | { status: "error" }; + +/** + * Server-side report fetch for rendering + metadata, returning a discriminated + * result instead of throwing so the page can choose to render, noindex, or 404. + * + * Wrapped in React `cache()` so `generateMetadata` and the page component share + * a single call per request; the underlying `wclQuery` also caches for 5 min. + */ +export const getReportMeta = cache( + async (code: string): Promise<ReportMetaResult> => { + if (!/^[a-zA-Z0-9]{10,20}$/.test(code)) return { status: "not_found" }; + + try { + const data = await wclQuery<{ reportData: { report: WCLReportData | null } }>( + REPORT_META_QUERY, + { code }, + ); + const report = data.reportData.report; + if (!report) return { status: "not_found" }; + return { status: "ok", meta: mapReportMeta(report) }; + } catch (err) { + if (err instanceof WCLError) { + if (err.kind === "not_found") return { status: "not_found" }; + if (err.kind === "private") return { status: "private" }; + } + // Transient (rate limit / timeout / upstream) — don't 404 or index, let + // the client retry. + return { status: "error" }; + } + }, +); From 0d34e921a1ec2f2e78813955a8c9620987d7660a Mon Sep 17 00:00:00 2001 From: Alexander Mayes <mayesalexander@gmail.com> Date: Thu, 16 Jul 2026 15:36:17 -0700 Subject: [PATCH 2/2] Self-populating report sitemap + richer SSR summary - kv-cache: recordRecentReport/getRecentReports back a Redis sorted set of public report codes (scored by last render). No new dependency; no-ops when shared Redis is absent (local dev). - report-meta: record each public report when it server-renders. - sitemap: emit the most recent public reports (hourly revalidate) so Google actively crawls them, alongside the static homepage + guides. - ReportSummary: add an at-a-glance stat row (encounters, kills, raiders, combat time) to the crawlable summary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TG7Bgzws6wZi3hA1P8Gs2i --- app/analyze/[reportCode]/ReportSummary.tsx | 27 ++++++++ app/sitemap.ts | 77 +++++++++------------- lib/kv-cache.ts | 41 ++++++++++++ lib/report-meta.ts | 3 + 4 files changed, 103 insertions(+), 45 deletions(-) diff --git a/app/analyze/[reportCode]/ReportSummary.tsx b/app/analyze/[reportCode]/ReportSummary.tsx index 04b17a1..1125e50 100644 --- a/app/analyze/[reportCode]/ReportSummary.tsx +++ b/app/analyze/[reportCode]/ReportSummary.tsx @@ -1,6 +1,25 @@ import { CLASS_COLORS } from "@/lib/constants"; import type { ReportMeta } from "@/lib/wcl-types"; +function formatDuration(ms: number): string { + const totalMin = Math.round(ms / 60_000); + if (totalMin < 60) return `${totalMin}m`; + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + <div className="surface-card rounded-lg px-4 py-2.5 min-w-[92px]"> + <div className="text-[11px] uppercase tracking-wider text-muted-foreground"> + {label} + </div> + <div className="text-lg font-semibold text-foreground">{value}</div> + </div> + ); +} + // Server-rendered summary of a public report. This is the crawlable content // that makes analyze pages indexable — real boss/player/zone entities in the // HTML rather than a fully client-rendered shell. Rendered below the @@ -14,6 +33,7 @@ export default function ReportSummary({ }) { const bosses = meta.fights; const kills = bosses.filter((f) => f.kill).length; + const combatMs = bosses.reduce((sum, f) => sum + (f.duration || 0), 0); const rawTitle = meta.title?.trim(); const hasTitle = !!rawTitle && !/^[?\s.]+$/.test(rawTitle); const headline = hasTitle ? rawTitle! : meta.zone; @@ -40,6 +60,13 @@ export default function ReportSummary({ </p> </div> + <div className="flex flex-wrap gap-2"> + <Stat label="Encounters" value={String(bosses.length)} /> + <Stat label="Kills" value={`${kills}/${bosses.length}`} /> + <Stat label="Raiders" value={String(meta.players.length)} /> + {combatMs > 0 && <Stat label="Combat" value={formatDuration(combatMs)} />} + </div> + {bosses.length > 0 && ( <div className="space-y-2"> <h3 className="text-heading-sm text-muted-foreground">Encounters</h3> diff --git a/app/sitemap.ts b/app/sitemap.ts index d51d562..7b1e6b6 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,48 +1,35 @@ import type { MetadataRoute } from "next"; +import { getRecentReports } from "@/lib/kv-cache"; -export default function sitemap(): MetadataRoute.Sitemap { - return [ - { - url: "https://parseforge.gg", - lastModified: new Date(), - changeFrequency: "weekly", - priority: 1, - }, - { - url: "https://parseforge.gg/guides", - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.8, - }, - { - url: "https://parseforge.gg/guides/how-to-analyze-wow-classic-logs", - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.7, - }, - { - url: "https://parseforge.gg/guides/improve-dps-wow-classic", - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.7, - }, - { - url: "https://parseforge.gg/guides/raid-preparation-checklist", - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.7, - }, - { - url: "https://parseforge.gg/guides/wow-classic-loot-council-tools", - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.7, - }, - { - url: "https://parseforge.gg/guides/warcraft-logs-vs-parseforge", - lastModified: new Date(), - changeFrequency: "monthly", - priority: 0.7, - }, - ]; +const BASE = "https://parseforge.gg"; + +// Regenerate hourly so newly-analyzed public reports enter the sitemap without +// a redeploy. +export const revalidate = 3600; + +const STATIC_ROUTES: MetadataRoute.Sitemap = [ + { url: BASE, changeFrequency: "weekly", priority: 1 }, + { url: `${BASE}/guides`, changeFrequency: "monthly", priority: 0.8 }, + { url: `${BASE}/guides/how-to-analyze-wow-classic-logs`, changeFrequency: "monthly", priority: 0.7 }, + { url: `${BASE}/guides/improve-dps-wow-classic`, changeFrequency: "monthly", priority: 0.7 }, + { url: `${BASE}/guides/raid-preparation-checklist`, changeFrequency: "monthly", priority: 0.7 }, + { url: `${BASE}/guides/wow-classic-loot-council-tools`, changeFrequency: "monthly", priority: 0.7 }, + { url: `${BASE}/guides/warcraft-logs-vs-parseforge`, changeFrequency: "monthly", priority: 0.7 }, +]; + +export default async function sitemap(): Promise<MetadataRoute.Sitemap> { + const now = new Date(); + const staticRoutes = STATIC_ROUTES.map((r) => ({ ...r, lastModified: now })); + + // Cap well under the 50k-URL sitemap limit; these are the freshest public + // reports we've server-rendered. + const recent = await getRecentReports(10_000); + const reportRoutes: MetadataRoute.Sitemap = recent.map((r) => ({ + url: `${BASE}/analyze/${r.code}`, + lastModified: new Date(r.ts), + changeFrequency: "weekly", + priority: 0.5, + })); + + return [...staticRoutes, ...reportRoutes]; } diff --git a/lib/kv-cache.ts b/lib/kv-cache.ts index 0c06de6..bfcfb50 100644 --- a/lib/kv-cache.ts +++ b/lib/kv-cache.ts @@ -86,3 +86,44 @@ export async function cacheSet(key: string, value: unknown, ttlMs: number): Prom } memSet(key, value, ttlMs); } + +// ─── Recently-indexed reports (feeds the sitemap) ──────────────────── +// A sorted set of report codes scored by last-seen timestamp, written each +// time a public report is server-rendered. Only active when shared Redis is +// configured; without it the sitemap simply omits report URLs (local dev). +const RECENT_REPORTS_KEY = "pf:recent_reports"; + +/** Record a public report as recently rendered. Best-effort; never throws. */ +export async function recordRecentReport(code: string, ts: number): Promise<void> { + if (!usingSharedCache) return; + try { + await redisCmd(["ZADD", RECENT_REPORTS_KEY, ts, code]); + } catch (err) { + console.error(`[kv-cache] recordRecentReport ${code} failed: ${(err as Error).message}`); + } +} + +/** Most-recently-rendered public report codes, newest first. */ +export async function getRecentReports( + limit: number, +): Promise<{ code: string; ts: number }[]> { + if (!usingSharedCache) return []; + try { + const { result } = await redisCmd([ + "ZREVRANGE", + RECENT_REPORTS_KEY, + 0, + limit - 1, + "WITHSCORES", + ]); + if (!Array.isArray(result)) return []; + const out: { code: string; ts: number }[] = []; + for (let i = 0; i < result.length; i += 2) { + out.push({ code: String(result[i]), ts: Number(result[i + 1]) }); + } + return out; + } catch (err) { + console.error(`[kv-cache] getRecentReports failed: ${(err as Error).message}`); + return []; + } +} diff --git a/lib/report-meta.ts b/lib/report-meta.ts index 5d6dc2c..1798a80 100644 --- a/lib/report-meta.ts +++ b/lib/report-meta.ts @@ -1,6 +1,7 @@ import { cache } from "react"; import { wclQuery, WCLError } from "@/lib/wcl-client"; import { REPORT_META_QUERY } from "@/lib/wcl-queries"; +import { recordRecentReport } from "@/lib/kv-cache"; import type { WCLReportData, ReportMeta } from "@/lib/wcl-types"; /** @@ -61,6 +62,8 @@ export const getReportMeta = cache( ); const report = data.reportData.report; if (!report) return { status: "not_found" }; + // Record this public report so the sitemap can surface it for crawling. + await recordRecentReport(code, Date.now()); return { status: "ok", meta: mapReportMeta(report) }; } catch (err) { if (err instanceof WCLError) {