From 00a2c44991643b29ba11ef59c59540b921ed0ee1 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 11:54:10 +0530 Subject: [PATCH 1/3] Show personal leaderboard ranks only outside displayed rows --- README.md | 4 + apps/web/src/components/leaderboard.tsx | 67 +++++++-------- apps/web/tests/leaderboard.test.tsx | 93 +++++++++++++++++++++ scripts/testing/leaderboard.mjs | 103 ++++++++++++++++++++---- 4 files changed, 218 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index b7ecc69f2..b676dae13 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ work. Here’s mine: you follow, with recommendations based on those choices. - **Choose how to read.** Switch between cards and a compact list, and use light, dark, or system appearance. +- **See your reading rank.** Compare longest streaks and total reading days among + readers with a public profile, claimed username, and recorded reading activity. + Each leaderboard shows your rank in the list, or below it when you are outside + the displayed ten readers. - **Bring a source with you.** Suggest an RSS or Atom feed for review from the web app or browser extensions. diff --git a/apps/web/src/components/leaderboard.tsx b/apps/web/src/components/leaderboard.tsx index 6f16c5043..3e37d5584 100644 --- a/apps/web/src/components/leaderboard.tsx +++ b/apps/web/src/components/leaderboard.tsx @@ -99,6 +99,7 @@ export function Leaderboard() { {boards.map(({ key, title, Icon }) => { const entries = current.value![key]; const mine = own?.value?.[key]; + const ownRankListed = entries.some((entry) => entry.username === mine?.username); return (
@@ -118,38 +119,40 @@ export function Leaderboard() { ) : (

No rankings yet.

)} -
- {loading ? ( -

Checking your account…

- ) : !user ? ( - - Sign in to join - - ) : own?.error ? ( - - ) : !own?.value ? ( -

Loading your ranking…

- ) : mine ? ( - - ) : profile?.username && profile.visibility?.public ? ( - - Start reading - - ) : ( - - {profileUnavailable - ? "Review your profile" - : profile?.username - ? "Make your profile public" - : "Claim your username"} - - )} -
+ {!ownRankListed && ( +
+ {loading ? ( +

Checking your account…

+ ) : !user ? ( + + Sign in to join + + ) : own?.error ? ( + + ) : !own?.value ? ( +

Loading your ranking…

+ ) : mine ? ( + + ) : profile?.username && profile.visibility?.public ? ( + + Start reading + + ) : ( + + {profileUnavailable + ? "Review your profile" + : profile?.username + ? "Make your profile public" + : "Claim your username"} + + )} +
+ )}
); })} diff --git a/apps/web/tests/leaderboard.test.tsx b/apps/web/tests/leaderboard.test.tsx index 9490ee48a..3fb72831c 100644 --- a/apps/web/tests/leaderboard.test.tsx +++ b/apps/web/tests/leaderboard.test.tsx @@ -64,6 +64,99 @@ it("shows the signed-in reader's global rank even outside the top ten and clears expect(screen.queryByRole("link", { name: "Reader, rank 15, 4 days, you" })).toBeNull(); }); +it.each([1, 10])( + "shows a listed reader once with %i public rows and no personal footer", + async (size) => { + state.user = { user_id: "mine" }; + const entries = Array.from({ length: size }, (_, index) => ({ + ...reader, + rank: index + 1, + username: `reader-${index}`, + })); + const mine = entries[size - 1]; + network.mockImplementation((url: string) => + Promise.resolve( + Response.json( + url.endsWith("/me") + ? { longest_streak: mine, reading_days: mine } + : { longest_streak: entries, reading_days: entries }, + ), + ), + ); + render(); + expect( + await screen.findAllByRole("link", { name: `Reader, rank ${size}, 21 days, you` }), + ).toHaveLength(2); + for (const title of ["Longest streak", "Most reading days"]) { + const board = screen.getByRole("region", { name: title }); + expect(within(board).getAllByRole("listitem")).toHaveLength(size); + expect(within(board).getAllByRole("link", { name: /, you$/ })).toHaveLength(1); + expect(within(board).queryByLabelText(`Your ${title.toLowerCase()} ranking`)).toBeNull(); + } + }, +); + +it.each([ + { key: "longest_streak", title: "Longest streak", otherTitle: "Most reading days" }, + { key: "reading_days", title: "Most reading days", otherTitle: "Longest streak" }, +] as const)( + "checks whether the reader is listed independently for $title", + async ({ key, title, otherTitle }) => { + state.user = { user_id: "mine" }; + network.mockImplementation((url: string) => + Promise.resolve( + Response.json( + url.endsWith("/me") ? { ...ownRanks, [key]: publicBoards[key][0] } : publicBoards, + ), + ), + ); + render(); + const board = await screen.findByRole("region", { name: title }); + await within(board).findByRole("link", { name: /, you$/ }); + expect(within(board).getAllByRole("link", { name: /, you$/ })).toHaveLength(1); + expect(within(board).queryByLabelText(`Your ${title.toLowerCase()} ranking`)).toBeNull(); + const other = screen.getByRole("region", { name: otherTitle }); + expect( + within(within(other).getByLabelText(`Your ${otherTitle.toLowerCase()} ranking`)).getByRole( + "link", + { name: /, you$/ }, + ), + ).toBeTruthy(); + }, +); + +it("keeps the personal footer for a tied rank whose username is outside the displayed ten", async () => { + state.user = { user_id: "mine" }; + const entries = Array.from({ length: 10 }, (_, index) => ({ + ...reader, + username: `reader-${index}`, + })); + const mine = { ...reader, username: "mine" }; + network.mockImplementation((url: string) => + Promise.resolve( + Response.json( + url.endsWith("/me") + ? { longest_streak: mine, reading_days: mine } + : { longest_streak: entries, reading_days: entries }, + ), + ), + ); + render(); + expect(await screen.findAllByRole("link", { name: "Reader, rank 1, 21 days, you" })).toHaveLength( + 2, + ); + for (const title of ["Longest streak", "Most reading days"]) { + const board = screen.getByRole("region", { name: title }); + expect(within(board).getAllByRole("listitem")).toHaveLength(10); + expect( + within(within(board).getByLabelText(`Your ${title.toLowerCase()} ranking`)).getByRole( + "link", + { name: "Reader, rank 1, 21 days, you" }, + ), + ).toBeTruthy(); + } +}); + it("keeps the public rankings when personal ranks fail and lets the reader retry", async () => { state.user = { user_id: "mine" }; network.mockImplementation((url: string) => diff --git a/scripts/testing/leaderboard.mjs b/scripts/testing/leaderboard.mjs index 6142954f4..6b87fa9ab 100644 --- a/scripts/testing/leaderboard.mjs +++ b/scripts/testing/leaderboard.mjs @@ -34,10 +34,25 @@ export async function checkLeaderboard(page, prefix, { signedIn = false } = {}) const base = extension ? previousUrl.split("#")[0] + "#" : new URL(previousUrl).origin; let mode = "normal"; let ownReads = 0; + const sparseBoards = { + longest_streak: [boards.longest_streak[0]], + reading_days: [boards.reading_days[0]], + }; + const mixedBoards = { + ...boards, + reading_days: boards.reading_days.map((row) => ({ ...row, rank: 1, days: 465 })), + }; const publicRoute = async (route) => route.fulfill({ status: mode === "public-error" ? 503 : 200, - json: mode === "empty" ? { longest_streak: [], reading_days: [] } : boards, + json: + mode === "empty" + ? { longest_streak: [], reading_days: [] } + : mode === "self-only" + ? sparseBoards + : mode === "mixed" + ? mixedBoards + : boards, }); const ownRoute = async (route) => { ownReads++; @@ -45,22 +60,36 @@ export async function checkLeaderboard(page, prefix, { signedIn = false } = {}) status: mode === "own-error" ? 503 : 200, json: ["unclaimed", "empty"].includes(mode) ? { longest_streak: null, reading_days: null } - : { - longest_streak: { - ...rows[0], - username: "own-reader", - display_name: "Your Reader", - rank: 1521, - days: 12, - }, - reading_days: { - ...rows[0], - username: "own-reader", - display_name: "Your Reader", - rank: 1420, - days: 24, - }, - }, + : mode === "self-only" + ? { + longest_streak: sparseBoards.longest_streak[0], + reading_days: sparseBoards.reading_days[0], + } + : mode === "mixed" + ? { + longest_streak: boards.longest_streak[9], + reading_days: { + ...mixedBoards.reading_days[0], + username: "own-reader", + display_name: "Your Reader", + }, + } + : { + longest_streak: { + ...rows[0], + username: "own-reader", + display_name: "Your Reader", + rank: 1521, + days: 12, + }, + reading_days: { + ...rows[0], + username: "own-reader", + display_name: "Your Reader", + rank: 1420, + days: 24, + }, + }, }); }; const profileRoute = (route) => @@ -151,6 +180,46 @@ export async function checkLeaderboard(page, prefix, { signedIn = false } = {}) await page.getByRole("heading", { name: "Leading Reader", exact: true }).waitFor(); await page.goto(`${base}/leaderboard`); await streaks.getByRole("listitem").first().waitFor(); + if (signedIn) { + mode = "self-only"; + await page.reload(); + await streaks + .getByRole("link", { name: "Leading Reader, rank 1, 365 days, you", exact: true }) + .waitFor(); + await days + .getByRole("link", { name: "Leading Reader, rank 1, 465 days, you", exact: true }) + .waitFor(); + for (const board of [streaks, days]) { + assert.equal(await board.getByRole("listitem").count(), 1); + assert.equal(await board.locator('a[data-own="true"]').count(), 1); + assert.equal(await board.locator("footer").count(), 0); + } + await page.screenshot({ path: `${prefix}-self-only-desktop.png`, fullPage: true }); + await page.setViewportSize({ width: 320, height: 844 }); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + false, + ); + await page.screenshot({ path: `${prefix}-self-only-320.png`, fullPage: true }); + await page.setViewportSize({ width: 1440, height: 1100 }); + mode = "mixed"; + await page.reload(); + await streaks + .getByRole("link", { + name: "A reader with a very long display name, rank 10, 275 days, you", + exact: true, + }) + .waitFor(); + await days + .getByRole("link", { name: "Your Reader, rank 1, 465 days, you", exact: true }) + .waitFor(); + assert.equal(await streaks.getByRole("listitem").count(), 10); + assert.equal(await days.getByRole("listitem").count(), 10); + assert.equal(await streaks.locator('a[data-own="true"]').count(), 1); + assert.equal(await streaks.locator("footer").count(), 0); + assert.equal(await days.locator('footer a[data-own="true"]').count(), 1); + assert.equal(await days.locator('ol a[data-own="true"]').count(), 0); + } mode = "public-error"; await page.reload(); // Restoring the account replaces the guest subtree. Wait for that remount From a8abc6ac8b297e7c0f1c5c305cde9f6fd0472cd3 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 23:17:30 +0530 Subject: [PATCH 2/3] Verify complete leaderboard top tens and username eligibility --- tests/test_reading_leaderboard.py | 49 ++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/test_reading_leaderboard.py b/tests/test_reading_leaderboard.py index d1ff908b0..b9be7cab2 100644 --- a/tests/test_reading_leaderboard.py +++ b/tests/test_reading_leaderboard.py @@ -73,7 +73,15 @@ def test_public_top_ten_uses_best_streak_and_distinct_days_with_safe_identity(re boards = response.json() streaks, days = boards["longest_streak"], boards["reading_days"] assert len(streaks) == len(days) == 10 - assert [row["username"] for row in streaks[:3]] == ["reader-00", "reader-01", "reader-02"] + assert [row["username"] for row in streaks] == [f"reader-{index:02}" for index in range(10)] + assert [row["username"] for row in days] == ["reader-11"] + [ + f"reader-{index:02}" for index in range(9) + ] + for board in (streaks, days): + assert len({row["username"] for row in board}) == 10 + assert [row["days"] for row in board] == sorted( + (row["days"] for row in board), reverse=True + ) assert [row["rank"] for row in streaks[:3]] == [1, 1, 3] assert streaks[0]["days"] == 50 # Historical best survives an expired current streak. assert days[0]["username"] == "reader-11" and days[0]["days"] == 200 @@ -85,6 +93,45 @@ def test_public_top_ten_uses_best_streak_and_distinct_days_with_safe_identity(re assert excluded not in text +def test_ties_at_cutoff_keep_ten_distinct_readers_and_global_own_rank(readers, database): + client, _, identifiers = readers + with database.begin() as session: + for account_id in identifiers[:12]: + streak = session.get(UserReadingStreak, account_id) + streak.longest_days = streak.total_days = 7 + + boards = client.get("/v1/user/leaderboard").json() + for board in boards.values(): + assert [row["username"] for row in board] == [f"reader-{index:02}" for index in range(10)] + assert [row["rank"] for row in board] == [1] * 10 + assert [row["days"] for row in board] == [7] * 10 + + own = client.get("/v1/user/leaderboard/me").json() + for rank in own.values(): + assert rank["username"] == "reader-10" + assert rank["rank"] == 1 + + +def test_claiming_username_immediately_includes_active_reader_in_top_ten(readers, database): + client, current, identifiers = readers + current.user_id, current.subject = str(identifiers[13]), "reader-13" + assert client.get("/v1/user/leaderboard/me").json() == { + "longest_streak": None, + "reading_days": None, + } + with database.begin() as session: + session.get(UserAccount, identifiers[13]).username = "reader-13" + + boards = client.get("/v1/user/leaderboard").json() + own = client.get("/v1/user/leaderboard/me").json() + for metric, board in boards.items(): + assert len(board) == len({row["username"] for row in board}) == 10 + assert board[0]["username"] == "reader-13" + assert board[0]["rank"] == 1 + assert own[metric] == board[0] + assert "reader-12" not in {row["username"] for row in board} + + def test_own_rank_is_global_outside_top_ten_and_checks_identity(readers): client, current, _ = readers response = client.get("/v1/user/leaderboard/me") From 702257b8336dd7bc3688ace31492a7b7b3c0af8c Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 23:31:52 +0530 Subject: [PATCH 3/3] Retry touch preparation when reader targets remount --- .../extensions/tests/reader-touch.browser.mjs | 100 ++++++++++++------ .../web/tests/browser/reader-interactions.mjs | 10 +- 2 files changed, 74 insertions(+), 36 deletions(-) diff --git a/apps/extensions/tests/reader-touch.browser.mjs b/apps/extensions/tests/reader-touch.browser.mjs index 84f53e738..49d3540a7 100644 --- a/apps/extensions/tests/reader-touch.browser.mjs +++ b/apps/extensions/tests/reader-touch.browser.mjs @@ -3,49 +3,81 @@ import test from "node:test"; import { chromium } from "playwright"; import { touchReaderTarget } from "../../web/tests/browser/reader-interactions.mjs"; -test("reader touch re-resolves a target replaced during scrolling", async () => { - const browser = await chromium.launch({ - executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, - channel: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - ? undefined - : process.env.DEVFEED_EXTENSION_BROWSER === "edge" - ? "msedge" - : "chromium", - headless: true, - }); - try { - const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); - const cdp = await page.context().newCDPSession(page); - await cdp.send("Emulation.setTouchEmulationEnabled", { - enabled: true, - maxTouchPoints: 1, - }); - await page.setContent(` +for (const replaceAfterPreflight of [false, true]) { + test( + replaceAfterPreflight + ? "reader touch retries bounds when the target remounts after preflight" + : "reader touch re-resolves a target replaced during scrolling", + async () => { + const browser = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + channel: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? undefined + : process.env.DEVFEED_EXTENSION_BROWSER === "edge" + ? "msedge" + : "chromium", + headless: true, + }); + try { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + const cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setTouchEmulationEnabled", { + enabled: true, + maxTouchPoints: 1, + }); + await page.setContent(` `); - await touchReaderTarget(cdp, page.getByRole("button", { name: "Open article" })); - await page.waitForFunction(() => window.result.clicks === 1); - assert.deepEqual(await page.evaluate(() => window.result), { - replacements: 1, - touches: 1, - clicks: 1, - }); - } finally { - await browser.close(); - } -}); + const target = page.getByRole("button", { name: "Open article" }); + let boundsReads = 0; + const locator = replaceAfterPreflight + ? { + click: (options) => target.click(options), + boundingBox: async () => { + boundsReads++; + if (boundsReads === 1) { + await page.waitForFunction(() => window.result.replacements === 1); + const previous = await target.elementHandle(); + await page.evaluate(() => window.replaceTarget()); + const bounds = await previous.boundingBox(); + await previous.dispose(); + assert.equal(bounds, null, "the remounted target has detached bounds"); + return bounds; + } + return target.boundingBox(); + }, + } + : target; + await touchReaderTarget(cdp, locator); + if (replaceAfterPreflight) assert.ok(boundsReads >= 2); + await page.waitForFunction(() => window.result.clicks === 1); + assert.deepEqual(await page.evaluate(() => window.result), { + replacements: replaceAfterPreflight ? 2 : 1, + touches: 1, + clicks: 1, + }); + } finally { + await browser.close(); + } + }, + ); +} diff --git a/apps/web/tests/browser/reader-interactions.mjs b/apps/web/tests/browser/reader-interactions.mjs index e495f1888..e73f7a6ad 100644 --- a/apps/web/tests/browser/reader-interactions.mjs +++ b/apps/web/tests/browser/reader-interactions.mjs @@ -20,8 +20,14 @@ export function notificationFixture() { /** Wait for a stable target before dispatching native touch events. */ export async function touchReaderTarget(cdp, locator) { // Trial actions re-resolve a detached locator without sending a desktop click. - await locator.click({ trial: true }); - const box = await locator.boundingBox(); + const deadline = Date.now() + 5000; + let box; + do { + await locator.click({ trial: true, timeout: Math.max(1, deadline - Date.now()) }); + box = await locator.boundingBox(); + // Rendering may replace the element between preflight and bounds lookup. + // Retry preparation only; never replay a dispatched touch. + } while (!box && Date.now() < deadline); assert.ok(box, "touch target has visible bounds"); await cdp.send("Input.dispatchTouchEvent", { type: "touchStart",