diff --git a/README.md b/README.md
index c6ae49b69..a0fcebea1 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,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/extensions/tests/auth.browser.mjs b/apps/extensions/tests/auth.browser.mjs
index 65caf6063..0491cd3c6 100644
--- a/apps/extensions/tests/auth.browser.mjs
+++ b/apps/extensions/tests/auth.browser.mjs
@@ -1,7 +1,7 @@
import { checkArticleViews } from "../../web/tests/browser/article-views.mjs";
import { dailyFixture, checkMustReads } from "../../../scripts/testing/must-reads.mjs";
import { checkAvatarUploads } from "../../../scripts/testing/avatar-uploads.mjs";
-import { checkLeaderboard } from "../../../scripts/testing/leaderboard.mjs";
+import { checkLeaderboard } from "../../web/tests/browser/leaderboard.mjs";
import { checkReadingStreak } from "../../../scripts/testing/reading-streak.mjs";
import {
checkReaderInteractions,
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/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.
)}
-
+ {!ownRankListed && (
+
+ )}
);
})}
diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs
index a50ea40d8..3ec6e76a9 100644
--- a/apps/web/tests/browser/feed.mjs
+++ b/apps/web/tests/browser/feed.mjs
@@ -1,7 +1,7 @@
import { checkArticleGrid } from "../../../../scripts/testing/article-grid.mjs";
import { checkArticleViews } from "./article-views.mjs";
import { dailyFixture, checkMustReads } from "../../../../scripts/testing/must-reads.mjs";
-import { checkLeaderboard, leaderboardProfile } from "../../../../scripts/testing/leaderboard.mjs";
+import { checkLeaderboard, leaderboardProfile } from "./leaderboard.mjs";
import { checkMcp, testMcpEndpoint } from "../../../../scripts/testing/mcp.mjs";
import { checkReadingStreak } from "../../../../scripts/testing/reading-streak.mjs";
import { checkReaderInteractions, notificationFixture } from "./reader-interactions.mjs";
diff --git a/scripts/testing/leaderboard.mjs b/apps/web/tests/browser/leaderboard.mjs
similarity index 68%
rename from scripts/testing/leaderboard.mjs
rename to apps/web/tests/browser/leaderboard.mjs
index 6142954f4..6b87fa9ab 100644
--- a/scripts/testing/leaderboard.mjs
+++ b/apps/web/tests/browser/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
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",
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/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")