Skip to content

Commit 4aee3e4

Browse files
authored
Merge pull request #25 from CodeForAfrica/fetch-fact-checks-grid
Fetch fact checks grid
2 parents b13c3d8 + f49195c commit 4aee3e4

8 files changed

Lines changed: 371 additions & 51 deletions

File tree

app/fact-checks/[desk]/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FactChecksExplorer } from "@/components/fact-checks/FactChecksExplorer"
99
import { FactChecksHero } from "@/components/fact-checks/FactChecksHero";
1010
import { ARTICLES, getArticleBySlug } from "@/lib/article-content";
1111
import { CONTENT_DESKS, deskBySlug } from "@/lib/content-desks";
12+
import { FEATURE, FEATURE_SECONDARY, STORIES } from "@/lib/fact-checks-content";
1213

1314
type Params = Promise<{ desk: string }>;
1415

@@ -76,7 +77,11 @@ export default async function ContentDeskOrArticlePage({
7677
return (
7778
<>
7879
<FactChecksHero topic={desk.name} />
79-
<FactChecksExplorer />
80+
<FactChecksExplorer
81+
stories={[FEATURE, FEATURE_SECONDARY, ...STORIES]}
82+
page={1}
83+
totalPages={1}
84+
/>
8085
<FactChecksContentDesks activeSlug={desk.slug} />
8186
</>
8287
);

app/fact-checks/page.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,56 @@
11
import type { Metadata } from "next";
22
import { FactChecksContentDesks } from "@/components/fact-checks/FactChecksContentDesks";
33
import { FactChecksExplorer } from "@/components/fact-checks/FactChecksExplorer";
4+
import {
5+
clampPage,
6+
pageOffset,
7+
parsePageParam,
8+
totalPages,
9+
} from "@/lib/data/pagination";
10+
import {
11+
FACT_CHECKS_PAGE_SIZE,
12+
type FactCheckListing,
13+
getFactChecks,
14+
} from "@/lib/data/stories";
15+
import { FEATURE, FEATURE_SECONDARY, STORIES } from "@/lib/fact-checks-content";
416

517
export const metadata: Metadata = {
618
title: "Fact-Checks — PesaCheck",
719
description:
820
"Browse PesaCheck's fact-checks across Africa. Filter by region, language and topic to find the verifications that matter to you.",
921
};
1022

11-
export default function FactChecksPage() {
23+
/** Static design pool, paged the same way as the live query, for fallback. */
24+
const STATIC_POOL = [FEATURE, FEATURE_SECONDARY, ...STORIES];
25+
26+
function staticPage(page: number): FactCheckListing {
27+
const pages = totalPages(STATIC_POOL.length, FACT_CHECKS_PAGE_SIZE);
28+
const current = clampPage(page, pages);
29+
const start = pageOffset(current, FACT_CHECKS_PAGE_SIZE);
30+
return {
31+
stories: STATIC_POOL.slice(start, start + FACT_CHECKS_PAGE_SIZE),
32+
page: current,
33+
totalPages: pages,
34+
};
35+
}
36+
37+
export default async function FactChecksPage({
38+
searchParams,
39+
}: {
40+
searchParams: Promise<{ page?: string | string[] }>;
41+
}) {
42+
const page = parsePageParam((await searchParams).page);
43+
44+
const listing =
45+
(await getFactChecks(page).catch(() => null)) ?? staticPage(page);
46+
1247
return (
1348
<>
14-
<FactChecksExplorer />
49+
<FactChecksExplorer
50+
stories={listing.stories}
51+
page={listing.page}
52+
totalPages={listing.totalPages}
53+
/>
1554
<FactChecksContentDesks />
1655
</>
1756
);

components/fact-checks/FactChecksExplorer.tsx

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,20 @@
11
"use client";
22

3+
import { usePathname, useRouter } from "next/navigation";
34
import { useMemo, useState } from "react";
45
import { Pagination } from "@/components/ui/Pagination";
56
import { Container } from "@/components/ui/SectionHeading";
67
import { StoryCard } from "@/components/ui/StoryCard";
78
import {
89
DEFAULT_FILTERS,
9-
FEATURE,
10-
FEATURE_SECONDARY,
1110
FILTERS,
1211
type FilterDimension,
13-
STORIES,
1412
} from "@/lib/fact-checks-content";
1513
import type { Story } from "@/lib/home-content";
1614
import { FilterBar, type Selection } from "./FilterBar";
1715

18-
// Featured story leads the pool, so filtering can promote any matching story
19-
// into the large feature slot.
20-
const POOL: Story[] = [FEATURE, FEATURE_SECONDARY, ...STORIES];
21-
2216
const EMPTY: Selection = { region: [], language: [], topic: [] };
2317

24-
// Grid cards per page (the lead feature + secondary always head the listing).
25-
const GRID_PAGE_SIZE = 8;
26-
2718
function clone(sel: Selection): Selection {
2819
return {
2920
region: [...sel.region],
@@ -41,7 +32,18 @@ function matches(story: Story, applied: Selection): boolean {
4132
});
4233
}
4334

44-
export function FactChecksExplorer() {
35+
export function FactChecksExplorer({
36+
stories,
37+
page,
38+
totalPages,
39+
}: {
40+
stories: Story[];
41+
page: number;
42+
totalPages: number;
43+
}) {
44+
const router = useRouter();
45+
const pathname = usePathname();
46+
4547
// `selected` is the staged set reflected by the dropdowns + chips; `applied`
4648
// is what actually filters the listing (committed via "Apply Filters"). The
4749
// page loads showing the design's chips staged but the full listing visible —
@@ -53,7 +55,6 @@ export function FactChecksExplorer() {
5355
const [openDropdown, setOpenDropdown] = useState<FilterDimension | null>(
5456
null,
5557
);
56-
const [page, setPage] = useState(1);
5758

5859
const chips = useMemo(
5960
() =>
@@ -64,8 +65,8 @@ export function FactChecksExplorer() {
6465
);
6566

6667
const results = useMemo(
67-
() => POOL.filter((s) => matches(s, applied)),
68-
[applied],
68+
() => stories.filter((s) => matches(s, applied)),
69+
[stories, applied],
6970
);
7071

7172
const toggleOption = (dimension: FilterDimension, value: string) => {
@@ -89,28 +90,25 @@ export function FactChecksExplorer() {
8990
const toggleDropdown = (dimension: FilterDimension) =>
9091
setOpenDropdown((cur) => (cur === dimension ? null : dimension));
9192

93+
const goToPage = (next: number) => {
94+
router.push(next <= 1 ? pathname : `${pathname}?page=${next}`);
95+
};
96+
9297
const apply = () => {
9398
setApplied(clone(selected));
9499
setOpenDropdown(null);
95-
setPage(1);
100+
goToPage(1);
96101
};
97102

98103
const clear = () => {
99104
setSelected(clone(EMPTY));
100105
setApplied(clone(EMPTY));
101106
setOpenDropdown(null);
102-
setPage(1);
107+
goToPage(1);
103108
};
104109

105110
const [feature, secondary, ...grid] = results;
106111

107-
const totalPages = Math.max(1, Math.ceil(grid.length / GRID_PAGE_SIZE));
108-
const currentPage = Math.min(page, totalPages);
109-
const gridPage = grid.slice(
110-
(currentPage - 1) * GRID_PAGE_SIZE,
111-
currentPage * GRID_PAGE_SIZE,
112-
);
113-
114112
return (
115113
<>
116114
{/* Filter panel — its own band below the hero, not overlapping it. */}
@@ -160,17 +158,17 @@ export function FactChecksExplorer() {
160158

161159
{grid.length > 0 && (
162160
<div className="mt-10 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
163-
{gridPage.map((story) => (
161+
{grid.map((story) => (
164162
<StoryCard key={story.href ?? story.title} story={story} />
165163
))}
166164
</div>
167165
)}
168166

169167
<div className="mt-12">
170168
<Pagination
171-
page={currentPage}
169+
page={page}
172170
totalPages={totalPages}
173-
onPageChange={setPage}
171+
onPageChange={goToPage}
174172
/>
175173
</div>
176174
</>

docs/track-a-tasks.md

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,52 @@ Lowest-risk vertical slice: stand up the data layer and convert the one 1:1 row.
116116
distinguishes language routes from other collections, so the set is editorial
117117
(mirrors migration-plan; PR4 reuses it for the language filter).
118118

119-
## PR 2b — Fact-checks grid — Phase 2
119+
## PR 2b — Fact-checks grid — Phase 2
120120
Heavier: client-component data lifting.
121-
- [ ] `lib/data/stories.ts``getFactChecks(): Story[]` (published articles)
122-
- [ ] Make `app/fact-checks/page.tsx` async, fetch grid data
123-
- [ ] Refactor `FactChecksExplorer` (client) to receive `stories` as a prop
124-
instead of importing `lib/fact-checks-content`
125-
- [ ] Keep static `GRID` as `?? fallback`
126-
- [ ] Verify grid renders against staging (filters still static for now)
121+
- [x] `lib/data/queries/fact-checks.ts``GET_FACT_CHECK_ARTICLES`: one page of
122+
published fact-checks (newest first) + an `_aggregate` total count. Filters
123+
**server-side** to the `Debunk` verdict via the normalized
124+
`swp_article_metadata.swp_article_metadata_subjects` relation, with
125+
`$limit`/`$offset`. Selects the same fields as `GET_CONTENT_LIST_ITEMS` so
126+
`mapStory` consumes the rows unchanged.
127+
- [x] `lib/data/stories.ts``getFactChecks(page): FactCheckListing` (`{ stories,
128+
page, totalPages }`), `FACT_CHECKS_PAGE_SIZE = 10`. **A fact-check is
129+
defined by carrying a `Debunk` verdict**, enforced in the query (the only
130+
signal separating fact-checks from homepage content-blocks + editorial test
131+
stubs that share `route`/`profile`). Route-agnostic, so it keeps working
132+
once fact-checks land on topic desks (staging desks are empty; everything
133+
sits on `english`).
134+
- [x] `app/fact-checks/page.tsx` made async; reads `?page=`, fetches that DB page
135+
with the `?? fallback` pattern (static pool, paged identically).
136+
- [x] `FactChecksExplorer` (client) takes `stories` + `page`/`totalPages`;
137+
pagination navigates by `?page=N` (server re-fetch) — never loads the corpus
138+
client-side.
139+
- [x] `app/fact-checks/[desk]/page.tsx` (PR5 placeholder) passes the static pool
140+
on a single page so it compiles + hides pagination.
141+
- [x] Verify against staging: grid renders live fact-checks (verdict badges,
142+
dates, readTime, real media). **Server pagination proven** end-to-end by
143+
temporarily setting page size to 2 (5 items → 3 pages): each page is a
144+
distinct DB slice, `?page=N` drives the fetch, boundary disables "Next".
145+
Restored to 10. tsc + biome clean; 27 Vitest tests pass.
146+
147+
**Notes:**
148+
- **Why server-side verdict filter (not the verified jsonb path):** the jsonb
149+
`metadata` column is exposed by Hasura as an opaque `String` (no jsonb
150+
operators), so it can't filter or paginate server-side. Pagination at scale
151+
(prod: tens of thousands of fact-checks) forces the normalized relation —
152+
which the reference prod app also uses (`GET_COLLECTION_BY_METADATA_QUERY`).
153+
- **Relation vs jsonb divergence:** the relation drops `altered-yvonne` on
154+
staging (its `swp_article_metadata` is `null` though the jsonb carries the
155+
verdict — a staging integrity gap). All relation-matched articles still carry
156+
the jsonb verdict *name*, so `mapStory`'s badge is unaffected. The French
157+
"FAUX" article (no structured `Debunk` tag) is also absent. Both are staging
158+
artifacts; production data is consistently pipelined.
159+
- Filters remain **static** (region/language/topic from `fact-checks-content`)
160+
and now operate **client-side on the current page only**: staged chips show
161+
but `applied` starts empty, so the full server page is visible on load. Live
162+
`Story`s carry no taxonomy yet, so "Apply Filters" narrows to empty — expected.
163+
PR4 makes filtering server-side, layering filter params onto the same `?page=`
164+
URL the pagination already uses.
127165

128166
## PR 3 — Single article — Phase 3
129167
- [ ] `lib/data/queries/` — single-article query (`articleService.queries.js`):
@@ -137,13 +175,18 @@ Heavier: client-component data lifting.
137175
working when fetching live (article lookup by slug vs desk lookup)
138176
- [ ] Verify a real fact-check (e.g. `false-equating-somalia-and-al-shabab-is-untrue`)
139177

140-
## PR 4 — Filters & pagination — Phase 4 (depends on 2b)
178+
## PR 4 — Filters — Phase 4 (depends on 2b)
179+
> Offset pagination already shipped in PR 2b (server-side `limit`/`offset` +
180+
> `_aggregate`, driven by `?page=`). PR 4 makes **filtering** server-side and
181+
> wires it onto that same URL mechanism.
141182
- [ ] Map filter dimensions to real taxonomy: `region``subject[countries]`,
142183
`topic``subject[01harm]`/route, `language` → article language/route
184+
(extend `mapStory` to populate `topic`/`region`/`language` on `Story`)
143185
- [ ] Derive filter option lists from live data (replace hardcoded `REGIONS`/
144186
`LANGUAGES`/`TOPICS` in `fact-checks-content`)
145-
- [ ] Offset pagination (cf. `collectionService` + `_aggregate` for counts)
146-
- [ ] Decide client-side filter vs server query params; verify filtering works
187+
- [ ] Push selected filters into the query `where` (server-side) + reflect them
188+
in the URL alongside `?page=`; reset to page 1 on filter change. Replace
189+
the interim client-side, current-page-only filtering in `FactChecksExplorer`.
147190

148191
## PR 5 — Desk pages — Phase 5 (depends on 3 for route dispatch)
149192
- [ ] `lib/data/stories.ts``getByDesk(slug): Story[]` (by route collection)

lib/data/pagination.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
clampPage,
4+
pageOffset,
5+
parsePageParam,
6+
totalPages,
7+
} from "@/lib/data/pagination";
8+
9+
describe("parsePageParam", () => {
10+
it("parses a positive integer string", () => {
11+
expect(parsePageParam("3")).toBe(3);
12+
});
13+
14+
it("defaults missing/empty to page 1", () => {
15+
expect(parsePageParam(undefined)).toBe(1);
16+
expect(parsePageParam("")).toBe(1);
17+
});
18+
19+
it("rejects non-positive and non-numeric values", () => {
20+
expect(parsePageParam("0")).toBe(1);
21+
expect(parsePageParam("-2")).toBe(1);
22+
expect(parsePageParam("abc")).toBe(1);
23+
expect(parsePageParam("NaN")).toBe(1);
24+
});
25+
26+
it("takes the first entry of a repeated query param", () => {
27+
expect(parsePageParam(["4", "9"])).toBe(4);
28+
expect(parsePageParam([])).toBe(1);
29+
});
30+
31+
it("truncates a leading-numeric string the same way parseInt does", () => {
32+
expect(parsePageParam("2things")).toBe(2);
33+
});
34+
});
35+
36+
describe("totalPages", () => {
37+
it("rounds up partial pages", () => {
38+
expect(totalPages(10, 10)).toBe(1);
39+
expect(totalPages(11, 10)).toBe(2);
40+
expect(totalPages(5, 2)).toBe(3);
41+
});
42+
43+
it("returns at least 1 page for an empty corpus", () => {
44+
expect(totalPages(0, 10)).toBe(1);
45+
});
46+
47+
it("is null-safe against a zero or negative page size", () => {
48+
expect(totalPages(50, 0)).toBe(1);
49+
expect(totalPages(50, -10)).toBe(1);
50+
});
51+
52+
it("clamps a negative count to one page", () => {
53+
expect(totalPages(-5, 10)).toBe(1);
54+
});
55+
});
56+
57+
describe("pageOffset", () => {
58+
it("is 0-based off the 1-based page", () => {
59+
expect(pageOffset(1, 10)).toBe(0);
60+
expect(pageOffset(2, 10)).toBe(10);
61+
expect(pageOffset(3, 2)).toBe(4);
62+
});
63+
64+
it("clamps sub-1 pages to the first page", () => {
65+
expect(pageOffset(0, 10)).toBe(0);
66+
expect(pageOffset(-3, 10)).toBe(0);
67+
});
68+
});
69+
70+
describe("clampPage", () => {
71+
it("passes through an in-range page", () => {
72+
expect(clampPage(2, 3)).toBe(2);
73+
});
74+
75+
it("clamps above the total down to the last page", () => {
76+
expect(clampPage(9, 3)).toBe(3);
77+
});
78+
79+
it("clamps below 1 up to the first page", () => {
80+
expect(clampPage(0, 3)).toBe(1);
81+
expect(clampPage(-5, 3)).toBe(1);
82+
});
83+
84+
it("never returns less than 1, even for a zero total", () => {
85+
expect(clampPage(1, 0)).toBe(1);
86+
});
87+
});
88+
89+
describe("offset + totalPages round-trip", () => {
90+
// The contract getFactChecks/staticPage rely on: a page within range maps to
91+
// an offset that lands inside the corpus.
92+
it("keeps every in-range page's offset below the item count", () => {
93+
const count = 5;
94+
const pageSize = 2;
95+
const pages = totalPages(count, pageSize); // 3
96+
for (let p = 1; p <= pages; p++) {
97+
expect(pageOffset(clampPage(p, pages), pageSize)).toBeLessThan(count);
98+
}
99+
});
100+
});

0 commit comments

Comments
 (0)