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>
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 | |