From f6bcf49da26422660193f9d59bb0ab8469326ab2 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:28:44 +0000 Subject: [PATCH] Give each week's patch notes its own page. The updates index stays a scroll of every week, and a week URL opens that note on its own. Co-authored-by: Cursor --- .../app/components/updates/EarlierArchive.tsx | 54 ++++++------------- .../components/updates/UpdatesPageView.tsx | 54 +++++++++++++------ .../components/updates/WeekArchiveList.tsx | 24 ++++----- frontend/app/updates/[week]/page.tsx | 35 ++++++++++++ frontend/app/updates/page.test.tsx | 27 ++++++---- frontend/app/updates/page.tsx | 2 +- frontend/lib/patchnotes/api.test.ts | 44 ++++++++++++++- frontend/lib/patchnotes/api.ts | 25 +++++++++ 8 files changed, 187 insertions(+), 78 deletions(-) create mode 100644 frontend/app/updates/[week]/page.tsx diff --git a/frontend/app/components/updates/EarlierArchive.tsx b/frontend/app/components/updates/EarlierArchive.tsx index edb4005..90037b2 100644 --- a/frontend/app/components/updates/EarlierArchive.tsx +++ b/frontend/app/components/updates/EarlierArchive.tsx @@ -5,15 +5,11 @@ import { useState } from "react"; import { loadPublishedNotes } from "@/lib/patchnotes/api"; import type { WeekNotes } from "@/lib/patchnotes/notes"; -import { WeekArchiveList } from "./WeekArchiveList"; +import { WeekArticle } from "./WeekArchiveList"; -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)]"; - -const PAGE_SIZE = 12; +const PAGE_SIZE = 8; export default function EarlierArchive({ before }: { before: string }) { - const [open, setOpen] = useState(false); const [weeks, setWeeks] = useState([]); const [cursor, setCursor] = useState(before); const [hasMore, setHasMore] = useState(true); @@ -36,37 +32,21 @@ export default function EarlierArchive({ before }: { before: string }) { } return ( -
{ - const isOpen = event.currentTarget.open; - setOpen(isOpen); - if (isOpen && weeks.length === 0 && !loading) { - void load(cursor); - } - }} - > - - Earlier - - {open ? ( -
- {weeks.length > 0 ? : null} - {loading ?

Loading…

: null} - {error ? ( -

Earlier weeks are unavailable right now.

- ) : null} - {hasMore && !loading && weeks.length > 0 ? ( - - ) : null} -
+
+ {weeks.map((notes) => ( + + ))} + {loading ?

Loading…

: null} + {error ?

Older weeks are unavailable right now.

: null} + {hasMore && !loading ? ( + ) : null} -
+ ); } diff --git a/frontend/app/components/updates/UpdatesPageView.tsx b/frontend/app/components/updates/UpdatesPageView.tsx index e42c545..283f3f4 100644 --- a/frontend/app/components/updates/UpdatesPageView.tsx +++ b/frontend/app/components/updates/UpdatesPageView.tsx @@ -1,6 +1,8 @@ +import Link from "next/link"; + import EarlierArchive from "@/app/components/updates/EarlierArchive"; import StaffTestPreview from "@/app/components/updates/StaffTestPreview"; -import { WeekArchiveList, WeekSections } from "@/app/components/updates/WeekArchiveList"; +import { WeekArticle, WeekSections } from "@/app/components/updates/WeekArchiveList"; import type { WeekNotes } from "@/lib/patchnotes/notes"; export default function UpdatesPageView({ @@ -12,30 +14,24 @@ export default function UpdatesPageView({ unavailable?: boolean; hasMore?: boolean; }) { - const [current, ...earlier] = weeks; + const oldest = weeks.at(-1)?.week; return (

Updates

-

A short summary, then the changes. Technical notes stay folded.

+

+ Scroll through the weeks. Each week also has its own page. Technical notes stay folded. +

{unavailable ? (

Patch notes are unavailable right now.

- ) : current ? ( -
-
-

{current.label}

- -
- {earlier.length > 0 ? ( -
-

Earlier

- -
- ) : hasMore ? ( - - ) : null} + ) : weeks.length > 0 ? ( +
+ {weeks.map((notes) => ( + + ))} + {hasMore && oldest ? : null}
) : (

Nothing has been published yet.

@@ -43,3 +39,27 @@ export default function UpdatesPageView({
); } + +export function WeekPageView({ + notes, + unavailable = false, +}: { + notes: WeekNotes | null; + unavailable?: boolean; +}) { + return ( +
+ + All updates + + {unavailable ? ( +

Patch notes are unavailable right now.

+ ) : notes ? ( + <> +

{notes.label}

+ + + ) : null} +
+ ); +} diff --git a/frontend/app/components/updates/WeekArchiveList.tsx b/frontend/app/components/updates/WeekArchiveList.tsx index 1a2be8d..5438c04 100644 --- a/frontend/app/components/updates/WeekArchiveList.tsx +++ b/frontend/app/components/updates/WeekArchiveList.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; + import { arrangeNote, SECTION_LABELS, type PublicBullet, type WeekNotes } from "@/lib/patchnotes/notes"; const detailsClass = @@ -51,19 +53,15 @@ export function WeekSections({ bullets }: { bullets: readonly PublicBullet[] }) ); } -export function WeekArchiveList({ weeks }: { weeks: readonly WeekNotes[] }) { +export function WeekArticle({ notes }: { notes: WeekNotes }) { return ( -
- {weeks.map((notes) => ( -
- - {notes.label} - -
- -
-
- ))} -
+
+

+ + {notes.label} + +

+ +
); } diff --git a/frontend/app/updates/[week]/page.tsx b/frontend/app/updates/[week]/page.tsx new file mode 100644 index 0000000..86dc49a --- /dev/null +++ b/frontend/app/updates/[week]/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { WeekPageView } from "@/app/components/updates/UpdatesPageView"; +import { loadPublishedWeek } from "@/lib/patchnotes/api"; +import { isWeekKey, weekLabel } from "@/lib/patchnotes/notes"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ week: string }>; +}): Promise { + const { week } = await params; + if (!isWeekKey(week)) { + return { title: "Updates · TFMC" }; + } + const label = weekLabel(week); + return { + title: `${label} · TFMC`, + description: `Patch notes for ${label}.`, + }; +} + +export default async function UpdateWeekPage({ params }: { params: Promise<{ week: string }> }) { + const { week } = await params; + if (!isWeekKey(week)) notFound(); + const notes = await loadPublishedWeek(week); + if (!notes.ok) { + if (notes.missing) notFound(); + return ; + } + return ; +} diff --git a/frontend/app/updates/page.test.tsx b/frontend/app/updates/page.test.tsx index d4f97c5..818762c 100644 --- a/frontend/app/updates/page.test.tsx +++ b/frontend/app/updates/page.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import UpdatesPageView from "../components/updates/UpdatesPageView"; +import UpdatesPageView, { WeekPageView } from "../components/updates/UpdatesPageView"; import type { WeekNotes } from "@/lib/patchnotes/notes"; const current: WeekNotes = { @@ -31,6 +31,7 @@ function markup(weeks: WeekNotes[], unavailable = false): string { describe("Updates page", () => { it("shows the latest week open and folds technical notes", () => { const html = markup([current]); + expect(html).toContain('href="/updates/2026-W39"'); expect(html).toContain("Week of 21 September 2026"); expect(html).toContain("Highlights"); expect(html).toContain("Crafting"); @@ -56,24 +57,32 @@ describe("Updates page", () => { expect(html).toContain("<script>alert(1)</script>"); }); - it("keeps older weeks in a collapsed archive", () => { + it("scrolls through older weeks and links each one", () => { const html = markup([current, earlier]); - expect(html).toContain("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"); - const archive = html.slice(html.indexOf("Earlier")); - expect(archive).toContain(" { + it("offers older weeks without including them in the first response", () => { const html = renderToStaticMarkup(); - expect(html).toContain("Earlier"); + 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(); + 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."); }); diff --git a/frontend/app/updates/page.tsx b/frontend/app/updates/page.tsx index 21354ef..1bc7c7e 100644 --- a/frontend/app/updates/page.tsx +++ b/frontend/app/updates/page.tsx @@ -11,7 +11,7 @@ export const metadata: Metadata = { }; export default async function UpdatesPage() { - const notes = await loadPublishedNotes({ limit: 1 }); + const notes = await loadPublishedNotes({ limit: 8 }); if (!notes.ok) { return ; } diff --git a/frontend/lib/patchnotes/api.test.ts b/frontend/lib/patchnotes/api.test.ts index 0eae0cd..ed74507 100644 --- a/frontend/lib/patchnotes/api.test.ts +++ b/frontend/lib/patchnotes/api.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { loadPublishedNotes } from "./api"; +import { loadPublishedNotes, loadPublishedWeek } from "./api"; afterEach(() => { vi.unstubAllEnvs(); @@ -55,4 +55,46 @@ describe("loadPublishedNotes", () => { const notes = await loadPublishedNotes(); expect(notes).toEqual({ ok: false }); }); +}); + +describe("loadPublishedWeek", () => { + it("loads one approved week and drops review rows", async () => { + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://api.test"); + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + week: "2026-W39", + bullets: [ + { id: "1", section: "new", body: "Visible", status: "approved" }, + { id: "2", section: "fixed", body: "Still pending", status: "pending" }, + ], + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const notes = await loadPublishedWeek("2026-W39"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://api.test/patchnotes/weeks/2026-W39", + expect.objectContaining({ cache: "no-store", signal: expect.any(AbortSignal) }), + ); + expect(notes.ok).toBe(true); + if (!notes.ok) return; + expect(notes.week.bullets.map((bullet) => bullet.body)).toEqual(["Visible"]); + }); + + it("treats an unknown week as missing", async () => { + vi.stubEnv("NEXT_PUBLIC_API_URL", "http://api.test"); + vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 400 }))); + expect(await loadPublishedWeek("2026-W39")).toEqual({ ok: false, missing: true }); + expect(await loadPublishedWeek("nope")).toEqual({ ok: false, missing: true }); + }); + + 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 loadPublishedWeek("2026-W39")).toEqual({ ok: false, missing: false }); + }); }); \ No newline at end of file diff --git a/frontend/lib/patchnotes/api.ts b/frontend/lib/patchnotes/api.ts index 6c9dfac..a780855 100644 --- a/frontend/lib/patchnotes/api.ts +++ b/frontend/lib/patchnotes/api.ts @@ -47,6 +47,10 @@ function readWeek(value: unknown): WeekNotes | null { return { week: row.week, label: weekLabel(row.week), bullets }; } +export type PublishedWeek = + | { ok: true; week: WeekNotes } + | { ok: false; missing: boolean }; + /** Approved weeks, newest first. One bounded request. Any failure becomes an unavailable page. */ export async function loadPublishedNotes(options?: { limit?: number; @@ -80,3 +84,24 @@ export async function loadPublishedNotes(options?: { return { ok: false }; } } + +/** One published week. A bad key or an empty week is missing; other failures leave the page unavailable. */ +export async function loadPublishedWeek(week: string): Promise { + if (!isWeekKey(week)) return { ok: false, missing: true }; + const base = apiBase(); + if (!base) return { ok: false, missing: false }; + try { + const res = await fetch(`${base}/patchnotes/weeks/${week}`, { + cache: "no-store", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (res.status === 400 || res.status === 404) return { ok: false, missing: true }; + if (!res.ok) return { ok: false, missing: false }; + const body: unknown = await res.json(); + const notes = readWeek(body); + if (!notes) return { ok: false, missing: true }; + return { ok: true, week: notes }; + } catch { + return { ok: false, missing: false }; + } +}