diff --git a/app/api/bio/route.js b/app/api/bio/route.js
new file mode 100644
index 0000000..46f632f
--- /dev/null
+++ b/app/api/bio/route.js
@@ -0,0 +1,17 @@
+import { NextResponse } from "next/server";
+
+// The live "Who he is" bio. READ-ONLY: it returns the cached copy that the
+// crawl tick refreshes from Wikipedia (lib/bio.js). A page load never calls
+// Wikipedia — so this is fast and a wiki outage can't break the front page
+// (getBio() falls back to a short static blurb).
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ try {
+ const { getBio } = await import("../../../lib/bio");
+ const bio = await getBio();
+ return NextResponse.json({ ok: true, ...bio });
+ } catch (e) {
+ return NextResponse.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 });
+ }
+}
diff --git a/app/api/crawl/route.js b/app/api/crawl/route.js
index dbfdda3..aad00a2 100644
--- a/app/api/crawl/route.js
+++ b/app/api/crawl/route.js
@@ -14,7 +14,19 @@ export async function POST(req) {
try {
const { crawlOnce } = await import("../../../lib/crawl");
const crawl = await crawlOnce();
- return NextResponse.json({ ok: true, crawl });
+
+ // Refresh the live "Who he is" bio from Wikipedia on the same tick
+ // (well over "daily"). Best-effort: a Wikipedia hiccup must never
+ // make the crawl report a failure or lose the news it already gathered.
+ let bio = null;
+ try {
+ const { refreshBio } = await import("../../../lib/bio");
+ bio = await refreshBio();
+ } catch (e) {
+ bio = { ok: false, error: String((e && e.message) || e).slice(0, 120) };
+ }
+
+ return NextResponse.json({ ok: true, crawl, bio });
} catch (e) {
return NextResponse.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 });
}
diff --git a/app/globals.css b/app/globals.css
index c6a84ef..ec5b62b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -705,6 +705,20 @@ footer.site .disc { max-width: 640px; margin: 0 auto 10px; line-height: 1.5; }
.navbtn.board:hover { background: var(--gold-2); box-shadow: var(--glow); }
.navbtn.board.active { background: var(--gold-2); box-shadow: var(--glow); }
+/* "Full article on Wikipedia" link in the Who-he-is box */
+.wholink {
+ color: var(--gold);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ text-decoration: none;
+ border-bottom: 1px solid var(--gold-dim);
+ padding-bottom: 1px;
+}
+.wholink:hover { color: var(--gold-2); border-bottom-color: var(--gold); box-shadow: var(--glow-soft); }
+
/* In Pictures */
.photoscroll {
display: grid;
diff --git a/app/page.js b/app/page.js
index 61f87d5..5a4b359 100644
--- a/app/page.js
+++ b/app/page.js
@@ -39,6 +39,47 @@ function Brand() {
);
}
+// The live "Who he is" box. Text comes from /api/bio (the crawl tick caches
+// it from the Wikipedia article, so it's current; the box links straight to
+// the article). Until the first bio lands, a short accurate default shows.
+function WhoBox({ bio }) {
+ const text = bio && bio.text;
+ const updated =
+ bio && bio.updatedAt
+ ? new Date(bio.updatedAt).toLocaleDateString("en-PH", { month: "long", day: "numeric", year: "numeric" })
+ : null;
+ return (
+
+
Who he is
+
+ {text ? (
+ <>
+ {text}
+
+ >
+ ) : (
+
+ Paolo Benigno "Bam" Aguirre Aquino IV — senator of
+ the Philippines, and the first gamer to reach the chamber.
+
+ )}
+
+
+ SOURCE: WIKIPEDIA{updated ? ` · UPDATED ${updated.toUpperCase()}` : ""}
+
+
+ );
+}
+
export default function Home() {
const [view, setView] = useState("front"); // front | | board
const [edition, setEdition] = useState(null);
@@ -46,6 +87,7 @@ export default function Home() {
const [stats, setStats] = useState(null);
const [boardPosts, setBoardPosts] = useState(null);
const [boardCount, setBoardCount] = useState(0);
+ const [bio, setBio] = useState(null); // live "Who he is" (cached from Wikipedia)
const [reading, setReading] = useState(null); // story in the drawer
const [toast, setToast] = useState("");
const toastTimer = useRef(null);
@@ -85,9 +127,20 @@ export default function Home() {
}
}
+ async function loadBio() {
+ try {
+ const r = await fetch("/api/bio", { cache: "no-store", signal: AbortSignal.timeout(10000) });
+ const j = await r.json();
+ if (j.ok && j.text) setBio(j);
+ } catch {
+ /* bio box shows the static fallback if this fails */
+ }
+ }
+
useEffect(() => {
loadFeed();
loadBoard();
+ loadBio();
return () => clearTimeout(toastTimer.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -321,18 +374,7 @@ export default function Home() {
-
-
Who he is
-
- Francis "Bam" Tan Aquino IV — Senator for
- Pampanga. The first gamer to reach the Philippine Senate: a runner-up in the
- 2025 PalaPinas esports championship, and
- a legislator who has made the fight for college
- students, scholarships, and fair electricity his signature. This desk follows
- the wire for every story that bears his name — from verified Philippine and
- international news outlets only.
-
-
+
diff --git a/lib/bio.js b/lib/bio.js
new file mode 100644
index 0000000..69ea4ce
--- /dev/null
+++ b/lib/bio.js
@@ -0,0 +1,83 @@
+// BidaAngMamamayan — live "Who he is" bio, sourced from the Wikipedia article.
+//
+// The front page's "Who he is" box used to be a hard-coded paragraph (which
+// drifted: it said "Francis 'Bam' Tan Aquino IV" and hand-picked a couple of
+// facts). Daniel (2026-09-16): source it from the LIVE Wikipedia article,
+// link to it, and keep it updated daily.
+//
+// How it stays fresh without slowing page loads:
+// * refreshBio() is called by the crawl tick (scripts/cron-crawl every 15
+// min). It fetches the article's intro via the Wikipedia API and upserts
+// a single doc in the `bio` collection. That is well over "daily".
+// * getBio() (used by /api/bio on page load) only READS that cached doc —
+// it never calls Wikipedia, so a page load costs zero external requests
+// and a Wikipedia outage can't break the site.
+// * If the cache is empty or stale-but-unrefreshable, getBio() falls back to
+// a short, accurate static blurb so the box is never blank.
+//
+// Wikipedia API: action=query, prop=extracts, exintro, explaintext,
+// titles="Bam Aquino", redirects=1. Requires a descriptive User-Agent
+// (Wikipedia blocks the default Node UA).
+
+import { getCollections } from "./db.js";
+
+const ARTICLE_TITLE = "Bam Aquino";
+const WIKI_API = "https://en.wikipedia.org/w/api.php";
+const WIKI_LINK = "https://en.wikipedia.org/wiki/Bam_Aquino";
+const UA = "BamaDeskBot/1.0 (bidaangmamamayan.com; contact danieldiaz@boyemma.com)";
+
+// Fallback blurb — used ONLY when the live cache is unavailable. Kept short
+// and accurate; it is a last resort, not the normal path.
+const FALLBACK_TEXT =
+ 'Paolo Benigno "Bam" Aguirre Aquino IV is a Filipino politician and media ' +
+ "personality who has served as a senator of the Philippines since 2025. He " +
+ "is the first gamer to reach the Philippine Senate — a runner-up in the " +
+ "2025 PalaPinas esports championship — and has made the fight for college " +
+ "students, scholarships, and fair electricity his legislative signature.";
+
+export async function refreshBio() {
+ const { bio } = await getCollections();
+ const params = new URLSearchParams({
+ action: "query",
+ prop: "extracts",
+ exintro: "1",
+ explaintext: "1",
+ titles: ARTICLE_TITLE,
+ format: "json",
+ redirects: "1",
+ });
+ const res = await fetch(`${WIKI_API}?${params}`, {
+ headers: { "User-Agent": UA, Accept: "application/json" },
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!res.ok) throw new Error(`wiki ${res.status}`);
+ const j = await res.json();
+ const page = j && j.query && j.query.pages
+ ? Object.values(j.query.pages)[0]
+ : null;
+ const text = (page && page.extract ? page.extract : "").trim();
+ if (!text) throw new Error("empty intro");
+ // Keep only the lead section (first few paragraphs) — the box is a blurb,
+ // not the whole article. The full text is one click away via the link.
+ const paragraphs = text.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
+ const lead = paragraphs.slice(0, 3).join(" ");
+ await bio.updateOne(
+ { _id: "primary" },
+ { $set: { _id: "primary", text: lead, full: text, url: WIKI_LINK, updatedAt: new Date() } },
+ { upsert: true }
+ );
+ return { ok: true, text: lead, updatedAt: new Date() };
+}
+
+export async function getBio() {
+ try {
+ const { bio } = await getCollections();
+ const row = await bio.findOne({ _id: "primary" });
+ if (row && row.text) {
+ return { ok: true, text: row.text, url: row.url || WIKI_LINK, updatedAt: row.updatedAt || null, source: "Wikipedia" };
+ }
+ } catch {
+ /* fall through to the static blurb */
+ }
+ return { ok: true, text: FALLBACK_TEXT, url: WIKI_LINK, updatedAt: null, source: "fallback" };
+}
diff --git a/lib/db.js b/lib/db.js
index a9312b7..7a320a2 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -48,5 +48,7 @@ export async function getCollections() {
board: db.collection("board"),
// Rate-limit counters (board spam control).
rate: db.collection("rate"),
+ // Live "Who he is" bio cached from Wikipedia (lib/bio.js).
+ bio: db.collection("bio"),
};
}
diff --git a/tests/bio-test.mjs b/tests/bio-test.mjs
new file mode 100644
index 0000000..5216860
--- /dev/null
+++ b/tests/bio-test.mjs
@@ -0,0 +1,50 @@
+// lib/bio.js — live Wikipedia "Who he is" cache.
+// refreshBio() pulls the article intro from the Wikipedia API and upserts the
+// `bio` doc; getBio() reads it (falling back to the static blurb).
+// Runs against a scratch Mongo. Needs network (Wikipedia) for refreshBio.
+// RUN: MONGODB_URI=mongodb://127.0.0.1:27099/bama node tests/bio-test.mjs
+import { MongoClient } from "mongodb";
+
+// bio.js -> db.js reads BAMA_DB_NAME at module load, so set it BEFORE the
+// dynamic import of ../lib/bio.js. Use a dedicated scratch db for this test.
+process.env.BAMA_DB_NAME = process.env.BAMA_DB_NAME || "bama_bio_test";
+const uri = process.env.MONGODB_URI || "mongodb://127.0.0.1:27099/bama";
+const mc = new MongoClient(uri, { serverSelectionTimeoutMS: 8000 });
+await mc.connect();
+const db = mc.db(process.env.BAMA_DB_NAME);
+await db.collection("bio").deleteMany({});
+
+let fails = 0;
+const check = (name, cond, detail = "") => {
+ console.log(` ${cond ? "ok " : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!cond) fails++;
+};
+
+// 1. getBio before any refresh -> static fallback, still has a link
+const { getBio, refreshBio } = await import("../lib/bio.js");
+let bio = await getBio();
+check("getBio falls back to text when cache empty", bio.ok === true && typeof bio.text === "string" && bio.text.length > 40, JSON.stringify(bio.text).slice(0, 60));
+check("fallback still carries the Wikipedia link", /wikipedia\.org\/wiki\/Bam_Aquino/.test(bio.url || ""), bio.url);
+check("fallback source flagged", bio.source === "fallback", bio.source);
+
+// 2. refreshBio pulls the live article (needs network)
+console.log("refreshing from Wikipedia...");
+try {
+ const r = await refreshBio();
+ check("refreshBio ok", r.ok === true);
+ check("refreshed text mentions the senator", /Aquino/i.test(r.text || ""), JSON.stringify(r.text).slice(0, 70));
+ // now getBio should serve the cached live copy
+ bio = await getBio();
+ check("getBio now serves cached live copy", bio.source === "Wikipedia", bio.source);
+ check("cached copy has updated date", bio.updatedAt instanceof Date || typeof bio.updatedAt === "object", String(bio.updatedAt));
+} catch (e) {
+ // No network (e.g. offline CI) -> refresh throws; getBio must still fall back.
+ console.log(" (network refresh failed: " + (e && e.message) + " — verifying graceful fallback)");
+ bio = await getBio();
+ check("offline: getBio still returns the fallback blurb", bio.ok === true && bio.source === "fallback", bio.source);
+}
+
+await db.collection("bio").deleteMany({});
+await mc.close();
+console.log(fails ? `\n${fails} FAILED` : "\nall passed");
+process.exit(fails ? 1 : 0);