Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 1 addition & 19 deletions src/lib/daily.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+".
Expand Down Expand Up @@ -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<Bookmark[]> {
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<void> {
// Encoded so an id can never reach another endpoint (e.g. `lists%2F<id>`).
Expand Down
12 changes: 8 additions & 4 deletions src/pages/api/bookmarks/index.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
58 changes: 53 additions & 5 deletions src/scripts/roulette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type GameState = {
type SavedState = Pick<GameState, "mode" | "chamber" | "current" | "killed" | "survived" | "total">;

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)!;
Expand Down Expand Up @@ -48,10 +50,29 @@ function tick(ctx: AudioContext, t: number, freq: number, gain: number) {
o.stop(t + 0.06);
}

async function fetchReal(): Promise<Bookmark[]> {
const res = await fetch("/api/bookmarks");
type BookmarkPage = { items: Bookmark[]; cursor?: string; hasNextPage: boolean };

async function fetchReal(cursor?: string): Promise<BookmarkPage> {
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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
70 changes: 70 additions & 0 deletions tests/e2e/real-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>, 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: "".
Expand Down
Loading