Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 84af63316d feat(net): et udfald siger "ingen forbindelse" frem for at bede om kodeordet
Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står
`user` som null — og null betyder "logget ud" alle andre steder i
programmet. Så et totalt udfald blev vist som login-skærmen: appen gav
sessionen skylden for, at nettet var nede, og tilbød et kodeordsfelt, der
umuligt kunne virke.

Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke.
Man taster kodeordet, der sker ingenting, og man konkluderer, at kontoen
er i stykker. Det skete i virkeligheden 17. september 2026, da instansens
DNS-post blev slettet: det, appen viste, var en langsom login-side.

Forskellen var der allerede i forespørgselslaget. Et netværkssvigt, der
har brugt sit forsøgsvindue op, kastes som `ApiError(0, 'offline')` —
koden laves af klienten selv, netop fordi der aldrig kom en status. En 401
er serveren, der svarer, og det er en rigtig udlogning. Butikken skelner nu
på den kode, og fladen viser NoConnection i stedet for SignIn.

Teksten siger, at data er i behold, for det er det spørgsmål, man faktisk
har, når ens noter forsvinder bag en login-boks.

Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt
forespørgsel er præcis det, en manglende DNS-post giver browseren.
Efterprøvet mod den gamle kode — den fejler på "Ingen forbindelse".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:29:57 +02:00
Kristian Wind 930522ecf6 Merge pull request #86 from kristianwind/ny-note-taber-ikke-tekst
fix(noter): en ny note taber ikke det, man skriver i den
2026-09-17 12:29:49 +02:00
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
kristianwindandClaude Opus 5 0110a86858 fix(opgave): teksten kan læses færdig, også på en telefon
Rapporteret med et billede af en beskrivelse, der manglede sit første bogstav på
hver linje: "hermostat RU0910301440" og "atteries". Det er ikke manglende tegn —
det er et felt, der er rullet til højre. Og det ligner ikke rulning; det ligner,
at bogstaverne ikke er der.

To ting på én skærm.

Titlen var et <input>, og et input kan ikke ombryde. "tado°: Your Smart
Thermostat needs new batteries" er en almindelig opgavetitel, og på 390 px var
feltet 490 px bredt i en kasse på 356 — halvdelen af overskriften stod uden for
kanten. Den er nu et <textarea>, der vokser med sin tekst. Retur gemmer frem for
at lave en ny linje: en titel er én linje, uanset hvor mange den fylder på
skærmen. Trækkehåndtaget er væk, for højden sættes af teksten, og et håndtag
ville love noget, det næste bogstav retter tilbage.

Beskrivelsen er tit et klippet stykke mail med en URL i. Et <textarea> ombryder
ved mellemrum, og en URL har ingen.

