xy: test sessions become a board entity, and labels compose onto them #42

Merged
pecheny merged 14 commits from labels-test-sessions into main 2026-07-27 16:07:08 +00:00
Owner

Why

Five open issues about labels were one issue about a string: a test label's only tie to its session was the name baked into it at creation, so it could not be renamed (#25), was too long (#8), carried a timestamp nobody could re-render in another zone (#7, #33), and said взяли/не взяли when the user wanted «видели» (#12).

A Test Session is now a board-level entity rather than a card in a special list, which closes #2 on the way past. A card carries Playings (it was played at that test), and a label ASSIGNMENT carries an optional session — so «взяли» is one ordinary board label composed onto a playing instead of one label per sitting.

See xy/docs/labels-redesign.md, ADR-0003 and ADR-0004.

🤖 Posted by Claude

# Why Five open issues about labels were one issue about a string: a test label's only tie to its session was the name baked into it at creation, so it could not be renamed (#25), was too long (#8), carried a timestamp nobody could re-render in another zone (#7, #33), and said взяли/не взяли when the user wanted «видели» (#12). A Test Session is now a board-level entity rather than a card in a special list, which closes #2 on the way past. A card carries Playings (it was played at that test), and a label ASSIGNMENT carries an optional session — so «взяли» is one ordinary board label composed onto a playing instead of one label per sitting. See `xy/docs/labels-redesign.md`, ADR-0003 and ADR-0004. 🤖 *Posted by Claude*
# Why

