From d5c859a1c7266caef1c7e6a59b5f190926644ff4 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 19 Oct 2026 20:15:17 +0000 Subject: [PATCH] Count only the part of a page the reader was shown `perPage` is a request, not a promise: a source whose own pages are 30 long answers a page of 12 with 30. The grid showed the reader the page it asked for, the mark kept 24 of those ids, and the daily check then counted every probe id the mark did not contain -- including ids 25 to 30 of the same page, which were never new and no amount of looking took off the badge. A saved search on sxyprn sat on "6" through every reload. So the count now goes only as far down the page as the mark reaches, and the mark holds enough ids (60) for a page larger than the one asked for. Order does not come into it, which keeps it right for sources that shift a listing between visits. And the whole list can be marked read in one press, for a reader who sees the badge and decides none of it is for them. That moves each mark on to what the source lists now rather than zeroing the count, which would come straight back on the next check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN --- frontend/css/style.css | 3 +- frontend/index.html | 1 + frontend/js/enhance.js | 8 +++ frontend/js/savedSearches.js | 97 +++++++++++++++++++++++++++++++---- frontend/js/ui.js | 15 ++++++ tests/smoke_saved_searches.py | 67 +++++++++++++++++++++++- 6 files changed, 179 insertions(+), 12 deletions(-) diff --git a/frontend/css/style.css b/frontend/css/style.css index b46319e..819330c 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -3256,7 +3256,8 @@ body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); .saved-head-actions { display: flex; align-items: center; - gap: 6px; + /* Two underlined links side by side need the space to read as two. */ + gap: 12px; flex-shrink: 0; } diff --git a/frontend/index.html b/frontend/index.html index afdc18d..0556773 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -251,6 +251,7 @@

