Refresh buttons turn from the press to the end of their scan; quick checks keep the count (#232, #233)

The buttons followed the feeds being checked, so they stopped between one feed and the next;
7ecbe82 held them for 600 ms over each gap. Now pressing one starts it, and scan_done stops
it only when it is the end of the scan asked for: scan_done carries `only` and `forced`, so a
scheduled scan finishing first does not stop a button whose scan is queued behind it. An
error event, a job that failed without a scan_done, stops them all.

A row's spinner stood in for its unread count from the moment its feed was checked; most
checks take milliseconds, so each count blinked as the scan went by. It now waits 300 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 15:00:50 +00:00
parent 7ecbe8293f
commit a5e64e520d
9 changed files with 87 additions and 36 deletions

View File

@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228). - Check every feed looks up the site icons of feeds without artwork a few at a time, alongside the fetches, instead of one after another (#228).
- A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230). - A feed's server that sends nothing for 15 s is given up on, instead of holding a scan for the full 30 s; a feed that keeps sending still has 30 s (#230).
- A scan's trace names the feed each fetch is for (#229). - A scan's trace names the feed each fetch is for (#229).
- `scan_done` on the event stream says which feed or folder the scan was limited to and whether someone asked for it (`only`, `forced`), so a page can tell the end of its own scan from a scheduled one's (#233).
## [1.1.0] - 2026-10-13 ## [1.1.0] - 2026-10-13

View File

@@ -36,7 +36,15 @@ pub enum Event {
DeadairDone { feed: String, enclosure: i64, saved_s: i64 }, DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
Reaped { path: String, bytes: u64 }, Reaped { path: String, bytes: u64 },
/// Terminal: a client that asked for work stops reading here. /// Terminal: a client that asked for work stops reading here.
ScanDone { feeds: usize }, ScanDone {
feeds: usize,
/// The feed or folder the scan was limited to, and whether someone asked for it: a page
/// stops the button they pressed at the end of their scan, not at a scheduled one's (#233).
#[serde(default)]
only: Option<String>,
#[serde(default)]
forced: bool,
},
ReapDone { files: usize, bytes: u64 }, ReapDone { files: usize, bytes: u64 },
Status { feeds: usize, pending: i64, downloaded: i64 }, Status { feeds: usize, pending: i64, downloaded: i64 },
Error { msg: String }, Error { msg: String },
@@ -82,7 +90,7 @@ impl Event {
Event::FeedStart { feed } => format!("{feed}: checking"), Event::FeedStart { feed } => format!("{feed}: checking"),
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"), Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"), Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
Event::ScanDone { feeds } => format!("scan complete, {feeds} feed(s)"), Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
}) })
} }
} }
@@ -434,7 +442,7 @@ mod tests {
#[test] #[test]
fn only_completion_events_end_a_client_session() { fn only_completion_events_end_a_client_session() {
assert!(Event::ScanDone { feeds: 1 }.is_terminal()); assert!(Event::ScanDone { feeds: 1, only: None, forced: false }.is_terminal());
assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal()); assert!(Event::ReapDone { files: 0, bytes: 0 }.is_terminal());
assert!(!Event::FeedDone { assert!(!Event::FeedDone {
feed: "a".into(), feed: "a".into(),

View File

@@ -1361,7 +1361,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
} }
} }
} }
ctx.out.emit(Event::ScanDone { feeds: scanned }); ctx.out.emit(Event::ScanDone { feeds: scanned, only: only.map(str::to_owned), forced: force });
// What people subscribe to may have changed, and new feeds have titles now: share it. // What people subscribe to may have changed, and new feeds have titles now: share it.
ctx.directory_wake.notify_one(); ctx.directory_wake.notify_one();
Ok(()) Ok(())
@@ -2321,7 +2321,7 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
} }
} }
// Terminal, so a UI waiting on this request stops here. // Terminal, so a UI waiting on this request stops here.
ctx.out.emit(Event::ScanDone { feeds: 1 }); ctx.out.emit(Event::ScanDone { feeds: 1, only: None, forced: false });
Ok(()) Ok(())
} }

View File

