From 968a29ca67494a738fe86cb353d62468ea6ad279 Mon Sep 17 00:00:00 2001
From: Kristian Wind
Date: Thu, 20 Aug 2026 21:01:40 +0200
Subject: [PATCH] =?UTF-8?q?feat(notes):=20listen=20st=C3=A5r=20i=20grupper?=
=?UTF-8?q?,=20og=20projektet=20har=20sin=20egen=20farve?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Meldt ind som "den er for tæt på en eller anden måde". Grunden var ikke afstanden
mellem rækkerne: hver række sagde tre ting, og to af dem gentog sig selv nedad.
Datoen stod på hver eneste række — også når tyve noter deler dag — og projektet
fik en linje for sig selv. Tre linjer pr. note, gange tolv hundrede.
Gentagelserne er flyttet derhen, hvor de siger noget.
**Datoen er blevet til overskriften.** I dag · I går · Denne uge · August 2026 …
og så er den væk fra rækken. Noten går fra tre linjer til to, og luften kommer af,
at der er mindre at skrive — ikke af at listen bliver længere.
**Favoritterne har fået deres egen gruppe.** De lå allerede først; der stod bare
ikke hvorfor.
**Grupperingen følger sorteringen.** Sorteret på "Oprettet" grupperes der på
oprettelsesdatoen, på "Senest rørt" på den — ellers ville listen stå i én
rækkefølge og være grupperet efter en anden, og så er overskrifterne løgn.
Sorteret på navn er der ingen dato at gruppere på, så det bliver forbogstavet, og
datoen kommer tilbage i rækken, hvor den så er det eneste tilbage, der siger noget.
**Arkivet grupperes med.** Det er dér, de tolv hundrede importerede ligger, og
dér, det betyder mest.
**Projektet har sin egen farve** — den samme prik som i sidebjælken. Mærkaten sagde
det samme som alle de andre i en grå, der var lysere end uddraget, og projektet er
dét, man skanner efter, når man leder i tolv hundrede noter. Prikken bærer farven
og ikke teksten: fem projektfarver som skriftfarve er fem forskellige grader af
læsbarhed, og den lyseste er ikke læsbar.
Fire ting, der blev fundet ved at se på det:
- Uddraget gentog titlen, fordi titlen *er* første linje. Det var skjult, så længe
en dato stod imellem dem.
- Uddraget klippes ikke af sig selv. Det lå før i en flex-boks, der ejede
afkortningen; da datoen flyttede op, forsvandt boksen, og sætningen løb videre
ind under favoritstjernen.
- Overskriftens stjerne hed `.star` ligesom rækkens favoritknap og arvede dens
gennemsigtighed. Den stod der aldrig. To ting, der ligner hinanden, er ikke det
samme.
- En `aria-label` på foldeknappen erstatter knappens indhold som dens navn — og
knappen *er* overskriften, så overskriften kom til at hedde "Fold I dag sammen".
Sidebjælkens gruppehoveder gør det rigtigt, og gør det nu begge steder.
Sammenfoldning huskes ikke på tværs af besøg: en gruppe, der åbner foldet uden at
nogen bad om det, er en liste, der ser tom ud.
Co-Authored-By: Claude Opus 5
---
web/e2e/smoke.spec.js | 75 ++++++
web/src/lib/locales/da.js | 5 +
web/src/lib/locales/en.js | 5 +
web/src/routes/noter/+page.svelte | 403 ++++++++++++++++++++++++------
4 files changed, 410 insertions(+), 78 deletions(-)
diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 9009a41..965df77 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -3303,3 +3303,78 @@ test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async
expect(trouble).toEqual([]);
});
+
+/**
+ * Notelisten står i grupper.
+ *
+ * Den føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række sagde
+ * tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste række,
+ * også når tyve noter deler dag, og projektet fik en linje for sig selv.
+ *
+ * Datoen er flyttet op i en overskrift, favoritterne har fået deres egen gruppe —
+ * de lå allerede først, der stod bare ikke hvorfor — og arkivet grupperes på samme
+ * måde, for det er dér, de tolv hundrede importerede ligger.
+ */
+test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page }) => {
+ const trouble = watchForTrouble(page);
+ await page.goto('/noter');
+
+ for (const title of ['Gruppe én', 'Gruppe to']) {
+ await page.getByRole('button', { name: 'Ny note' }).click();
+ const ed = page.getByRole('textbox', { name: 'Notens tekst' });
+ await ed.click();
+ await page.keyboard.type(title);
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('Brødtekst i ' + title);
+ await expect(ed.locator('h1')).toHaveText(title);
+ await page.waitForTimeout(800);
+ }
+ await page.reload();
+
+ // Skrevet i dag, så de står under dagens overskrift.
+ const today = page.getByRole('heading', { name: /^I dag/ });
+ await expect(today, 'der er ingen datogruppe').toBeVisible();
+
+ // En favorit får sin egen gruppe. Uden overskriften ligger den bare først, og
+ // der står ikke hvorfor.
+ await expect(page.getByRole('heading', { name: /Favoritter/i })).toBeHidden();
+ const row = page.locator('li').filter({ hasText: 'Gruppe én' }).first();
+ await row.hover();
+ await row.getByRole('button', { name: /favorit/i }).click();
+ const favourites = page.getByRole('heading', { name: /Favoritter/i });
+ await expect(favourites).toBeVisible();
+
+ // Og den ligger under den, ikke under dagen.
+ const inFavourites = page
+ .locator('h3.group', { hasText: /Favoritter/i })
+ .locator('+ ul li')
+ .filter({ hasText: 'Gruppe én' });
+ await expect(inFavourites).toBeVisible();
+
+ // Grupper kan foldes sammen. Ikke husket på tværs af besøg: en gruppe, der
+ // åbner foldet uden at nogen bad om det, er en liste, der ser tom ud.
+ await favourites.getByRole('button').click();
+ await expect(inFavourites).toBeHidden();
+ await favourites.getByRole('button').click();
+ await expect(inFavourites).toBeVisible();
+
+ // En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
+ // matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget
+ // andet end det, der blev spurgt om.
+ await page.getByPlaceholder('Søg i noter').fill('Brødtekst');
+ await expect(page.locator('li').filter({ hasText: 'Gruppe to' }).first()).toBeVisible();
+ await expect(page.locator('h3.group')).toHaveCount(0);
+ await page.getByPlaceholder('Søg i noter').fill('');
+
+ // Arkivet grupperes på samme måde. Det er dér, det betyder mest.
+ await row.hover();
+ await row.getByRole('button', { name: /Arkivér/i }).click();
+ await page.getByRole('button', { name: 'Vis arkivet' }).click();
+ await expect(
+ page.locator('h3.group'),
+ 'arkivet står ugrupperet'
+ ).not.toHaveCount(0);
+ await expect(page.locator('li').filter({ hasText: 'Gruppe én' }).first()).toBeVisible();
+
+ expect(trouble).toEqual([]);
+});
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index 2489d52..0edd7e7 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -75,6 +75,11 @@ export const da = {
'notes.syntaxProject': 'projekt',
'notes.syntaxNote': 'en anden note',
'notes.yesterday': 'i går',
+ 'notes.groupFavourites': 'Favoritter',
+ 'notes.groupToday': 'I dag',
+ 'notes.groupYesterday': 'I går',
+ 'notes.groupWeek': 'Denne uge',
+ 'notes.groupUnknown': 'Uden dato',
'notes.title': 'Noter',
'notes.new': 'Ny note',
'notes.search': 'Søg i noter',
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index 187fe92..4797581 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -71,6 +71,11 @@ export const en = {
'notes.syntaxProject': 'project',
'notes.syntaxNote': 'another note',
'notes.yesterday': 'yesterday',
+ 'notes.groupFavourites': 'Favourites',
+ 'notes.groupToday': 'Today',
+ 'notes.groupYesterday': 'Yesterday',
+ 'notes.groupWeek': 'This week',
+ 'notes.groupUnknown': 'No date',
'notes.title': 'Notes',
'notes.new': 'New note',
'notes.search': 'Search notes',
diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte
index dbe11cc..7b24a32 100644
--- a/web/src/routes/noter/+page.svelte
+++ b/web/src/routes/noter/+page.svelte
@@ -16,6 +16,7 @@
import { page } from '$app/stores';
import { t, tag } from '$lib/i18n.svelte.js';
import { NOTE, startDrag } from '$lib/dnd.js';
+ import { colorVar } from '$lib/colors.js';
import NoteEditor from '$lib/components/NoteEditor.svelte';
let notes = $state([]);
@@ -71,6 +72,100 @@
const field = order === 'created' ? 'created_at' : 'updated_at';
return new Date(b[field]).getTime() - new Date(a[field]).getTime();
});
+
+ // --- listen i grupper ----------------------------------------------------------
+ //
+ // Listen føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række
+ // sagde tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste
+ // række — også når tyve noter deler dag — og projektet fik en linje for sig selv.
+ // Tre linjer pr. note, gange tolv hundrede.
+ //
+ // Gentagelserne flyttes derhen, hvor de siger noget. Datoen bliver til
+ // overskriften over en gruppe, og så forsvinder den fra rækken; projektet bliver
+ // en lille mærkat ude til højre. Noten går fra tre linjer til to, og luften
+ // kommer af, at der er mindre at skrive — ikke af at listen bliver længere.
+
+ /** Grupper, der er foldet sammen. Kun i denne session. */
+ let folded = $state(new Set());
+
+ function toggleGroup(key) {
+ const next = new Set(folded);
+ next.has(key) ? next.delete(key) : next.add(key);
+ folded = next;
+ }
+
+ /**
+ * Hvilken dato en gruppe måles på: den, der er sorteret efter.
+ *
+ * Ellers ville listen stå i én rækkefølge og være grupperet efter en anden, og
+ * så er overskrifterne løgn — en note ville stå under "August" mellem to fra
+ * juli, fordi den var *rørt* i august og *oprettet* i juli.
+ */
+ const groupDate = (note) => (order === 'created' ? note.created_at : note.updated_at);
+
+ /**
+ * Hvad en gruppe hedder. I dag, i går, denne uge — og derefter måneden.
+ *
+ * Nøglen og etiketten er to ting: etiketten er sproget, og nøglen er den, der
+ * afgør om to noter hører sammen. Måneden som nøgle må bære året med sig, eller
+ * ville august 2026 og august 2025 blive én bunke.
+ */
+ function bucketOf(note) {
+ if (order === 'title') {
+ // Sorteret på navn er der ingen dato at gruppere på, så det bliver
+ // forbogstavet — og datoen kommer tilbage i rækken, hvor den så er den
+ // eneste, der siger noget.
+ const first = (note.title || '').trim().charAt(0).toUpperCase();
+ const letter = /\p{L}|\p{N}/u.test(first) ? first : '#';
+ return { key: 'a-' + letter, label: letter };
+ }
+
+ const at = new Date(groupDate(note));
+ if (Number.isNaN(at.getTime())) return { key: 'ukendt', label: t('notes.groupUnknown') };
+
+ const now = new Date();
+ const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const days = Math.floor((midnight - new Date(at.getFullYear(), at.getMonth(), at.getDate())) / 86400000);
+
+ if (days <= 0) return { key: 'i-dag', label: t('notes.groupToday') };
+ if (days === 1) return { key: 'i-gaar', label: t('notes.groupYesterday') };
+ if (days < 7) return { key: 'ugen', label: t('notes.groupWeek') };
+
+ const label = at.toLocaleDateString(tag(), { month: 'long', year: 'numeric' });
+ return { key: `m-${at.getFullYear()}-${at.getMonth()}`, label };
+ }
+
+ /**
+ * Listen, delt op.
+ *
+ * En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
+ * matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget,
+ * der ikke er svaret på det, der blev spurgt om.
+ */
+ let groups = $derived.by(() => {
+ if (query.trim()) return [{ key: 'fundet', label: '', notes: ordered, plain: true }];
+
+ const out = [];
+ const favourites = ordered.filter((n) => n.pinned);
+ if (favourites.length) {
+ out.push({ key: 'favoritter', label: t('notes.groupFavourites'), notes: favourites, star: true });
+ }
+
+ let current = null;
+ for (const note of ordered) {
+ if (note.pinned) continue;
+ const bucket = bucketOf(note);
+ if (!current || current.key !== bucket.key) {
+ current = { key: bucket.key, label: bucket.label, notes: [] };
+ out.push(current);
+ }
+ current.notes.push(note);
+ }
+ return out;
+ });
+
+ /** Projektets egen farve, som i sidebjælken. */
+ const projectColour = (id) => colorVar(app.projects.find((p) => p.id === id)?.color);
let selected = $state(null);
// Hvilken række der er valgt. Se open(): editoren venter på hele noten.
let selectedId = $state(null);
@@ -258,6 +353,25 @@
* which is exactly the thing the editor was built to stop showing. What is
* wanted here is the sentence, not the notation.
*/
+ /**
+ * Uddraget, uden titlen foran.
+ *
+ * Titlen *er* første linje — det er hele reglen for, hvad en note hedder — så et
+ * uddrag, der begynder fra toppen, siger navnet én gang til. Det var svært at se,
+ * så længe en dato stod imellem dem; nu står de oven på hinanden, og så står der
+ * "Verdande" og under det "Verdande Forbindelse til connectoren".
+ */
+ function preview(note) {
+ const body = note.preview ?? note.body ?? '';
+ const nl = body.indexOf('\n');
+ const rest = nl === -1 ? '' : body.slice(nl + 1);
+ // Kun hvis den første linje faktisk *er* titlen. Et uddrag fra serveren er
+ // afkortet, så en lang første linje kan være hele uddraget — og så er der
+ // ikke andet at vise end den.
+ const withoutTitle = plain(rest);
+ return withoutTitle || plain(body);
+ }
+
function plain(body) {
return (body ?? '')
// Et billede har ingen ord i sig. Uden det her stod der
@@ -515,69 +629,99 @@
{query ? t('notes.noneFound') : showArchive ? t('notes.emptyArchive') : t('notes.noneYet')}
{:else}
-
- {#each ordered as note (note.id)}
-
-
-
-
-
-
-
-
+ {#each groups as group (group.key)}
+
+ {#if !group.plain}
+
+
+
+
+ {/if}
+ {#if !folded.has(group.key)}
+
+ {#each group.notes as note (note.id)}
+
+
+
+
+
+
+
+
+
-
-
- {/each}
-
+
+ {/each}
+
+ {/if}
+ {/each}
{/if}
@@ -925,19 +1069,99 @@
color: var(--accent);
}
+ /* --- grupperne ----------------------------------------------------------
+ *
+ * Overskriften klæber. Med tolv hundrede noter er det forskellen på at rulle og
+ * på at lede: man kan altid se, hvilken måned man er nede i.
+ *
+ * Baggrunden er ikke gennemsigtig. En klæbende overskrift uden en er en
+ * overskrift med rækkerne kørende igennem sig. */
+ h3.group {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ margin: 0;
+ background: var(--ground);
+ border-bottom: 1px solid var(--line);
+ }
+
+ h3.group button {
+ display: flex;
+ align-items: center;
+ gap: var(--s2);
+ width: 100%;
+ padding: var(--s2) var(--s2);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+ }
+
+ h3.group button:hover {
+ color: var(--ink-muted);
+ }
+
+ h3.group .mark {
+ flex: none;
+ width: 0.75em;
+ font-size: 0.85em;
+ line-height: 1;
+ color: var(--ink-faint);
+ transition: transform var(--fast) var(--ease);
+ }
+
+ h3.group .mark.closed {
+ transform: rotate(-90deg);
+ }
+
+ /* Sit eget navn og ikke `.star`. Rækkens favoritknap hedder også det og er
+ gennemsigtig, indtil man peger på rækken — så overskriftens stjerne arvede
+ den gennemsigtighed og stod der aldrig. To ting, der ligner hinanden, er
+ ikke det samme, og et delt klassenavn er en påstand om, at de er. */
+ h3.group .favmark {
+ color: var(--accent);
+ font-size: 1em;
+ }
+
+ h3.group .count {
+ margin-left: auto;
+ font-weight: 500;
+ letter-spacing: 0;
+ }
+
+ /* Stregen mellem rækkerne er væk.
+ *
+ * Grupperne giver strukturen nu, og en streg pr. note er tolv hundrede streger.
+ * Luften kommer af, at rækken siger to ting i stedet for tre — ikke af at listen
+ * bliver længere. */
.row {
display: block;
flex: 1;
min-width: 0;
text-align: left;
- padding: var(--s2) var(--s2) var(--s3);
+ /* Plads til højre for de to knapper, der ligger oven på rækken. Uden den
+ løber uddraget ind under stjernen — usynligt, indtil man peger på rækken
+ og knappen dukker op oven i ordene. */
+ padding: var(--s2) 26px calc(var(--s2) + 2px) var(--s2);
border-radius: var(--radius);
color: var(--ink-muted);
- border-bottom: 1px solid var(--line);
}
- li:last-child .row {
- border-bottom: 0;
+ /* Titel, dato og projekt på én linje. Projektet ude til højre, hvor øjet kan
+ løbe ned ad søjlen uden at læse resten. */
+ .head {
+ display: flex;
+ align-items: baseline;
+ gap: var(--s2);
+ min-width: 0;
+ }
+
+ .head strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.row:hover {
@@ -964,33 +1188,56 @@
/* Dato og begyndelse på samme linje, som Apple Noter gør det. To linjer pr.
note frem for tre, og datoen først, fordi det er den, man skanner efter. */
- .under {
- display: flex;
- gap: var(--s2);
- min-width: 0;
- font-size: var(--text-xs);
- color: var(--ink-faint);
- }
.when {
flex: none;
color: var(--ink-muted);
}
+ /* `display: block`, og det er ikke pynt.
+ *
+ * Uddraget lå før inde i en flex-boks, der ejede afkortningen. Da datoen flyttede
+ * op i gruppens overskrift, forsvandt den boks — og et inline-element klippes
+ * ikke af `overflow: hidden`, så den sidste sætning løb videre ind under
+ * favoritstjernen. Reglerne skal stå på det element, der faktisk skal klippes. */
.preview {
+ display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
- /* Hvor den ligger, med en mappe foran — Apple Noters egen måde at sige det på,
- og den eneste linje her, der ikke er notens eget indhold. */
+ /* Projektets egen farve, den samme som prikken i sidebjælken.
+ *
+ * Mærkaten sagde det samme som alle de andre i en grå, der var lysere end
+ * uddraget — og projektet er dét, man skanner efter, når man leder i tolv
+ * hundrede noter. En prik i projektets farve og en tekst, der ikke er lysere end
+ * det, den står ved siden af: Hjem kan kendes fra Claude uden at ordet læses.
+ *
+ * Prikken bærer farven, ikke teksten. Fem projekters farver som skriftfarve på
+ * en lys flade er fem forskellige grader af læsbarhed, og den lyseste af dem er
+ * ikke læsbar. */
.filed {
- display: block;
- margin-top: 2px;
+ display: flex;
+ align-items: center;
+ gap: 0.375em;
+ flex: none;
+ margin-left: auto;
+ max-width: 45%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
font-size: var(--text-xs);
- color: var(--ink-faint);
+ color: var(--ink-muted);
+ }
+
+ .filed .dot {
+ width: 6px;
+ height: 6px;
+ flex: none;
+ border-radius: var(--radius-full);
+ background: var(--project, var(--line-strong));
}
.filed::before {