Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84af63316d | ||
|
|
930522ecf6 | ||
|
|
9ed6f49589 |
+91
-5
@@ -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();
|
||||
}
|
||||
|
||||
@@ -4696,6 +4703,85 @@ test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) =
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
@@ -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 ------------------------------------------------
|
||||
|
||||
@@ -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 ------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -561,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;
|
||||
@@ -577,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;
|
||||
@@ -691,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));
|
||||
|
||||
Reference in New Issue
Block a user