• v0.3.0 35f9d8b4fe

    v0.3.0 — Phase 2 review ergonomics
    All checks were successful
    CI / frontend (push) Successful in 2m7s
    CI / e2e (push) Successful in 2m56s
    CI / backend (push) Successful in 36m15s
    Stable

    claude-bot released this 2026-08-05 18:23:08 +00:00 | 32 commits to main since this release

    The reviewer's screen, and the front door that fills it. All 15 issues on the milestone are closed — the first milestone this project has finished as scoped.

    Exit criteria met: a full decision costs one or two interactions instead of seven, the browser shows photographs rather than placeholders and can reach every one of them, and a reviewer can resume where they left off.

    1452 backend tests · 224 component tests · 12 end-to-end journeys, up from 1109 / 58 / 7 at v0.2.0.

    The reviewer's screen

    It was showing camera emoji. The grid rendered a placeholder for every photograph, because no derivative had ever been built. Ingest now builds a thumbnail and a review-size image for every scan, and a bare /media/front still returns the untouched original — the archival bytes stay the default and the optimisation is opt-in.

    It could not reach past the first page. Keyset pagination now follows a cursor through the whole collection, including sorts over columns that are NULL for exactly the photographs this application exists to work on — an undated photo at a page boundary used to end the listing silently, looking complete, with the rest of the archive unreachable.

    A decision cost seven interactions. Evidence sits directly above the form it populates; clicking a row fills the form with its dates and precision, and the decision records which evidence it was adopted from as a foreign key rather than as prose, so the history can say what a date was based on. The queue the browser's filter defines is now one the workspace walks — prev/next, a remaining count, auto-advance, and the next photograph prefetched.

    The queue never emptied. A scanner produces a file for every pass, so a box of prints arrives with the lid closed on an empty bed, the envelope, and the album cover. None is a photograph, none can be dated, and needs_review is where each one sat forever. They can now be excluded — a soft delete, recoverable, with the reason recorded.

    Also: zoom and pan for reading a pencilled date on the back of a print, a conflict state that explains itself instead of refusing a save silently, superseded evidence marked and made inert, display names wherever a raw UUID used to be shown, a logout, and an accessibility pass over the controls.

    The front door

    Nothing had ever created an album. parse_filename extracted an album slug on every ingest and the result was discarded, so album_id was NULL in every deployment — GET /api/photos?album_id=… was a working, tested filter that matched nothing, ever. Ingest now files a scan into its album, conservatively: a name must carry structure past the slug, and camera prefixes are not albums, because an unfiled scan is easy to revisit and an invented album has to be un-invented by hand. The browser reads the collection by album and in album order.

    Ingest took one photograph per HTTP request. A folder now goes in at once, with each print's two sides paired by album and sequence — across separate runs, not only within a single upload. A back whose front is already here is written onto that row rather than becoming a second one. A back whose front is not here is kept rather than rejected, and folds into its front whenever that arrives, so a box scanned over two sessions still ends with one row per print.

    Every file presented to the archive is now recorded, including the ones that could not be read. Ingest wrote to the audit log only on success, so an undecodable scan left no trace once the terminal scrolled. "Box 12 had four files nobody could ever read" is the kind of fact an archive of irreplaceable objects has to be able to state a year later.

    Under it

    Four index regressions were measured rather than assumed, after one earlier attempt sent every listing from 0.06 ms to roughly 250 ms with the answers still correct — the kind of fault nothing above the query plan would ever report. The rule that came out of it, written into three migrations: an index must carry the order a query asks for, not merely the predicate it filters on.

    Full detail in CHANGELOG.md.

    Downloads