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

+ +
+ )} + + {meta.players.length > 0 && ( +
+

Roster

+ +
+ )} + +

+ 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" }; + } + }, +);