diff --git a/app/analyze/[reportCode]/ReportSummary.tsx b/app/analyze/[reportCode]/ReportSummary.tsx
new file mode 100644
index 0000000..1125e50
--- /dev/null
+++ b/app/analyze/[reportCode]/ReportSummary.tsx
@@ -0,0 +1,117 @@
+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 (
+
+
+ {label}
+
+
{value}
+
+ );
+}
+
+// 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 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;
+
+ return (
+
+
+
+ {headline} — WoW Classic raid analysis
+
+
+ Performance analysis of{" "}
+ {headline} 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.
+
+
+
+
+
+
+
+ {combatMs > 0 && }
+
+
+ {bosses.length > 0 && (
+
+
Encounters
+
+ {bosses.map((f) => (
+ -
+ {f.name}
+
+ {f.kill ? "Kill" : "Wipe"}
+
+
+ ))}
+
+
+ )}
+
+ {meta.players.length > 0 && (
+
+
Roster
+
+ {meta.players.map((p) => (
+ -
+ {p.name}
+
+ ))}
+
+
+ )}
+
+
+ Source:{" "}
+
+ Warcraft Logs report {reportCode}
+
+
+
+ );
+}
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>;
@@ -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 ;
+ 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 (
+ <>
+
+ {result.status === "ok" && (
+
+ )}
+ >
+ );
}
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/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 {
+ 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 {
+ 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
new file mode 100644
index 0000000..1798a80
--- /dev/null
+++ b/lib/report-meta.ts
@@ -0,0 +1,78 @@
+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";
+
+/**
+ * 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 => {
+ 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" };
+ // 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) {
+ 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" };
+ }
+ },
+);