SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
2 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%

Rankings: 'all observed' companion rows for coverage-gated rankings (worker + API mode=all + web toggle); admin project list; watch button on project pages

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 20 days ago (Sep 12, 2026) parent d2ac2b9

6 changed files +48 −8

modified apps/api/src/repositories/rankings.ts +2 −2
@@ -35,7 +35,7 @@ export function toRankingRows(scope: string, raw: unknown, limit?: number): Rank
35 35
36 36 export async function listRankings(): Promise<RankingListItem[]> {
37 37 const sql = pg();
38 − const rows = await sql<Row[]>`select key, label, scope, unit, methodology, computed_at, total, min_coverage from rankings where is_current order by scope, key`;
38 + const rows = await sql<Row[]>`select key, label, scope, unit, methodology, computed_at, total, min_coverage from rankings where is_current and key not like '%:all' order by scope, key`;
39 39 return rows.map((r) => ({ key: reqStr(r.key), label: reqStr(r.label), scope: asScope(r.scope), unit: reqStr(r.unit), methodology: reqStr(r.methodology), computedAt: reqIso(r.computed_at), total: int(r.total), ...(num(r.min_coverage) != null ? { minCoverage: num(r.min_coverage)! } : {}) }));
40 40 }
41 41
@@ -54,7 +54,7 @@ export async function getRanking(key: string, limit?: number): Promise<Ranking |
54 54 /** Positions of one entity (matched by id or slug) across all current rankings of a scope. */
55 55 export async function rankingPositions(scope: Ranking["scope"], ids: string[]): Promise<Array<{ key: string; label: string; rank: number; total: number; value: number | null }>> {
56 56 const sql = pg();
57 − const rows = await sql<Row[]>`select key, label, total, rows from rankings where is_current and scope = ${scope} order by key`;
57 + const rows = await sql<Row[]>`select key, label, total, rows from rankings where is_current and scope = ${scope} and key not like '%:all' order by key`;
58 58 const wanted = new Set(ids.map((s) => s.toLowerCase()));
59 59 const out: Array<{ key: string; label: string; rank: number; total: number; value: number | null }> = [];
60 60 for (const r of rows) {
modified apps/api/src/routes/admin/projects.ts +21 −0
@@ -37,7 +37,28 @@ const patchSchema = z.object({
37 37 const CACHE_PREFIXES = ["/projects", "/dashboard", "/map", "/explore", "/pulse"];
38 38 async function bust(): Promise<void> { await Promise.all(CACHE_PREFIXES.map((p) => invalidate(p))); }
39 39
40 +const listSchema = z.object({ hidden: z.enum(["true", "false", "all"]).optional(), q: z.string().max(200).optional(), min_priority: z.coerce.number().int().min(0).max(100).optional(), page: z.coerce.number().int().min(1).optional(), per_page: z.coerce.number().int().min(1).max(200).optional() }).strict();
41 +
40 42 export async function projectAdminRoutes(app: FastifyInstance): Promise<void> {
43 + app.get("/projects", { schema: { summary: "Admin project list: hidden=true|false|all (default all), q, min_priority, page, per_page — includes hidden / merged rows, review priority, class, evidence, open flags" } }, async (req) => {
44 + const q = listSchema.parse(req.query ?? {});
45 + const sql = pg();
46 + const perPage = q.per_page ?? 50, page = q.page ?? 1;
47 + const like = q.q ? `%${q.q.replace(/[%_\\]/g, (m) => `\\${m}`)}%` : null;
48 + const hiddenCond = q.hidden === "true" ? sql`and p.hidden` : q.hidden === "false" ? sql`and not p.hidden and p.merged_into is null` : sql``;
49 + const rows = await sql`
50 + select p.id, p.slug, p.name, p.status, p.planned_mw, p.investment_usd, p.country_iso2, p.city, p.project_class, p.evidence_level, p.ai_evidence, p.capacity_scope, p.investment_scope, p.hidden, p.merged_into, p.review_priority, p.source_url, p.announced_on, p.updated_at,
51 + o.name as operator_name, o.slug as operator_slug,
52 + (select count(*)::int from quality_flags f where f.entity_type = 'project' and f.entity_id = p.id and f.status = 'open') as open_flags,
53 + (select json_agg(json_build_object('code', f.code, 'severity', f.severity, 'message', f.message)) from (select code, severity, message from quality_flags f where f.entity_type = 'project' and f.entity_id = p.id and f.status = 'open' order by priority desc limit 5) f) as flags,
54 + count(*) over() as total
55 + from projects p left join operators o on o.id = p.operator_id
56 + where true ${hiddenCond} ${like ? sql`and (p.name ilike ${like} or p.slug ilike ${like})` : sql``} ${q.min_priority != null ? sql`and p.review_priority >= ${q.min_priority}` : sql``}
57 + order by p.review_priority desc, p.updated_at desc limit ${perPage} offset ${(page - 1) * perPage}`;
58 + const total = rows.length ? Number(rows[0]!.total) : 0;
59 + return envelope(rows.map((r) => ({ id: r.id, slug: r.slug, name: r.name, status: r.status, plannedMw: r.planned_mw == null ? null : Number(r.planned_mw), investmentUsd: r.investment_usd == null ? null : Number(r.investment_usd), countryIso2: r.country_iso2, city: r.city, projectClass: r.project_class, evidenceLevel: r.evidence_level, aiEvidence: r.ai_evidence, capacityScope: r.capacity_scope, investmentScope: r.investment_scope, hidden: Boolean(r.hidden), mergedInto: r.merged_into, reviewPriority: Number(r.review_priority ?? 0), sourceUrl: r.source_url, announcedOn: r.announced_on, updatedAt: r.updated_at, operator: r.operator_name ? { name: r.operator_name, slug: r.operator_slug } : null, openFlags: Number(r.open_flags ?? 0), flags: r.flags ?? [] })), { total, page, perPage });
60 + });
61 +
41 62 app.post("/projects/:id/hide", { schema: { summary: "Hide a project (false positive kept for audit, never listed) {reason}; writes src_manual provenance + a project_status_changed event {hidden}" } }, async (req) => {
42 63 const { id } = req.params as { id: string };
43 64 const body = parseBody(z.object({ reason: z.string().min(1).max(500) }), req.body);
modified apps/api/src/routes/public/discovery.ts +5 −3
@@ -49,10 +49,12 @@ export async function discoveryRoutes(app: FastifyInstance): Promise<void> {
49 49 const items = await listRankings();
50 50 return { data: items, meta: { total: items.length } };
51 51 });
52 − publicGet(app, { url: "/rankings/:key", ttl: TTL.rankings, query: z.object({ limit: intParam }), summary: "One ranking with rows (Ranking)", tags: ["rankings"], params: { key: "ranking key" } }, async (q, params) => {
53 − const r = await getRanking(params.key!, q.limit && q.limit > 0 ? Math.min(q.limit, 1000) : undefined);
52 + publicGet(app, { url: "/rankings/:key", ttl: TTL.rankings, query: z.object({ limit: intParam, mode: z.enum(["strict", "all"]).optional() }), summary: "One ranking with rows (Ranking). mode=all returns the ungated 'all observed' companion of a coverage-gated ranking (coverage shown per row, nothing estimated)", tags: ["rankings"], params: { key: "ranking key" } }, async (q, params) => {
53 + const limit = q.limit && q.limit > 0 ? Math.min(q.limit, 1000) : undefined;
54 + const key = params.key!.replace(/:all$/, "");
55 + const r = (q.mode === "all" ? await getRanking(`${key}:all`, limit) : null) ?? (await getRanking(key, limit));
54 56 if (!r) throw notFound("ranking");
55 − return { data: r, meta: { total: r.total, methodology: r.methodology } };
57 + return { data: { ...r, key }, meta: { total: r.total, methodology: r.methodology, mode: q.mode === "all" && r.minCoverage == null ? "all" : "strict", hasAllObserved: (await getRanking(`${key}:all`)) != null } };
56 58 });
57 59
58 60 publicGet(app, { url: "/dashboard", ttl: TTL.dashboard, summary: "Home dashboard (Dashboard)", tags: ["dashboard"] }, async () => {
modified apps/web/src/app/(site)/projects/[slug]/page.tsx +5 −1
@@ -1,3 +1,4 @@
1 +import { WatchButton } from "@/components/intelligence/watch-button";
1 2 import type { EventType, ProjectDetail } from "@dci/core";
2 3 import { partialDateSortKey } from "@dci/core";
3 4 import type { Metadata } from "next";
@@ -189,7 +190,10 @@ export default async function ProjectPage({ params }: Props) {
189 190 </>
190 191 )}
191 192 </div>
192 − <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{p.name}</h1>
193 + <div className="flex flex-wrap items-start justify-between gap-2">
194 + <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">{p.name}</h1>
195 + <WatchButton entityType="project" slug={p.slug} entityId={p.id} name={p.name} />
196 + </div>
193 197 <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-ink-2">
194 198 {!p.operator && <span className="text-ink-3">Operator not published</span>}
195 199 {p.developer && p.developer.id !== p.operator?.id && (
modified apps/web/src/app/(site)/rankings/[key]/page.tsx +2 −2
@@ -37,7 +37,7 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
37 37 export default async function RankingPage({ params, searchParams }: { params: Params; searchParams: Promise<SearchParams> }) {
38 38 const [{ key }, sp] = await Promise.all([params, searchParams]);
39 39 const mode = sp.mode === "all" ? "all" : "strict";
40 − const r = await api.ranking(key, { limit: 200 });
40 + const r = await api.ranking(key, { limit: 200, mode });
41 41 if (!r.ok && (r.status === 404 || r.status === 400)) notFound();
42 42 if (!r.ok) {
43 43 return (
@@ -98,7 +98,7 @@ export default async function RankingPage({ params, searchParams }: { params: Pa
98 98 ]}
99 99 />
100 100 <p className="mt-2 text-[11.5px] text-ink-3">
101 − <strong className="font-medium text-ink-2">Strict</strong> = coverage gate applied (≥ 5 facilities, ≥ {fmtPct(d.minCoverage)} with a figure). <strong className="font-medium text-ink-2">All observed</strong> would list every entity with at least one published figure regardless of coverage — the API applies the gate server-side and does not expose an ungated view yet, so this tab shows the same rows.
101 + <strong className="font-medium text-ink-2">Strict</strong> = coverage gate applied (≥ 5 facilities, ≥ {fmtPct(d.minCoverage)} with a figure). <strong className="font-medium text-ink-2">All observed</strong> lists every entity with at least one published figure regardless of coverage; the coverage column shows how incomplete each row is. Nothing is estimated in either view.
102 102 </p>
103 103 </div>
104 104 )}
modified apps/worker/src/rankings.ts +13 −0
@@ -265,6 +265,19 @@ export async function computeRankings(db: Db = getDb()): Promise<RankingsResult>
265 265 // keep the table small: drop superseded snapshots older than 90 days
266 266 await tx.execute(sql`delete from rankings where key = ${spec.key} and is_current = false and computed_at < now() - interval '90 days'`);
267 267 total += rows.length;
268 + // "ALL OBSERVED" companion for gated rankings: every entity with a published figure, coverage shown per row, never estimated
269 + if (spec.minCoverage != null) {
270 + const allKey = `${spec.key}:all`;
271 + const allSpec: RankingSpec = { ...spec, key: allKey, eligible: (a) => a.facilities > 0, minCoverage: undefined, label: `${spec.label} — all observed`, methodology: `${spec.methodology} This "all observed" variant lists every entity with at least one published figure regardless of coverage; the coverage column shows how incomplete each row is. Nothing is estimated.` };
272 + const prevAll = (await tx.execute(sql`select rows from rankings where key = ${allKey} and is_current = true order by computed_at desc limit 1`))[0];
273 + const prevAllMap = new Map<string, number>();
274 + for (const r of ((prevAll?.rows as RankingRow[] | undefined) ?? [])) prevAllMap.set(r.id, r.rank);
275 + const allRows = buildRows(allSpec, byScope[spec.scope] ?? [], prevAllMap);
276 + await tx.execute(sql`update rankings set is_current = false where key = ${allKey} and is_current = true`);
277 + await tx.execute(sql`insert into rankings (id, key, scope, label, unit, methodology, min_coverage, rows, total, computed_at, is_current)
278 + values (${newId("ranking")}, ${allKey}, ${spec.scope}, ${allSpec.label}, ${spec.unit}, ${allSpec.methodology}, null, ${JSON.stringify(allRows)}::jsonb, ${allRows.length}, ${now}, true)`);
279 + await tx.execute(sql`delete from rankings where key = ${allKey} and is_current = false and computed_at < now() - interval '90 days'`);
280 + }
268 281 }
269 282 });
270 283 return { rankings: RANKING_SPECS.length, rows: total };
271 284