Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 9ed6f49589 fix(noter): en ny note taber ikke det, man skriver i den
Serveren tømmer `body` på listerækker med vilje, så en tom krop var sidens
tegn på "det her er en række, hent noten hel". En nyoprettet note er også
tom — og hel — så den blev sendt samme vej: editoren blev pillet af
skærmen, noten hentet igen, og editoren sat op på ny.

I det hul er der ingen valgt note, og save() går tavst tilbage uden en.
Skrev man der, forsvandt teksten sporløst: ingen fejl, ingen toast, bare en
tom note. Svaret på oprettelsen var hele noten hele tiden, så opslaget var
også overflødigt.

Det er samtidig grunden til, at søgeprøven var ustabil. Dens værn var en
ventetid på "Gemt", og den tekst er hvilestanden — feltet siger "Gemmer"
mens en gemning kører og "Gemt" ellers, altså også før den første gemning
er begyndt. Prøven fik grønt med det samme og målte ingenting; i CI var
maskinen langsom nok til at ramme hullet bagefter. Den venter nu på et tomt
felt før den skriver, og på rækken i listen bagefter.

Den nye prøve tæller opslag frem for at måle et blink: et blink ville kræve
en prøve, der er hurtigere end programmet, hvilket er præcis den fejl den
lukker. Efterprøvet mod den gamle kode — den fejler på
`expect(fetched).toEqual([])`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:20:02 +02:00
kristianwindandClaude Opus 5 de673260a3 docs(site): besøgstælling på verdandetodo.com
Plausible på vores egen maskine. Ingen cookie, ingen gemt adresse, ingen
tredjepart — og derfor ingen samtykkebjælke på en side, der skal sælge noget.

`defer`, så tælleren aldrig er det, der holder siden tilbage.

Det gælder siden, ikke programmet. En installation melder det, der står under
"It counts itself, out loud", og ikke mere; de to ting deler ikke noget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:31:00 +02:00
Kristian Wind 804c81f74e Merge pull request #84 from kristianwind/soegefeltet-taber-ikke-bogstaver
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 9m21s
CI / Docker image builds (push) Successful in 1m47s
Release / Build and publish image (push) Failing after 4s
CI / Go (push) Failing after 14m17s
fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
2026-09-16 08:34:59 +02:00
kristianwindandClaude Opus 5 87054ac8ca fix(noter): søgefeltet taber ikke bogstaver, og det nyeste svar vinder
Fra en skærmoptagelse, to billeder i sekundet:

    2,5 s  feltet "verd"      listen ufiltreret
    3,0 s  feltet "nde"       listen filtreret
    6,0 s  feltet "verdande"  listen ufiltreret
    6,5 s  feltet tomt        listen filtreret

To fejl oven i hinanden, og den ene er min fra #79.

Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk, så en
søgning kunne gemmes og deles. `replaceState` opdaterer `$page.url` et øjeblik
efter, den kaldes, så to hurtige tastetryk gav en effekt, der læste den *forrige*
adresse, fandt den uenig med det skrevne, og skrev den gamle værdi tilbage i
feltet. `untrack` fjernede sløjfen mellem de to effekter; den fjernede ikke
kapløbet med browserens egen forsinkelse.

En markør, der skal holde to retninger enige, taber kapløbet et sted. Adressen
skrives derfor ikke længere af siden — kun af navigation — og der er én effekt
tilbage, som læser den. Prisen er, at et ryddet søgefelt ikke rydder `q` af
adressen. Det er en mindre løgn end at æde bogstaver.

Den anden fejl har ligget der hele tiden: `load()` havde ingen vagt mod forældede
svar. To hentninger kunne være undervejs på én gang, og den langsomste vandt — så
listen viste svaret på et spørgsmål, der ikke længere blev stillet. Pausen på
250 ms gjorde det sjældnere og kunne ikke gøre det umuligt; det stod endda i
kommentaren ved siden af. Hver hentning har nu et nummer, og kun det nyeste
skrives i listen.

Prøven skriver i et menneskeligt tempo, for det var hastigheden, der afslørede
det, og holder den ene af to hentninger tilbage for at tvinge rækkefølgen om.
Kørt mod den gamle kode fejler den med `Received: ""` — feltet var tømt helt.

