Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions app/analyze/[reportCode]/ReportSummary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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
// 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 (
<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&apos;s DPS, HPS, gear, enchants, and consumable usage
against the top-ranked parses and generates specific improvement
suggestions.
</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>
<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>
);
}
79 changes: 68 additions & 11 deletions app/analyze/[reportCode]/page.tsx
Original file line number Diff line number Diff line change
@@ -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>>;
Expand All @@ -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,
Expand All @@ -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],
},
Expand All @@ -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} />
)}
</>
);
}
32 changes: 3 additions & 29 deletions app/api/report/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`);
}
Expand Down
77 changes: 32 additions & 45 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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];
}
Loading