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
54 changes: 17 additions & 37 deletions frontend/app/components/updates/EarlierArchive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WeekNotes[]>([]);
const [cursor, setCursor] = useState(before);
const [hasMore, setHasMore] = useState(true);
Expand All @@ -36,37 +32,21 @@ export default function EarlierArchive({ before }: { before: string }) {
}

return (
<details
className={`${detailsClass} mt-12`}
onToggle={(event) => {
const isOpen = event.currentTarget.open;
setOpen(isOpen);
if (isOpen && weeks.length === 0 && !loading) {
void load(cursor);
}
}}
>
<summary className="cursor-pointer px-4 py-3 font-[family-name:var(--font-fraunces)] text-xl text-[var(--tfmc-cream)]">
Earlier
</summary>
{open ? (
<div className="border-t border-[color-mix(in_srgb,var(--tfmc-cream)_10%,transparent)] px-4 pb-4">
{weeks.length > 0 ? <WeekArchiveList weeks={weeks} /> : null}
{loading ? <p className="mt-4 text-sm text-[var(--tfmc-stone)]">Loading…</p> : null}
{error ? (
<p className="mt-4 text-sm text-[var(--tfmc-stone)]">Earlier weeks are unavailable right now.</p>
) : null}
{hasMore && !loading && weeks.length > 0 ? (
<button
type="button"
className="mt-4 text-sm text-[var(--tfmc-mist)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline"
onClick={() => void load(cursor)}
>
Show older
</button>
) : null}
</div>
<div className="mt-12">
{weeks.map((notes) => (
<WeekArticle key={notes.week} notes={notes} />
))}
{loading ? <p className="mt-8 text-sm text-[var(--tfmc-stone)]">Loading…</p> : null}
{error ? <p className="mt-8 text-sm text-[var(--tfmc-stone)]">Older weeks are unavailable right now.</p> : null}
{hasMore && !loading ? (
<button
type="button"
className="mt-8 text-sm text-[var(--tfmc-mist)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline"
onClick={() => void load(cursor)}
>
Show older
</button>
) : null}
</details>
</div>
);
}
54 changes: 37 additions & 17 deletions frontend/app/components/updates/UpdatesPageView.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -12,34 +14,52 @@ export default function UpdatesPageView({
unavailable?: boolean;
hasMore?: boolean;
}) {
const [current, ...earlier] = weeks;
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)]">A short summary, then the changes. Technical notes stay folded.</p>
<p className="mt-2 text-[var(--tfmc-mist)]">
Scroll through the weeks. Each week also has its own page. Technical notes stay folded.
</p>
<StaffTestPreview />

{unavailable ? (
<p className="mt-10 text-[var(--tfmc-stone)]">Patch notes are unavailable right now.</p>
) : current ? (
<div className="mt-10">
<article>
<h2 className="font-[family-name:var(--font-fraunces)] text-2xl text-[var(--tfmc-cream)]">{current.label}</h2>
<WeekSections bullets={current.bullets} />
</article>
{earlier.length > 0 ? (
<section className="mt-12">
<h2 className="font-[family-name:var(--font-fraunces)] text-xl text-[var(--tfmc-cream)]">Earlier</h2>
<WeekArchiveList weeks={earlier} />
</section>
) : hasMore ? (
<EarlierArchive key={current.week} before={current.week} />
) : null}
) : weeks.length > 0 ? (
<div className="mt-10 [&>article:first-child]:mt-0 [&>article:first-child]:border-t-0 [&>article:first-child]:pt-0">
{weeks.map((notes) => (
<WeekArticle key={notes.week} notes={notes} />
))}
{hasMore && oldest ? <EarlierArchive key={oldest} before={oldest} /> : null}
</div>
) : (
<p className="mt-10 text-[var(--tfmc-stone)]">Nothing has been published yet.</p>
)}
</main>
);
}

export function WeekPageView({
notes,
unavailable = false,
}: {
notes: WeekNotes | null;
unavailable?: boolean;
}) {
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">
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>
<WeekSections bullets={notes.bullets} />
</>
) : null}
</main>
);
}
24 changes: 11 additions & 13 deletions frontend/app/components/updates/WeekArchiveList.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Link from "next/link";

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

const detailsClass =
Expand Down Expand Up @@ -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 (
<div className="mt-4 space-y-3">
{weeks.map((notes) => (
<details key={notes.week} className={detailsClass}>
<summary className="cursor-pointer px-4 py-3 text-sm font-semibold text-[var(--tfmc-cream)]">
{notes.label}
</summary>
<div className="border-t border-[color-mix(in_srgb,var(--tfmc-cream)_10%,transparent)] px-4 pb-4">
<WeekSections bullets={notes.bullets} />
</div>
</details>
))}
</div>
<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>
);
}
35 changes: 35 additions & 0 deletions frontend/app/updates/[week]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 <WeekPageView notes={null} unavailable />;
}
return <WeekPageView notes={notes.week} />;
}
27 changes: 18 additions & 9 deletions frontend/app/updates/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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");
Expand All @@ -56,24 +57,32 @@ describe("Updates page", () => {
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
});

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("<details");
expect(archive).not.toContain("<details open");
expect(archive.indexOf("<details")).toBeLessThan(archive.indexOf("Fixed a chest"));
expect(html.indexOf("Added a station")).toBeLessThan(html.indexOf("Fixed a chest"));
expect(html).not.toContain("Show older");
});

it("offers the archive without including older weeks in the first response", () => {
it("offers older weeks without including them in the first response", () => {
const html = renderToStaticMarkup(<UpdatesPageView weeks={[current]} hasMore />);
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(<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.");
});
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/updates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UpdatesPageView weeks={[]} unavailable />;
}
Expand Down
44 changes: 43 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 } from "./api";
import { loadPublishedNotes, loadPublishedWeek } from "./api";

afterEach(() => {
vi.unstubAllEnvs();
Expand Down Expand Up @@ -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 });
});
});
25 changes: 25 additions & 0 deletions frontend/lib/patchnotes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PublishedWeek> {
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 };
}
}