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:
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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).
|
||||
- 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).
|
||||
|
||||
|
||||
@@ -1413,8 +1413,11 @@ mod tests {
|
||||
trace: None,
|
||||
span: 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}");
|
||||
}
|
||||
// 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>,
|
||||
span: Option<String>,
|
||||
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 {
|
||||
// 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 (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) =
|
||||
(e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
|
||||
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,
|
||||
"{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,
|
||||
}
|
||||
// The timings in /metrics as well, so weeks of them are percentiles without reading each line
|
||||
|
||||
@@ -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)],
|
||||
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
|
||||
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
|
||||
row("ipx: API")
|
||||
|
||||
@@ -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('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
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
// 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('.feed', { hasText: 'Test Show' }).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 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.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 }) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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).
|
||||
- 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).
|
||||
|
||||
@@ -159,12 +159,57 @@ document.addEventListener('touchend', () => {
|
||||
if(g.dy >= PULL) refreshFeed();
|
||||
return;
|
||||
}
|
||||
// Across, mostly sideways, and not a slow drag while selecting text.
|
||||
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
|
||||
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
|
||||
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
|
||||
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
|
||||
// What came of it, where the reader stood (#384): a swipe that only bounces sends no request,
|
||||
// and #383's stop at the 50th item was seen by nobody but the person swiping.
|
||||
const swiped = (what: string) => report('swipe', {msg: what, ms: Date.now() - g.t, feed: S.feed, filter: S.filter,
|
||||
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));
|
||||
});
|
||||
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(', ')});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user