`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan stå på
en linje alene, mens den anden bryder ved først givne lejlighed og delte
"GarageRisteriet" midt i en stavelse. Den lektion står allerede ved h1 på
projektsiden og er betalt én gang.

Prøven måler frem for at beskrive: et felt, der ruller vandret, har en
scrollWidth større end sin clientWidth. Kørt mod den gamle komponent fejler den
med "Opgavens tekst: 490 > 356".

Men kun den ene halvdel. Beskrivelsen løber ikke over i Chromium, heller ikke
uden reglen — det var Safari, den blev set i. Prøven måler derfor den regel frem
for dens virkning, og det står skrevet i prøven, så den næste ved, hvad den
vogter og hvad den ikke gør.

Bemærket undervejs: "en delt note tager de noter med, den peger på" fejlede én
gang ud af to fulde kørsler med "Delt med Mikkel." uden antallet. Den består
alene og i næste fulde kørsel. Den er ikke rørt her, men den flakker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:12:28 +02:00
9 changed files with 474 additions and 47 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>
+235 -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,94 @@ 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 totalt udfald siger "ingen forbindelse" — ikke "log ind".
*
* Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står `user`
* som null, og null betyder "logget ud" alle andre steder i programmet — så et
* udfald blev vist som login-skærmen. Appen gav altså sessionen skylden for, at
* nettet var nede, og tilbød et kodeordsfelt, der umuligt kunne virke.
*
* Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke: man
* taster kodeordet, der sker ingenting, og man konkluderer, at kontoen er i
* stykker. Det skete i virkeligheden 17. september 2026, da instansens DNS-post
* blev slettet.
*
* Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt forespørgsel
* er præcis det, en manglende DNS-post giver browseren — fetch afviser, uden at der
* nogensinde kom en status. En 500 ville være serveren, der svarer, og det er den
* anden sag.
*/
test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({ page }) => {
await page.route('**/api/v1/**', (route) => route.abort());
await page.goto('/');
// Prøvetiden skal kunne rumme hele forsøgsvinduet: forespørgselslaget prøver
// igen i femten sekunder, før det kalder det et udfald. Det er med vilje —
// et blink på nettet må ikke smide folk ud — så prøven må vente det ud.
await expect(page.getByText('Ingen forbindelse', { exact: true })).toBeVisible({
timeout: 25000
});
// Og ikke kodeordsfeltet. Det er hele pointen.
await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
@@ -4755,3 +4842,143 @@ test('et indsat billede kan åbnes stort og kopieres', async ({ page, context })
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
* tit et klippet stykke mail med en URL i, et <textarea> ombryder ved mellemrum,
* og en URL har ingen.
*
* Og titlen var et <input>, som ikke kan ombryde overhovedet. "tado°: Your Smart
* Thermostat needs new batteries" er en almindelig opgavetitel, og halvdelen af
* den stod uden for kanten.
*/
test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const langTitel = 'tado°: Your Smart Thermostat needs new batteries';
// "i dag" for at den lander på siden, prøven står på — parseren klipper det ud
// af titlen igen, så den hedder præcis det, den skal.
await page.getByLabel('Ny opgave').fill(`${langTitel} i dag`);
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('tado°', { exact: false }).first().click();
const titel = page.getByLabel('Opgavens tekst');
await expect(titel).toBeVisible();
await expect(titel).toHaveValue(langTitel);
// Beskrivelsen: en URL uden et eneste mellemrum at bryde ved.
const url = 'https://link.tado.com/emails/8/ttid/1-a37f2c9b4e6d8a1f0c3b5e7d9a2f4c6b8e0d1a3f5c7b9e2d4a6f8c0b3e5d7a9f';
const beskrivelse = page.getByLabel('Beskrivelse');
await beskrivelse.fill(`Thermostat RU0910301440\nBatteries\n${url}`);
await beskrivelse.blur();
// Målt frem for beskrevet: et felt, der ruller vandret, har en scrollWidth
// større end sin clientWidth. Det var dét, der klippede venstre kant.
//
// Felterne måles gennem deres egne lokatorer og ikke med en forespørgsel på
// aria-label: beskrivelsen har et <label for>, ikke et aria-label, så en
// forespørgsel fandt ingenting og ville have bestået tomt.
const spild = [];
for (const [navn, felt] of [
['Opgavens tekst', titel],
['Beskrivelse', beskrivelse]
]) {
const m = await felt.evaluate((el) => ({ ind: el.scrollWidth, ude: el.clientWidth }));
if (m.ind - m.ude > 1) spild.push(`${navn}: ${m.ind} > ${m.ude}`);
}
expect(spild).toEqual([]);
// Beskrivelsen løber ikke over i Chromium, heller ikke uden reglen — det var
// Safari, den blev set i, og det er en anden motor. Prøven måler derfor reglen
// selv frem for dens virkning. Det er svagere, og det står her, så den næste
// ved det: den vogter mod at reglen bliver slettet, ikke mod at den er forkert.
await expect(beskrivelse).toHaveCSS('overflow-wrap', 'break-word');
// Og titlen er blevet høj nok til at rumme sig selv frem for at klippe.
const linjer = await titel.evaluate((el) => ({
hoejde: el.clientHeight,
linje: parseFloat(getComputedStyle(el).lineHeight)
}));
expect(linjer.hoejde).toBeGreaterThan(linjer.linje * 1.5);
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([]);
});
@@ -0,0 +1,95 @@
<script>
/**
* What the app shows when it could not reach the server at all.
*
* Before this, an outage rendered the sign-in screen: the boot asks who you
* are, the request never gets an answer, `user` stays null, and null means
* "logged out" everywhere else. So the app blamed the session for the network
* being down, and offered a password box that could not possibly work. That is
* worse than an error message, because it looks like it might work — people
* type the password, get nothing, and conclude their account is broken.
*
* It happened for real on 17 September 2026: the instance's DNS record was
* deleted, and what the app showed was a slow-loading login page.
*
* The text says the data is safe, because that is the question somebody
* actually has when their notes vanish behind a login box.
*/
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
let trying = $state(false);
async function again() {
trying = true;
try {
await app.load();
} finally {
trying = false;
}
}
</script>
<div class="screen">
<div class="card">
<div class="brand">
<span class="rune" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
</div>
<h2>{t('net.downTitle')}</h2>
<p class="lede">{t('net.downBody')}</p>
<button onclick={again} disabled={trying}>
{trying ? t('net.reconnecting') : t('net.downRetry')}
</button>
</div>
</div>
<style>
.screen {
height: 100dvh;
display: grid;
place-items: center;
padding: var(--s4);
background: var(--ground);
}
.card {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: var(--s4);
text-align: center;
}
.brand {
display: flex;
align-items: center;
gap: var(--s3);
justify-content: center;
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
line-height: 1;
}
h1 {
font-size: var(--text-xl);
margin: 0;
}
h2 {
font-size: var(--text-lg);
margin: 0;
}
.lede {
margin: 0;
color: var(--ink-soft);
line-height: 1.5;
}
</style>
+56 -2
View File
@@ -171,6 +171,32 @@
* took is said out loud, because a title that edits itself without a word is
* worse than one that does nothing.
*/
/**
* Lader et felt være lige så højt som sin tekst.
*
* `height: auto` først, og så `scrollHeight`: uden nulstillingen kan feltet kun
* vokse, for scrollHeight på et felt, der allerede er for højt, er dets egen
* højde.
*/
function grow(node) {
const fit = () => {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
};
fit();
node.addEventListener('input', fit);
// Bredden skifter, når ruden gør — og en tekst, der fyldte to linjer, fylder
// tre på en smallere skærm uden at der er rørt ved den.
const watch = new ResizeObserver(fit);
watch.observe(node);
return {
destroy() {
node.removeEventListener('input', fit);
watch.disconnect();
}
};
}
async function saveContent() {
if (!task) return;
const trimmed = content.trim();
@@ -418,13 +444,27 @@
<div class="scroll">
<div class="field">
<input
<!-- Et felt frem for en linje. En opgavetitel er tit længere end en telefon
er bred — "tado°: Your Smart Thermostat needs new batteries" er en
almindelig en — og et <input> kan ikke ombryde: det ruller, og så står
halvdelen af overskriften uden for kanten. -->
<textarea
class="title"
rows="1"
use:focusOnMount
use:grow
bind:value={content}
onblur={saveContent}
onkeydown={(e) => {
// Retur gemmer frem for at lave en ny linje. En titel er én linje,
// uanset hvor mange den fylder på skærmen.
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.blur();
}
}}
aria-label={t('detail.text')}
/>
></textarea>
</div>
<div class="field">
@@ -894,6 +934,16 @@
resize: vertical;
font-family: inherit;
line-height: 1.5;
/* En beskrivelse er tit et klippet stykke mail med en URL i. Et <textarea>
ombryder ved mellemrum, og en URL har ingen — så feltet rullede vandret,
og på en telefon stod teksten forskudt med venstre kant klippet af. Det
ser ikke ud som rulning; det ser ud som manglende bogstaver.
`break-word` og ikke `anywhere`: den første bryder kun et ord, der ikke kan
stå på en linje alene, mens den anden bryder ved først givne lejlighed og
delte "GarageRisteriet" midt i en stavelse. Se h1 på projektets side — den
lektion er betalt én gang. */
overflow-wrap: break-word;
}
/* The title is the one field that should not look like a field until you are in
@@ -905,6 +955,10 @@
background: transparent;
border-color: transparent;
padding-left: 0;
/* Højden sættes af `grow`, så håndtaget ville love noget, det ikke kan
holde: trækker man i det, retter det næste bogstav det tilbage. */
resize: none;
overflow: hidden;
}
.title:hover {
+3
View File
@@ -237,6 +237,9 @@ export const da = {
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
'net.reconnecting': 'Genopretter forbindelse…',
'net.offline': 'Ingen forbindelse',
'net.downTitle': 'Ingen forbindelse',
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
'net.downRetry': 'Prøv igen',
'nav.doItToday': 'Gør det i dag',
// --- the sidebar's own sections ------------------------------------------------
+3
View File
@@ -234,6 +234,9 @@ export const en = {
'nav.offlineHint': 'Changes from other people are not showing right now',
'net.reconnecting': 'Reconnecting…',
'net.offline': 'No connection',
'net.downTitle': 'No connection',
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
'net.downRetry': 'Try again',
'nav.doItToday': 'Do it today',
// --- the sidebar's own sections ------------------------------------------------
+19
View File
@@ -29,6 +29,20 @@ class AppState {
people = $state([]);
tasks = $state([]);
loading = $state(true);
/**
* True when the boot never reached the server — as opposed to reaching it and
* being told the session is gone.
*
* The two are indistinguishable from `user === null`, and they are opposite
* things to the person looking at the screen: one means "log ind", the other
* means "der er intet at logge ind på herfra, og et kodeord hjælper ikke". A
* total outage used to show the login screen, so the app blamed the user's
* session for the network being down — and a login page that cannot possibly
* work is worse than an error, because it looks like it might.
*/
unreachable = $state(false);
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
@@ -96,6 +110,7 @@ class AppState {
this.loading = true;
try {
this.user = await api.me();
this.unreachable = false;
// The interface's language follows the account, so it is set the moment
// the session is known — before anything has drawn a string.
i18n.set(this.user.locale);
@@ -111,6 +126,10 @@ class AppState {
this.loadNotifications();
} catch (e) {
this.user = null;
// `offline` is the one code the request layer makes up itself: it is what
// is thrown when the retry window ran out without any status ever
// arriving. A 401 is the server answering, and that is a real sign-out.
this.unreachable = e?.code === 'offline';
} finally {
this.loading = false;
}
+5
View File
@@ -8,6 +8,7 @@
import Sidebar from '$lib/components/Sidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import NoConnection from '$lib/components/NoConnection.svelte';
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
@@ -166,6 +167,10 @@
<div class="booting"></div>
{:else if standalone}
{@render children()}
{:else if app.unreachable}
<!-- Ikke login-skærmen: en total udfald er ikke en udløbet session, og et
kodeord hjælper ikke mod en manglende forbindelse. -->
<NoConnection />
{:else if !app.user}
<SignIn />
{:else}
+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));