test #15

Merged
brian merged 9 commits from test into main 2026-08-08 04:07:10 +00:00
Owner
No description provided.
refactor(memories): a memory is a collection, not one file
All checks were successful
Build and Deploy / test (push) Successful in 26s
Build and Deploy / build (push) Successful in 11s
Build and Deploy / deploy-main (push) Has been skipped
Build and Deploy / deploy (push) Successful in 14s
e319d5a6c9
A family memory was one primary file with pictures beside it, so an
afternoon that produced a recording, a video and a stack of photographs had
to be filed as several unrelated memories. Files now live in memory_items —
audio, image, video or document, any number, in one memory — with the
transcript on the item rather than the memory, since one column could only
ever hold one recording's.

Also: people as rows rather than a JSON list of strings, so the same person
can be found across memories and linked to an account when they have one; a
date with a precision of year/month/day, because family history is mostly
remembered as "summer 1994" and forcing a day onto that invents a fact; and
links to journals and notebooks, which is the only thing that says what was
written at the time and what was recorded of it are the same occasion.

Notes is the `description` column, which has been on the model, in the API
and in the Android database since the beginning and was never once shown to
a user in either client.

Video is finally a video. It was classified as a document, the web's file
picker wouldn't accept one, and Android had no code for it at all.

Nothing on disk moved. memory_photos is *renamed* to memory_items so every
picture keeps its id, and each memory's primary file becomes a row whose id
is the memory id — which is already the name of that file on disk. Relocating
multi-gigabyte recordings on a live volume can only be half-done if the
process dies; storage.read_item knows the three places bytes can be instead.

The migration was replayed against a real Postgres holding old rows rather
than reasoned about, which found the ordering bug it turns on: create_all
runs before the column sync, so it would have created an empty memory_items,
the guarded rename would have skipped, and every existing photograph would
have been stranded under the old name. Renames now run first. Verified by
putting the order back and watching 5 of 7 items disappear.

Old clients keep working. The seven fields the installed Android release
reads are computed from the items on the way out, its routes are kept as
thin wrappers, and the upsert applies only fields actually present in the
request — without that, its next background sync would blank the notes and
date it has never heard of.

Two more bugs the API test found before deploy did: a newly created memory
lazy-loaded `people` in async context, which is a 500 on every create; and
because sessions use expire_on_commit=False, every write route returned
pre-write state — upload a file, get back a memory with no files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(memories): supply created_at when backfilling people
All checks were successful
Build and Deploy / build (push) Successful in 11s
Build and Deploy / deploy-main (push) Has been skipped
Build and Deploy / deploy (push) Successful in 24s
Build and Deploy / test (push) Successful in 24s
85b5b125c1
The test stack wouldn't start. `create_all` builds memory_people from the
model, where created_at's default is applied by Python on insert — so the
column has no server default, and the backfill's raw INSERT omitted it and
hit NOT NULL. The CREATE TABLE in the column sync does carry a default, but
it never runs: create_all has already made the table by then.

Everything runs inside one transaction, so each failed attempt rolled back
whole and the database stayed on the old schema throughout.

The integration test missed this because its stand-in for create_all was
wrong in exactly the way that mattered — it declared `created_at TIMESTAMPTZ
NOT NULL DEFAULT NOW()`, which create_all does not emit. A stand-in has to
mirror what the real thing leaves out, not just what it includes. Corrected,
and it now reproduces the outage against the old SQL and passes against the
fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(memories): the web client, rebuilt around a collection of files
All checks were successful
Build and Deploy / test (push) Successful in 25s
Build and Deploy / build (push) Successful in 10s
Build and Deploy / deploy-main (push) Has been skipped
Build and Deploy / deploy (push) Successful in 13s
48ca56067b
A memory's detail panel was a primary file, a strip of pictures, one
transcript and a tag list. It is now a list of files of any kind — each
rendered as what it is, so a recording plays, a photograph shows and a
document downloads — plus people, a date, notes, and the journals it
belongs with.

Adding files no longer filters by type. The picker's accept list was
"audio/*,image/*,.pdf", which is why video could not be added at all even
though the panel would happily have played one. Several files chosen at
once land in a single memory: one afternoon is one occasion, not five.

The transcript moved into the file it describes, with its own Transcribe
button, because a memory can now hold several recordings. That needed a
PATCH route for item metadata — a hand-corrected transcript had nowhere to
be saved otherwise, since the upload route only takes bytes.

The date is stored whole so it sorts, with a precision of year, month or
day saying how much of it to believe; "summer 1994" is how family history
is actually remembered, and the picker changes shape to match rather than
demanding a day nobody knows.

Records written by the old build are reshaped on read rather than migrated
in place — one made offline may never have reached a server that could have
converted it. Thirteen checks cover that conversion, run in CI, against the
shipped source rather than a copy of it.

Also swept: every $("#…") in app.js now resolves to an element that exists,
and deleting a memory forgets an object URL per file rather than one per
memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android client now speaks the same shape as the server and the web: any
number of files of any kind in one memory, the people who were there, when
it happened, notes, and links to journals.

