Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dba99b7ecc | ||
|
|
f34690f41a | ||
|
|
1bb2c9cda6 | ||
|
|
cac6c14a23 | ||
|
|
f632fb8643 | ||
|
|
eabb00ecc2 | ||
|
|
d2b5821e57 | ||
|
|
4874f7bbd8 | ||
|
|
8bf4f8fbe4 | ||
|
|
84af63316d | ||
|
|
930522ecf6 | ||
|
|
9ed6f49589 | ||
|
|
de673260a3 |
@@ -819,6 +819,15 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+363
-19
@@ -38,6 +38,61 @@ async function projectAction(page, name) {
|
|||||||
await item.click();
|
await item.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Venter på, at en gemning faktisk er nået frem til serveren.
|
||||||
|
*
|
||||||
|
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
|
||||||
|
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
|
||||||
|
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
|
||||||
|
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
|
||||||
|
* og fejler på noget, der ikke er i stykker.
|
||||||
|
*
|
||||||
|
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
|
||||||
|
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
|
||||||
|
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
|
||||||
|
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
|
||||||
|
*
|
||||||
|
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
|
||||||
|
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
|
||||||
|
*
|
||||||
|
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
|
||||||
|
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
|
||||||
|
* allerede kommet og gået.
|
||||||
|
*/
|
||||||
|
function gemt(side, sti) {
|
||||||
|
return side.waitForResponse(
|
||||||
|
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
|
||||||
|
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
|
||||||
|
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
|
||||||
|
*
|
||||||
|
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
|
||||||
|
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
|
||||||
|
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
|
||||||
|
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
|
||||||
|
* findes den titel, prøven leder efter, slet ikke.
|
||||||
|
*
|
||||||
|
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
|
||||||
|
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
|
||||||
|
*
|
||||||
|
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
|
||||||
|
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
|
||||||
|
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
|
||||||
|
*/
|
||||||
|
async function nyNote(side) {
|
||||||
|
await side.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(felt).toHaveText('');
|
||||||
|
await felt.click();
|
||||||
|
return felt;
|
||||||
|
}
|
||||||
|
|
||||||
function watchForTrouble(page) {
|
function watchForTrouble(page) {
|
||||||
const trouble = [];
|
const trouble = [];
|
||||||
page.on('console', (message) => {
|
page.on('console', (message) => {
|
||||||
@@ -1683,6 +1738,11 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
|||||||
// HTML5 drag occasionally does not register the first time under CI load, and a
|
// HTML5 drag occasionally does not register the first time under CI load, and a
|
||||||
// single dragTo then leaves the group empty and the page assertion failing for a
|
// single dragTo then leaves the group empty and the page assertion failing for a
|
||||||
// reason that has nothing to do with what the test is about.
|
// reason that has nothing to do with what the test is about.
|
||||||
|
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
|
||||||
|
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
|
||||||
|
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
|
||||||
|
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
|
||||||
|
const flyttet = projektGemt(page);
|
||||||
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await sidebar
|
await sidebar
|
||||||
@@ -1690,6 +1750,7 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
|||||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||||
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
await expect(nested).toHaveCount(1, { timeout: 1000 });
|
||||||
}).toPass({ timeout: 10000 });
|
}).toPass({ timeout: 10000 });
|
||||||
|
await flyttet;
|
||||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||||
@@ -1703,8 +1764,21 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
|||||||
const about = page.getByLabel('Om gruppen');
|
const about = page.getByLabel('Om gruppen');
|
||||||
await expect(about).toBeVisible();
|
await expect(about).toBeVisible();
|
||||||
await about.fill('Alt der larmer og støver.');
|
await about.fill('Alt der larmer og støver.');
|
||||||
|
|
||||||
|
// Gemningen skal være nået frem, FØR der genindlæses. Beskrivelsen tegnes
|
||||||
|
// optimistisk — siden sætter teksten på gruppen med det samme og sender PATCH'en
|
||||||
|
// bagefter — så en kontrol på teksten er grøn, før serveren har hørt om den. En
|
||||||
|
// reload i det hul henter den gamle gruppe tilbage, og prøven fejler på noget,
|
||||||
|
// der ikke er i stykker. Svaret er det, der kan ventes på.
|
||||||
|
const gemt = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.request().method() === 'PATCH' &&
|
||||||
|
new URL(r.url()).pathname.includes('/project-groups/') &&
|
||||||
|
r.ok()
|
||||||
|
);
|
||||||
await about.blur();
|
await about.blur();
|
||||||
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
||||||
|
await gemt;
|
||||||
|
|
||||||
// And it survives a reload, which is the difference between the page keeping up
|
// And it survives a reload, which is the difference between the page keeping up
|
||||||
// and the server taking it.
|
// and the server taking it.
|
||||||
@@ -2724,7 +2798,9 @@ test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilb
|
|||||||
// And the round trip, which is the thing that can quietly ruin a note: what is
|
// And the round trip, which is the thing that can quietly ruin a note: what is
|
||||||
// stored is Markdown, and reopening it has to give back the same document. A
|
// stored is Markdown, and reopening it has to give back the same document. A
|
||||||
// converter that disagrees with itself reshapes a note a little on every save.
|
// converter that disagrees with itself reshapes a note a little on every save.
|
||||||
|
const rundtur = noteGemt(page);
|
||||||
await page_.blur();
|
await page_.blur();
|
||||||
|
await rundtur;
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole('button', { name: /Møde med Anders/ }).click();
|
await page.getByRole('button', { name: /Møde med Anders/ }).click();
|
||||||
|
|
||||||
@@ -2911,8 +2987,9 @@ test('note links i en delt note fører til den rigtige note, eller siger hvorfor
|
|||||||
await body.click();
|
await body.click();
|
||||||
await gæst.keyboard.press('Control+End');
|
await gæst.keyboard.press('Control+End');
|
||||||
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
|
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
|
||||||
|
const gemt = noteGemt(gæst);
|
||||||
await body.blur();
|
await body.blur();
|
||||||
await expect(gæst.locator('footer .hint')).toHaveText('Gemt');
|
await gemt;
|
||||||
|
|
||||||
await gæst.goto(noterURL);
|
await gæst.goto(noterURL);
|
||||||
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
|
||||||
@@ -2975,10 +3052,33 @@ test('en delt note tager de noter med, den peger på', async ({ browser, page })
|
|||||||
'Aftale om levering\nse [[Prisliste i kroner]]'
|
'Aftale om levering\nse [[Prisliste i kroner]]'
|
||||||
]) {
|
]) {
|
||||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
// Tomt felt før der skrives, og rækken i listen bagefter. IKKE "Gemt" i
|
||||||
|
// foden: den tekst er hvilestanden — der står "Gemmer" mens en gemning
|
||||||
|
// kører og "Gemt" ellers, altså også før den første er begyndt. En ventetid
|
||||||
|
// på den er grøn med det samme og venter på ingenting.
|
||||||
|
await expect(felt).toHaveText('');
|
||||||
|
await felt.click();
|
||||||
await page.keyboard.type(text);
|
await page.keyboard.type(text);
|
||||||
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
await felt.blur();
|
||||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
const titel = text.split('\n')[0];
|
||||||
|
// Rækkens egen titel, og kun den. To fælder ligger på vejen hertil, og jeg
|
||||||
|
// gik i begge:
|
||||||
|
//
|
||||||
|
// `filter({ hasText: 'Aftale om levering' })` med en STRENG matcher
|
||||||
|
// delstreng og uden hensyn til store og små bogstaver — den fandt også
|
||||||
|
// "Fælles aftale om leveringen" fra en tidligere prøve, for databasen deles
|
||||||
|
// af hele suiten.
|
||||||
|
//
|
||||||
|
// `.notes` alene rækker for langt: en `[[henvisning]]` i editoren er også
|
||||||
|
// tekst med den titel i, så et opslag på teksten fandt tre.
|
||||||
|
//
|
||||||
|
// `button.row strong` er rækkens titelelement, og en forankret regex er både
|
||||||
|
// eksakt og versalfølsom. Titlerne her er almindelige ord uden
|
||||||
|
// regex-tegn — står der en dag en parentes i en af dem, skal den undviges.
|
||||||
|
await expect(
|
||||||
|
page.locator('.notes button.row strong').filter({ hasText: new RegExp(`^${titel}$`) })
|
||||||
|
).toHaveCount(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
|
||||||
@@ -3064,8 +3164,9 @@ test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ p
|
|||||||
|
|
||||||
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
|
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
|
||||||
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
|
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
|
||||||
|
const kildeGemt = noteGemt(page);
|
||||||
await body.blur();
|
await body.blur();
|
||||||
await expect(page.locator('footer .hint')).toHaveText('Gemt');
|
await kildeGemt;
|
||||||
await page.getByRole('button', { name: /Vis kilden/ }).click();
|
await page.getByRole('button', { name: /Vis kilden/ }).click();
|
||||||
await expect(page.locator('textarea.source')).toHaveValue(/`/);
|
await expect(page.locator('textarea.source')).toHaveValue(/`/);
|
||||||
|
|
||||||
@@ -3137,8 +3238,11 @@ test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
|||||||
await expect(inline).toBeVisible();
|
await expect(inline).toBeVisible();
|
||||||
|
|
||||||
// It survives the round-trip through Markdown and a reload.
|
// It survives the round-trip through Markdown and a reload.
|
||||||
|
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
|
||||||
|
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
|
||||||
|
const linketGemt = noteGemt(page);
|
||||||
await ed.blur();
|
await ed.blur();
|
||||||
await page.waitForTimeout(900);
|
await linketGemt;
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole('button', { name: /Mine planer/ }).click();
|
await page.getByRole('button', { name: /Mine planer/ }).click();
|
||||||
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
|
||||||
@@ -3675,9 +3779,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
|||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/noter');
|
await page.goto('/noter');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
const ed = await nyNote(page);
|
||||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
|
||||||
await ed.click();
|
|
||||||
await page.keyboard.type('Titel');
|
await page.keyboard.type('Titel');
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
@@ -3713,8 +3815,7 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
|||||||
|
|
||||||
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
|
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
|
||||||
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
|
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
|
||||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
await nyNote(page);
|
||||||
await ed.click();
|
|
||||||
await page.keyboard.type('Rundtur');
|
await page.keyboard.type('Rundtur');
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.getByRole('button', { name: 'Formatér' }).click();
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
@@ -3722,8 +3823,9 @@ test('en monospace-linje kan forlades — på retur og gennem menuen', async ({
|
|||||||
await page.keyboard.type('docker pull');
|
await page.keyboard.type('docker pull');
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.keyboard.type('og så videre i almindelig skrift');
|
await page.keyboard.type('og så videre i almindelig skrift');
|
||||||
|
const rundturGemt = noteGemt(page);
|
||||||
await ed.blur();
|
await ed.blur();
|
||||||
await page.waitForTimeout(1200);
|
await rundturGemt;
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole('button', { name: /Rundtur/ }).click();
|
await page.getByRole('button', { name: /Rundtur/ }).click();
|
||||||
await expect(ed.locator('pre')).toContainText('docker pull');
|
await expect(ed.locator('pre')).toContainText('docker pull');
|
||||||
@@ -4025,9 +4127,10 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
|
|||||||
// Og datoen kan rettes bagefter uden at tage timen med sig.
|
// Og datoen kan rettes bagefter uden at tage timen med sig.
|
||||||
const date = page.locator('#due');
|
const date = page.locator('#due');
|
||||||
const day = await date.inputValue();
|
const day = await date.inputValue();
|
||||||
|
const datoGemt = opgaveGemt(page);
|
||||||
await date.fill(day);
|
await date.fill(day);
|
||||||
await date.blur();
|
await date.blur();
|
||||||
await page.waitForTimeout(400);
|
await datoGemt;
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByText('ringe til tandlægen').click();
|
await page.getByText('ringe til tandlægen').click();
|
||||||
await expect(
|
await expect(
|
||||||
@@ -4657,16 +4760,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
|
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
|
||||||
// Gem-knap at huske.
|
// Gem-knap at huske.
|
||||||
//
|
//
|
||||||
// Der ventes på "Gemt", før den næste note laves. Uden det klikkes "Ny note",
|
// Der ventes på et TOMT tekstfelt, før der skrives. Uden det kan udfyldningen
|
||||||
// mens den forrige stadig er undervejs, og listen når ikke at få den med — det
|
// ramme den forrige notes felt, mens den nye er undervejs, og så bliver teksten
|
||||||
// fejlede i CI én gang ud af tre kørsler, og bestod hver gang her på maskinen.
|
// enten skrevet på den forkerte note eller tabt undervejs.
|
||||||
// En prøve, der er hurtigere end programmet, måler maskinen den kører på.
|
//
|
||||||
|
// 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']) {
|
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
|
||||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
const body = page.getByLabel('Notens tekst');
|
const body = page.getByLabel('Notens tekst');
|
||||||
|
await expect(body).toHaveText('');
|
||||||
await body.fill(`${title}\n\nnoget om den`);
|
await body.fill(`${title}\n\nnoget om den`);
|
||||||
await body.blur();
|
await body.blur();
|
||||||
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
|
|
||||||
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
|
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4687,6 +4797,24 @@ 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('Fyrreskov syd')).toBeVisible();
|
||||||
await expect(list.getByText('Egeskov')).toHaveCount(0);
|
await expect(list.getByText('Egeskov')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
|
||||||
|
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
|
||||||
|
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
|
||||||
|
// tilbage til den første. Man kunne søge og ikke vælge.
|
||||||
|
await list.getByText('Fyrreskov syd').click();
|
||||||
|
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
|
||||||
|
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
|
||||||
|
|
||||||
|
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
|
||||||
|
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
|
||||||
|
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
|
||||||
|
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
|
||||||
|
// kunne så ikke komme tilbage til den note, man kom fra.
|
||||||
|
await page.getByRole('button', { name: /Søg/ }).click();
|
||||||
|
await palette.getByRole('textbox').fill('Fyrreskov nord');
|
||||||
|
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
|
||||||
|
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
|
||||||
|
|
||||||
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
|
// 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
|
// 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
|
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
|
||||||
@@ -4696,6 +4824,222 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klokkens panel bliver inden for skærmen på en telefon.
|
||||||
|
*
|
||||||
|
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
|
||||||
|
* kant end det — der er en temaknap og en kantafstand til højre for den — så
|
||||||
|
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
|
||||||
|
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
|
||||||
|
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
|
||||||
|
* den.
|
||||||
|
*
|
||||||
|
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
|
||||||
|
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
|
||||||
|
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
|
||||||
|
* det stikke ud til højre, er ikke en rettelse.
|
||||||
|
*/
|
||||||
|
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Beskeder/ }).click();
|
||||||
|
const panel = page.getByRole('dialog', { name: 'Beskeder' });
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
|
||||||
|
const kasse = await panel.boundingBox();
|
||||||
|
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
|
||||||
|
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
|
||||||
|
|
||||||
|
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
|
||||||
|
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
|
||||||
|
// ud på telefonen.
|
||||||
|
const overskrift = panel.getByText('Beskeder', { exact: true });
|
||||||
|
const tekst = await overskrift.boundingBox();
|
||||||
|
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En punktliste kan have niveauer, og de overlever gemningen.
|
||||||
|
*
|
||||||
|
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
|
||||||
|
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
|
||||||
|
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
|
||||||
|
*
|
||||||
|
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
|
||||||
|
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
|
||||||
|
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
|
||||||
|
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
|
||||||
|
*
|
||||||
|
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
|
||||||
|
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
|
||||||
|
*/
|
||||||
|
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
await nyNote(page);
|
||||||
|
await page.keyboard.type('Indkøb');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
|
||||||
|
await page.keyboard.type('Frugt');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Aebler');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
// Niveauet er der med det samme: en liste inde i en liste.
|
||||||
|
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
|
||||||
|
|
||||||
|
const gemt = noteGemt(page);
|
||||||
|
await ed.blur();
|
||||||
|
await gemt;
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Indkøb/ }).click();
|
||||||
|
|
||||||
|
// Og efter turen gennem Markdown står det der stadig.
|
||||||
|
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
|
||||||
|
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
|
||||||
|
|
||||||
|
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
|
||||||
|
await ed.locator('ul ul li').click();
|
||||||
|
await page.keyboard.press('Shift+Tab');
|
||||||
|
await expect(ed.locator('ul ul li')).toHaveCount(0);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En kodeblok med flere linjer kommer tilbage med dem alle.
|
||||||
|
*
|
||||||
|
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
|
||||||
|
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
|
||||||
|
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
|
||||||
|
* skrive flere linjer og ikke gemme dem.
|
||||||
|
*
|
||||||
|
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
|
||||||
|
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
|
||||||
|
* ovenfor stopper lige før.
|
||||||
|
*/
|
||||||
|
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
await nyNote(page);
|
||||||
|
await page.keyboard.type('Opskrift');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Monotype' }).click();
|
||||||
|
await page.keyboard.type('first line');
|
||||||
|
await page.keyboard.press('Shift+Enter');
|
||||||
|
await page.keyboard.type('second line');
|
||||||
|
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
const gemt = noteGemt(page);
|
||||||
|
await ed.blur();
|
||||||
|
await gemt;
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Opskrift/ }).click();
|
||||||
|
|
||||||
|
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
|
||||||
|
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
|
||||||
|
//
|
||||||
|
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
|
||||||
|
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
|
||||||
|
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
|
||||||
|
// en blocks tekst — bare fra den anden side.
|
||||||
|
const blok = ed.locator('pre');
|
||||||
|
await expect(blok).toHaveCount(1);
|
||||||
|
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
|
||||||
|
'first line\nsecond line'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
|
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
|
||||||
*
|
*
|
||||||
@@ -4851,7 +5195,7 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
|
|||||||
const krop = page.getByLabel('Notens tekst');
|
const krop = page.getByLabel('Notens tekst');
|
||||||
await krop.fill('Kaffemølle\n\nnoget om den');
|
await krop.fill('Kaffemølle\n\nnoget om den');
|
||||||
await krop.blur();
|
await krop.blur();
|
||||||
await expect(page.getByText('Gemt', { exact: true })).toBeVisible();
|
// Rækken i listen er værnet. "Gemt" stod her før og er hvilestanden — se noteGemt().
|
||||||
await expect(page.locator('.list').getByText('Kaffemølle', { 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
|
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -788,6 +788,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Står markøren i en punktliste eller en nummereret liste?
|
||||||
|
*
|
||||||
|
* Spurgt gennem kommandotilstanden frem for ved at gå op gennem træet: det er
|
||||||
|
* den samme kilde, værktøjslinjen allerede markerer knappen efter, så de to kan
|
||||||
|
* ikke blive uenige om, hvad der er en liste.
|
||||||
|
*/
|
||||||
|
const inList = () =>
|
||||||
|
document.queryCommandState('insertUnorderedList') ||
|
||||||
|
document.queryCommandState('insertOrderedList');
|
||||||
|
|
||||||
function onkeydown(event) {
|
function onkeydown(event) {
|
||||||
// The suggestion list owns the arrows and return while it is open, the way
|
// The suggestion list owns the arrows and return while it is open, the way
|
||||||
// every other completion does. Escape closes it without choosing.
|
// every other completion does. Escape closes it without choosing.
|
||||||
@@ -816,6 +827,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab rykker et punkt ind og ud, som i enhver anden liste. Kun i en liste:
|
||||||
|
// alle andre steder er Tab den tast, der flytter fokus videre, og en editor,
|
||||||
|
// der stjæler den, er en editor, man ikke kan komme ud af med tastaturet.
|
||||||
|
if (event.key === 'Tab' && inList()) {
|
||||||
|
event.preventDefault();
|
||||||
|
apply(event.shiftKey ? 'outdent' : 'indent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
// A code block is the one place the browser keeps the caret inside on
|
// A code block is the one place the browser keeps the caret inside on
|
||||||
// Enter rather than starting a new block. Step out of it ourselves, or a
|
// Enter rather than starting a new block. Step out of it ourselves, or a
|
||||||
|
|||||||
@@ -20,6 +20,31 @@
|
|||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let wrap = $state(null);
|
let wrap = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
|
||||||
|
*
|
||||||
|
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
|
||||||
|
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
|
||||||
|
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
|
||||||
|
* klippet. Der stod tre en halv linje tekst uden for skærmen.
|
||||||
|
*
|
||||||
|
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
|
||||||
|
* kantafstand til højre for klokken — men så ville den her fil kende sine
|
||||||
|
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
|
||||||
|
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
|
||||||
|
* skærmen med en kant i hver side.
|
||||||
|
*/
|
||||||
|
let below = $state(0);
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open;
|
||||||
|
if (open) place();
|
||||||
|
}
|
||||||
|
|
||||||
const KINDS = {
|
const KINDS = {
|
||||||
assigned: 'notif.assigned',
|
assigned: 'notif.assigned',
|
||||||
'note.changed': 'notif.noteChanged',
|
'note.changed': 'notif.noteChanged',
|
||||||
@@ -72,6 +97,7 @@
|
|||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
onclick={outside}
|
onclick={outside}
|
||||||
|
onresize={() => open && place()}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Escape' && open) open = false;
|
if (e.key === 'Escape' && open) open = false;
|
||||||
}}
|
}}
|
||||||
@@ -81,7 +107,7 @@
|
|||||||
<button
|
<button
|
||||||
class="bell"
|
class="bell"
|
||||||
class:on={open}
|
class:on={open}
|
||||||
onclick={() => (open = !open)}
|
onclick={toggle}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-label={app.unread
|
aria-label={app.unread
|
||||||
@@ -101,7 +127,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="panel" role="dialog" aria-label={t('notif.title')}>
|
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
|
||||||
<header>
|
<header>
|
||||||
<span>{t('notif.title')}</span>
|
<span>{t('notif.title')}</span>
|
||||||
{#if app.unread}
|
{#if app.unread}
|
||||||
@@ -193,6 +219,21 @@
|
|||||||
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
|
||||||
|
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
|
||||||
|
toplinjens egen menuknap, så de to skifter form samtidig. */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--below) + 6px);
|
||||||
|
left: var(--s3);
|
||||||
|
right: var(--s3);
|
||||||
|
width: auto;
|
||||||
|
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.panel header {
|
.panel header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -237,6 +237,9 @@ export const da = {
|
|||||||
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
|
||||||
'net.reconnecting': 'Genopretter forbindelse…',
|
'net.reconnecting': 'Genopretter forbindelse…',
|
||||||
'net.offline': 'Ingen 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',
|
'nav.doItToday': 'Gør det i dag',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ export const en = {
|
|||||||
'nav.offlineHint': 'Changes from other people are not showing right now',
|
'nav.offlineHint': 'Changes from other people are not showing right now',
|
||||||
'net.reconnecting': 'Reconnecting…',
|
'net.reconnecting': 'Reconnecting…',
|
||||||
'net.offline': 'No connection',
|
'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',
|
'nav.doItToday': 'Do it today',
|
||||||
|
|
||||||
// --- the sidebar's own sections ------------------------------------------------
|
// --- the sidebar's own sections ------------------------------------------------
|
||||||
|
|||||||
+44
-8
@@ -314,6 +314,41 @@ const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
|
|||||||
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
/** Whether an element holds blocks, in which case reading it as one line loses them. */
|
||||||
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teksten i en kodeblok, med linjeskiftene i behold.
|
||||||
|
*
|
||||||
|
* `textContent` er ikke nok, og det er ikke en detalje: et <br> har den tomme
|
||||||
|
* streng som textContent, og <br> er præcis det, browseren lægger ind, når man
|
||||||
|
* trykker shift+retur i en blok. Blokken blev derfor skrevet ned som én linje —
|
||||||
|
* "first linesecond line" — så man kunne skrive flere linjer og ikke gemme dem.
|
||||||
|
* Det er usynligt indtil en genindlæsning, for på skærmen står de rigtigt hele
|
||||||
|
* tiden.
|
||||||
|
*
|
||||||
|
* Kopiér-knappen springes over. Den ligger inde i <pre> for at kunne sidde i
|
||||||
|
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
|
||||||
|
* gemning.
|
||||||
|
*
|
||||||
|
* Rekursivt, fordi indholdet ligger i et <code> inde i blokken: en filtrering over
|
||||||
|
* <pre>'ens egne børn ser kun det ene element og spørger det om en textContent,
|
||||||
|
* der allerede har tabt linjeskiftet.
|
||||||
|
*/
|
||||||
|
function codeText(node) {
|
||||||
|
let out = '';
|
||||||
|
for (const n of node.childNodes) {
|
||||||
|
if (n.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
out += n.textContent ?? '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n.classList?.contains('copy')) continue;
|
||||||
|
if (n.tagName === 'BR') {
|
||||||
|
out += '\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out += codeText(n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function htmlToMarkdown(root) {
|
export function htmlToMarkdown(root) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
|
||||||
@@ -366,14 +401,7 @@ export function htmlToMarkdown(root) {
|
|||||||
// else and an indent does not.
|
// else and an indent does not.
|
||||||
const lang = child.getAttribute('data-lang') ?? '';
|
const lang = child.getAttribute('data-lang') ?? '';
|
||||||
emit('```' + lang);
|
emit('```' + lang);
|
||||||
// Knapper og andet, editoren har lagt oven på blokken, hører ikke til
|
const code = codeText(child);
|
||||||
// i filen. Kopiér-knappen ligger inde i <pre> for at kunne placeres i
|
|
||||||
// hjørnet af den, og `textContent` ville ellers skrive ordet "Kopiér"
|
|
||||||
// ind i hver eneste kodeblok, hver gang noten blev gemt.
|
|
||||||
const code = [...child.childNodes]
|
|
||||||
.filter((n) => n.nodeType !== Node.ELEMENT_NODE || !n.classList?.contains('copy'))
|
|
||||||
.map((n) => n.textContent ?? '')
|
|
||||||
.join('');
|
|
||||||
for (const l of code.replace(/\n$/, '').split('\n')) {
|
for (const l of code.replace(/\n$/, '').split('\n')) {
|
||||||
emit(l);
|
emit(l);
|
||||||
}
|
}
|
||||||
@@ -408,6 +436,14 @@ export function htmlToMarkdown(root) {
|
|||||||
const pad = ' '.repeat(depth);
|
const pad = ' '.repeat(depth);
|
||||||
|
|
||||||
for (const li of el.children) {
|
for (const li of el.children) {
|
||||||
|
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
|
||||||
|
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
|
||||||
|
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
|
||||||
|
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
|
||||||
|
if (li.tagName === 'UL' || li.tagName === 'OL') {
|
||||||
|
list(li, depth + 1, emit);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (li.tagName !== 'LI') continue;
|
if (li.tagName !== 'LI') continue;
|
||||||
|
|
||||||
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ class AppState {
|
|||||||
people = $state([]);
|
people = $state([]);
|
||||||
tasks = $state([]);
|
tasks = $state([]);
|
||||||
loading = $state(true);
|
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. */
|
/** Transient messages: a failed save, a rolled-back change. */
|
||||||
toasts = $state([]);
|
toasts = $state([]);
|
||||||
connected = $state(false);
|
connected = $state(false);
|
||||||
@@ -96,6 +110,7 @@ class AppState {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
this.user = await api.me();
|
this.user = await api.me();
|
||||||
|
this.unreachable = false;
|
||||||
// The interface's language follows the account, so it is set the moment
|
// The interface's language follows the account, so it is set the moment
|
||||||
// the session is known — before anything has drawn a string.
|
// the session is known — before anything has drawn a string.
|
||||||
i18n.set(this.user.locale);
|
i18n.set(this.user.locale);
|
||||||
@@ -111,6 +126,10 @@ class AppState {
|
|||||||
this.loadNotifications();
|
this.loadNotifications();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.user = null;
|
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 {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
import SignIn from '$lib/components/SignIn.svelte';
|
||||||
|
import NoConnection from '$lib/components/NoConnection.svelte';
|
||||||
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
import TaskDetail from '$lib/components/TaskDetail.svelte';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { setBadge } from '$lib/badge.js';
|
import { setBadge } from '$lib/badge.js';
|
||||||
@@ -166,6 +167,10 @@
|
|||||||
<div class="booting"></div>
|
<div class="booting"></div>
|
||||||
{:else if standalone}
|
{:else if standalone}
|
||||||
{@render children()}
|
{@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}
|
{:else if !app.user}
|
||||||
<SignIn />
|
<SignIn />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
import { NOTE, startDrag } from '$lib/dnd.js';
|
import { NOTE, startDrag } from '$lib/dnd.js';
|
||||||
import { colorVar } from '$lib/colors.js';
|
import { colorVar } from '$lib/colors.js';
|
||||||
@@ -511,15 +511,43 @@
|
|||||||
query = inURL;
|
query = inURL;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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.
|
* En note, man kommer MED — en besked fra klokken, et link fra en opgave, en
|
||||||
let asked = $derived($page.url.searchParams.get('note'));
|
* træffer i ⌘K. Den åbnes, så man ikke bliver sat af på en liste og skal finde
|
||||||
$effect(() => {
|
* den igen.
|
||||||
if (!asked || selectedId === asked) return;
|
*
|
||||||
const found = notes.find((n) => n.id === asked);
|
* Bundet til selve navigationen og ikke til adressens indhold, og det er hele
|
||||||
|
* pointen. Siden skriver aldrig adressen — det blev fjernet, fordi det kostede
|
||||||
|
* bogstaver ud af søgefeltet — så `?note=` bliver stående på den, man kom med,
|
||||||
|
* uanset hvad man klikker bagefter. En effekt, der læste den igen, hver gang
|
||||||
|
* noget ændrede sig, så derfor `selectedId` uenig med adressen og åbnede den
|
||||||
|
* gamle igen: man kunne søge og ikke vælge, for hvert klik på en anden række
|
||||||
|
* sprang tilbage til den første.
|
||||||
|
*
|
||||||
|
* En adresse, der ikke følger med, er ikke en tilstand at holde sig enig med.
|
||||||
|
* Den er en besked, der kommer én gang — så den læses ved ankomsten frem for
|
||||||
|
* ved hver ændring.
|
||||||
|
*/
|
||||||
|
function openFromURL(url) {
|
||||||
|
const id = url?.searchParams.get('note');
|
||||||
|
if (!id || id === selectedId) return;
|
||||||
|
const found = notes.find((n) => n.id === id);
|
||||||
if (found) open(found);
|
if (found) open(found);
|
||||||
else api.note(asked).then(open).catch(() => {});
|
else api.note(id).then(open).catch(() => {});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// To veje ind, fordi der er to måder at ankomme på, og hver af dem er blind for
|
||||||
|
// den anden.
|
||||||
|
//
|
||||||
|
// `afterNavigate` alene rækker ikke: layoutet tegner ingenting, mens kontoen
|
||||||
|
// hentes, så den her side bliver monteret EFTER den første navigation er forbi.
|
||||||
|
// En lytter, der registreres der, hører den aldrig — `/noter?note=<id>` tastet
|
||||||
|
// eller fulgt udefra åbnede så ingenting.
|
||||||
|
//
|
||||||
|
// Og `onMount` alene rækker heller ikke: går man til en anden note inde i
|
||||||
|
// programmet, monteres siden ikke om.
|
||||||
|
onMount(() => openFromURL($page.url));
|
||||||
|
afterNavigate((nav) => openFromURL(nav?.to?.url ?? $page.url));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hvilken hentning der gælder.
|
* Hvilken hentning der gælder.
|
||||||
@@ -561,7 +589,17 @@
|
|||||||
* Derfor hentes den hele her. Rækken vises med det samme, så det ikke føles som
|
* 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.
|
* 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);
|
clearTimeout(timer);
|
||||||
if (!note) {
|
if (!note) {
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
@@ -577,9 +615,9 @@
|
|||||||
// id'et være det samme begge gange, så den anden aldrig blev vist. Man 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.
|
// sidde med en tom note, der lige havde haft en titel.
|
||||||
selectedId = note.id;
|
selectedId = note.id;
|
||||||
if (note.body) {
|
if (whole || note.body) {
|
||||||
selected = note;
|
selected = note;
|
||||||
draft = note.body;
|
draft = note.body ?? '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selected = null;
|
selected = null;
|
||||||
@@ -691,7 +729,8 @@
|
|||||||
try {
|
try {
|
||||||
const note = await api.createNote({ body: '' });
|
const note = await api.createNote({ body: '' });
|
||||||
notes = [note, ...notes];
|
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();
|
document.querySelector('.editor textarea')?.focus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.toast(humanMessage(e));
|
app.toast(humanMessage(e));
|
||||||
|
|||||||
Reference in New Issue
Block a user