Session slots, history runs, lifetime counter, and a Study hub that stops popping in - #30
Merged
Merged
Conversation
The cards are static text and are on screen from the first frame; the BAND is four queries, and `q` answers null while a fetch is in flight — which renders identically to "nothing running". So the band drew empty, then grew, shoving the whole card grid down the page a beat after it had settled. A placeholder of the same shape rather than a spinner: the row's own box with no content in it, so what the reader sees is a shape settling rather than a thing arriving, and the real content fades up in place. SIZED BY MEASUREMENT, not by eye. Two ghost rows made it WORSE in the other direction — the grid jumped 49px UP when the real band turned out shorter. One row plus the coverage strip is what the band resolves to in the common cases (one plan running, or none and the invitation shows), and the measured shift went from 49px to 3. A read that never answers must not strand the skeleton, so after three seconds the band shows whatever it has — which for a failed query is the honest empty state. `prefers-reduced-motion` gets none of it. The point was never the animation; it was not moving the page. Android takes the same treatment, keyed on its own fetch completing. Caught by a guard I did not know existed: `const t = setTimeout(...)` shadows the catalogue's `t()`, and the check named it.
The recents list is one entry per chapter opened, so an evening in Genesis 1, 2 and 3 filled the sheet with three lines saying almost the same thing and pushed everything else off the bottom. A run of contiguous chapters in one book is one thing the reader did. THE RULE THAT MATTERS is that adjacency is in the LIST, not merely similarity: [Gen 3, John 1, Gen 2] stays three lines. Merging those two Genesis entries would claim the reader went 2→3 without leaving, when they went to John in between — and the order they did things in is the entire content of a history. The mutation that merges into any matching run instead of the current one fails exactly that test in both shells. A tap opens the run's MOST RECENT chapter — where the reader actually was — not the lowest number in the span. WRITTEN TWICE, deliberately. A view-model would normally live in the core, but the shells own this list: `pushHistory` prepends to `config.history` locally on every navigation and the config only reaches the engine on a debounced save, so anything the core derived from it would be stale the moment the reader turned a page. Since there are two implementations, the rules are written down where both are checked against them — HistorySpansTest (7 cases) and e2e/history.spec.ts.
…y evening A reader's last chapter is not one thing. Somebody who studies on weekday mornings, sits in a Sunday service and goes to a Wednesday meeting has three separate places they were, and one "last chapter" serves whichever they did most recently — so arriving at church reopened Saturday night's study, and Monday morning reopened the sermon passage. Four seatings, and the boundaries are a JUDGEMENT, so they are stated in one place (crates/core/src/session_slot.rs) rather than buried in a shell: sunday-morning Sunday before noon sunday-evening Sunday from noon wednesday-evening Wednesday from 5pm other everything else Wednesday MORNING is deliberately `other`: the slot exists for the midweek meeting, and a Wednesday morning is a weekday morning like any other. THE RULE IS THE CORE'S, and the shells pass their own LOCAL date and hour. The core has no clock and no timezone, and a slot computed in UTC would put a Sunday-evening service in Monday for half the world. `plumbline_session_slot` is engine-independent, like the theme palette — the drift guard caught the missing Kotlin declaration, which is what it is for. Weekday comes from the existing civil arithmetic. 1970-01-01 was a Thursday, and `date_to_days` goes negative before the epoch, so the remainder is brought back into range rather than left as C's negative modulo — tested against dates either side. A seating never used falls through to the plain last position, which is exactly what every reader has today; that fall-through has its own test, because it is the half that must not regress for anyone not using this. The web's restore is fire-and-forget (the panes are built before the answer lands) and is guarded by `#navigatedSinceBoot`, so a reader who taps something in those few ms is never yanked away. Android resolves it synchronously — it is a string comparison, not a file read.
How many times this reader has been through the whole Bible, on the Study hub beside the coverage bar — one says how far through this pass you are, the other how many passes there have been. SEEDED ONCE by hand, because somebody arriving with thirty years behind them should not start at nought: a numeric prompt (`inputmode="numeric"` on the web, `KeyboardType.Number` on Android — both raise a phone's numpad). After that it is EARNED. Nothing in the UI edits it; a number you can retype is a number that means nothing. -1 IS "NEVER SAID", deliberately not 0. A reader who answers "none" has told us something, and if unset were 0 they would be asked again every time — which is its own test. CREDITED EXACTLY ONCE per finished canon. `bibleReadsCredited` marks the current complete state as counted and is cleared if the map ever drops below full, so the number moves on FINISHING rather than on every visit to the screen. The mutation that credits on every observation reaches 1,002 and fails that test. Seeding sets the flag true whatever the canon says, so a reader who is already finished is not immediately credited with the read they have just declared; if the canon is not in fact complete the hub reconciles the flag on sight. NOT BUILT, and worth saying: nothing resets the reading map, so a second full pass needs the map to drop below complete and return. A "start a new pass" action is the follow-up that would make this count repeatedly.
A minor rather than a patch: session slots, history runs and the lifetime counter are features, not fixes. The release workflow gates the tag against Cargo.toml and apps/web/package.json and fails the build if they disagree, so both move here rather than after the tag is pushed. Cargo.lock carries the workspace version and CI builds --locked, so it is refreshed in the same commit; package-lock.json's own version field goes with it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four asks from UAT, one commit each.
1. Where you were, per SEATING
A reader's last chapter is not one thing. Somebody who studies on weekday mornings, sits in a Sunday service and goes to a Wednesday meeting has three separate places they were — and one "last chapter" serves whichever they did most recently, so arriving at church reopened Saturday night's study.
Four slots, boundaries stated in the core rather than buried in a shell:
sunday-morning(before noon) ·sunday-evening(from noon) ·wednesday-evening(from 5pm) ·other. Wednesday morning is deliberatelyother— the slot is for the midweek meeting, and a Wednesday morning is a weekday morning like any other.The rule is the core's (
plumbline_session_slot, engine-independent like the theme palette) and the shells pass their own local date and hour — a slot computed in UTC would put a Sunday-evening service in Monday for half the world. Weekday comes from the existing civil arithmetic, with the pre-epoch negative-modulo case tested either side of 1970.A seating never used falls through to the plain last position — what every reader has today — and that fall-through has its own test, because it is the half that must not regress for anyone not using this.
2. History reads as runs
"Genesis 1–3", not three lines. Adjacency is in the LIST, not merely similarity:
[Gen 3, John 1, Gen 2]stays three lines, because merging those two would claim the reader went 2→3 without leaving, and the order they did things in is the entire content of a history. A tap opens the run's most recent chapter.Written twice on purpose — the shells own this list (
pushHistoryprepends locally; the config only reaches the engine on a debounced save, so anything core-derived would be stale the moment the reader turned a page). Both implementations are held to the same written rules:HistorySpansTest(7 cases) ande2e/history.spec.ts.3. Lifetime Bible-read counter
On the Study hub beside the coverage bar. Seeded once by hand via a numpad (
inputmode="numeric"/KeyboardType.Number), because somebody arriving with thirty years behind them should not start at nought — and earned after that. Nothing edits it; a number you can retype is a number that means nothing.-1 is "never said", deliberately not 0: a reader who answers "none" has told us something, and if unset were 0 they'd be asked forever. Credited exactly once per finished canon — the mutation that credits on every observation reaches 1,002 and fails its test.
Not built, and worth knowing: nothing resets the reading map, so a second full pass needs the map to drop below complete and return. A "start a new pass" action is the natural follow-up.
4. The hub stops popping in
qanswers null while fetching, which renders identically to "nothing running" — so the band drew empty, then grew, shoving the card grid down a beat after it settled. It now draws a placeholder of its own shape (the row's box with no content), and the real content fades up in place.Sized by measurement, not by eye. Two ghost rows made it worse in the other direction — the grid jumped 49px up when the real band turned out shorter. One row plus the coverage strip took the measured shift from 49px to 3px. A read that never answers can't strand it: after three seconds the band shows whatever it has.
prefers-reduced-motiongets none of the animation.Verification
13 cargo suites, clippy
--workspace --all-targetsclean, fmt clean, bindings regenerated (the drift guard caught the missing Kotlin declaration for the new export, which is what it's for), wasm rebuilt before the web suite ran — twice, since two commits touched the i18n catalogue. 73 Playwright specs green; AndroidassembleDebug+ JVM unit tests green.Each new behaviour is mutation-tested against the defect it describes: record-but-never-restore kills the slot test, merge-on-similarity kills the history rule, credit-on-every-visit inflates the counter to 1,002, and slicing the plan list to one kills the multi-plan row test.
Also caught by a guard I didn't know existed:
const t = setTimeout(...)shadows the catalogue'st(), and the check named it.