Skip to content

feat(lists): collaborative freshness poll + presence — and a fix for blank grid cells (#53) - #123

Merged
Adron merged 1 commit into
parity/queuefrom
issue/53-list-collab-poll
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/53-list-collab-poll

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #53. Part of epic #49.

⚠️ Read this first: a serious pre-existing bug, found and fixed here

RowDto read the cell map from a data key, but the API sends rowData. So every list row's
cells decoded as empty — the grid has been rendering "—" for every cell against real data.

I verified this independently of the agent that found it: rowData appears 12 times in the
OpenAPI spec
and 4 times in the help-centre lists reference; data appears nowhere as the row
field name. RowDto now reads rowData ?: data and carries version; ListRow gained
version: Int? = null. Existing data-shaped tests still pass unchanged.

This is adjacent to #53 rather than inside it, but the poll could not have worked without it — there
would have been no versions to quote.

The heartbeat

  • Tied to the screen's lifecycle. DisposableEffect(Unit) { start(); onDispose { stop() } }, plus
    an isOnScreen flag so a late callback (a row save completing after the user navigated away)
    cannot revive the loop, plus onCleared(). Presence is cleared on stop, so a returning screen
    never shows who was here.
  • Surgical refresh, not a refetch. Each beat sends rowVersions for the rows in state plus
    focusedRowId; changed replaces exactly those rows and deleted removes them.
    getListDetail/getRows is never called from the poll path — asserted with a call counter.
  • Presence mirrors the documents editor's avatar cluster (same 28dp circles, −8dp overlap, +N
    overflow, nothing when empty) as a deliberate copy — no dependency on :feature:documents.
  • Back-off: 10s while a beat brings news, 60s when it brings none or fails, stop after 10 quiet
    minutes (any interaction revives it), and stop outright on collaborative: false — and stay
    stopped in that case. This mirrors the first-party grid's own policy documented in
    helpapi/lists.txt, which warns the backing database bills for being awake and does not autosuspend
    while anything polls. Requests are capped at the documented 500-row ceiling.

Live probe: what it established, and the cleanup

Created a list tmp-53-freshness-probe, one row, and one collaborator share link; then revoked
the link, deleted the row, and deleted the list
. (I independently re-verified: the account is back
to its single pre-existing list, "New list", which was never touched.)

Previously unobservable, now confirmed:

  • changed[] elements are whole rows in the same shape GET …/data returns
    { id, rowData, version, createdAt, updatedAt, createdByUser{id,username,displayName,avatar}, lastEditedByUser{…} }. Note rowData, and user objects keyed id, not userId.
  • deleted is a bare array of id strings (an unresolvable id comes back verbatim).
  • A stale version returns the row even when collaborative is false — so the flag is advice about
    polling, not an access gate.
  • An unclaimed share link does not flip collaborative — it stayed false. The flag tracks
    actual watchers/collaborators, not link existence.

Still unconfirmed: the users[] element shape. Establishing it needs a second real person on the
list, and the only route to that would have been emailing a stranger an invite. It is modelled from
the help centre (userId, name, username, color, focusedRowId) and decodes tolerantly for
id/displayName; a nameless entry falls back to username then id, and an entry with no resolvable
id is dropped rather than drawn as a blank avatar.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 1440 tests, 0 failures
(:feature:lists is 268). 14 heartbeat cases, 6 repository, 3 presence, plus mapper additions.
Compose presence tests compile, not executed (no emulator).

Follow-ups

  • The endpoint only reports rows you asked about, so rows another person inserts are not picked
    up.
    A changed row we were not holding is appended defensively, but insertions are not in
    changed. A manual reload is still needed — worth its own issue if it matters.
  • Row writes still use the whole-row { data } form; If-Match + { changes, expect } per-field
    deltas and 409 version_conflict handling are untouched. version is now plumbed through, which
    is the prerequisite.
  • If the server ever stops returning version, the poll degrades to presence-only rather than
    spamming the whole table. Deliberate.

Wires POST /api/lists/{id}/data/versions into the list detail screen as a
combined freshness poll and presence heartbeat.

- The heartbeat is tied to the composition: DisposableEffect starts it on
  entry and stops it on dispose, and the ViewModel refuses to beat once the
  screen is gone (so a late edit callback cannot revive it).
- Each beat quotes the versions of the rows on screen plus the focused row,
  and the answer is applied surgically: `changed` repaints exactly those
  rows, `deleted` drops them. The table is never refetched.
- Presence is rendered as an avatar cluster in the top bar, mirroring the
  documents editor's indicator — a separate copy, since features do not
  depend on each other.
- Polling is frugal: 10s while rows are moving, 60s when a beat brings no
  news or fails, stop after 10 quiet minutes (interaction revives it), and
  stop entirely when the server reports `collaborative: false`. At most 500
  row versions per request, the server's documented ceiling.

Rows now also carry their `version`, and RowDto accepts the `rowData` key
the live API actually sends (`data` still works), without which the poll has
nothing to compare against.

Closes #53
@Adron
Adron merged commit 95c0407 into parity/queue Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant