Swipes and a reader that will not scroll are reported (#384)

A swipe that only bounces sends no request and throws nothing, so
#383's stop at the 50th item left nothing in the log or the traces, and
the reader that would not scroll in the same report was never found.

The page now reports each swipe in the reader (next, prev, list, end,
short; a tap is not one) with where the reader stood, under the tap's
trace, and scroll_stuck when a drag up or down with room to move the
reader left it where it was. That one is watched by where the finger
was, not by what it landed on, so a layer lying over the reader is
caught, and its state names what is under the finger, what is inert,
the dialog, the reader's transform, a #dpeek left behind, and how long
since the app came back. ipx logs swipe as info and scroll_stuck as a
warning, in a new free-text state field, and the dashboard counts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 20:46:31 +00:00
parent 385589ff77
commit 5c1970e8ee
7 changed files with 94 additions and 7 deletions

View File

@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384).
- `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369). - `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369).
- Traces for what had none: each command the worker runs, from the CLI, the socket or the page, with its job inside (#373); the daemon's start, from opening the database to serving (#374); a list's sync and an OPML upload, step by step (#372); and the tap or key in the page that set a trace's requests off, as its root span, from the tap to its last answer (#376). - Traces for what had none: each command the worker runs, from the CLI, the socket or the page, with its job inside (#373); the daemon's start, from opening the database to serving (#374); a list's sync and an OPML upload, step by step (#372); and the tap or key in the page that set a trace's requests off, as its root span, from the tap to its last answer (#376).

View File

@@ -1413,8 +1413,11 @@ mod tests {
trace: None, trace: None,
span: None, span: None,
start: None, start: None,
state: Some("3/50 of 120".into()),
}; };
for ev in ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown"] { for ev in
["page_error", "rejection", "player_error", "play_start", "stall", "list_shown", "swipe", "scroll_stuck"]
{
assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}"); assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}");
} }
// A tap's span takes a trace's ids and a time the server can believe (#376). // A tap's span takes a trace's ids and a time the server can believe (#376).
@@ -2783,12 +2786,14 @@ struct ClientEvent {
trace: Option<String>, trace: Option<String>,
span: Option<String>, span: Option<String>,
start: Option<u64>, start: Option<u64>,
/// For `swipe` and `scroll_stuck` (#384): where the reader stood, and what could hold it.
state: Option<String>,
} }
async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode { async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode {
// From the browser: only the kinds the page sends, and nothing long enough to fill the log. // From the browser: only the kinds the page sends, and nothing long enough to fill the log.
let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>()); let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>());
let (msg, src, feed, filter) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter)); let (msg, src, feed, filter, state) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter), cut(&e.state));
let (ev, line, enclosure, deadair, ms, user) = let (ev, line, enclosure, deadair, ms, user) =
(e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str()); (e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
match ev { match ev {
@@ -2815,6 +2820,17 @@ async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> Stat
target: "ipx::client", ev, ms, feed, filter, user, target: "ipx::client", ev, ms, feed, filter, user,
"{user}'s page: list_shown {} ms", ms.unwrap_or(0) "{user}'s page: list_shown {} ms", ms.unwrap_or(0)
), ),
// A swipe in the reader and what came of it, so one that did nothing is seen (#384): a
// swipe sends no request when it only bounces, and #383's stopped at the 50th unseen.
"swipe" => tracing::info!(
target: "ipx::client", ev, msg, ms, feed, filter, state, user,
"{user}'s page: swipe {} at {}", msg.as_deref().unwrap_or(""), state.as_deref().unwrap_or("")
),
// A drag up or down on an item with room to scroll that did not move it (#384).
"scroll_stuck" => tracing::warn!(
target: "ipx::client", ev, feed, state, user,
"{user}'s page: scroll_stuck: {}", state.as_deref().unwrap_or("")
),
_ => return StatusCode::BAD_REQUEST, _ => return StatusCode::BAD_REQUEST,
} }
// The timings in /metrics as well, so weeks of them are percentiles without reading each line // The timings in /metrics as well, so weeks of them are percentiles without reading each line

View File

