From 8aad7c13b041d1cab3ca642735900ead8d9cbc80 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Thu, 20 Aug 2026 10:36:35 +0200 Subject: [PATCH] perf(notes): listen sendte hele noter for at vise halvfems tegn af dem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome frøs på notesiden, og der var to grunde. **Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i hukommelsen hver gang siden blev åbnet. Nu 763 KB. Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt, der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten kan se efter — og den gør det og henter hele noten, når den åbner en. Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der lige havde haft en titel. **Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme ud af trit med sig selv. **Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal fundne. Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én. Co-Authored-By: Claude Opus 5 --- internal/httpapi/notes_handlers.go | 40 ++++- internal/httpapi/notes_test.go | 9 +- internal/store/notes.go | 10 ++ web/src/lib/locales/da.js | 2 + web/src/lib/locales/en.js | 2 + web/src/routes/noter/+page.svelte | 213 ++++++++++++++++++++--- web/src/routes/projekt/[id]/+page.svelte | 4 +- 7 files changed, 247 insertions(+), 33 deletions(-) diff --git a/internal/httpapi/notes_handlers.go b/internal/httpapi/notes_handlers.go index 6198774..0600360 100644 --- a/internal/httpapi/notes_handlers.go +++ b/internal/httpapi/notes_handlers.go @@ -48,7 +48,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "search notes", err) return } - writeJSON(w, http.StatusOK, map[string]any{"notes": s.readable(r, found)}) + writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))}) return } @@ -62,7 +62,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "notes in project", err) return } - writeJSON(w, http.StatusOK, map[string]any{"notes": found}) + writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)}) return } @@ -74,7 +74,41 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "list notes", err) return } - writeJSON(w, http.StatusOK, map[string]any{"notes": found}) + writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)}) +} + +// briefly replaces each note's body with the first of it. +// +// The list shows a title and about ninety characters. It was being sent whole +// notes to do that: with twelve hundred of them the response was 845 KB, of which +// ninety-eight per cent was body text nobody would read on that screen — fetched, +// parsed and held in memory on every visit. +// +// Body is emptied rather than shortened, and that is the important half. A field +// called `body` that holds a *piece* of the body is a trap: the editor would open +// it, the debounce would save it, and the note would be cut down to its own +// preview by nothing more than being looked at. Empty is a shape the client can +// check, and it does — see the notes page, which fetches the whole note when it +// opens one. +// +// Four hundred characters rather than ninety: the client strips Markdown before +// it shows anything, and a heading marker and a bold word are characters that +// vanish. Cheap insurance against a preview that ends up shorter than the line it +// is meant to fill. +const previewChars = 400 + +func briefly(notes []store.Note) []store.Note { + out := make([]store.Note, 0, len(notes)) + for _, n := range notes { + body := []rune(n.Body) + if len(body) > previewChars { + body = body[:previewChars] + } + n.Preview = string(body) + n.Body = "" + out = append(out, n) + } + return out } // readable drops what this person may not see. diff --git a/internal/httpapi/notes_test.go b/internal/httpapi/notes_test.go index 2c4fbe3..d120b73 100644 --- a/internal/httpapi/notes_test.go +++ b/internal/httpapi/notes_test.go @@ -328,12 +328,19 @@ func TestAnImportedNoteKeepsItsPictures(t *testing.T) { t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files) } + // The list carries a preview, not the whole note — see briefly(). The body is + // fetched by id, which is what the interface does when it opens one. _, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil) notes := listed["notes"].([]any) if len(notes) != 1 { t.Fatalf("found %d notes", len(notes)) } - body := notes[0].(map[string]any)["body"].(string) + noteID := notes[0].(map[string]any)["id"].(string) + if got := notes[0].(map[string]any)["body"]; got != nil && got != "" { + t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got) + } + _, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil) + body := whole["body"].(string) // The link points at the file where it now lives, not at a path inside an // archive nobody kept. diff --git a/internal/store/notes.go b/internal/store/notes.go index 9a3ec87..c226fa4 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -20,6 +20,16 @@ type Note struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` + // Preview is the first of the body, for a list that shows one line of it. + // + // It exists so a list of twelve hundred notes does not have to carry twelve + // hundred whole notes: the list rendered 845 KB of JSON to show ninety + // characters of each. When it is set, Body is deliberately empty — see + // handleListNotes. That is not tidiness but a guard: an editor handed a + // truncated body would save the truncation, and the note would be cut down to + // its own preview. + Preview string `json:"preview,omitempty"` + // Links is what the body points at, read out of the text on the way in. Never // set by a caller: it is derived, and a caller that set it would be describing // a note whose text says something else. diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index e128ba5..c084446 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -46,6 +46,8 @@ export const da = { 'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.', 'notes.newHere': 'Ny note', 'notes.noneInProject': 'Ingen noter endnu. En note, der nævner #projektet, dukker også op her.', + 'notes.clearSearch': 'Ryd søgningen', + 'notes.found': '{count} noter', 'notes.strike': 'Gennemstreget', 'notes.showSource': 'Vis som kode', 'notes.source': 'Notens kildetekst', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 8763940..f3faff0 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -42,6 +42,8 @@ export const en = { 'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.', 'notes.newHere': 'New note', 'notes.noneInProject': 'No notes yet. A note that mentions #theproject turns up here too.', + 'notes.clearSearch': 'Clear the search', + 'notes.found': '{count} notes', 'notes.strike': 'Strikethrough', 'notes.showSource': 'Show as code', 'notes.source': 'Note source', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte index 21e347d..14b87d5 100644 --- a/web/src/routes/noter/+page.svelte +++ b/web/src/routes/noter/+page.svelte @@ -72,7 +72,33 @@ return new Date(b[field]).getTime() - new Date(a[field]).getTime(); }); let selected = $state(null); + // Hvilken række der er valgt. Se open(): editoren venter på hele noten. + let selectedId = $state(null); let query = $state(''); + + /** + * Det, der står i feltet, og det, der er søgt efter. + * + * To ting, fordi de ikke er den samme: `draftQuery` opdateres ved hvert + * tastetryk, + * så feltet føles som et felt, og `query` halter en kvart sekund bagefter, fordi + * det er den, der koster en forespørgsel. Uden det sendte "kaffe" fem søgninger + * gennem tolv hundrede noter, hvoraf de fire var forældede, inden de kom + * tilbage — og svarene kunne lande i den forkerte rækkefølge. + */ + let draftQuery = $state(''); + let typing = $state(false); + let searchTimer; + + function search(next) { + draftQuery = next; + typing = next.trim() !== '' && next !== query; + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + query = draftQuery; + typing = false; + }, 250); + } let status = $state('loading'); let saving = $state(false); @@ -89,7 +115,7 @@ // opens it rather than dropping the person on a list to find it again. let asked = $derived($page.url.searchParams.get('note')); $effect(() => { - if (!asked || selected?.id === asked) return; + if (!asked || selectedId === asked) return; const found = notes.find((n) => n.id === asked); if (found) open(found); else api.note(asked).then(open).catch(() => {}); @@ -99,17 +125,57 @@ try { notes = (await api.notes(q ? { q } : {})).notes ?? []; status = 'ready'; - if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null); + if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null); } catch (e) { status = 'failed'; app.toast(humanMessage(e)); } } - function open(note) { + /** + * Åbner en note — og henter den hel først, hvis listen kun har uddraget. + * + * Listen bærer `preview` og en tom `body`, så en liste på tolv hundrede noter + * ikke er tolv hundrede hele noter. Editoren må aldrig få det uddrag: den ville + * gemme det efter sin pause, og noten ville være skåret ned til sit eget uddrag + * af ingenting andet end at være blevet kigget på. + * + * Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som + * at vente; teksten kommer, når den er der. + */ + async function open(note) { clearTimeout(timer); - selected = note; - draft = note?.body ?? ''; + if (!note) { + selectedId = null; + selected = null; + draft = ''; + return; + } + + // Rækken fremhæves med det samme; editoren får først noten, når den er hel. + // + // De to er skilt ad, fordi editoren indlæser sin tekst, når notens *id* + // skifter — og hvis den fik uddraget først og hele noten bagefter, ville + // id'et være det samme begge gange, så den anden aldrig blev vist. Man ville + // sidde med en tom note, der lige havde haft en titel. + selectedId = note.id; + if (note.body) { + selected = note; + draft = note.body; + return; + } + selected = null; + + try { + const full = await api.note(note.id); + // Kun hvis den stadig er den, der er valgt: skifter man note, mens + // hentningen er undervejs, må den forrige ikke lande oven i den nye. + if (selectedId !== note.id) return; + selected = full; + draft = full.body ?? ''; + } catch (e) { + app.toast(humanMessage(e)); + } } /** @@ -210,7 +276,10 @@ saving = true; try { const saved = await api.updateNote(id, { body }); - notes = notes.map((n) => (n.id === id ? saved : n)); + // Svaret er hele noten. Rækken viser `preview`, så den skal med — ellers + // står linjen tom, indtil siden hentes igen. + const forList = { ...saved, preview: saved.body ?? '' }; + notes = notes.map((n) => (n.id === id ? forList : n)); // Only if it is still the one on screen: switching notes mid-save must not // drag the previous one's title back onto this one. if (selected?.id === id) selected = saved; @@ -315,13 +384,29 @@ - + + + {#if query && status === 'ready'} +

{t('notes.found', { count: notes.length })}

+ {/if} {#if status === 'loading'}

{t('history.loading')}

@@ -334,7 +419,7 @@