From c657afa253f7ea9050387ded35ab5b8493c2386c Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Wed, 14 Oct 2026 01:36:26 +0200 Subject: [PATCH] web: load the news list on /aktuelt a page at a time --- web/src/app/api/v1/news/route.test.ts | 65 +++++++++++++++++++++++ web/src/app/api/v1/news/route.ts | 36 +++++++++++++ web/src/components/news/NewsIndexView.tsx | 34 +++++++++--- web/src/components/news/NewsList.tsx | 51 ++++++++++++++---- web/src/lib/news.ts | 6 +++ web/tests/smoke/smoke.test.ts | 18 +++++++ 6 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 web/src/app/api/v1/news/route.test.ts create mode 100644 web/src/app/api/v1/news/route.ts diff --git a/web/src/app/api/v1/news/route.test.ts b/web/src/app/api/v1/news/route.test.ts new file mode 100644 index 0000000..51ea9cb --- /dev/null +++ b/web/src/app/api/v1/news/route.test.ts @@ -0,0 +1,65 @@ +import { NextRequest } from "next/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const queryMock = vi.fn(); +// mirrors cmsQuery: returns the data, throws on a GraphQL error +vi.mock("@/app/client", () => ({ + cmsQuery: async (...args: unknown[]) => { + const { data, error } = await queryMock(...args); + if (error) throw new Error(error.message); + return data; + }, +})); + +import { GET } from "./route.ts"; + +const request = (query = "") => + new NextRequest(`http://localhost/api/v1/news${query}`); + +const articles = (count: number) => + Array.from({ length: count }, (_, i) => ({ id: String(i), slug: `nyhet-${i}` })); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/v1/news", () => { + it("returns a page of articles and the total", async () => { + queryMock.mockResolvedValue({ data: { index: {}, news: articles(40) } }); + + const res = await GET(request("?offset=17&limit=16")); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.total).toBe(40); + expect(body.news.map((n: { id: string }) => n.id)).toEqual( + Array.from({ length: 16 }, (_, i) => String(17 + i)) + ); + }); + + it("defaults to the first page", async () => { + queryMock.mockResolvedValue({ data: { index: {}, news: articles(40) } }); + + const body = await (await GET(request())).json(); + + expect(body.news).toHaveLength(16); + expect(body.news[0].id).toBe("0"); + }); + + it("returns what is left at the end, and nothing past it", async () => { + queryMock.mockResolvedValue({ data: { index: {}, news: articles(20) } }); + + expect((await (await GET(request("?offset=17"))).json()).news).toHaveLength(3); + expect((await (await GET(request("?offset=500"))).json()).news).toEqual([]); + }); + + it.each(["?offset=-1", "?offset=abc", "?limit=0", "?limit=49", "?limit=1.5"])( + "rejects %s", + async (query) => { + const res = await GET(request(query)); + + expect(res.status).toBe(400); + expect(queryMock).not.toHaveBeenCalled(); + } + ); +}); diff --git a/web/src/app/api/v1/news/route.ts b/web/src/app/api/v1/news/route.ts new file mode 100644 index 0000000..e3d8ea7 --- /dev/null +++ b/web/src/app/api/v1/news/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { loadAllNews } from "@/components/news/NewsIndexView"; +import { NEWS_PAGE_SIZE, NewsListItemFragment } from "@/lib/news"; + +const MAX_LIMIT = 48; + +export type NewsPageResponse = { + news: NewsListItemFragment[]; + total: number; +}; + +function nonNegativeInt(value: string | null, fallback: number): number | null { + if (value === null) return fallback; + return /^\d+$/.test(value) ? Number(value) : null; +} + +// GET /api/v1/news?offset=17&limit=16: the next articles for "load more" on +// /aktuelt, newest first. Reads the same cached list as the page +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + const offset = nonNegativeInt(searchParams.get("offset"), 0); + const limit = nonNegativeInt(searchParams.get("limit"), NEWS_PAGE_SIZE); + + if (offset === null || limit === null || limit < 1 || limit > MAX_LIMIT) { + return NextResponse.json( + { error: `offset must be >= 0 and limit between 1 and ${MAX_LIMIT}` }, + { status: 400 } + ); + } + + const { news } = await loadAllNews(); + return NextResponse.json({ + news: news.slice(offset, offset + limit), + total: news.length, + } satisfies NewsPageResponse); +} diff --git a/web/src/components/news/NewsIndexView.tsx b/web/src/components/news/NewsIndexView.tsx index 52a5b2c..67c658e 100644 --- a/web/src/components/news/NewsIndexView.tsx +++ b/web/src/components/news/NewsIndexView.tsx @@ -1,28 +1,48 @@ import { cmsQuery } from "@/app/client"; import { PageHeader } from "@/components/general/PageHeader"; import { NewsList } from "@/components/news/NewsList"; -import { NewsIndexFragment, NewsListItemFragment, newsQuery } from "@/lib/news"; +import { + NEWS_FIRST_PAGE, + NewsIndexFragment, + NewsListItemFragment, + newsQuery, +} from "@/lib/news"; export type NewsIndexViewProps = { index: NewsIndexFragment; + // the first page only; NewsList fetches the rest from /api/v1/news news: NewsListItemFragment[]; + total: number; }; +// All articles, newest first. One cached CMS query serves both the page and +// the API that pages through the rest +export async function loadAllNews() { + const data = await cmsQuery(newsQuery, {}); + return { + index: data?.index as NewsIndexFragment | undefined, + news: (data?.news ?? []) as NewsListItemFragment[], + }; +} + export async function loadNewsIndexProps(overrides?: { indexOverride?: NewsIndexFragment; }): Promise { - const data = await cmsQuery(newsQuery, {}); - const index = overrides?.indexOverride ?? (data?.index as NewsIndexFragment | undefined); + const all = await loadAllNews(); + const index = overrides?.indexOverride ?? all.index; if (!index) throw new Error("Failed to load /aktuelt"); - const news = (data?.news ?? []) as NewsListItemFragment[]; - return { index, news }; + return { + index, + news: all.news.slice(0, NEWS_FIRST_PAGE), + total: all.news.length, + }; } -export function NewsIndexView({ index, news }: NewsIndexViewProps) { +export function NewsIndexView({ index, news, total }: NewsIndexViewProps) { return (
- +
); } diff --git a/web/src/components/news/NewsList.tsx b/web/src/components/news/NewsList.tsx index 27be59f..06fb9ef 100644 --- a/web/src/components/news/NewsList.tsx +++ b/web/src/components/news/NewsList.tsx @@ -3,37 +3,70 @@ import { useState } from "react"; import { SectionHeader } from "../general/SectionHeader"; import { NewsItem } from "./NewsItem"; import styles from "./newsList.module.scss"; -import { NewsFragment, NewsListItemFragment } from "@/lib/news"; +import { NEWS_PAGE_SIZE, NewsFragment, NewsListItemFragment } from "@/lib/news"; import { SectionFooter } from "../general/SectionFooter"; +import type { NewsPageResponse } from "@/app/api/v1/news/route"; + +type NewsListItem = NewsFragment | NewsListItemFragment; export const NewsList = ({ news, + total, heading, featured }: { - news: (NewsFragment | NewsListItemFragment)[]; + news: NewsListItem[]; + // how many articles exist; more than `news` holds means the rest can be + // fetched with "load more" + total?: number; heading?: string; featured?: boolean; }) => { - const [limit, setLimit] = useState(featured ? 4 : 17); - function increaseLimit() { - setLimit(limit + 16); + const [items, setItems] = useState(news); + const [knownTotal, setKnownTotal] = useState(total ?? news.length); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + + async function loadMore() { + setLoading(true); + setFailed(false); + try { + const res = await fetch( + `/api/v1/news?offset=${items.length}&limit=${NEWS_PAGE_SIZE}` + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const page = (await res.json()) as NewsPageResponse; + // an article published since the page was rendered shifts the offsets + const seen = new Set(items.map((item) => item.id)); + setItems([...items, ...page.news.filter((item) => !seen.has(item.id))]); + setKnownTotal(page.total); + } catch { + setFailed(true); + } finally { + setLoading(false); + } } + const shown = featured ? items.slice(0, 4) : items; + return (
{heading && ( )}
    - {news.slice(0, limit).map((singleNews) => ( + {shown.map((singleNews) => ( ))}
- {!featured && limit && news.length > limit && ( + {!featured && knownTotal > items.length && (
-
)} diff --git a/web/src/lib/news.ts b/web/src/lib/news.ts index dc8241c..64211ec 100644 --- a/web/src/lib/news.ts +++ b/web/src/lib/news.ts @@ -49,6 +49,12 @@ const NewsIndexFragmentDefinition = graphql(` } `); +// How many articles /aktuelt shows at first, and how many each "load more" +// adds. With around 850 articles, sending them all made the page (and every +// prefetch of it from the header) several hundred kilobytes +export const NEWS_FIRST_PAGE = 17; +export const NEWS_PAGE_SIZE = 16; + export const newsIndexMetadataQuery = graphql(` query newsIndexMetadata { index: newsIndex { diff --git a/web/tests/smoke/smoke.test.ts b/web/tests/smoke/smoke.test.ts index db43fdd..efdb38d 100644 --- a/web/tests/smoke/smoke.test.ts +++ b/web/tests/smoke/smoke.test.ts @@ -167,6 +167,24 @@ describe("events api", () => { }); }); +describe("news api", () => { + test("pages through the articles for \"load more\" on /aktuelt", async () => { + const res = await fetch(app("/api/v1/news?offset=0&limit=5")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + news: Array<{ slug: string }>; + total: number; + }; + expect(body.total).toBe(1); + expect(body.news.map((n) => n.slug)).toEqual([SLUGS.news]); + }); + + test("rejects a limit that is too large", async () => { + const res = await fetch(app("/api/v1/news?limit=1000")); + expect(res.status).toBe(400); + }); +}); + describe("image optimizer", () => { test("optimizes a CMS-hosted image", async () => { const src = encodeURIComponent(mock("/media/pig.png"));