Files go through the one endpoint that takes anything, keyed by a
client-generated id so a retried upload can't duplicate. Rows queued by the
previous build still name the endpoint they were queued against and are sent
there, because those uploads may be the only copy of a recording made away
from signal.

Transcripts moved into the file they describe, each with its own Transcribe
button, since a memory can hold several recordings. Corrections are queued
per item and sent as a PATCH — the memory upsert carries no items, so there
was nowhere else for a hand-typed transcript to go.

The date is stored whole so it sorts, with a precision of year, month or day
saying how much of it to believe, and the fields shown change to match.
Notes is the `description` column that has been in this database since the
beginning and was never once displayed.

Room v4→v5. Every new column has a default so a row written by the previous
build decodes without being rewritten, and the migration is replayed against
a real database holding an unsynced memory — offline work in this table may
be the only copy of a recording that exists.

241 tests. FakeJournalApi earned its keep again: one new endpoint would have
broken every fake in the suite, and instead the default landed in one place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(memories): start with a name, and fold long transcripts away
All checks were successful
Build and Deploy / build (push) Successful in 12s
Build and Deploy / deploy (push) Successful in 14s
Build and Deploy / test (push) Successful in 25s
Build and Deploy / deploy-main (push) Has been skipped
90d8b2de06
A memory is an occasion, not a file. Both clients made you pick one before
anything could be created, so an occasion couldn't be written down until the
recording had been dug out of a drawer — and the name is usually the part
you know first. Both now offer starting with just a name and hanging the
files on afterwards; choosing files up front still works and still puts them
all in the one memory.

The web needed a plain text prompt for that, which it didn't have — its own
modal rather than window.prompt, for the same reason confirmModal exists.

Transcripts are collapsed to start. An hour of speech is thousands of words,
and left open it pushed the people, the date and everything else off the
screen. The header is the toggle and says how many words are in there, since
a collapsed section with no hint of its size reads as an empty one. On the
web it is a <details>, so the behaviour is the browser's rather than
hand-rolled. Finishing a transcription opens it — otherwise the run appears
to have done nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The detail screen was still the picture strip it had been when a memory held
one primary file and photographs beside it — the picker asked for image/*
and the repository refused anything that wasn't an image before it was even
queued. So a memory created by name had nowhere to put a recording, a video
or a PDF, which is most of what one is for.

addPhoto becomes addFile and refuses nothing: the one non-image slot it was
guarding no longer exists, and the only limits left are the server's, which
it reports itself. The strip lists every file — a picture shows its picture,
everything else its kind and its name, which is what tells one recording
from another when a memory holds several.

Playback followed the same mistake: the whole-memory download could only
ever reach the lead file, so a second recording was attached and unplayable.
There is now a player per recording, fetched per item.

The web was already right — its picker has carried no accept filter since
the refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every file in a memory that isn't a picture now gets a row of its own: a
recording plays, a video plays, and anything else hands itself to whatever
the device has. Fetched once to a local file and reused, so opening a PDF
twice doesn't download it twice.

Video uses the platform's VideoView rather than a media library — one local
file with the standard controls is exactly what it is for, and it costs no
dependency. Paused on the first frame, or several videos in one memory would
all start at once.

Opening a document would never have worked, including before this change.
FileProvider only had cache/attachments/ declared, and memory files land in
cache/memories/ — so getUriForFile threw "Failed to find configured root",
the caller caught it, and the user was told the device had nothing that
could open the file. Both that directory and the not-yet-uploaded queue are
now declared; the queue matters because a file captured here is the only
copy until it syncs, and it should be openable before then.

The test for it is one method on purpose. FileProvider caches its parsed
roots statically while Robolectric hands each test method a fresh data
directory, so whichever ran first cached roots the others' files could never
match — it failed for a reason that had nothing to do with the config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The black box was the lightbox. Every tile in the Files strip opened it,
including the ones that aren't pictures, and it is an AsyncImage — handed an
mp4 it renders nothing and shows a black rectangle. Only pictures open it
now; a video's player is its own row further down, where it always was.

Second bug in the same feature: VideoPlayer had an AndroidView update block
calling setVideoURI, which tears the surface down and starts over. This
screen recomposes on every sync tick and every keystroke in a transcript, so
playback was being reset out from under itself. The file behind an item
never changes and a different item gets a different view, so there is
nothing for an update block to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blank space above the title box was the video. VideoView is a
SurfaceView, which composites in its own window rather than being drawn into
the view hierarchy — inside a scrolling Compose column it comes out clipped
or ordered behind, so it reserved its 220dp and drew nothing. That is also
why the row "had a transcription box and that's it": the player was there,
occupying space, invisible.

Replaced with the video's own first frame and a play button that hands it to
the device's player through the FileProvider path fixed a commit ago. The
frame is pulled off the local file, not downloaded again. Arguably the
better answer regardless: full screen, the device's own controls, and
scrubbing an hour of footage inside a 220dp box was never going to work.

Files also moved below the title and the picture strip. They were rendering
above everything, so the page opened on a filename — which reads as a file
rather than an occasion, and a memory is named first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
brian merged commit 770e59723f into main 2026-08-08 04:07:10 +00:00
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
brian/Journal!15
No description provided.