From cfcf2ff9ac217885925b650fd1f5b08009f31204 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 9 Oct 2026 14:10:00 +0200 Subject: [PATCH 1/2] feat(roulette): play real bookmarks one page at a time, oldest-ward Loading the roulette paginated the whole pile (up to 1000 bookmarks, 20 Public API requests) on every visit, and free daily.dev accounts get a small monthly API quota shared with every other tool they use. - GET /api/bookmarks now returns one page of 50 (one daily.dev request) - finishing a page rides on to the next, older one; past the last page it wraps around to the newest - the page's cursor is kept in localStorage so a later visit resumes there (only the opaque cursor, never bookmarks); a stale cursor falls back to the newest page Part of #74. Co-Authored-By: Claude Opus 5.5 --- src/lib/daily.ts | 20 +-------- src/pages/api/bookmarks/index.ts | 12 ++++-- src/scripts/roulette.ts | 58 +++++++++++++++++++++++--- tests/e2e/real-mode.spec.ts | 70 ++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 28 deletions(-) diff --git a/src/lib/daily.ts b/src/lib/daily.ts index e4fa73c..282c029 100644 --- a/src/lib/daily.ts +++ b/src/lib/daily.ts @@ -64,7 +64,7 @@ type BookmarksPage = { /** * Cheap chamber tally for the homepage. Fetches a single page instead of - * paginating the whole pile (which `listAllBookmarks` would do just to count). + * paginating the whole pile just to count it. * Returns an exact count when the API exposes a total or the pile fits in one * page; otherwise `exact` is false and the count is the first-page size, which * the UI renders as "N+". @@ -109,24 +109,6 @@ export async function listBookmarks( }; } -/** Follows the cursor to pull every bookmark (capped to avoid runaway). */ -export async function listAllBookmarks( - token: string, - opts: { unreadOnly?: boolean; maxItems?: number } = {}, -): Promise { - const cap = opts.maxItems ?? 1000; - const all: Bookmark[] = []; - let cursor: string | undefined; - - do { - const page = await listBookmarks(token, { unreadOnly: opts.unreadOnly, limit: 50, cursor }); - all.push(...page.items); - cursor = page.hasNextPage ? page.cursor : undefined; - } while (cursor && all.length < cap); - - return all.slice(0, cap); -} - /** Pulls the trigger: permanently removes a bookmark. 204 on success. */ export async function deleteBookmark(token: string, id: string): Promise { const res = await fetch(`${BASE}/bookmarks/${id}`, { diff --git a/src/pages/api/bookmarks/index.ts b/src/pages/api/bookmarks/index.ts index cb956f9..5ab165c 100644 --- a/src/pages/api/bookmarks/index.ts +++ b/src/pages/api/bookmarks/index.ts @@ -1,16 +1,20 @@ import type { APIRoute } from "astro"; -import { listAllBookmarks } from "../../../lib/daily"; +import { listBookmarks } from "../../../lib/daily"; import { getToken } from "../../../lib/session"; -// GET /api/bookmarks?unreadOnly=true — proxies the user's real bookmarks (all pages). +// GET /api/bookmarks?cursor=…&unreadOnly=true — one page (50) of the user's +// real bookmarks, newest first. One daily.dev request per call: free accounts +// get a small monthly API quota, so the client walks the pile page by page +// instead of loading it all up front. export const GET: APIRoute = async ({ url, cookies }) => { const token = getToken(cookies); if (!token) return new Response(JSON.stringify({ error: "Not signed in" }), { status: 401 }); const unreadOnly = url.searchParams.get("unreadOnly") === "true"; + const cursor = url.searchParams.get("cursor") || undefined; try { - const items = await listAllBookmarks(token, { unreadOnly }); - return new Response(JSON.stringify({ items }), { + const page = await listBookmarks(token, { unreadOnly, cursor }); + return new Response(JSON.stringify(page), { headers: { "Content-Type": "application/json" }, }); } catch (err) { diff --git a/src/scripts/roulette.ts b/src/scripts/roulette.ts index 2d91806..f4bec96 100644 --- a/src/scripts/roulette.ts +++ b/src/scripts/roulette.ts @@ -20,6 +20,8 @@ type GameState = { type SavedState = Pick; const STORAGE_KEY = "ddr_progress"; +// Real mode only: the daily.dev cursor of the page being played (see loadRealPage). +const CURSOR_KEY = "ddr_cursor"; const CHAMBERS = 6; // 1 live round in 6 — classic odds const $ = (id: string): HTMLElement => document.getElementById(id)!; @@ -48,10 +50,29 @@ function tick(ctx: AudioContext, t: number, freq: number, gain: number) { o.stop(t + 0.06); } -async function fetchReal(): Promise { - const res = await fetch("/api/bookmarks"); +type BookmarkPage = { items: Bookmark[]; cursor?: string; hasNextPage: boolean }; + +async function fetchReal(cursor?: string): Promise { + const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""; + const res = await fetch(`/api/bookmarks${query}`); if (!res.ok) throw new Error("Could not load your bookmarks. Try signing in again."); - return ((await res.json()).items ?? []) as Bookmark[]; + const body = await res.json(); + return { items: body.items ?? [], cursor: body.cursor, hasNextPage: !!body.hasNextPage }; +} + +function readCursor(): string | undefined { + try { + return localStorage.getItem(CURSOR_KEY) || undefined; + } catch { + return undefined; + } +} + +function writeCursor(cursor: string | undefined) { + try { + if (cursor) localStorage.setItem(CURSOR_KEY, cursor); + else localStorage.removeItem(CURSOR_KEY); + } catch {} } function clearState() { @@ -217,6 +238,27 @@ export function initRoulette(): void { o.stop(t + 0.3); } + // Real mode plays one page (50 bookmarks, one daily.dev request) at a time. + // The API only lists newest first, so finishing a page moves on to the next, + // older one, and a later visit resumes there: over time the forgotten + // bookmarks come up too. Only that opaque cursor is stored, never bookmarks. + let pageCursor = readCursor(); + let nextCursor: string | undefined; + + async function loadRealPage() { + let page: BookmarkPage; + try { + page = await fetchReal(pageCursor); + } catch (e) { + if (!pageCursor) throw e; + pageCursor = undefined; // a stale cursor: start over from the newest + page = await fetchReal(); + } + nextCursor = page.hasNextPage ? page.cursor : undefined; + writeCursor(pageCursor); + startGame(page.items); + } + function startGame(bookmarks: Bookmark[]) { state.chamber = bookmarks.slice(); state.killed = 0; @@ -376,6 +418,12 @@ export function initRoulette(): void { function gameOver() { clearState(); + if (state.mode === "real") { + // Past the last page, wrap around to the newest bookmarks. + pageCursor = nextCursor; + writeCursor(pageCursor); + $("restart").textContent = pageCursor ? "Ride on to older bookmarks" : "Reload & ride again"; + } hide($("verdict")); hide($("spin")); $("over-total").textContent = String(state.total); @@ -405,7 +453,7 @@ export function initRoulette(): void { hide($("over")); show($("loading")); try { - startGame(await fetchReal()); + await loadRealPage(); } catch (e: any) { $("loading").textContent = "⚠️ " + String(e.message || e); return; @@ -425,7 +473,7 @@ export function initRoulette(): void { startGame(MOCK_BOOKMARKS); } else { try { - startGame(await fetchReal()); + await loadRealPage(); } catch (e: any) { $("loading").textContent = "⚠️ " + String(e.message || e); return; diff --git a/tests/e2e/real-mode.spec.ts b/tests/e2e/real-mode.spec.ts index 7295889..4d22f19 100644 --- a/tests/e2e/real-mode.spec.ts +++ b/tests/e2e/real-mode.spec.ts @@ -95,6 +95,76 @@ test.describe("real mode: loading bookmarks", () => { }); }); +// GET /api/bookmarks?cursor=… -> one page, keyed by cursor ("" = newest). +async function servePages(page: Page, pages: Record, seen: string[]) { + await page.route( + (url) => url.pathname === "/api/bookmarks", + async (route) => { + const cursor = new URL(route.request().url()).searchParams.get("cursor") ?? ""; + seen.push(cursor); + const body = pages[cursor]; + if (!body) return route.fulfill({ status: 502, json: { error: "Failed to load bookmarks" } }); + await route.fulfill({ json: body }); + }, + ); +} + +async function spareCurrent(page: Page) { + await page.locator("#spin").click(); + await expect(page.locator("#verdict")).toBeVisible({ timeout: 8000 }); + await page.getByRole("button", { name: /Spare it/ }).click(); +} + +test.describe("real mode: one page at a time", () => { + test.beforeEach(async ({ page }) => { + page.context().on("page", (p) => p.close().catch(() => {})); + }); + + test("finishing a page rides on to the next, older one, then wraps around", async ({ page }) => { + const seen: string[] = []; + await servePages( + page, + { + "": { items: [REAL_BOOKMARKS[0]], cursor: "c2", hasNextPage: true }, + c2: { items: [REAL_BOOKMARKS[1]], cursor: null, hasNextPage: false }, + }, + seen, + ); + await loadRouletteAsReal(page); + + await spareCurrent(page); + const restart = page.locator("#restart"); + await expect(restart).toHaveText("Ride on to older bookmarks"); + // A later visit resumes at the older page, not at the newest. + expect(await page.evaluate(() => localStorage.getItem("ddr_cursor"))).toBe("c2"); + + await restart.click(); + await expect(page.locator("#stat-chamber")).toHaveText("1"); + await spareCurrent(page); + await expect(restart).toHaveText("Reload & ride again"); + expect(await page.evaluate(() => localStorage.getItem("ddr_cursor"))).toBeNull(); + + await restart.click(); + await expect(page.locator("#spin")).toBeVisible(); + expect(seen).toEqual(["", "c2", ""]); + }); + + test("a stale saved cursor falls back to the newest page", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("ddr_cursor", "stale")); + const seen: string[] = []; + await servePages( + page, + { "": { items: REAL_BOOKMARKS, cursor: "c2", hasNextPage: true } }, + seen, + ); + + await loadRouletteAsReal(page); + + await expect(page.locator("#stat-chamber")).toHaveText(String(REAL_BOOKMARKS.length)); + expect(seen).toEqual(["stale", ""]); + }); +}); + test.describe("real mode: sparing", () => { test("a bookmark with no url opens its daily.dev post instead", async ({ page }) => { // Shares, freeform posts and collections come back with url: "". From 8a37916b83477000f3bf8b945ae13f80b065ac2f Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 9 Oct 2026 14:11:46 +0200 Subject: [PATCH 2/2] docs: mention the one-page-at-a-time loading in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9ca72f5..2af37a5 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Your bookmarks pile up and rot — this forces a reckoning. One spin serves one ### Two ways to play - **Demo mode** — runs entirely in the browser on a fake bookmark pile. No account, no token required. This is the headline experience and always works. -- **Real mode** — **Sign in with daily.dev** (OAuth, authorization code + PKCE), or paste a daily.dev API token (Settings → API, no Plus subscription needed) as a fallback. Tokens are stored in httpOnly cookies and OAuth access tokens are refreshed server-side; every API call is proxied through Astro server routes, so no token ever touches client JS and there are no CORS issues. Deletes hit the real `DELETE /bookmarks/{id}` endpoint. +- **Real mode** — **Sign in with daily.dev** (OAuth, authorization code + PKCE), or paste a daily.dev API token (Settings → API, no Plus subscription needed) as a fallback. Tokens are stored in httpOnly cookies and OAuth access tokens are refreshed server-side; every API call is proxied through Astro server routes, so no token ever touches client JS and there are no CORS issues. Bookmarks load 50 at a time (one API request), moving on to older ones as you clear each batch, to go easy on daily.dev's monthly API quota. Deletes hit the real `DELETE /bookmarks/{id}` endpoint. ## Run locally