Five open issues about labels were one issue about a string. A test label's
only tie to its test session was the name baked into it at creation, so it
could not be renamed (#25), was too long (#8), carried a timestamp nobody
could re-render in another zone (#7, #33), and said взяли/не взяли when the
user wanted «видели» (#12).

A test label now carries `session_id` + `mark` and derives its name on render.
Test sessions become a board-level entity instead of a card in a test list, so
test lists go away entirely — which closes #2 on the way past.

- migrateV18 moves each test card's ciphertext into `test_sessions.meta_enc`
  verbatim (the server cannot decrypt); the client folds the old shape forward.
- A session's time is wall-clock plus a zone, and its output is a copyable
  invite line: `20 июля, 19:00 (Берлин) / 21:00 (Москва)`.
- The «🏷️ Метки» editor renames and recolours; a test label's colour may be
  null, meaning "inherit the board's mark template".
- A card gains a derived «Видели» line, and a comment can name the test it
  came out of.
- The person directory is a plaintext localStorage cache, gathered from every
  board this device has unlocked and purged when its password is forgotten.

Design note: xy/docs/labels-redesign.md. ADR-0003 records why a transferred
session is a dated snapshot rather than a replica.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

/profile threw «page is missing #tzBtn» at module load, so nothing on the page
bound its handlers — including «Выйти», which is how it was noticed. The
button never made it into profile.dopeui.

TestPageSelectorContract exists to catch exactly this and did not, for two
reasons, both fixed here:

- Its script pattern didn't allow the `dist/` path segment, so the closure it
  walked was only the kit's menu.js — none of xy's own page scripts were ever
  scanned. Relative imports now resolve against the importer's directory too.
- wireModal() takes its ids as string arguments, which the getElementById
  literal pattern cannot see. That covers the whole /profile dialog family.

Removing the button from the page now fails the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

The whole label pill was one button whose click removed it, so brushing a
label took it off the card with no warning and no undo. The pill is now inert
markup with the × as its own button — the only remove affordance. Same change
for the announce-city chips, which had copied the shape.

Two layout fixes in the session editor found alongside it:

- The tester row's × sat on its own line at every width. A phone fix had given
  the name input `flex-basis: 100%` unconditionally; it now shrinks instead, so
  seg + name + × stay one row down to ~200px.
- The session, label and mark modals held more than a screenful with no way to
  scroll. Their bodies now cap at 65vh and scroll, the shape .lists-manage-rows
  already used, keeping the title and «Готово» pinned either side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

Marks made «взяли» a per-session label, so a board with thirty tests carried
thirty of them, each needing its name derived from its session to stay
readable. ADR-0004 composes instead: a label is an ordinary board label, and
its ASSIGNMENT carries an optional session.

- card_sessions is the Playing — this question was played at that test. It is
  what «Видели» reads, not a label.
- card_labels gains a nullable session_id and widens its key. SQLite compares
  NULLs as distinct, so the unscoped assignment additionally needs a partial
  unique index or it silently duplicates; there is a test for that.
- labels lose kind, session_id and mark, plus the board's mark template and its
  editor. A label is just a label again.
- migrateV18 was rewritten rather than followed by a v19 — it had only ever run
  on staging, so there is no deployed schema to upgrade from. It stays fully
  server-side: 1↔1 label naming means nothing needs decrypting.

Client still to come.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

Finishes ADR-0004 on the client. The card now has two pickers instead of one
mixed list: «Метки» is the author's view of the question, «Тесты» is where it
was played, and a label added under a test is what the testers thought there.
The same label can sit on one card twice, once each way.

- state.cardLabels becomes a list of assignments, state.cardSessions the
  playings; the marks layer, its template and its editor are gone.
- Removing a playing confirms with the count of labels it will take.
- A tour compiles its own «Вопросы тестировали» line: the list ⋯ menu opens a
  modal with each session's coverage («8 из 12»), those over half pre-ticked per
  the ЧГК custom, the ticks remembered per tour. Dropping test lists had lost
  this, and a board-level panel cannot answer it.
- Cross-board transfer carries playings before assignments, since a scoped
  assignment must name a playing that exists.
- The offline mirror folds the pre-ADR-0004 card_labels shape forward — a device
  that synced before this would otherwise throw mid-replay and lose its outbox.
- Session form: dd.mm.yyyy and 24h via text inputs, because native date/time
  widgets render in the BROWSER's locale whatever the page says; timezone and
  town autocompletes (towns.ts joins ЧГК's list to GeoNames, so «Алматы» brings
  Asia/Almaty); «Название» → «Алиас»; save/copy above the лента; the лента reuses
  the card's timeline markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

Reported from xytest: the + inside a test opened a popup that landed over the
board, the + sat on a line of its own, and «тестов на доске нет» showed when
the board plainly had tests.

- The popup had no positioned ancestor, so it anchored to the page.
  .playing-labels is now position:relative and the whole playing is ONE wrapping
  row rather than a stack.
- Both empty states now distinguish three different nothings: the board has
  none, they are all already on this question, or the filter matched none.
- The session лента wears the card's OWN classes (.tl-event / .tl-meta /
  .tl-comment, .card-desc on the box) instead of .tl-item / .tl-body, which
  nothing had ever styled — which is why it looked like a lookalike. Wording is
  «Лента» / «Комментарий…», and the author is resolved the same way.
- A test on a board card is now a 🧪 alone when the testers recorded nothing,
  or the 🧪 with their verdicts' colours in a thin border when they did.
- «Видели» on a card subtracts the testers the tour already names, so what is
  left is who actually needs warning — the questions moved in from elsewhere.

TestNoUndefinedCSSClasses is the reverse of the existing dead-CSS check: a class
the frontend SETS that no stylesheet defines. That is the direction that let
.tl-item ship. It found four of mine and six pre-existing hooks, which are
allowlisted with a reason each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

Reported from xytest.

- The town autocomplete only worked on Enter: its popup is absolutely
  positioned, and the parent was marked as the positioning context at BIND
  time, when a field wired before being appended still has no parent. Marked at
  append time instead, so all three autocompletes (testers, towns, timezones)
  anchor to their own row.
- City chips wore .label-pick, which paints grey-on-white — unreadable for both
  the name and its UTC offset. They get their own surface+border chip.
- A section heading belongs to what is under it: «Лента» sat nearer the delete
  button above than its own feed.
- On a board card a test's verdicts are DOTS rather than pills (a card can
  carry several tests, and pills ate the row), and the author's own pills are
  shorter and taller so they line up with the 🧪 instead of sitting as thin bars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

Third report of the same annoyance: a section heading reading as if it belonged
to the section ABOVE it. That is the Gestalt proximity rule, and it is
mechanical — a heading must have more space above than below.

The design system already encodes it: .section-label is margin-top --space-5
with a NEGATIVE margin-bottom, tucking the heading into what follows.
Hand-built headings keep missing it, so the invariant is now a test over the
resolved CSS (it understands the --space-N scale and calc(x * -1)).

Its first catch was my own fix from an hour ago, which had put the spacing on
the wrapper instead of the heading. Moved onto .fld-head, where it reads
correctly in isolation and survives being moved.

Two limits, both deliberate: it decides what is a heading by name (title/head,
plus .section-label), and it cannot see flex `gap` — so a heading in a
uniform-gap column needs an explicit margin-top to pass, which is the fix such
a heading needs anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

Both reported from xytest.

- The suggestion popup had position:absolute with no offsets, so it stayed at
  its STATIC position — inline after the input, off to the right of the row.
  Pinned to the anchor's bottom-left, which is what makes it read as a dropdown
  rather than a detached panel.
- The 🧪 glyph overflows a line-height of 1, so it sat on the group's bottom
  border. It now centres in a box tall enough to hold it (2px clear either
  side), and the group's padding is even.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

The item opens a modal to pick from, so «Копировать» promised something it does
not do — the copying happens inside.

Also drops #previewCopyTesters, dead since test lists went away: permanently
hidden, no handler, and its only remaining reference was the line hiding it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

Findings from the Standards and Spec reviews of this branch.

Correctness:
- migrateV18 kept a test card's session labels when the card survived to hold an
  attachment, but dropped them when it dissolved. Now dropped either way — the
  session's own labels belong on neither.
- quota.go coalesced a column the shipped schema declares not null, a leftover
  from when a test label's colour could be inherited.
- people.ts held a literal NUL byte, so git classified a TypeScript file as
  binary: no diff, no blame. It is an escape now.

Retired code and stale docs:
- Mark, DEFAULT_MARKS, markLabel and canonicalLabel were exported and tested but
  referenced nowhere since ADR-0004 retired the concept. Deleted with their tests.
- labels-redesign.md now says at the top which parts ADR-0004 superseded, and
  which three features were asked for after it was written. Its migration step 6
  claimed a label_id backfill that cannot happen — payload_enc is encrypted and
  the migration holds no key — now struck with the reason.
- The codebase map listed none of the six new modules.

Duplication:
- One filteredPopup replaces two ~55-line near-identical dropdowns.
- One userPrefs row type replaces the same six-column select and unpack in
  handleMe and handleGetBoard.
- guessZone lived in two modules; it belongs with the other zone helpers.

Comment budget: cut the what-comments from the new modules, keeping the whys a
reader cannot infer (crypto constraints, platform quirks, ADR references).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

The re-review found a bug my own fix introduced, plus doc and quota gaps.

- forgetCardLabels still did `delete state.cardLabels[c.id]` from when
  cardLabels was a Record. It is a flat list now, so that punched a hole at
  that ARRAY INDEX — dropping an unrelated card's assignment while leaving the
  dead card's own rows behind. Filters on cardId now, and drops the card's
  playings too. A test pins the distinction, since the two forms differ only in
  which rows survive.
- labels-redesign.md's migration steps 3 and 5, its transfer paragraph and its
  #25 section still asserted the retired mark model with no marker; step 2 did
  not say the surviving card now sheds the session's labels.
- quota.go dropped a timeline event that carries BOTH a card and a session when
  only the card was tombstoned, so a session comment stopped billing while the
  session still held it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

card_labels shipped as a flat list of rows while card_sessions stayed a map of
card id to session ids, so two things that mirror two identical tables were read
two different ways — and the map was keyed by string but indexed with numbers
everywhere. That mismatch is what let `delete state.cardLabels[c.id]` survive
the conversion and delete by array index.

Both are rows now, server to mirror to client: {card_id, session_id} beside
{card_id, label_id, session_id}. The offline mirror folds the old map shape
forward, since a device that synced before this holds one.

Verified in a browser that a playing survives remove, re-add and reload, and
that its scoped labels still cascade when it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Why

The ticks lived in localStorage, so they outlived the database they named — you
reset xytest from prod and an unticked session stayed unticked. Worse, they
stored bare session ids: had a session been deleted before the copy, the same
ids would have silently named DIFFERENT testers in a preamble.

A Declaration is board data (CONTEXT.md): the preamble ships with the package,
so two editors preparing it must see one answer. migrateV19 adds tour_testers,
keyed by list or group — exactly one, matching exportScope, both cascading, so a
Declaration cannot outlive its tour.

Grilled decisions:
- Linking a list into a group, or dissolving one, DROPS the Declarations
  involved. The claim was about a set of questions; regrouping makes it a claim
  about a different one. Carrying it forward is what would put a tester who saw
  9 of 12 into a merged 24-question preamble.
- Adding or removing questions does NOT invalidate it. Coverage is already shown
  per row, so drift reads as wrong on sight; a Declaration dies only when its
  tour stops being a tour.
- An empty Declaration is representable — a marker row with a null session_id.
  Without it, unticking the last session was indistinguishable from never having
  declared, and the custom silently re-ticked what the editor had just cleared.

Also: TestNoStrayControlBytes. A literal NUL reached this repo from a scripted
edit where the escape in the replacement text was interpreted before it was
written; git then treated a TypeScript file as binary, with no diff and no
blame. Verified the guard fires on a planted byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pecheny/dopesuite!42
No description provided.