@@ -1697,15 +1697,30 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => { test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' }); const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await page.evaluate(() => setScanning('test-show', true)); // A check of a few milliseconds leaves the count where it is, or every count blinked as a scan
// went by; the spinner comes once the check has taken a moment (#232).
const atOnce = await page.evaluate(() => {
setScanning('test-show', true);
return document.querySelector('#feedlist .feed[data-id="test-show"]').classList.contains('scanning');
});
expect(atOnce, 'no spinner the moment a check starts').toBe(false);
await expect(row).toHaveClass(/\bscanning\b/); await expect(row).toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden(); await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
await page.evaluate(() => setScanning('test-show', false)); await page.evaluate(() => setScanning('test-show', false));
await expect(row).not.toHaveClass(/\bscanning\b/); await expect(row).not.toHaveClass(/\bscanning\b/);
await expect(row.locator('.badge')).toBeVisible(); await expect(row.locator('.badge')).toBeVisible();
// The button turns from the press to the end of the scan it asked for (#233): a scheduled
// scan's end, or the end of a scan of one other feed, leaves it turning.
const spins = () => document.body.classList.contains('scan-any');
expect(await page.evaluate(spins => { spinStart(null); spinEnd({ only: null, forced: false });
const a = document.body.classList.contains('scan-any'); spinEnd({ only: 'test-show', forced: true });
return a && document.body.classList.contains('scan-any'); })).toBe(true);
await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await page.evaluate(spins)).toBe(false);
// Checking every feed says so on the rows, not in a toast (issue #37). // Checking every feed says so on the rows, not in a toast (issue #37).
await page.locator('#scanAll').click(); expect(await page.evaluate(() => { scanAll(); return document.body.classList.contains('scan-any'); }),
await page.waitForTimeout(1500); 'turning the moment it is pressed').toBe(true);
await expect(page.locator('body')).not.toHaveClass(/\bscan-any\b/, { timeout: 15_000 });
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0); await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
}); });
@@ -1766,20 +1781,28 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
]); ]);
}); });
test('a refresh button turns while what it checks is being checked', async ({ page }) => { test('a refresh button turns from the press to the end of the scan it asked for', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.feed', { hasText: 'Test Show' }).click();
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName); const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
const btn = '.fhead [data-a=scan] .i'; const btn = '.fhead [data-a=scan] .i';
await expect(page.locator(btn)).toBeVisible(); await expect(page.locator(btn)).toBeVisible();
expect(await spin(btn)).toBe('none'); expect(await spin(btn)).toBe('none');
await page.evaluate(() => setScanning('test-show', true)); // Pressed: this feed's button turns, the toolbar's does not (#78, #233).
await page.evaluate(() => spinStart('test-show'));
expect(await spin(btn)).toBe('ipxspin'); expect(await spin(btn)).toBe('ipxspin');
expect(await spin('#scanAll .i')).toBe('ipxspin'); expect(await spin('#scanAll .i')).toBe('none');
// Another feed being checked turns the toolbar's, not this page's. // Feeds being checked one by one no longer start or stop it (#231).
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); }); await page.evaluate(() => { setScanning('test-show', true); setScanning('test-show', false); });
expect(await spin(btn)).toBe('ipxspin');
// Another feed's scan ending, or a scheduled one's, leaves it turning; its own stops it.
await page.evaluate(() => { spinEnd({ only: 'multi-show', forced: true }); spinEnd({ only: null, forced: false }); });
expect(await spin(btn)).toBe('ipxspin');
await page.evaluate(() => spinEnd({ only: 'test-show', forced: true }));
expect(await spin(btn)).toBe('none'); expect(await spin(btn)).toBe('none');
// The toolbar's turns for a scan of every feed, until that scan ends.
await page.evaluate(() => spinStart(null));
expect(await spin('#scanAll .i')).toBe('ipxspin'); expect(await spin('#scanAll .i')).toBe('ipxspin');
await page.evaluate(() => setScanning('multi-show', false)); await page.evaluate(() => spinEnd({ only: null, forced: true }));
expect(await spin('#scanAll .i')).toBe('none'); expect(await spin('#scanAll .i')).toBe('none');
}); });

View File

@@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- The check-every-feed button turns smoothly through a scan instead of jerking back upright between one feed and the next (#231). - The refresh buttons turn from the moment they are pressed until the scan they asked for has finished, smoothly, instead of jerking back upright between one feed and the next (#231, #233).
- A feed's unread count stays put while a scan checks it quickly; the spinner takes its place only when a check takes a moment (#232).
## [1.0.1] - 2026-10-13 ## [1.0.1] - 2026-10-13

View File

@@ -729,7 +729,11 @@ function opmlModal(){
} }
// No toast: the spinners on the rows being checked say it (issue #37). // No toast: the spinners on the rows being checked say it (issue #37).
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } async function scanAll(){
spinStart(null);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
catch(e){ spinEnd(); throw e; }
}
$('#scanAll').onclick=scanAll; $('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal; $('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while

View File

@@ -66,7 +66,9 @@ function connect(){
// every open page reloaded the whole list each time (#103). // every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the // Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
// page reloads; reload the list here when a scan synced an OPML if that ever matters. // page reloads; reload the list here when a scan synced an OPML if that ever matters.
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); } else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
else if(ev.ev==='error') spinEnd();
}; };
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
} }

View File

@@ -159,7 +159,11 @@ function renderGroup(f,kids){
} }
async function feedAction(a,f){ async function feedAction(a,f){
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } if(a==='scan'){
spinStart(f.id);
try{ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
catch(e){ spinEnd(); throw e; }
}
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f); if(a==='rm') removeFeed(f);
if(a==='pin'){ if(a==='pin'){

View File

@@ -34,34 +34,42 @@ const VIEWS={
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder /// Feeds being checked right now, from the event stream, and since when: their rows, and the row
/// holding one, carry a spinner. /// of a folder holding one, carry a spinner.
const scanning=new Set<string>(); const scanning=new Map<string, number>();
/// A row's spinner takes the place of its unread count, so it waits this long: most feeds are
/// checked in a few milliseconds, and each count blinked out and back as its feed went by.
const SPIN_AFTER=300;
function setScanning(id: string, on: boolean){ function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id); if(on){ scanning.set(id,Date.now()); setTimeout(paintScanning,SPIN_AFTER); } else scanning.delete(id);
paintScanning(); paintScanning();
} }
function paintScanning(){ function paintScanning(){
const slow=(id: string)=>scanning.has(id)&&Date.now()-scanning.get(id)>=SPIN_AFTER;
for(const row of $$('#feedlist .feed')){ for(const row of $$('#feedlist .feed')){
const id=row.dataset.id; const id=row.dataset.id;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id)); const on=slow(id)||S.feeds.some(c=>c.group===id&&slow(c.id));
row.classList.toggle('scanning',on); row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
} }
// The refresh buttons turn while what they check is being checked (#78). On <body>, because // A feed's button turns on its own page only, wherever the person has gone since pressing it.
// a feed's page is redrawn as its items come in and would take a class on the button with it. document.body.classList.toggle('scan-this',spinFeed!==null&&spinFeed===S.feed);
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
spin('scan-this',busy(S.feed));
spin('scan-any',S.feeds.some(f=>scanning.has(f.id)));
} }
/// Starts a refresh button turning at once, and stops it only after a moment with nothing being /// The refresh buttons turn from the press until that scan's scan_done (#78, #233). On <body>,
/// checked: a scan takes its feeds one at a time, and in the gap between two the class came off, /// because a feed's page is redrawn as its items come in and would take a class on the button
/// the icon snapped back upright and started again, many times a second. /// with it. Following the feeds being checked instead, the icon snapped back upright in the gap
const spinStop: Record<string, number>={}; /// between one feed and the next, many times a second (#231).
function spin(cls: string, on: boolean){ let spinFeed: string|null=null;
clearTimeout(spinStop[cls]); function spinStart(feed: string|null){
if(on) document.body.classList.add(cls); if(feed===null) document.body.classList.add('scan-any');
else spinStop[cls]=setTimeout(()=>document.body.classList.remove(cls),600); else { spinFeed=feed; paintScanning(); }
}
/// `only` and `forced` from scan_done: a scheduled scan, or one limited to another feed, leaves
/// a pressed button turning, since the scan it asked for is still queued behind it. Nothing
/// (an error ended the job) stops them all.
function spinEnd(done?: {only: string|null, forced: boolean}){
if(!done||done.forced&&done.only==null) document.body.classList.remove('scan-any');
if(!done||done.forced&&done.only===spinFeed){ spinFeed=null; paintScanning(); }
} }
function renderFeeds(){ function renderFeeds(){