From bc96a6245bc2f04c03fde28897a79e61a7ce9c39 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 5 Aug 2026 15:45:48 -0400 Subject: [PATCH] hot inventions --- apps/api/src/inventions-db.ts | 51 ++++++++++++------- apps/api/src/routes/avatar.ts | 13 +++-- apps/api/src/test/integration/api.test.ts | 40 +++++++++++---- packages/domain/src/inventory-invention-db.ts | 30 +++++++++++ 4 files changed, 99 insertions(+), 35 deletions(-) diff --git a/apps/api/src/inventions-db.ts b/apps/api/src/inventions-db.ts index b5961a9..5ea4491 100644 --- a/apps/api/src/inventions-db.ts +++ b/apps/api/src/inventions-db.ts @@ -14,10 +14,11 @@ * shaped after a real `GET /api/inventions/v1?inventionId=…` response. * * Who OWNS an invention is a separate table (`inventory_invention`, written by the - * `econ` worker at purchase time); this module only reads it, to fold bought inventions - * into the caller's own list. See @repo/domain's inventory-invention-db.ts. + * `econ` worker at purchase time); this module only reads it — to fold bought inventions + * into the caller's own list, and to rank the "top today" feed by what players actually + * picked up today. See @repo/domain's inventory-invention-db.ts. */ -import { getOwnedInventionIds } from '@repo/domain' +import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain' /** * Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql + @@ -351,31 +352,43 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise JSON.parse(r.data) as SavedInvention) } -/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */ -function topScore(invention: SavedInvention): number { - const n = (v: unknown): number => (typeof v === 'number' ? v : 0) - return ( - n(invention.NumDownloads) * 3 + - n(invention.CheerCount) * 2 + - n(invention.NumPlayersHaveUsedInRoom) - ) +/** Midnight UTC today, as the ISO timestamp `acquired_at` is compared against. */ +function startOfUtcDay(): string { + return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z` } /** - * The "top today" feed — published inventions ranked by engagement. The real feed - * ranks by *today's* activity; we don't track per-day counters, so this ranks by - * lifetime engagement instead. Ties fall back to invention id so paging is stable. - * Paginated via skip/take; returns a bare array, like the other invention feeds. + * The "top today" feed — the inventions other players picked up TODAY, most first. + * + * Ranked from the acquisitions the `econ` worker records in `inventory_invention` at + * purchase time, grouped by invention, rather than from the lifetime counters on the + * invention itself: those never reset, so "top today" used to mean "top ever" and the + * shelf only changed when something overtook a total built up over months. + * + * "Today" is the UTC day, matching the timestamps econ writes. The day therefore rolls + * over at 00:00 UTC wherever the player is, and the feed IS EMPTY until the first + * acquisition of that day — nothing stands in for it, the same way the featured feed + * serves nothing while nothing is curated. + * + * An acquired invention that has since been unpublished or hidden drops out: this is a + * public feed, so it is filtered like every other one. Paginated via skip/take AFTER + * that filtering, so a hidden invention doesn't leave a hole in a page. */ export async function getTopInventions( db: D1Database, skip: number, take: number ): Promise { - const inventions = await publicInventions(db) - return inventions - .sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId) - .slice(skip, skip + take) + const counts = await getInventionAcquisitionCounts(db, startOfUtcDay()) + if (counts.length === 0) return [] + + // getInventionsByIds answers in the order it is asked, so the ranking survives the + // load; ids with no invention row left (deleted) simply drop out. + const ranked = await getInventionsByIds( + db, + counts.map((c) => c.inventionId) + ) + return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take) } /** diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index e037468..b1b1209 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -622,17 +622,20 @@ export const avatarRoutes = new Hono({ strict: false }) } ) - // The "top today" invention feed — published inventions ranked by engagement - // (lifetime, not per-day: we keep no daily counters). Paginated via skip/take - // (take defaults to 50, as the client asks for). Bare array. + // The "top today" invention feed — the inventions most acquired since 00:00 UTC, + // counted from the purchase rows the `econ` worker writes. A real day window, so an + // empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults + // to 50, as the client asks for). Bare array. .get( '/api/inventions/v1/toptoday', describeRoute({ tags: ['Inventions'], summary: 'The “top today” feed', description: - 'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' + - 'daily counters, so “today” is a label, not a window.', + 'Published inventions ranked by how many players acquired them TODAY (since ' + + '00:00 UTC), counted from the purchase records — free grants included, one per ' + + 'player per invention. Genuinely a day window: empty until the day’s first ' + + 'acquisition, and it resets at midnight UTC.', parameters: pageParams(50), responses: { 200: json(InventionDto.array(), 'The top inventions') }, }), diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 6278279..ed1c69e 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1138,14 +1138,15 @@ describe('public endpoints', () => { const ids = async (res: Response): Promise => ((await res.json()) as SavedInvention[]).map((i) => i.InventionId) - // Nothing is flagged IsFeatured yet, so featured is EMPTY — it does not stand in the - // top feed, which by now has published inventions in it. - const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`)) - expect(beforeTop.length).toBeGreaterThan(0) - const beforeFeatured = await ids( - await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`) + // Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and + // the only inventions acquired so far in this file are an unpublished one and an id + // with no invention row — neither of which a public feed may show. + expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual( + [] + ) + expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual( + [] ) - expect(beforeFeatured).toEqual([]) const feedInvention = ( id: number, @@ -1183,11 +1184,24 @@ describe('public endpoints', () => { .run() } - // Top: engagement-ranked, so the biggest download counts lead. + // Today's acquisitions, which is what "top today" now counts: 201 picked up by three + // players, 203 by one. 204/205 are acquired too — an unpublished and a hidden + // invention can still be owned — and must not surface in a public feed. + for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201) + await grantInvention(env.DB, 7001, 203) + await grantInvention(env.DB, 7001, 204) + await grantInvention(env.DB, 7002, 205) + // 202 was acquired, but not today — the window is the current UTC day, so it is out. + await env.DB.prepare( + 'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)' + ) + .bind(7004, 202, '2020-01-01T00:00:00.000Z') + .run() + + // Top: most acquisitions today first. Download counts no longer rank anything — 202 + // has the biggest of them and is absent entirely. const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`)) - expect(top.slice(0, 3)).toEqual([202, 203, 201]) - expect(top).not.toContain(204) - expect(top).not.toContain(205) + expect(top).toEqual([201, 203]) // Featured: only the flagged, visible inventions — newest first. 201 is published but // unflagged, so it stays out however popular it is. @@ -1197,6 +1211,10 @@ describe('public endpoints', () => { // skip/take paginate both feeds. const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`) expect(await ids(page)).toEqual([203]) + // Pagination happens after the visibility filter, so the hidden/unpublished + // acquisitions don't leave holes in a page. + const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`) + expect(await ids(firstPage)).toEqual([201]) const featuredPage = await exports.default.fetch( `${ORIGIN}/api/inventions/v1/featured?skip=1&take=1` ) diff --git a/packages/domain/src/inventory-invention-db.ts b/packages/domain/src/inventory-invention-db.ts index 997371e..b2c0ffb 100644 --- a/packages/domain/src/inventory-invention-db.ts +++ b/packages/domain/src/inventory-invention-db.ts @@ -62,6 +62,36 @@ export async function ownsInvention( return row !== null } +/** + * How many times each invention was acquired at or after `since`, most-acquired first + * (ties broken by newest invention, so paging is stable). Backs the `api` worker's "top + * today" feed, which passes the start of the current UTC day. + * + * `acquired_at` holds `toISOString()` output, which is fixed-width UTC, so a lexical + * `>=` on the string is a chronological comparison — no date parsing in SQL. + * + * This counts ACQUISITIONS, not spend: a free invention's grant is a row here just like + * a paid one, and one player can only ever contribute a single row per invention (the + * table's primary key), so a popular invention can't be inflated by one buyer. Creators + * are absent by design — they own theirs through `CreatorPlayerId` and never buy it — + * which is what makes this a measure of what other people picked up. + */ +export async function getInventionAcquisitionCounts( + db: D1Database, + since: string +): Promise> { + const { results } = await db + .prepare( + `SELECT invention_id, COUNT(*) AS count FROM inventory_invention + WHERE acquired_at >= ?1 + GROUP BY invention_id + ORDER BY count DESC, invention_id DESC` + ) + .bind(since) + .all<{ invention_id: number; count: number }>() + return results.map((r) => ({ inventionId: r.invention_id, count: r.count })) +} + /** The ids of every invention a player has bought, oldest purchase first. */ export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise { const { results } = await db