Bemærket: "en gruppe er en side med sine projekter" fejlede to fulde kørsler i
træk og bestod på den tredje, og alene. Den er ikke rørt her. Sammen med "en delt
note tager de noter med" i går er der nu to prøver, der flakker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 08:25:10 +02:00
Kristian Wind c62dcc3d7c Merge pull request #83 from kristianwind/opgavens-tekst-kan-laeses
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 10m47s
CI / Docker image builds (push) Successful in 2m1s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 14m57s
fix(opgave): teksten kan læses færdig, også på en telefon
2026-09-15 20:22:38 +02:00
3 changed files with 193 additions and 45 deletions
+9
View File
@@ -819,6 +819,15 @@
color: var(--accent);
}
</style>
<!-- Besøgstælling for denne side — ikke for programmet, som melder det, der
står under "It counts itself". Plausible på vores egen maskine: ingen
cookie, ingen gemt adresse, ingen tredjepart, og derfor ingen bjælke at
møde folk med. `defer`, så tælleren aldrig holder siden tilbage. -->
<script
defer
data-domain="verdandetodo.com"
src="https://plausible.yggdrasilpanel.com/js/script.outbound-links.js"
></script>
</head>
<body>
+135 -8
View File
@@ -4657,16 +4657,23 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
// Gem-knap at huske.
//
// Der ventes på "Gemt", før den næste note laves. Uden det klikkes "Ny note",
// mens den forrige stadig er undervejs, og listen når ikke at få den med — det
// fejlede i CI én gang ud af tre kørsler, og bestod hver gang her på maskinen.
// En prøve, der er hurtigere end programmet, måler maskinen den kører på.
// Der ventes på et TOMT tekstfelt, før der skrives. Uden det kan udfyldningen
// ramme den forrige notes felt, mens den nye er undervejs, og så bliver teksten
// enten skrevet på den forkerte note eller tabt undervejs.
//
// Og der ventes IKKE på "Gemt": den tekst er hvilestanden, ikke et signal.
// Feltet siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før
// den første gemning overhovedet er begyndt. En prøve, der venter på den, får
// grønt med det samme og måler ingenting. Det var den fejl, der gjorde den her
// prøve ustabil: rettelsen var en ventetid, der ikke kunne vente på noget.
// Rækken i listen er det rigtige tegn — den kommer først, når serveren har
// svaret.
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await expect(body).toHaveText('');
await body.fill(`${title}\n\nnoget om den`);
await body.blur();
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
}
@@ -4687,14 +4694,60 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og adressen lyver ikke, når feltet tømmes: uden det ville et genbesøg hente
// en søgning tilbage, man selv havde ryddet væk.
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
// den skrives kun af navigation. Den første udgave skrev den ved hvert
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
// prøven nedenfor.
await page.getByPlaceholder('Søg i noter').fill('');
await expect(page).not.toHaveURL(/[?&]q=/);
await expect(list.getByText('Egeskov')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En ny note åbnes med det samme, uden at editoren forsvinder undervejs.
*
* Serveren tømmer `body` på listerækker med vilje, og siden brugte den tomme krop
* som tegn på "det her er en række, hent noten hel". En nyoprettet note er også
* tom — og hel — så den blev sendt samme vej: editoren blev pillet af skærmen,
* noten hentet igen, og editoren sat op på ny.
*
* Det hul var ikke kun et blink. Mens det stod åbent, var der ingen valgt note, og
* gemningen går tavst tilbage uden en. Skrev man i det sekund, var teksten væk —
* ingen fejl, ingen toast, bare en tom note. Det er også det, der gjorde
* søgeprøven ovenfor ustabil: i CI var maskinen langsom nok til at ramme hullet.
*
* Prøven tæller opslagene frem for at måle et blink. Et blink er en tidsting og
* ville kræve, at prøven var hurtigere end programmet — præcis den fejl, den er
* skrevet for at lukke. Et opslag, der ikke sker, er et tal.
*/
test('en ny note hentes ikke igen, når den lige er lavet', async ({ page }) => {
const trouble = watchForTrouble(page);
const fetched = [];
page.on('request', (r) => {
// Selve noten, ikke dens delinger: /notes/<id> og ikke /notes/<id>/shares.
const m = new URL(r.url()).pathname.match(/^\/api\/v1\/notes\/([0-9a-f-]+)$/);
if (r.method() === 'GET' && m) fetched.push(m[1]);
});
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
// Editoren er der, og den er tom — ikke væk og på vej tilbage.
const body = page.getByLabel('Notens tekst');
await expect(body).toHaveText('');
await body.fill('Grantræ\n\nnoget om det');
await body.blur();
await expect(page.locator('.list').getByText('Grantræ', { exact: true })).toBeVisible();
// Noten blev aldrig hentet igen: svaret på oprettelsen var den hele.
expect(fetched).toEqual([]);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
@@ -4821,3 +4874,77 @@ test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Søgefeltet taber ikke bogstaver, og listen viser det, der blev spurgt om.
*
* Fra en skærmoptagelse, to billeder i sekundet:
*
* 2,5 s feltet "verd" listen ufiltreret
* 3,0 s feltet "nde" listen filtreret
* 6,0 s feltet "verdande" listen ufiltreret
* 6,5 s feltet tomt listen filtreret
*
* To fejl oven i hinanden.
*
* Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk:
* `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, så en effekt
* læste den forrige adresse og skrev den gamle værdi tilbage i feltet.
*
* Og resultaterne landede for sent, fordi to hentninger kunne være undervejs på
* én gang uden at nogen holdt styr på, hvilken der gjaldt. Den langsomste vandt.
*/
test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Sin egen note at finde. Et ord fra en anden prøve ville gøre den her
// afhængig af rækkefølgen, og den kobling har allerede narret mig i denne fil.
await page.getByRole('button', { name: 'Ny note' }).click();
const krop = page.getByLabel('Notens tekst');
await krop.fill('Kaffemølle\n\nnoget om den');
await krop.blur();
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
// afslørede det: en pause mellem hvert bogstav skjuler kapløbet helt.
const felt = page.getByPlaceholder('Søg i noter');
await felt.click();
await page.keyboard.type('kaffemølle', { delay: 40 });
await expect(felt).toHaveValue('kaffemølle');
// Og det bliver stående. Fejlen viste sig et halvt sekund efter, ikke med det
// samme — adressen skal nå at komme tilbage, før den kan skrive over noget.
await page.waitForTimeout(1500);
await expect(felt).toHaveValue('kaffemølle');
// Det nyeste svar vinder, også når det kommer først.
//
// Den første søgning holdes tilbage, den anden slippes igennem. Uden vagten
// lander den gamle sidst og overskriver listen med svaret på et spørgsmål, der
// ikke længere bliver stillet.
let held;
await page.route('**/api/v1/notes?*', async (route) => {
const q = new URL(route.request().url()).searchParams.get('q');
if (q === 'aaaaa' && !held) {
held = route;
return; // holdes, indtil den næste er kommet igennem
}
await route.continue();
});
await felt.fill('');
await felt.fill('aaaaa');
await page.waitForTimeout(500);
await felt.fill('kaffemølle');
await page.waitForTimeout(900);
if (held) await held.continue();
await page.waitForTimeout(700);
// Feltet og listen er enige: det, der står, er det, der blev spurgt om.
await expect(felt).toHaveValue('kaffemølle');
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
+49 -37
View File
@@ -15,7 +15,7 @@
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { untrack } from 'svelte';
import { goto, replaceState } from '$app/navigation';
import { goto } from '$app/navigation';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
import { colorVar } from '$lib/colors.js';
@@ -485,48 +485,32 @@
});
/**
* En søgning, man kommer med, og en søgning, man kan gå videre med.
* En søgning, man kommer med.
*
* ⌘K viste noter, der passede, i sin egen liste — og når man valgte en, åbnede
* den noten på en side, hvis liste stod uændret. Så var de andre træffere væk,
* og det eneste sted, de havde været, var en popup, der lige var lukket. "Jeg
* får kun præsenteret en popup med resultater, og trykker jeg retur, kommer jeg
* tilbage til den normale liste."
* ⌘K sender `?note=…&q=…`, så listen ved siden af den åbne note er de andre
* træffere frem for den almindelige liste.
*
* Nu bærer adressen søgningen, så listen ved siden af den åbne note er de andre
* træffere. Og den skrives tilbage, når feltet ændres, så adressen kan gemmes og
* deles — og frem for alt ikke lyver, når man har tømt feltet.
* Læst, aldrig skrevet. Den første udgave skrev også søgningen tilbage i
* adressen ved hvert tastetryk, så den kunne gemmes og deles — og det kostede
* bogstaver. `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes,
* så to hurtige tastetryk gav en effekt, der læste den *forrige* adresse, fandt
* den uenig med det skrevne, og skrev den gamle værdi tilbage i feltet. Man
* skrev "verd", og der stod "nde". Målt på en skærmoptagelse, ikke gættet.
*
* `settled` er det, de to retninger er blevet enige om — og den læses med
* `untrack`, hvilket er hele forskellen på at virke og ikke.
*
* Uden den: den nederste effekt sætter `settled`, det vækker den øverste, og
* `replaceState` har endnu ikke opdateret `$page.url`. Den øverste læser derfor
* den *gamle* adresse, finder en søgning, der ikke er enig med `settled`, og
* skriver den tilbage i feltet. Man tømmer søgefeltet, og ordet kommer igen.
* Hver effekt skal reagere på sin egen kilde — adressen og feltet — og aldrig
* på den fælles markør.
* En markør, der skal holde to retninger enige, er en markør, der taber
* kapløbet et sted. Adressen skrives derfor kun af navigation, og det her er
* den eneste, der læser den.
*/
let settled = $state(null);
let cameWith = $state(null);
$effect(() => {
const inURL = $page.url.searchParams.get('q') ?? '';
if (inURL === untrack(() => settled)) return;
settled = inURL;
if (inURL === untrack(() => cameWith)) return;
cameWith = inURL;
draftQuery = inURL;
query = inURL;
});
$effect(() => {
const now = query;
if (now === untrack(() => settled)) return;
settled = now;
const url = new URL(untrack(() => $page.url));
if (now) url.searchParams.set('q', now);
else url.searchParams.delete('q');
replaceState(url, {});
});
// Arriving from somewhere that names a note — a task's panel, a project's page —
// opens it rather than dropping the person on a list to find it again.
let asked = $derived($page.url.searchParams.get('note'));
@@ -537,13 +521,30 @@
else api.note(asked).then(open).catch(() => {});
});
/**
* Hvilken hentning der gælder.
*
* To søgninger kan være undervejs på én gang, og den langsomste kan lande
* sidst. Uden det her betød det, at listen viste svaret på et spørgsmål, der
* ikke længere blev stillet — på optagelsen stod resultaterne for "verdande" i
* listen, efter feltet var tømt. Pausen på 250 ms gjorde det sjældnere og kunne
* ikke gøre det umuligt.
*/
let newest = 0;
async function load(q) {
const mine = ++newest;
try {
const params = q ? { q } : showArchive ? { archived: '1' } : {};
notes = (await api.notes(params)).notes ?? [];
const got = (await api.notes(params)).notes ?? [];
// Et svar, der er blevet overhalet, kastes væk. Det er ikke forkert —
// det er bare ikke længere svaret på det, der blev spurgt om.
if (mine !== newest) return;
notes = got;
status = 'ready';
if (selectedId && !notes.some((n) => n.id === selectedId)) open(notes[0] ?? null);
} catch (e) {
if (mine !== newest) return;
status = 'failed';
app.toast(humanMessage(e));
}
@@ -560,7 +561,17 @@
* 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) {
/**
* `whole` siger, at noten allerede er hel, og at der ikke skal hentes.
*
* En tom krop bruges ellers som tegn på, at det her er en listerække — serveren
* tømmer `body` på dem med vilje. Men en *nyoprettet* note er også tom, og den
* er hel. Uden det her blev den lukket ud som en halv note: editoren blev pillet
* af skærmen, noten hentet igen, og editoren sat op på ny. I det hul står
* `selected` som null, og `save()` går tavst tilbage uden at gemme — så tekst
* skrevet der forsvandt sporløst.
*/
async function open(note, { whole = false } = {}) {
clearTimeout(timer);
if (!note) {
selectedId = null;
@@ -576,9 +587,9 @@
// 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) {
if (whole || note.body) {
selected = note;
draft = note.body;
draft = note.body ?? '';
return;
}
selected = null;
@@ -690,7 +701,8 @@
try {
const note = await api.createNote({ body: '' });
notes = [note, ...notes];
open(note);
// Svaret er hele noten, så den skal ikke hentes igen — se open().
await open(note, { whole: true });
document.querySelector('.editor textarea')?.focus();
} catch (e) {
app.toast(humanMessage(e));