diff --git a/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts b/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts index 27b35b41b1..728462d22a 100644 --- a/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts +++ b/trios/agent-server/apps/server/src/api/routes/queen-public-leaderboard.ts @@ -21,16 +21,27 @@ import { createQueenPool } from '../../lib/db/queen-pool' import { logger } from '../../lib/logger' import { leaderboard } from '../services/queen-leaderboard' -/** The window, in days. A month covers the swarm's memory of its own turns. */ -const DEFAULT_DAYS = 30 -const MAX_DAYS = 90 +/** + * ALL TIME BY DEFAULT. `?days=N` narrows it. + * + * This answered for thirty days until 2026-09-23, which was the wrong default + * for a record of who carried the swarm: a lender whose lanes worked hard last + * month and rested this one read as having done nothing, and the board quietly + * shrank as time passed rather than growing with the work. + */ +const MAX_DAYS = 3650 export function createQueenPublicLeaderboardRoute() { return new Hono().get('/', async (c) => { - const asked = Number(c.req.query('days')) - const days = Number.isFinite(asked) - ? Math.min(MAX_DAYS, Math.max(1, Math.trunc(asked))) - : DEFAULT_DAYS + const raw = c.req.query('days') + const asked = Number(raw) + // No `days` at all means the whole record; a `days` that is not a number is + // a mistake in the request rather than a request for everything, so it is + // treated as absent only when it was absent. + const days = + raw === undefined || !Number.isFinite(asked) + ? null + : Math.min(MAX_DAYS, Math.max(1, Math.trunc(asked))) const url = process.env.DATABASE_URL if (!url) return c.json({ error: 'No database configured' }, 503) try { diff --git a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts index b5cc563a1a..41ff3cddac 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts @@ -148,20 +148,35 @@ export function rank( * the archive is where the turns before it live; leaving it out counted the * last attempt of each issue and called it the swarm's whole history. */ -export async function keyWork(pool: Pool, days = 30): Promise { +export async function keyWork( + pool: Pool, + days: number | null = null, +): Promise { + // ALL TIME IS THE DEFAULT, and `days` narrows it. + // + // A thirty-day window was the first shape of this and it was the wrong one: + // the board is a record of who carried the swarm, and a record that forgets + // last month tells a lender their work stopped counting. It also shrinks on + // its own - a lane that worked hard in August and stopped simply vanished, + // which reads as "did nothing" rather than "did this, earlier". + // + // `null` means no time predicate at all rather than a very large number of + // days, so the query has nothing to be off-by-one about, and the archive's + // own rows decide how far back the answer goes. + const windowed = days !== null const { rows } = await pool.query( `WITH turns AS ( SELECT key_index, review_state, dispatched_at, finished_at FROM queen_dispatch - WHERE dispatched_at > now() - ($1::integer * interval '1 day') + ${windowed ? "WHERE dispatched_at > now() - ($1::integer * interval '1 day')" : ''} UNION ALL SELECT (snapshot->>'key_index')::integer, snapshot->>'review_state', (snapshot->>'dispatched_at')::timestamptz, (snapshot->>'finished_at')::timestamptz FROM queen_dispatch_history - WHERE archived_at > now() - ($1::integer * interval '1 day') - AND snapshot->>'key_index' ~ '^[0-9]+$' + WHERE snapshot->>'key_index' ~ '^[0-9]+$' + ${windowed ? "AND archived_at > now() - ($1::integer * interval '1 day')" : ''} ) SELECT key_index, count(*) FILTER (WHERE review_state = 'accept') AS accepted, @@ -175,7 +190,7 @@ export async function keyWork(pool: Pool, days = 30): Promise { WHERE key_index IS NOT NULL GROUP BY key_index ORDER BY key_index`, - [days], + windowed ? [days] : [], ) return rows.map((row) => ({ keyIndex: Number(row.key_index), @@ -186,14 +201,18 @@ export async function keyWork(pool: Pool, days = 30): Promise { } export interface Leaderboard { - days: number + /** The window in days, or null for the whole record. */ + days: number | null measuredAt: string /** What one accepted issue and one bee-hour are worth, so the page can say so. */ scoring: { acceptedXp: number; hourXp: number } contributors: Contributor[] } -export async function leaderboard(pool: Pool, days = 30): Promise { +export async function leaderboard( + pool: Pool, + days: number | null = null, +): Promise { const work = await keyWork(pool, days) return { days, diff --git a/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts b/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts index 726df20f1c..a5ca3d9887 100644 --- a/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'bun:test' +import type { Pool } from 'pg' + import { ACCEPTED_XP, githubLoginOf, HOUR_XP, type KeyWork, + keyWork, parseOwners, rank, xpFor, @@ -112,6 +115,44 @@ describe('the score', () => { expect(row.claimed).toBe(true) }) + /** + * The window is the one branch here that SQL decides rather than TypeScript, + * so it is pinned by what the query actually says. A fake pool is enough: + * the question is whether a time predicate is written and whether a parameter + * is passed, and both are visible without a database. + */ + describe('the window', () => { + const spy = () => { + const seen: { text: string; params: unknown[] } = { text: '', params: [] } + const pool = { + query: (text: string, params: unknown[]) => { + seen.text = text + seen.params = params + return Promise.resolve({ rows: [] }) + }, + } as unknown as Pool + return { pool, seen } + } + + it('asks for the whole record by default, with no time predicate at all', async () => { + const { pool, seen } = spy() + await keyWork(pool) + expect(seen.text).not.toContain('interval') + expect(seen.params).toEqual([]) + // The archive still keeps its own guard, which is not about time. + expect(seen.text).toContain("snapshot->>'key_index' ~ ") + }) + + it('narrows both halves when days is given, and passes it once', async () => { + const { pool, seen } = spy() + await keyWork(pool, 30) + expect(seen.params).toEqual([30]) + // Both the live table and the archive, or the answer is half-windowed. + expect(seen.text).toContain('dispatched_at > now()') + expect(seen.text).toContain('archived_at > now()') + }) + }) + it('gives an unclaimed lane no handle at all', () => { const [row] = rank([lane(7, 1, 1, 0)], {}) expect(row.name).toBe('key #7')