- HTML 85.7%
- Go 12.6%
- Shell 0.8%
- JavaScript 0.3%
- Awk 0.3%
- Other 0.3%
| .agents/agents | ||
| cmd/import | ||
| db | ||
| design | ||
| nix | ||
| scripts | ||
| templates | ||
| web | ||
| .gitignore | ||
| admin.go | ||
| admin_test.go | ||
| agedrift_test.go | ||
| AGENTS.md | ||
| auth.go | ||
| auth_test.go | ||
| best.go | ||
| best_test.go | ||
| bookedit.go | ||
| bookedit_test.go | ||
| config.go | ||
| config_test.go | ||
| flake.lock | ||
| flake.nix | ||
| forget.go | ||
| forget_test.go | ||
| go.mod | ||
| go.sum | ||
| interview.go | ||
| interview_test.go | ||
| lexiledate_test.go | ||
| likepage.go | ||
| mail.go | ||
| mail_test.go | ||
| main.go | ||
| Makefile | ||
| pages.go | ||
| paseo.json | ||
| profile.go | ||
| QUESTIONS-FOR-ELI.md | ||
| readers.go | ||
| readers_card_test.go | ||
| readers_test.go | ||
| README.md | ||
| reason_test.go | ||
| recency_test.go | ||
| recommend.go | ||
| recommend_test.go | ||
| review.go | ||
| scatter.go | ||
| scatter_test.go | ||
| SCENARIOS.md | ||
| scenarios_test.go | ||
| search.go | ||
| search_test.go | ||
| shelf_print_test.go | ||
| similar.go | ||
| similar_test.go | ||
| stats.go | ||
| stats_test.go | ||
| summary_test.go | ||
| suppression_test.go | ||
| wayfinding.go | ||
McKelle's Books
A book-recommendation site for parents, librarians and teachers.
The reader interview is server-rendered, and both its answers and the catalog behind it live in Postgres: 3,579 books with their genres, tags, identifiers, reading measures, reading-research provenance and award/curriculum listings.
The interview's job is not to sort a reader into a type. It gathers the fewest pieces of high-value evidence it can — what a reader wants today, what they have loved and why, what did not work — and turns them into weighted signals a recommendation engine scores the catalog against. Every answer is kept as the reader gave it, alongside whatever was derived from it.
Layout
| Path | Purpose |
|---|---|
main.go |
HTTP server. Listens on $BOOKS_ADDR. |
config.go |
Environment parsing — BOOKS_ADDR, POSTGRES_DSN, and the SMTP settings. |
interview.go |
The question bank, branching rules, and signal derivation. |
recommend.go |
Scoring the catalog, choosing a shelf, and reaction handling. |
review.go |
The -review report, and the notes on a shelf. |
profile.go |
The reader profile page: what we believe, and how to correct it. |
auth.go |
Accounts, sessions, password hashing, and the password-reset flow. |
admin.go |
The admin page and who may reach it. |
bookedit.go |
The book editor: its form, its rules, and the revision it records. |
mail.go |
The SMTP relay, and the reset email it sends. |
stats.go |
The statistics page: its queries, and its chart layout. |
search.go |
Search, its suggestions, and the two detail pages. |
scatter.go |
The statistics page's scatter plot: axes and layout. |
pages.go |
The site map, routing, and the page handlers. |
templates/ |
html/template sources, embedded in the binary. |
web/scss/ |
Stylesheet sources, including the colour palette. |
web/assets/ |
Served assets, embedded in the binary (see Styles). |
web/design/ |
Design mockups, embedded and served at /design/. |
Makefile |
The css build step, plus build/run wrappers. |
db/ |
Pool, Ex, and the typed Execute* helpers. |
db/migrations/ |
goose SQL migrations, embedded in the binary. |
db/query/ |
Hand-written go-jet queries, one file per table. |
db/gen/ |
go-jet's generated table/model code — committed. |
db/jet/ |
The code generator. Dev tool, not shipped. |
cmd/import/ |
The catalog importer. Dev tool, not shipped. |
nix/package.nix |
buildGoModule derivation for the binary. |
nix/module.nix |
NixOS module: systemd unit + firewall opt. |
flake.nix |
Exposes packages.default and the module. |
Pages
pages.go holds the site map: an ordered list of pages that is at once the
router, the top navbar and the content of the generic page template. Adding a
page is one entry there, plus a handler if it needs one. Everything except the
interview and the statistics page is placeholder copy for now.
| Route | Method | Behaviour |
|---|---|---|
/ |
GET |
Home. |
/about/history |
GET |
About → History. |
/about/goals |
GET |
About → Goals. |
/about/sources |
GET |
About → Sources. |
/use/how-to |
GET |
Use → How To. |
/use/most-beneficial |
GET |
Use → Most Beneficial. |
/discover/best |
GET |
The catalog's award and curriculum lists, at the age being shopped for. |
/discover/interview |
GET |
The next interview question, or the shelf. Requires an account. |
/discover/interview |
POST |
Stores one answer, then redirects. Requires an account. |
/discover/interview/reaction |
POST |
Records a reaction to a recommended book. Requires an account. |
/discover/interview/review |
POST |
Records what a reader thought of a recommended book, or of the shelf as a whole. Requires an account. |
/discover/reader |
GET |
What the system believes about this reader, and where each belief came from. |
/discover/reader |
POST |
Saves the reader's age, grade, reading level or name. |
/discover/reader/signal |
POST |
Records that an inferred belief is wrong. |
/discover/reader/read-on |
POST |
Records (or clears) the date a book was read. |
/discover/statistics |
GET |
Counts of the catalog by genre and by decade. |
/discover/survey |
GET |
Discover → Survey. |
/register |
GET |
The registration form. |
/register |
POST |
Creates the account and logs it in. |
/login |
GET |
The login form. |
/login |
POST |
Verifies the password and starts a session. |
/logout |
POST |
Ends the session. |
/forgot-password |
GET |
The "email me a link" form. |
/forgot-password |
POST |
Mails a reset link, if the address has an account. |
/reset-password |
GET |
The new-password form, for a valid token. |
/reset-password |
POST |
Sets the new password and consumes the token. |
/admin |
GET |
The admin page. Administrators only. |
/admin/books/{slug} |
GET |
The book editor, filled with what is stored. Administrators only. |
/admin/books/{slug} |
POST |
Saves an edit and records the state it replaced. Administrators only. |
/search |
GET |
Everything matching a query, by title or author. |
/search/suggest |
GET |
The same matches as JSON, for the type-ahead. |
/books/{slug} |
GET |
One book. |
/authors/{id} |
GET |
One author and their books. |
/assets/ |
GET |
Embedded stylesheet and JavaScript. |
/design/ |
GET |
The design mockups (see Design mockups). |
Each interview step is its own screen and its own form-encoded POST, which redirects on success so a reload cannot resubmit an answer. A step that fails validation re-renders with the reader's choices still in place. Bootstrap's own bundle is loaded for the navbar's dropdowns and mobile toggle.
The site's own script is web/assets/js/search.js, and it is an enhancement
rather than a dependency: with it, the navbar's box suggests matches as you
type; without it, the same box is an ordinary GET to /search. Nothing else on
the site needs scripts.
The reader interview
/discover/interview is behind an account — a logged-out visitor is sent to the
login form and returned afterwards (see Accounts).
It is adaptive: which questions appear depends on what has already been answered and on what the reader's profile already holds. A new reader sees about five to nine screens; a returning one sees as few as the current request and an avoidance question, because their favourites, dislikes and reading level are already recorded. The interview stops as soon as it has enough to produce a shelf.
interview.go holds the question bank. Each question declares its fields and an
Applies predicate, and the interview is simply the first unasked question whose
predicate holds. The rules that matter:
- Ability already known (a declared Lexile, or two named books the catalog can measure) → the reading-level question is not asked.
- One favourite → ask for a second, unless the reader already named two preferences. One book can mislead a similarity judgement.
- A broad genre on its own ("fantasy") → ask what kind. Not asked when favourite-book evidence already answers it.
- Three or four favourites whose reasons agree → stop asking generic preference questions.
- A disliked book without a reason → ask why. The title alone is weak evidence.
Answers are kept twice: interview_response.answer holds the structured form,
and raw_response holds what the reader actually wrote. The taxonomy in
interview.go will change; the sentences will not.
From answers to signals
An answer becomes weighted rows in reader_signal, each keeping its source and
scope. Evidence accumulates across interviews, but restating the same fact
updates it rather than adding a second copy:
| Family | Matches against | Comes from |
|---|---|---|
topic |
tags, then the description | request, interests, why-liked, a favourite's own tags |
tag |
tags only | a stated dislike (too scary, too sad) |
genre |
the book's genre | a favourite's genre, a reaction |
length |
pages or word count | how much book they want right now |
effort |
Lexile relative to the reader | "something challenging", "too hard" |
density |
words per page, Lexile relative | "the writing was too dense" |
exclusion |
tags only | an explicit avoidance |
note |
nothing | a reason with no book-level meaning |
shelf |
nothing | "surprise me", an instruction about the shelf |
Three decisions are load-bearing:
- Negative evidence stays narrow. "Lord of the Rings was too long and dense"
becomes negative
lengthanddensitysignals — never a negative on fantasy, on Tolkien, or on quests. Anotesignal records "boring" without ranking anything on it, because boredom names nothing a book can be matched against. - Persistent taste is separate from today's appetite.
scopedistinguishes them, and a shelf reads every durable signal plus only this interview's current ones. A request for something short for the car does not permanently lower a reader's length preference. - A repeated fact is one fact. A durable signal is keyed by reader, family and name, and a current one by interview as well; one index serves both. A reader who mentions fantasy in three interviews ranks fantasy once, not three times — before this, repetition was indistinguishable from intensity. The strongest statement of a fact wins, measured by absolute weight so a firm "too long" is not displaced by a mild one, and the newest row breaks a tie.
A favourite is matched against the catalog by its significant words, with
articles and punctuation set aside, so that The Hobbit, Hobbit and
a hobbit all reach the same book however the reader phrases it. The catalog
stores titles in published order — migration 00019 rewrote the 832 that had
arrived inverted, as Hobbit, The — but a reader is not held to that. A title
the catalog does not hold is recorded as such, with no book attached, rather
than forced onto the closest match.
Where a list is alphabetical it is ordered by that published title, article and
all: The Hobbit files under T, between The Holiness of God and The Horse Catcher, not under H beside Hobbit. This is the plan we are working to, not
an accident of rewriting the titles — a list that skipped a leading The, A
or An would have to say so deliberately, and nothing asks for one today. If a
process later turns out to depend on the old shelf order, that is the moment to
revisit the decision, in the open, rather than to bend one query quietly.
Filters and ranking
Two things can happen to a book: it is ruled out, or it is ranked.
Only three things rule a book out:
-
An explicit avoidance. Matched against tags only. A history of the Second World War is not removed by a reader who asked to avoid intense violence, because the exclusion never reads descriptions.
-
A developmental mismatch. Two statements in the catalog say who a book was written for, and either rules it out when it places the book far outside the reader: the AR Interest Level (LG, MG, MG+, UG), and the ages the catalog associates with the book — the suggested reading range, or the Ambleside year a book is read in where there is no range. A fifth grader is not offered lower-grade books, and a first grader is not offered a book for four-year-olds or for teenagers. Both are compared with the reader as they are today.
The one thing reading difficulty may do is lift the floor of an AR Interest band, and only its floor. "MG" spans grades four to eight, and a six-year-old who has finished Charlotte's Web is in that band already, so a band's floor yields to what the reader has actually read. What that means is narrow: every measure the catalog states for the book and the reader must agree — its Lexile within 150 points of the reader's own level, its ATOS within a grade — and a book with neither measure is not within reach at all, because reach is shown rather than assumed. A band that begins in high school is never opened this way, however easy the book: Night and Beloved are someone else's reader. The age range and the Ambleside year are left alone, because they are statements about one particular book rather than a five-grade span. A book the catalog says nothing about is kept — unknown is not a disqualification.
-
Already spent. A book the reader has finished, quit, dismissed from a shelf, or already listed as a favourite is not suggested again.
Everything else only raises or lowers a book. Ranking sums the signals that match, adds a nudge for curated award and recommended-reading tags, and adds an accessibility term — but a book with no Lexile is never penalised for it, and a reader with no known level is never penalised either. Missing data stays missing.
The shelf is not a top-N. At most two books share a genre while anything else is
available, and one wildcard is added from further down the ranking, chosen
deterministically from the interview's id so reloading does not reshuffle it. A
reader who asked to be surprised gets a stricter genre cap and a second wildcard,
because that is what the request asks for; the request ranks nothing itself. Per
book, the page says why in the reader's own evidence, and offers four reactions.
Each card also carries the book's reading measures where the catalog has them —
the Lexile and, beside it, the ATOS, with the AR interest level underneath — so
"is this at the right level?" can be answered at a glance rather than only
inferred from the explanation. The three are independent and the card shows
whichever the sheet carried, either alone if that is all there is; a book with
none of them says nothing rather than showing a dash. The ATOS is the same number
the book page calls the Accelerated Reader level, under the name the reading
programmes use for it, and the interest level is written as the code and the
grades it covers — "LG (K–3)" — because "LG" alone means nothing to a parent.
The same block is drawn by the search results and an author's bibliography, which
are the other two places a person chooses between titles; one partial draws it
in all three, from public.Levels.
A reaction feeds back: "interested" contributes weak preference signals from the
book's genre and tags, "already read it" is capability evidence, and "not for me"
is recorded without being generalised into a dislike of the genre. "Not for me"
and "already read it" both spend the book, so the card just answered leaves the
shelf; the page returns the reader to the card that has taken its place, rather
than to the top, because with eight cards of a dozen controls each the card they
answered is somewhere below the fold and hunting for it reads as "did that even
save?". Each card posts its own position back for this, so a shelf that has
shrunk past it lands on the last card and an empty shelf lands on the top.
Under each book, and once for the shelf as a whole, is a collapsed "I wish I knew…" box. What it records is not which cell is empty but what information was missing — the question that turns a failed recommendation into a task. Notes are append-only and keep the book they concern, if they concern one.
Reviewing a reader
-review turns the binary into a diagnostic instead of a server. It prints the
evidence a reader has accumulated, what the engine removed and why, every book it
ranked with the contributions that scored it, and the shelf's notes. The header
states the reader's age and grade, the level the ranking used, and the reading
reach the developmental screen weighed a band's floor against — the last two are
what a shelf that looks wrong is usually wrong about:
$ POSTGRES_DSN='postgresql:///books?host=/run/postgresql' ./books -review <reader-uuid>
It takes a comma-separated list, so several readers can be compared in one run,
and -session <uuid> pins a particular interview instead of the reader's latest
finished one. It lives in this package rather than in its own command because it
reports on the engine in this package, and an explanation that could drift from
the ranking it explains would be worse than none.
The reader profile
/discover/reader shows what the system believes about the reader and where each
belief came from: the basics it was told, the signals it worked out, and the
books it has been told about. It exists because the reader model is built by
inference — a favourite's tags become interests, a complaint about length becomes
a length preference — and an inference that is wrong is worse than a missing one,
because it silently steers every shelf that follows.
Every inferred belief has a "Not true" button. That records a suppression rather than only deleting the row: the next interview would otherwise derive the same belief from the same evidence and the correction would quietly revert. A suppression is keyed by the belief, not the row, and the profile lists what has been suppressed so a quiet absence is never a mystery. Suppressed beliefs are permanent; there is no undo yet. A blank field in the basics form means "leave what we have alone", not "forget this".
What V1 deliberately does not do
- No interpretation of free text. "The dragon was cool" is preserved and keyword-matched; it is not parsed. That is why the raw sentences are kept.
- No complete taxonomy.
signalTokenscovers what the question bank asks about. Which books deserve richer annotation is what the gold-set testing is for; the catalog's tags are user-facing labels, not a recommendation schema. - No second difficulty measure for ranking. Lexile is the measure the ranking reads, and an approximate Lexile↔ATOS conversion would be a guess presented as data. ATOS is read too, but only by the developmental screen and only to compare one book against one reader — never to rank two books against each other.
- No age-based filtering without a reason. A stated age or grade, or one implied by the other, is screened only against what the catalog says about who a book was written for: its AR Interest Level, its suggested reading range, or the Ambleside year it is read in. Age on its own removes nothing, and what the reader has already read can open a band's floor but never close one.
Statistics
/discover/statistics answers three questions about the catalog. Every figure
is inline SVG laid out in stats.go and scatter.go, so there is no chart
library and no JavaScript: the page works with scripts disabled. Each figure
carries an aria-label describing what it shows, because an SVG is announced as
a single image.
The first two count the catalog by genre, and by decade of publication.
Two figures are deliberately excluded from the charts and reported in their
captions instead: the handful of titles with no genre, and the 462 published
before 1900. Decade-resolution bars across the catalog's full span — the oldest
title is from 800 BC — would flatten every bar before the twentieth century to a
pixel. Widen histogramFrom in stats.go to cover more of it.
The third figure is a scatter plot of any two measures, chosen from the two
menus beside it: word count, pages, Lexile, Guided Reading Level, the two
suggested ages, the two Ambleside years, and year published. scatter.go holds
the menu and the layout. The choice travels in the URL as ?x=pages&y=words,
so a plot can be linked to, and the page renders it without scripts.
Three things about that plot are worth knowing before changing it:
- A missing measure is not a zero. A book with no Lexile has no position on a Lexile axis, so it is left off rather than piled onto the axis; the caption says how many books carry both measures.
- Each axis is drawn over the 1st to 99th percentile of the values on it. An eleven-volume set at 14,777 pages and an 800 BC book are real data points, but an axis spanning the whole of either range compresses the other two thousand books into a corner. The books outside the frame are counted in the caption, not silently dropped.
- Guided Reading Levels are letters, A to Z, stored as the ordinal that
sorts correctly and written back out as the letter it means, in
axisKind.text. Every other measure is already a number of the same kind, so it needs no such translation.
Best books ever
/discover/best is the catalog's curation lists, opened at the age of the reader
being shopped for. It is the one door of the four in the current design that is
already a query: the lists exist, and only a page was missing.
A curation list is a row of collection — a spreadsheet column someone
filled in, promoted in 00016_book_detail_fields.sql. The catalog holds fifteen,
holding 2,837 books between them of which 330 stand on two or more. They are
different kinds of thing: Newbery and the ALSC Notables are awards and librarian
selections, Harvard Classics and St. John's College are canons of adult books,
and Mensa Kids, Beautiful Feet, Deep Roots and the TJEd lists are curricula.
They disagree with each other, which is why the page does not merge them into
one ranking and call the result "the best": each list is drawn as itself, and
every book names the other lists it stands on. Where several lists agree is the
closest thing to a verdict the catalog holds.
The age filter is the page's other half, and it reads
book_reading_level.age_from/age_to — the retailer's grade range converted to
ages at ingestion (see Collections). The ages on offer are the
ones the books actually span, so the filter cannot drift from the data. A book
whose range has one end known covers everything past it, because that is what the
source said. A book with no age recorded is not shown at any age: choosing an
age is what leaves it out, and the page says how many books that is — 879 of the
2,837 — rather than treating an unknown age as a licence to be shown to everyone.
With no age chosen, every book is on the page.
bestLists in best.go names which of the fifteen lists the page draws from,
and that is an editorial decision rather than a technical one: the collection
table says what a list is, and bestLists says whether we trust it. Every list
is named today so that nothing is left off by default while the decision is open.
A name that collection does not hold is logged and skipped rather than drawn as
an empty list.
One list, at one age, is a page of its own — ?list=8, or ?list=8&age=10, with
the age filter keeping whichever list is open. The whole page shows each list's
first bestPreview books; a list's own page shows up to maxBestBooks and says
so when it has cut the list short. There is no paging: the link promises only
what the next page can keep.
The page is drawn light on purpose. It is a directory of fifteen lists and
about a hundred books, so a book is one line — title, then author, year, age and
its reading measures inline — rather than the stacked bookLevels block, which
is what a card about a single book wants and what would make every row of a
hundred three lines tall. Counts are written with their comma by the num
template function, which is likeNumber, so how the site punctuates a number
still lives in one place. A list's own page counts and describes that list; only
the page showing all fifteen counts all fifteen.
Each list is drawn as a panel folded shut. The name, how many books it holds
and what it is are on the page; the titles are behind data-bs-toggle="collapse"
until a reader opens the panel, because fifteen lists and a thousand titles
arriving at once is a wall rather than a directory. The titles are in the HTML
and the fold is only a class, so nothing is fetched to open one, and the panels
do not close each other — they are fifteen lists to read against each other
rather than a set of alternatives. The Best in class roads above them fold
the same way and by the same mechanism, since a road's shelf is a list too; the
classes are named .fold-* rather than after the lists for that reason. The fold is Bootstrap's, whose bundle is on
every page; with no script there is no way to open a panel, so <noscript>
shows every list whole and takes the carets away. Failing to the long directory
is the right failure. The caret is the only thing that says a name is a control,
so it is drawn in web/scss/main.scss and follows aria-expanded — the
attribute Bootstrap keeps in step with the open panel — rather than a class of
our own that could drift out of step with it.
A book standing on three lists is three rows of book_collection, and the page
counts books rather than rows — 2,837, not 3,364. The whole of the chosen lists
comes back from one query and is narrowed and counted in Go, which is what lets
the shelves and the numbers on them be drawn from the same rows.
Accounts
The interview is behind an account. It is the part of the site that records what
a child read and what they thought of it, and that is only worth keeping if the
reader can come back to it — and only theirs to see. The rest of the site stays
open: the catalog, search, statistics and the reader profile page all work
without logging in, and an anonymous reader is still just the books_reader
cookie's unguessable public id.
An account is what ties that history to a person: app_user holds the account,
user_session one logged-in browser, and reader.user_id links a reader to the
account that owns it — so every interview, interaction and signal already
attached to that reader is attributable to someone who can log back in.
auth.go holds the whole of it.
| Route | Method | Behaviour |
|---|---|---|
/register |
GET |
The registration form. |
/register |
POST |
Creates the account and logs it in. |
/login |
GET |
The login form. |
/login |
POST |
Verifies the password and starts a session. |
/logout |
POST |
Ends the session. |
These are addresses rather than site-map destinations, because whether they are reachable depends on whether someone is logged in, which a static navbar entry cannot express. The navbar draws the account area itself: log in and register links when logged out, the username and a log out button when logged in.
The rules that matter:
- The plaintext password is never stored. Only a bcrypt hash is written, at bcrypt's default cost; verification reads the cost from the stored hash, so raising it later invalidates nothing. A malformed stored hash fails the login rather than the request.
- A username is a free-form handle. Any characters, any case; the only limits are that it is present and at most 64 characters. It is unique case-insensitively, and, with the email, one of the two login names.
- The email is the durable identifier. It is required, format-checked, stored lowercased and unique case-insensitively — it is what a password reset is sent to, and the only thing a reset will accept, so a username cannot be used to trigger mail to someone else's address.
- A failed login says nothing about which names exist. An unknown name and a wrong password produce the same message and the same work, a dummy bcrypt compare, so response timing cannot enumerate accounts.
- Sessions are rows, not flags. A session is a
user_sessionrow with an expiry and an unguessable UUID token in an HttpOnly, SameSite=Lax cookie; logging out deletes the row. The cookie is marked Secure whenever the connection was https, which behind the TLS-terminating proxy is visible only inX-Forwarded-Proto. - A reader is adopted, never taken. Registering or logging in claims the
browser's reader only while it has no owner, so an interview answered before
registering is not thrown away. A reader already owned by another account is
left alone and a fresh one is opened. Deleting an account detaches its reader
(
ON DELETE SET NULL) rather than destroying a child's history with the login. - An owned reader is not readable without its account. Once a session has expired, the reader cookie alone no longer acts as that account's reader; the browser falls back to a new anonymous reader, and logging back in restores the original one.
- The interview is gated, and the gate remembers where you were. A
logged-out request to any interview address — the questions, the shelf, a
reaction, a shelf note — is sent to
/login?next=…, and both the login and register forms carrynextthrough, so answering one lands back where the visitor was headed.nextis accepted only as a rooted path on this site, so it cannot be used to bounce a freshly logged-in visitor somewhere else; a POST resolves to the GET address of its interview, never to the write itself.
Admins
Some accounts administer the site. The flag is a single column,
app_user.is_admin, defaulting to false, so every account stays unprivileged
until it is deliberately changed:
$ psql -d books -c "UPDATE app_user SET is_admin = true WHERE email = 'you@example.com';"
Nothing in the site sets it. Granting privilege should leave a trail in an operator's shell rather than in a web form, and there is one privilege so far — a role table would be a schema in search of a second role.
An administrator sees an Admin entry in the account menu, which leads to
/admin. The page lists what tools exist and what is still to come. The first
one is the book editor.
Who may reach it is decided per request, not per session: is_admin is read when
each request's session is resolved, so granting or revoking it takes effect on
the next page load without the account logging in again. A visitor who is not
logged in is sent to the login form and returned to what they asked for; a
logged-in visitor who is not an administrator gets the site's ordinary 404
rather than a refusal, because a 403 would confirm that the address exists.
Editing a book
A book's page carries an Edit details button for an administrator, which
leads to /admin/books/{slug}: a form holding the same fields the page shows —
the catalog row, the reading measures, the ISBNs and the collection
memberships — filled with what is stored. Saving writes all of it.
The one thing it does not do is write the row and move on. Every save records what it replaced:
| Column | Holds |
|---|---|
book_id |
the book the edit was made to |
edited_by |
the account that made it, while that account exists |
edited_by_username |
the username as it was at the time |
edited_at |
timestamptz: an absolute instant, written and read in UTC |
before |
the book's whole editable state before the change, as JSONB |
The book row is the state after the latest edit, so the row plus its revisions is the whole history — no diff has to be reconstructed, and any past state can be read back as a record rather than inferred. Nothing surfaces those revisions in the UI yet; the table is the audit, and it is complete on its own.
Four things are worth knowing before changing it:
- The revision and the edit are one transaction. A change cannot exist without its revision, and a failed save cannot leave one behind.
- The row is locked before it is read. The state recorded as
beforehas to be the state the edit actually replaced, and two saves arriving together would otherwise both read the same one. - A save that changes nothing records nothing. The submitted state is compared against the stored one through the same JSON encoding the audit uses, so "nothing changed" means exactly that; the form says so instead of writing a revision whose before and after are identical.
- An edit is protected from the next import, once.
cmd/importleaves a book alone when an administrator edited it more recently than the last completed import, and names each such book in its log — sheet row, title, book id, when it was edited and by whom — so the operator can reconcile it against the sheet row by row. The sheet is still the source of truth for this data, so the protection lasts one cycle: an edit nobody reconciles is taken back by the following import, and the log says so. See Catalog import.
What the editor deliberately leaves alone: a book's tags (a many-to-many
vocabulary that no tool edits yet, and every revision would otherwise carry a
copy of), its slug (the address the page is served at, not a detail), its
emoji, and the Open Library identifiers. If a tag editor is ever added, the
before snapshot should grow to include them so a revision stays a complete
record of what that tool can change.
Password reset
A reset is a password_reset row: a random UUID, the account it belongs to, and
an expiry one hour out. Only the link that carries the token has it in the clear,
and the row is deleted the moment the password changes. Asking for a new link
deletes the account's outstanding tokens, so an abandoned link cannot be used
later by whoever holds the mailbox.
The flow is auth.go throughout:
/forgot-passwordtakes an email address. The answer is the same whether or not it matches an account, and whether or not the mail is delivered — a send failure is logged, not shown — so the form cannot be used to find out which addresses are registered.- The link goes to
/reset-password?token=…, which shows the new-password form only for a token that is still valid. - Submitting looks the token up, writes the new hash, deletes the token and ends every session the account holds, all in one transaction. Two submissions of the same link cannot both find it valid, and whoever was signed in on another device with the old password is signed out.
- The login page thanks the visitor and asks them to sign in with the new password.
BOOKS_BASE_URL is what makes the link absolute. It is configured rather than
taken from the request, because a link built from the Host header can be
pointed at an attacker's domain.
Mail configuration
Password-reset email is optional. With SMTP_ADDR unset the site still runs,
and the reset pages say mail is unavailable rather than pretending to send.
Setting it turns mail on, and then four things are required or defaulted:
| Variable | Meaning |
|---|---|
SMTP_ADDR |
Relay as host:port. Unset disables mail. |
SMTP_FROM |
Envelope and header sender. Required with SMTP_ADDR. |
SMTP_TLS |
starttls (default), tls for implicit TLS, or none. |
SMTP_USERNAME |
Relay user, for PLAIN auth. Optional. |
SMTP_PASSWORD |
Relay password. Optional. |
BOOKS_BASE_URL |
Public origin, e.g. https://books.example.com. Required with SMTP_ADDR. |
starttls is the default because it is what a submission port (587) expects.
none is for a local relay that offers neither; it still refuses to send in the
clear when the server advertises STARTTLS and the upgrade fails. An SMTP_ADDR
without SMTP_FROM or BOOKS_BASE_URL is a startup failure, like a missing
POSTGRES_DSN.
Search
search.go matches a query as a plain substring of a book's title or author, or
of an author's name, case-insensitively — no ranking, no stemming, no fuzzy
matching. Queries shorter than four characters are refused, and PostgreSQL's own
LIKE metacharacters are escaped first, so searching for 100% means those
four characters rather than "anything".
Matches come back in alphabetical order of the published title
(above), so The Hobbit appears among the T's and
an A-titled book among the A's; an author's page lists their books by year
and, within a year, by that same title. The article is not skipped when sorting.
Both lists are places a person chooses between titles, so each result carries the
book's reading measures — Lexile, ATOS and AR interest level — in the same block
the shelf cards use. BookSummary, the shape both lists return, carries the
three columns from book_reading_level, joined in bookSearch and
BooksByAuthor; a book with no measures of its own shows nothing.
| Surface | Shows |
|---|---|
/search/suggest |
Up to 6 books and 4 authors, each labelled Book or Author, as JSON. |
| type-ahead | The same list under the navbar's box: four characters, then arrow keys and Enter, or click a row. |
/search |
Up to 50 of each, in two sections. |
Selecting a suggestion goes straight to /books/{slug} or /authors/{id};
those two pages are addresses rather than destinations, so they are registered
in registerRoutes beside the site map rather than in it.
A book's page lists its catalog fields and description, its reading measures, the curation lists it appears on, its ISBNs, its URL and its award and curriculum tags, and links to its author. The reading measures are whatever the sheet carried, each shown as published and none of them invented:
| Shown | Comes from |
|---|---|
| Accelerated Reader level | book_reading_level.ar_level, as published |
| AR interest level | book_reading_level.ar_interest, code spelled out |
| Lexile | book_reading_level.lexile, written 600L |
| Lexile code | book_reading_level.lexile_code, code spelled out |
| Guided reading level | book_reading_level.grl, the letter A–Z |
| Suggested reading age | book_reading_level.age_from/age_to, in ages |
| Ambleside year | book_reading_level.ambleside_from/ambleside_to, a curriculum year |
| Collections | book_collection, with the source's value where it has one |
| URL | book.url |
| ISBN-13 / ISBN-10 | book_identifier |
Three of those labels are deliberate. Suggested reading age is the sheet's
From/To columns — a third-party retailer's US grade range, converted to ages
at ingestion because a grade names different ages in different countries. The
workbook does not record which retailer they came from, so the page does not
name one: it is a suggestion, and it says so. AR interest level is spelled
out in full (MG+ — upper middle grades, 6–8) because the code says who a book
was written for, which is not the same question as how hard it is to read.
Ambleside year is a year of the Ambleside Online programme, not an age and
not a calendar year: it maps roughly to a grade, year 0 is its first year, and
many books are used across several, which is why the sheet has a start and an
end. The scale runs 0 to 12, a check constraint says so, and a book whose year 0
is the whole story reads Year 0 onwards — the page notes beside the table that
the years are not ages, because "suggested reading age" sits directly above
them.
A measure that is missing is left out rather than shown as a dash: the section
itself is absent when a book carries no measures at all, which is the case for
436 of them. Where a Lexile code arrives without a measure — always true of
NP — the code stands on its own line.
Collections
A list's small print is whatever the source recorded beside the book: an age band for Mensa Kids, a grade for Deep Roots and The Good & The Beautiful, a set of years for St. John's College. Those are shown as they stand.
Beautiful Feet is the exception, because its column carries the vendor's study
codes rather than a band — HHtL, EAH46, MUSW912. Those are spelled out
from the vendor's own catalogue (HHtL — History of the Horse through Literature), and a row naming more than one study is split before each is
looked up: the sheet separates them with a comma or the word "and", and two
books read HWE48, EAHK3. The list lives in beautifulFeetStudies in
search.go; the codes are case-sensitive.
Two codes in the sheet are not on that list and are shown as themselves:
EAWH79 and GtL47, three books each. The vendor has renamed and retired
series, so guessing at them would put a plausible name on a study nobody
confirmed. Adding them is a one-line change once their names are known.
An author's page lists their books oldest first.
Styles
The site is themed with Bootstrap 5 compiled from source, not with a prebuilt Bootstrap stylesheet, so the palette can override Bootstrap's defaults.
| Path | Purpose |
|---|---|
web/scss/_variables.scss |
The colour palette and type scale. The only file to edit to re-theme. |
web/scss/main.scss |
Entry point: the palette, then Bootstrap. |
web/scss/vendor/bootstrap/ |
Bootstrap 5.3.8's scss/, vendored verbatim (MIT). |
web/assets/js/ |
Bootstrap's JavaScript bundle, vendored. |
web/assets/css/main.css |
Build output — embedded in the binary, not committed. |
Design mockups
web/design/ holds whole-page mockups — drafts of screens that do not exist
yet — and the binary serves them at /design/, with web/design/index.html as
the shelf that links to each one. They are embedded the same way the stylesheet
is, so a mockup travels with the binary and can be looked at from a phone at the
real address rather than from a file on somebody's laptop.
Each mockup is a single self-contained HTML file: its stylesheet, its
JavaScript, and its fonts travel inside it, so it makes no network requests and
needs no build step. They are deliberately not entries in the site map, and
nothing in the navbar links to them — a draft of a page is not somewhere a
reader should be sent. They are reached by typing the address. Each carries
<meta name="robots" content="noindex"> so a draft cannot turn up in a search
result.
make css compiles web/scss/main.scss into web/assets/css/main.css, which is
embedded into the binary and served at /assets/css/main.css. The Go build
cannot succeed without it, so make build and make run compile the stylesheet
first; a bare go build needs a make css beforehand. nix/package.nix runs
the same step in preBuild, so a deployed binary always carries a stylesheet
built from the sources in its own store path.
Database
Configured entirely by one environment variable:
POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable'
There is no default. If it is unset the process exits immediately rather than starting up unable to serve.
The process migrates itself on startup: it asks goose whether any embedded migration is outstanding and applies them before the listener opens, so a deployed binary brings its own schema. The DSN's role therefore needs to own the schema.
Migrations
db/migrations/NNNNN_snake_case.sql, goose-style, with -- +goose Up and
-- +goose Down. The sequence must not skip: a gap makes goose fail at
startup. 00003_catalog.sql moves the quiz's five placeholder books into the
real catalog model rather than dropping them; 00004_reading_levels.sql adds
book_reading_level, the measures the statistics page plots;
00005_reader_interview.sql drops the personality quiz's three tables and adds
the reader, session, response, interaction, signal and feedback tables;
00006_ar_interest.sql adds ar_interest, the one developmental signal, and
backfills it from staging. 00007_signal_supersession.sql collapses the
duplicate evidence repeated interviews had produced and adds the indexes that
stop it recurring; 00008_recommendation_review.sql adds the notes a reader
leaves on a shelf or one of its books; 00009_reading_dates.sql adds read_on,
which is when a book was read rather than when the row was written;
00010_signal_suppression.sql records the beliefs a reader's profile has been
corrected to exclude.
00011_age_ranges.sql renames grade_from/grade_to to age_from/age_to and
converts the values already stored, so the model's unit matches the one
ingestion now writes. 00012_developmental_suitability.sql adds
book_developmental, developmental_tag and book_developmental_tag, the
project's own judgement about a book — see Developmental
suitability. 00013_user_accounts.sql adds
app_user and user_session, and a nullable reader.user_id, so a reading
history can belong to an account — see Accounts.
00014_password_reset.sql adds password_reset, the single-use tokens a reset
link carries — see Password reset.
00015_admin_users.sql adds app_user.is_admin — see Admins.
00016_book_detail_fields.sql adds book_reading_level.ar_level and
lexile_code, book.url, and the collection / book_collection tables that
carry the sheet's curation lists — see Search. It backfills from
staging, which still holds the source columns, and cmd/import/transform.sql
writes them directly on a re-import.
00017_book_revision.sql adds book_revision, the audit the book editor writes
— see Editing a book.
00018_ambleside_year_scale.sql puts a 0-to-12 check on
book_reading_level.ambleside_from/ambleside_to, which are curriculum years
rather than ages — see Search.
00019_natural_title_order.sql rewrites the catalog's titles from library
order (Hobbit, The) into published order (The Hobbit), which is what every
list now sorts by; cmd/import/transform.sql writes the published form on a
re-import, so the two agree — see the ordering note under From answers to
signals.
Four of these depend on how the database was set up: 00007, 00009, 00011
and 00013 change or extend tables that 00005 created, so they require the
role named in POSTGRES_DSN to own those tables. 00016 extends tables from
00003 and 00004 as well, 00018 adds a constraint to one of them and 00019
rewrites rows in book, so they need the same ownership of the catalog. A role
that can create tables but not alter them will fail here, which is a deployment
problem rather than a migration problem — the DSN's role is expected to own the
schema.
Add a migration, then regenerate the query-builder code:
$ nix develop
(nix) $ go run ./db/jet # reads POSTGRES_DSN; applies nothing itself
db/jet needs a database that already has the migrations applied. The
generated files under db/gen/ are committed, so the Nix build never needs a
database.
Catalog import
The customer's data arrives as two CSV files in the repository root: the catalog itself, and a description-provenance log exported from the same workbook. Both file paths are flags, so the files can live anywhere:
$ nix develop # go, go-jet, goose and psql
(nix) $ export POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable'
(nix) $ go run ./cmd/import -dry-run # parse and report, touches nothing
(nix) $ go run ./cmd/import # loads the two default paths
(nix) $ go run ./cmd/import -tab1 /path/to/catalog.csv -tab2 /path/to/research.csv
(nix) $ go build -o books-import ./cmd/import # standalone binary
-dry-run needs no database at all. A real run reads $POSTGRES_DSN (or
-dsn), applies any outstanding migrations itself, and then, in one
transaction:
- copies both files verbatim into
staging_tab1_raw/staging_tab2_raw, repairing the character encoding on the way in; - derives the normalized tables from staging using the steps in
cmd/import/transform.sql; - writes anything it cannot place to
quarantine_row; - prints a reconciliation report and fails if a count disagrees.
Re-running
The import is idempotent. Books are recognised by their external identifiers
and by a (title, author, year) fingerprint, which is unique across all 3,579
source rows, so a second run updates rather than duplicates. Staging is
truncated per batch, and every batch is recorded in import_batch with the
source file's SHA-256.
A book an administrator has edited
An edit made in the book editor is newer information than the
export it was made against, so the import does not quietly revert it. The
transform's first step builds a protected_book list — books whose latest
revision is newer than the last completed import — and every step that writes
something about a book skips those: genre, publisher, author links, tags,
identifiers, URL, reading measures, collections and the description-research
row. The book is left exactly as the administrator left it.
Each skipped book is logged, one line at a time, because a skip the operator cannot see is only a discrepancy moved somewhere nobody is looking:
2026/09/23 17:48:44 import: row 7 "100 Cupboards" (book 6) was edited since the last import — 2026-09-23T17:48:38Z by smoke-editor, 1 revision(s); the sheet's values for it were left alone
2026/09/23 17:48:44 import: 1 book(s) protected; reconcile each against the sheet, because the next import will apply it
The reference is the last completed import, not "ever edited". The sheet stays the source of truth for this data, and a book frozen forever would drift away from it, so the protection lasts exactly one cycle: an edit nobody reconciles is taken back by the following import. Reconcile by updating the sheet to match the edit — then the import writes the same values and nothing changes — or by correcting the book in the editor. Books the sheet does not mention are not protected, because nothing would write to them anyway.
The list is a temp table built inside the import's transaction, and the log is emitted only after that transaction commits, so a failed import cannot report skips that never happened.
What the source data requires
These are the reason the transform looks the way it does; all are enforced by tests or by the reconciliation report.
- Encoding. The export read UTF-8 bytes as Windows-1252, so some characters
are double-encoded (
’for’) while others in the same file are intact. The repair is applied per run of characters, never to the whole file, because a whole-file transcode corrupts 499 characters of good text. ~15U+FFFDare already in the source; those bytes are unrecoverable and are quarantined. --is the null sentinel in every column, and ISBN fields carry a leading apostrophe from the spreadsheet's text marker.- Davidson Age has been corrupted by the spreadsheet. Some values are dates
(
3/8/2026) where an age or grade belongs, so they are quarantined rather than carried forward. Nothing reads the column; it stays in staging with the other research columns. Meta Tagssplits on;only. Commas occur inside tag names (Recommended Reading-California Recommended Lit., English, K-2), so splitting on comma would shred 261 rows.tab2.ROWaddresses the book's row in tab1, not tab2's own row, and four of its 1,436 values no longer agree with tab1 — a re-export shifted those rows. Rows are therefore matched on(title, author), which resolves 1,435 of 1,436; ROW is recorded, and disagreements are quarantined.tab2is provenance, not a patch set. EveryDESCRIPTION_PATCHis byte-identical to the description already on the book, so it is imported asdescription_research(where the text came from, and which 33 books have no defensible description) rather than as an overwrite.
Staging is not the final model
The reading measures are promoted into book_reading_level by the
reading_levels step: Lexile, the two suggested ages and AR Interest Level as
themselves, Guided Reading Level as the letter it actually is. Anything that
cannot be read as a measure — the -- sentinel, a grade of 0 0 1 — is left
NULL rather than guessed at.
The source's From/To are US grade labels bought from a third-party retailer
— Scholastic or another large one; the workbook did not record which — so they
are a suggested reading range rather than anything this project worked out. They
are stored as ages, because a grade does not name the same thing in every
country, and the conversion happens here, at ingestion, where the data's US
origin is known: US grade N is age N + 5, so kindergarten is 5 and
pre-kindergarten is 4. The raw labels stay in staging_tab1_raw untouched, so
staging holds grades and the model holds ages; readerGrade runs the same
conversion backwards, which is what keeps a reader and a book comparable.
AR Interest Level is promoted because it is not a difficulty measure at all: it says who a book was written for, and the recommendation pipeline needs that to screen developmentally without falling back on reading difficulty, which is a different question. The AR level (ATOS) is promoted beside it, on both the book's card and the reader's evidence, and it is the one difficulty measure the screen reads — and then only to compare a book against the reader, never to rank two books against each other, which is still the Lexile's job.
The 17 award and curriculum columns stay in staging_tab1_raw un-promoted. They
are deliberately left as text: they mix booleans (y, X), grades (7),
ranges (K-3), program codes (EAH46) and comma-separated multi-values
(1,2), and promoting them properly needs a book_curation shape that nothing
asks for yet. Nothing is lost — staging holds every column of every row, and
import_batch records which file it came from.
Licensing
Descriptions and ratings were sourced from Goodreads, AR BookFinder, Open
Library and Google Books. Every imported description keeps its origin in
description_research.source_stage / source_url so the provenance survives
while the redistribution question is settled.
Developmental suitability
Two age ranges now live in the catalog, and they answer different questions.
book_reading_level.age_from/age_to is a retailer's suggested range: a third
party's grade label converted to ages at import. book_developmental is the
project's own judgement — from what age a book is a worthwhile developmental
experience, and how far its value runs. Those are different questions asked by
different people. A retailer's range can say a book is pitched at eight-year-olds;
it cannot say whether the book is worth an eight-year-old's time, or that a
twelve-year-old would still get something out of it. Holding both in one place
would invite reading one as the other, which is why they are two tables rather
than two pairs of columns.
A judgement is from_age + to_age + a support value + an occasional note +
developmental tags. Neither age is a prohibition: to_age is the top of the
book's especially valuable sweet spot, not the point at which a reader has
outgrown it, and from_age is the youngest age at which the book is worth
recommending rather than the youngest age able to decode it. support records
how much adult help makes the book work — independent, awareness,
conversation or guided — because support is what unlocks satire, grief,
history and deliberate ambiguity, not only a shield from content.
The developmental tags are a controlled vocabulary that is deliberately not
enumerated. developmental_tag starts empty and grows as books are judged,
because the model says the terms have to come out of the evidence; seeding it
would invent a taxonomy before there is anything to categorise. A tag attaches
to a judgement rather than to a book, so a book with no developmental row has no
demands to describe yet.
Nothing writes these tables and nothing reads them. The model is designed and
the numbers have to come from evidence that has not been gathered, so this is
storage waiting for the tool that will fill it. The evidence itself belongs
somewhere else when that work starts, and there is nowhere for it yet: the model
is explicit that a source's age judgement stays the source's, so from_age is a
conclusion drawn from evidence rather than a copy of it, and the two must not be
collapsed into one column.
The reader side of the same question
SCENARIOS.md holds ten real readers worked through by hand before the engine
existed, with what they were offered and what was wrong with it. It is the reader
counterpart to book_developmental: evidence of what a shelf should look like,
gathered from readers rather than invented, and — like those tables — mostly
describing judgements the code cannot make yet. scenarios_test.go asserts the
part of it the developmental screen already decides, so a change to that screen
has to answer to the six-year-old and the tenth grader at Lexile 1770 rather than
to the code alone. Read it before widening or narrowing the screen, and before
concluding that a shelf is right because nothing refused it.
Local run
$ POSTGRES_DSN='postgresql://books@localhost/books?sslmode=disable' nix run .#
books: listening on 127.0.0.1:10210
Then open http://127.0.0.1:10210/. The interview needs an account, so register first; to walk it without a browser, keep the cookie and follow the form:
$ curl -s -c jar -b jar -L -d 'username=me&email=me@example.com&confirm=correct+horse&password=correct+horse' \
localhost:10210/register >/dev/null
$ curl -s -c jar -b jar localhost:10210/discover/interview | grep -o 'name="session" value="[^"]*"'
$ curl -s -c jar -b jar -L -d 'session=<uuid>&question=request&want=fantasy&want=adventure' \
localhost:10210/discover/interview | grep -o 'name="question" value="[^"]*"'
A question's fields are named in interview.go; a choice field takes one
field=value pair per selection.
Working on the stylesheet needs the dev shell, which carries dart-sass:
$ nix develop
(nix) $ make css # rebuild web/assets/css/main.css
(nix) $ make run # css, then the server
Deploying
The flake exposes nixosModules.default, which installs the binary as a
hardened systemd service. It binds to loopback by default, so the reverse proxy
must run on the same host:
{
inputs.books.url = "path:/home/eliribble/src/books"; # or a git URL
services.books.enable = true;
services.books.environmentFile = "/var/run/secrets/books-env"; # POSTGRES_DSN=..., SMTP_ADDR=..., SMTP_FROM=..., BOOKS_BASE_URL=...
# services.books.port = 10210; # default
# services.books.host = "127.0.0.1"; # default
# services.books.openFirewall = false;
services.caddy.virtualHosts."books.example.com".extraConfig = ''
reverse_proxy 127.0.0.1:10210
'';
}
environmentFile is a path rather than a plain option so the database password
never lands in the world-readable Nix store. Enabling the service without it
produces a warning, because the app cannot start without POSTGRES_DSN.
services.books.package is overridable if the server should come from
somewhere other than this flake.
Because the service uses DynamicUser and keeps no local state, it needs no
directories created and no secrets configured beyond the DSN.