@@ -198,6 +198,14 @@ ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap
table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)], table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)],
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}]) 12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
y += 8 y += 8
# Swipes in the reader and what came of them, and a reader that would not scroll (#384).
SWIPE = SEL + ' |= "\\"ev\\":\\"swipe\\"" | json | __error__=""'
STUCK = SEL + ' |= "\\"ev\\":\\"scroll_stuck\\"" | json | __error__=""'
ts("Swipes in the reader", [loki(f'sum by (msg) (count_over_time({SWIPE} [$__interval]))', legend="{{msg}}"),
loki(f'sum(count_over_time({STUCK} [$__interval]))', ref="B", legend="would not scroll")],
0, w=24, bars=True, stack=True,
desc="next, prev and list turned the item; end bounced off the last one loaded; short was too short or slow to count. Would not scroll is a drag with room to move the reader that left it where it was: its line in the log says what lay over it.")
y += 8
# ---- ipx: the API it answers behind ipx-web # ---- ipx: the API it answers behind ipx-web
row("ipx: API") row("ipx: API")

View File

@@ -273,3 +273,13 @@ test('the next page starts after the unread ones on the Unread tab, after all of
assert.equal(run('nextOffset()'), 3); assert.equal(run('nextOffset()'), 3);
assert.equal(run('hasMore()'), false); assert.equal(run('hasMore()'), false);
}); });
test('a reader is stuck only when a drag up or down had room to move it and did not (#384)', () => {
assert.equal(run('stuckDrag(-200, 5, 0, 0, 1000)'), true); // up from the top, more below, did not move
assert.equal(run('stuckDrag(-200, 5, 0, 180, 1000)'), false); // it moved
assert.equal(run('stuckDrag(200, 5, 0, 0, 1000)'), false); // down at the top: nowhere to go
assert.equal(run('stuckDrag(-200, 5, 1000, 1000, 1000)'), false);// up at the bottom: nowhere to go
assert.equal(run('stuckDrag(-200, 5, 0, 0, 0)'), false); // nothing to scroll
assert.equal(run('stuckDrag(-20, 0, 0, 0, 1000)'), false); // too short to mean it
assert.equal(run('stuckDrag(-100, 150, 0, 0, 1000)'), false); // a swipe, not a scroll
});

View File

