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
52 changes: 0 additions & 52 deletions frontend/app/components/updates/EarlierArchive.tsx

This file was deleted.

29 changes: 13 additions & 16 deletions frontend/app/components/updates/UpdatesPageView.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,35 @@
import Link from "next/link";

import EarlierArchive from "@/app/components/updates/EarlierArchive";
import StaffTestPreview from "@/app/components/updates/StaffTestPreview";
import { WeekArticle, WeekSections } from "@/app/components/updates/WeekArchiveList";
import { WeekSections } from "@/app/components/updates/WeekArchiveList";
import type { WeekNotes } from "@/lib/patchnotes/notes";

const weekButtonClass =
"inline-flex w-full items-center justify-start rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_35%,transparent)] bg-transparent px-6 py-4 text-left text-sm font-semibold tracking-wide text-[var(--tfmc-cream)] transition-colors hover:border-[var(--tfmc-cream)] hover:bg-[color-mix(in_srgb,var(--tfmc-cream)_8%,transparent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--tfmc-cream)]";

export default function UpdatesPageView({
weeks,
unavailable = false,
hasMore = false,
}: {
weeks: readonly WeekNotes[];
weeks: readonly { week: string; label: string }[];
unavailable?: boolean;
hasMore?: boolean;
}) {
const oldest = weeks.at(-1)?.week;

return (
<main className="mx-auto min-h-[calc(100dvh-var(--tfmc-header-h))] max-w-3xl px-6 py-16">
<h1 className="font-[family-name:var(--font-fraunces)] text-4xl text-[var(--tfmc-cream)]">Updates</h1>
<p className="mt-2 text-[var(--tfmc-mist)]">
Scroll through the weeks. Each week also has its own page. Technical notes stay folded.
</p>
<p className="mt-2 text-[var(--tfmc-mist)]">Each week that has notes has its own page.</p>
<StaffTestPreview />

{unavailable ? (
<p className="mt-10 text-[var(--tfmc-stone)]">Patch notes are unavailable right now.</p>
) : weeks.length > 0 ? (
<div className="mt-10 [&>article:first-child]:mt-0 [&>article:first-child]:border-t-0 [&>article:first-child]:pt-0">
<nav className="mt-10 flex flex-col gap-3" aria-label="Patch notes by week">
{weeks.map((notes) => (
<WeekArticle key={notes.week} notes={notes} />
<Link key={notes.week} href={`/updates/${notes.week}`} className={weekButtonClass}>
{notes.label}
</Link>
))}
{hasMore && oldest ? <EarlierArchive key={oldest} before={oldest} /> : null}
</div>
</nav>
) : (
<p className="mt-10 text-[var(--tfmc-stone)]">Nothing has been published yet.</p>
)}
Expand All @@ -49,14 +46,14 @@ export function WeekPageView({
}) {
return (
<main className="mx-auto min-h-[calc(100dvh-var(--tfmc-header-h))] max-w-3xl px-6 py-16">
<Link href="/updates" className="text-sm text-[var(--tfmc-mist)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline">
<Link href="/updates" className={weekButtonClass}>
All updates
</Link>
{unavailable ? (
<p className="mt-10 text-[var(--tfmc-stone)]">Patch notes are unavailable right now.</p>
) : notes ? (
<>
<h1 className="mt-6 font-[family-name:var(--font-fraunces)] text-4xl text-[var(--tfmc-cream)]">{notes.label}</h1>
<h1 className="mt-8 font-[family-name:var(--font-fraunces)] text-4xl text-[var(--tfmc-cream)]">{notes.label}</h1>
<WeekSections bullets={notes.bullets} />
</>
) : null}
Expand Down
17 changes: 1 addition & 16 deletions frontend/app/components/updates/WeekArchiveList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import Link from "next/link";

import { arrangeNote, SECTION_LABELS, type PublicBullet, type WeekNotes } from "@/lib/patchnotes/notes";
import { arrangeNote, SECTION_LABELS, type PublicBullet } from "@/lib/patchnotes/notes";

const detailsClass =
"rounded-md border border-[color-mix(in_srgb,var(--tfmc-cream)_14%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_35%,transparent)]";
Expand Down Expand Up @@ -52,16 +50,3 @@ export function WeekSections({ bullets }: { bullets: readonly PublicBullet[] })
</>
);
}

export function WeekArticle({ notes }: { notes: WeekNotes }) {
return (
<article className="mt-12 border-t border-[color-mix(in_srgb,var(--tfmc-cream)_12%,transparent)] pt-10">
<h2 className="font-[family-name:var(--font-fraunces)] text-2xl text-[var(--tfmc-cream)]">
<Link href={`/updates/${notes.week}`} className="hover:underline">
{notes.label}
</Link>
</h2>
<WeekSections bullets={notes.bullets} />
</article>
);
}
89 changes: 42 additions & 47 deletions frontend/app/updates/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import { describe, expect, it } from "vitest";
import UpdatesPageView, { WeekPageView } from "../components/updates/UpdatesPageView";
import type { WeekNotes } from "@/lib/patchnotes/notes";

const current: WeekNotes = {
const current = {
week: "2026-W39",
label: "Week of 21 September 2026",
};

const earlier = {
week: "2026-W38",
label: "Week of 14 September 2026",
};

const notes: WeekNotes = {
week: "2026-W39",
label: "Week of 21 September 2026",
bullets: [
Expand All @@ -18,20 +28,41 @@ const current: WeekNotes = {
],
};

const earlier: WeekNotes = {
week: "2026-W38",
label: "Week of 14 September 2026",
bullets: [{ id: "old-1", section: "fixed", body: "Fixed a chest" }],
};

function markup(weeks: WeekNotes[], unavailable = false): string {
function markup(weeks: { week: string; label: string }[], unavailable = false): string {
return renderToStaticMarkup(<UpdatesPageView weeks={weeks} unavailable={unavailable} />);
}

describe("Updates page", () => {
it("shows the latest week open and folds technical notes", () => {
const html = markup([current]);
it("lists each week as a button and leaves the notes off the index", () => {
const html = markup([current, earlier]);
expect(html).toContain('href="/updates/2026-W39"');
expect(html).toContain('href="/updates/2026-W38"');
expect(html).toContain("Week of 21 September 2026");
expect(html).toContain("Week of 14 September 2026");
expect(html).toContain("rounded-sm border");
expect(html).not.toContain("Added a station");
expect(html).not.toContain("Fixed a chest");
expect(html).not.toContain("hover:underline");
expect(html.indexOf("2026-W39")).toBeLessThan(html.indexOf("2026-W38"));
});

it("says when nothing is published", () => {
expect(markup([])).toContain("Nothing has been published yet.");
});

it("says when the notes cannot be loaded", () => {
const html = markup([current], true);
expect(html).toContain("Patch notes are unavailable right now.");
expect(html).not.toContain("Week of 21 September 2026");
});
});

describe("Week page", () => {
it("shows that week and folds technical notes", () => {
const html = renderToStaticMarkup(<WeekPageView notes={notes} />);
expect(html).toContain('href="/updates"');
expect(html).toContain("All updates");
expect(html).toContain("rounded-sm border");
expect(html).toContain("Week of 21 September 2026");
expect(html).toContain("Highlights");
expect(html).toContain("Crafting");
Expand All @@ -52,44 +83,8 @@ describe("Updates page", () => {
});

it("renders bullet text instead of HTML", () => {
const html = markup([current]);
const html = renderToStaticMarkup(<WeekPageView notes={notes} />);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
});

it("scrolls through older weeks and links each one", () => {
const html = markup([current, earlier]);
expect(html).toContain('href="/updates/2026-W39"');
expect(html).toContain('href="/updates/2026-W38"');
expect(html).toContain("Week of 14 September 2026");
expect(html).toContain("Fixed a chest");
expect(html.indexOf("Added a station")).toBeLessThan(html.indexOf("Fixed a chest"));
expect(html).not.toContain("Show older");
});

it("offers older weeks without including them in the first response", () => {
const html = renderToStaticMarkup(<UpdatesPageView weeks={[current]} hasMore />);
expect(html).toContain("Show older");
expect(html).toContain("Added a station");
expect(html).not.toContain("Fixed a chest");
});

it("shows one week on its own page", () => {
const html = renderToStaticMarkup(<WeekPageView notes={earlier} />);
expect(html).toContain('href="/updates"');
expect(html).toContain("Week of 14 September 2026");
expect(html).toContain("Fixed a chest");
expect(html).not.toContain("Added a station");
expect(html).not.toContain('href="/updates/2026-W38"');
});

it("says when nothing is published", () => {
expect(markup([])).toContain("Nothing has been published yet.");
});

it("says when the notes cannot be loaded", () => {
const html = markup([current], true);
expect(html).toContain("Patch notes are unavailable right now.");
expect(html).not.toContain("Added a station");
});
});
6 changes: 3 additions & 3 deletions frontend/app/updates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Metadata } from "next";

import UpdatesPageView from "@/app/components/updates/UpdatesPageView";
import { loadPublishedNotes } from "@/lib/patchnotes/api";
import { loadPublishedWeekIndex } from "@/lib/patchnotes/api";

export const dynamic = "force-dynamic";

Expand All @@ -11,9 +11,9 @@ export const metadata: Metadata = {
};

export default async function UpdatesPage() {
const notes = await loadPublishedNotes({ limit: 8 });
const notes = await loadPublishedWeekIndex();
if (!notes.ok) {
return <UpdatesPageView weeks={[]} unavailable />;
}
return <UpdatesPageView weeks={notes.weeks} hasMore={notes.hasMore} />;
return <UpdatesPageView weeks={notes.weeks} />;
}
32 changes: 31 additions & 1 deletion frontend/lib/patchnotes/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPublishedNotes, loadPublishedWeek } from "./api";
import { loadPublishedNotes, loadPublishedWeek, loadPublishedWeekIndex } from "./api";

afterEach(() => {
vi.unstubAllEnvs();
Expand Down Expand Up @@ -97,4 +97,34 @@ describe("loadPublishedWeek", () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 502 })));
expect(await loadPublishedWeek("2026-W39")).toEqual({ ok: false, missing: false });
});
});

describe("loadPublishedWeekIndex", () => {
it("lists published weeks without their lines", async () => {
vi.stubEnv("NEXT_PUBLIC_API_URL", "http://api.test");
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ weeks: ["2026-W39", "2026-W38"] }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);

const notes = await loadPublishedWeekIndex();

expect(fetchMock).toHaveBeenCalledWith(
"http://api.test/patchnotes/weeks",
expect.objectContaining({ cache: "no-store", signal: expect.any(AbortSignal) }),
);
expect(notes).toEqual({
ok: true,
weeks: [
{ week: "2026-W39", label: "Week of 21 September 2026" },
{ week: "2026-W38", label: "Week of 14 September 2026" },
],
});
});

it("reports the page unavailable when the API fails", async () => {
vi.stubEnv("NEXT_PUBLIC_API_URL", "http://api.test");
vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 502 })));
expect(await loadPublishedWeekIndex()).toEqual({ ok: false });
});
});
29 changes: 29 additions & 0 deletions frontend/lib/patchnotes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ export type PublishedWeek =
| { ok: true; week: WeekNotes }
| { ok: false; missing: boolean };

export type PublishedWeekIndex =
| { ok: true; weeks: { week: string; label: string }[] }
| { ok: false };

/** Weeks that have published notes, newest first. The index does not need the lines. */
export async function loadPublishedWeekIndex(): Promise<PublishedWeekIndex> {
const base = apiBase();
if (!base) return { ok: false };
try {
const res = await fetch(`${base}/patchnotes/weeks`, {
cache: "no-store",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) return { ok: false };
const body: unknown = await res.json();
if (!body || typeof body !== "object" || !Array.isArray((body as { weeks?: unknown }).weeks)) {
return { ok: false };
}
const weeks: { week: string; label: string }[] = [];
for (const entry of (body as { weeks: unknown[] }).weeks) {
if (typeof entry !== "string" || !isWeekKey(entry)) return { ok: false };
weeks.push({ week: entry, label: weekLabel(entry) });
}
return { ok: true, weeks };
} catch {
return { ok: false };
}
}

/** Approved weeks, newest first. One bounded request. Any failure becomes an unavailable page. */
export async function loadPublishedNotes(options?: {
limit?: number;
Expand Down