+
diff --git a/frontend/js/enhance.js b/frontend/js/enhance.js index 8db7953..94f52c6 100644 --- a/frontend/js/enhance.js +++ b/frontend/js/enhance.js @@ -196,6 +196,14 @@ App.enhance = App.enhance || {}; hint: 'Source · channel · filters · query', run: () => App.ui.savedSearches.toggleCurrent() }); + const total = App.savedSearches.totalNew(); + if (total) { + out.push({ + label: 'Mark all saved searches read', + hint: `${App.savedSearches.badgeLabel(total)} new`, + run: () => App.savedSearches.markAllRead() + }); + } saved.forEach((entry) => { const label = App.savedSearches.badgeLabel(entry.newCount); out.push({ diff --git a/frontend/js/savedSearches.js b/frontend/js/savedSearches.js index 96f8589..490be2e 100644 --- a/frontend/js/savedSearches.js +++ b/frontend/js/savedSearches.js @@ -19,10 +19,13 @@ App.savedSearches = App.savedSearches || {}; (function() { const KEY = App.constants.SAVED_SEARCHES_KEY; - // How many of the newest ids are kept as the "already seen" mark. Twice - // the probe below, so a listing that shifts by a page between visits is - // still measured against something. - const SEEN_KEEP = 24; + // How many of the newest ids are kept as the "already seen" mark. Several + // times the page asked for below: `perPage` is a request, not a promise -- + // sources answer a page of 12 with 30 whenever that is how their own pages + // are cut -- so the mark has to be able to hold a page larger than the one + // that was asked for, and then some, for a listing that shifts between + // visits to still be measured against something. + const SEEN_KEEP = 60; // One page is enough to answer "is there anything new, and roughly how // much": the badge stops counting at 9+ anyway. const PROBE_PER_PAGE = 12; @@ -303,11 +306,32 @@ App.savedSearches = App.savedSearches || {}; return ask(entry.channelId, PROBE_PER_PAGE, extra); }; - const checkOne = async function(entry) { + // How many of the videos the source lists now are ones the reader has not + // been shown -- counted only as far down the page as the mark reaches. + // + // `perPage` is a request, not a promise: a source whose own pages are 30 + // long answers a page of 12 with 30. The reader was shown as much of that + // as the grid asked for, so an id further down than the mark goes has not + // been seen and never was new either -- it was on the same page the whole + // time. Counting those would put a number on the badge that no amount of + // looking takes off again. + const countNew = function(ids, seen) { + return ids.slice(0, seen.size).filter((id) => !seen.has(id)).length; + }; + + const askSource = async function(entry) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); try { - const ids = await probeIds(entry, controller.signal); + return await probeIds(entry, controller.signal); + } finally { + window.clearTimeout(timer); + } + }; + + const checkOne = async function(entry) { + try { + const ids = await askSource(entry); const seen = new Set(entry.seenIds || []); // A search nobody has ever opened has nothing to compare against, // so the whole first page is its mark rather than its news. @@ -315,15 +339,12 @@ App.savedSearches = App.savedSearches || {}; patch(entry.id, { seenIds: ids.slice(0, SEEN_KEEP), lastCheckedAt: now(), newCount: 0, unreachable: false }); return; } - const fresh = ids.filter((id) => !seen.has(id)).length; - patch(entry.id, { lastCheckedAt: now(), newCount: fresh, unreachable: false }); + patch(entry.id, { lastCheckedAt: now(), newCount: countNew(ids, seen), unreachable: false }); } catch (err) { // A source that is down must not look like a search with nothing // new in it, and must not reset the clock either: it is tried // again on the next look. patch(entry.id, { unreachable: true }); - } finally { - window.clearTimeout(timer); } }; @@ -362,6 +383,62 @@ App.savedSearches = App.savedSearches || {}; App.savedSearches.isChecking = function() { return !!running; }; + let marking = null; + + // The whole list, read. For a reader who sees the badge, decides none of + // it is for them, and wants the slate clean rather than nine rows to open + // one after another. + // + // The mark is moved on to what each source lists now rather than the count + // merely being zeroed: a count that is only zeroed comes back on the next + // check, because the videos it counted are still ones nobody was shown. + // That is one listing request per saved search, the same one the daily + // check makes. + App.savedSearches.markAllRead = function() { + if (marking) return marking; + const entries = App.savedSearches.getAll(); + if (!entries.length) return Promise.resolve(0); + + const queue = entries.slice(); + const worker = async function() { + while (queue.length) { + const entry = queue.shift(); + try { + const ids = await askSource(entry); + patch(entry.id, { + seenIds: ids.slice(0, SEEN_KEEP), + lastViewedAt: now(), + lastCheckedAt: now(), + newCount: 0, + unreachable: false + }); + } catch (err) { + // The source did not answer, so the mark cannot be moved + // forward. The count still goes out -- the reader said they + // are done with it -- and the row says it could not be + // reached, so the number coming back later is explained. + patch(entry.id, { newCount: 0, lastViewedAt: now(), unreachable: true }); + } + changed(); + } + }; + marking = Promise.all( + Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker) + ).then(() => { + marking = null; + changed(); + return entries.length; + }).catch(() => { + marking = null; + changed(); + return 0; + }); + changed(); + return marking; + }; + + App.savedSearches.isMarkingAllRead = function() { return !!marking; }; + // ---- opening one ----------------------------------------------------- // Puts the whole saved search back: source, channel, filters, query. The diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 9c91cf0..2c59836 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -683,6 +683,7 @@ App.ui = App.ui || {}; const note = el('saved-head-note'); const add = el('saved-add-btn'); const check = el('saved-check-btn'); + const read = el('saved-read-btn'); if (!list) return; const entries = App.savedSearches.getAll(); @@ -702,6 +703,12 @@ App.ui = App.ui || {}; check.disabled = !entries.length || App.savedSearches.isChecking(); check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now'; } + if (read) { + const marking = App.savedSearches.isMarkingAllRead(); + // Nothing to clear is not a button worth pressing. + read.disabled = marking || !App.savedSearches.totalNew(); + read.textContent = marking ? 'Marking…' : 'Mark all read'; + } if (add) { const match = App.savedSearches.findMatching(); add.disabled = !!match || !App.savedSearches.current(); @@ -834,6 +841,14 @@ App.ui = App.ui || {}; }); } + const read = el('saved-read-btn'); + if (read) { + read.addEventListener('click', () => { + App.savedSearches.markAllRead(); + render(); + }); + } + document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && isOpen()) { event.stopPropagation(); diff --git a/tests/smoke_saved_searches.py b/tests/smoke_saved_searches.py index 1d799b9..a230354 100644 --- a/tests/smoke_saved_searches.py +++ b/tests/smoke_saved_searches.py @@ -58,6 +58,11 @@ ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')) stamp: row.querySelector('.saved-row-stamp').textContent }))""" +READ_BTN = """() => { + const button = document.getElementById('saved-read-btn'); + return { disabled: button.disabled, text: button.textContent }; +}""" + IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')""" PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')""" SESSION = """() => { @@ -83,13 +88,16 @@ def main(): # Which ids the listing hands back. Shifting this window is how the test # says "three new videos were uploaded". offset = {"at": 0, "fail": False} + # How many videos a page comes back with, whatever was asked for: real + # sources cut pages their own way and send what they send. + size = {"n": 12} def items(query): start = offset["at"] return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}", "url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL, "duration": 1, "isLive": False, "tags": []} - for i in range(start, start + 12)] + for i in range(start, start + size["n"])] with sync_playwright() as p: browser = p.chromium.launch(args=[ @@ -313,6 +321,63 @@ def main(): badge["hidden"] is False and badge["text"] == "4", str(badge)) c.ok("the search survived too", len(page.evaluate(SAVED)) == 1) + print("\na source that sends more than the page it was asked for") + # sxyprn answers a page of 12 with 30. Everything past the twelfth was + # never shown to the reader and was never new either -- it sat on the + # same page all along -- so none of it may end up on the badge. + size["n"] = 30 + page.evaluate("""async () => { + App.videos.resetGrid(); + await App.videos.loadVideos(); + App.savedSearches.markViewed(App.savedSearches.getAll()[0].id); + }""") + page.wait_for_timeout(800) + saved = page.evaluate(SAVED) + c.ok("the mark holds the whole page the source sent, not the one asked for", + len(saved[0].get("seenIds") or []) == 30, str(len(saved[0].get("seenIds") or []))) + page.evaluate("() => App.savedSearches.check({ force: true })") + page.wait_for_timeout(900) + saved = page.evaluate(SAVED) + badge = page.evaluate(BADGE) + c.ok("the same page, checked again, is not news", + saved[0].get("newCount") == 0, str(saved[0].get("newCount"))) + c.ok("so the badge stays out instead of sticking on a number", + badge["hidden"] is True, str(badge)) + c.ok("and nothing is counted anywhere else either", + page.evaluate("() => App.savedSearches.totalNew()") == 0) + + print("\nnews on such a source still counts") + offset["at"] = offset["at"] + 5 + page.evaluate("() => App.savedSearches.check({ force: true })") + page.wait_for_timeout(900) + saved = page.evaluate(SAVED) + c.ok("five turning up is five", saved[0].get("newCount") == 5, str(saved[0].get("newCount"))) + + print("\nmarking the whole list read") + page.click("#saved-searches-btn") + page.wait_for_timeout(300) + c.ok("the button is offered while something is new", + page.evaluate(READ_BTN)["disabled"] is False, str(page.evaluate(READ_BTN))) + asked.clear() + page.click("#saved-read-btn") + page.wait_for_timeout(1400) + saved = page.evaluate(SAVED) + badge = page.evaluate(BADGE) + c.ok("every count goes out", all(entry.get("newCount") == 0 for entry in saved), str(saved)) + c.ok("and the header count with them", badge["hidden"] is True, str(badge)) + c.ok("the mark moves on to what the source lists now, so it stays out", + f"vid-{offset['at']}" in (saved[0].get("seenIds") or []) and + f"vid-{offset['at'] - 5}" not in (saved[0].get("seenIds") or []), + str((saved[0].get("seenIds") or [])[:3])) + c.ok("which took asking the source", any(b.get("query") == "sauna" for b in asked), str(asked)) + page.evaluate("() => App.savedSearches.check({ force: true })") + page.wait_for_timeout(900) + saved = page.evaluate(SAVED) + c.ok("a check straight after finds nothing new", + saved[0].get("newCount") == 0, str(saved[0].get("newCount"))) + c.ok("and the button turns itself off with nothing left to clear", + page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN))) + c.ok("nothing threw along the way", not crashes, str(crashes[:2])) browser.close()