@@ -1731,6 +1731,9 @@ test.describe('touch gestures on a phone', () => {
} }
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => { test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
// Each swipe says what came of it, and ipx takes it (#384); a scroll is not one.
const swipes = [];
page.on('request', r => { if (r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'swipe') swipes.push(r); });
await page.locator('#burger').click(); await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click(); await page.locator('.tabs button', { hasText: 'All' }).first().click();
@@ -1761,6 +1764,9 @@ test.describe('touch gestures on a phone', () => {
await expect(shown).toHaveText(titles[0]); await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list
await expect(page.locator('body')).not.toHaveClass(/reading/); await expect(page.locator('body')).not.toHaveClass(/reading/);
await expect.poll(() => swipes.length).toBe(6);
expect(swipes.map(r => r.postDataJSON().msg)).toEqual(['short', 'short', 'next', 'end', 'prev', 'list']);
for (const r of swipes) expect((await r.response())?.status()).toBe(204);
}); });
test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => { test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => {

View File

@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- The page tells ipx what came of each swipe in the reader, and when a drag up or down would not scroll it, so a reader that stops responding can be found in the log (#384).
- Caddy's own metrics, its requests and their times and the 502s ipx never sees, for Prometheus on a port of their own, `METRICS_BIND` (#371). - Caddy's own metrics, its requests and their times and the 502s ipx never sees, for Prometheus on a port of their own, `METRICS_BIND` (#371).
- A tap or a key is its trace's root span: the page sends ipx when it began and when its last answer came, so Tempo shows how long it took beside the requests it made (#376). - A tap or a key is its trace's root span: the page sends ipx when it began and when its last answer came, so Tempo shows how long it took beside the requests it made (#376).
- Fonts of your choosing in Settings, beside the theme, kept by each browser as the theme is: one for the interface and one for reading an item's title and text, which is the interface's until chosen, with a little more room between the lines in a serif. Inter, your system's, the reading fonts Atkinson Hyperlegible, Lexend, Literata, Source Serif and OpenDyslexic, your device's serif, or monospace. The default keeps each theme's face as it was (#359). - Fonts of your choosing in Settings, beside the theme, kept by each browser as the theme is: one for the interface and one for reading an item's title and text, which is the interface's until chosen, with a little more room between the lines in a serif. Inter, your system's, the reading fonts Atkinson Hyperlegible, Lexend, Literata, Source Serif and OpenDyslexic, your device's serif, or monospace. The default keeps each theme's face as it was (#359).

View File

@@ -159,12 +159,57 @@ document.addEventListener('touchend', () => {
if(g.dy >= PULL) refreshFeed(); if(g.dy >= PULL) refreshFeed();
return; return;
} }
// Across, mostly sideways, and not a slow drag while selecting text.
const i = S.entries.findIndex(x => entryKey(x) === S.sel); const i = S.entries.findIndex(x => entryKey(x) === S.sel);
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800 // What came of it, where the reader stood (#384): a swipe that only bounces sends no request,
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0); // and #383's stop at the 50th item was seen by nobody but the person swiping.
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1)); const swiped = (what: string) => report('swipe', {msg: what, ms: Date.now() - g.t, feed: S.feed, filter: S.filter,
if(i > 0) return slideOn(g.dx, () => stepEntry(-1)); state: `${i + 1}/${S.entries.length} of ${S.total}${hasMore() ? ', more to load' : ''}`});
// Across, mostly sideways, and not a slow drag while selecting text. A tap is not a swipe at all.
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800){
if(Math.abs(g.dx) > 30) swiped('short');
return slide(0);
}
if(g.dx < 0 && i >= S.entries.length - 1){ swiped('end'); return slide(0); }
if(g.dx < 0){ swiped('next'); return slideOn(g.dx, () => stepEntry(1)); }
if(i > 0){ swiped('prev'); return slideOn(g.dx, () => stepEntry(-1)); }
swiped('list');
slideOn(g.dx, () => showDetail(null)); slideOn(g.dx, () => showDetail(null));
}); });
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; }); document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });
/// A reader that will not scroll (#384): a drag up or down over an item with room to move that
/// way, after which it has not moved. Watched by where the finger is, not by what it landed on,
/// so something lying over the reader is caught too, and named. Reported with what could hold it,
/// since the cause of the one in #383 was never found.
let drag: {x: number, y: number, top: number, on: string} | null = null, shownAt = Date.now();
document.addEventListener('visibilitychange', () => { if(!document.hidden) shownAt = Date.now(); });
const named = (el: Element | null) => !el ? 'nothing'
: el.id ? '#' + el.id : el.tagName.toLowerCase() + (typeof el.className === 'string' && el.className ? '.' + el.className.split(' ')[0] : '');
/// A drag of dy down (dx across) that left the reader at the same scrollTop, from `before`, with
/// `room` to scroll in all. Only one with somewhere to go: up with more below, down with more above.
const stuckDrag = (dy: number, dx: number, before: number, after: number, room: number) =>
Math.abs(dy) >= 40 && Math.abs(dy) >= 2 * Math.abs(dx) && room >= 2 && after === before
&& (dy < 0 ? before < room - 1 : before > 0);
document.addEventListener('touchstart', ev => {
const d = $('#detail'), t = ev.touches[0];
drag = null;
if(ev.touches.length !== 1 || !d || !S.sel) return;
const r = d.getBoundingClientRect();
if(t.clientX < r.left || t.clientX > r.right || t.clientY < r.top || t.clientY > r.bottom) return;
drag = {x: t.clientX, y: t.clientY, top: d.scrollTop, on: named(ev.target as Element)};
}, {passive: true});
document.addEventListener('touchend', ev => {
const d = $('#detail'), t = ev.changedTouches[0], g = drag;
drag = null;
if(!g || !d || !t) return;
const dy = t.clientY - g.y, room = d.scrollHeight - d.clientHeight;
if(!stuckDrag(dy, t.clientX - g.x, g.top, d.scrollTop, room)) return;
const inert = ([...document.body.children] as HTMLElement[]).filter(el => el.inert).map(named).join(' ');
report('scroll_stuck', {feed: S.feed, state: [
`top ${g.top} of ${room}`, `drag ${Math.round(dy)}px`, `started on ${g.on}`,
`under the finger ${named(document.elementFromPoint(t.clientX, t.clientY))}`,
`inert ${inert || 'none'}`, `dialog ${$('#modal')?.classList.contains('on') ? 'open' : 'closed'}`,
`transform ${d.style.transform || 'none'}`, `peek ${$('#dpeek') ? 'left behind' : 'gone'}`,
`overflow ${getComputedStyle(d).overflowY}`, `reading ${document.body.classList.contains('reading')}`,
`shown ${Math.round((Date.now() - shownAt) / 1000)}s ago`, `${S.entries.length} loaded`].join(', ')});
});