Compare commits

..
1 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 968a29ca67 feat(notes): listen står i grupper, og projektet har sin egen farve
Release / Build and publish image (push) Failing after 9m56s
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 <[email protected]>
2026-08-20 21:01:40 +02:00
4 changed files with 410 additions and 78 deletions
+75
View File
@@ -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([]);
});
+5
View File
@@ -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',
+5
View File
@@ -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',
+325 -78
View File
@@ -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')}
</p>
{:else}
<ul>
{#each ordered as note (note.id)}
<li>
<div class="rowline">
<button
class="row"
class:on={selectedId === note.id}
class:picked={picked.has(note.id)}
onclick={(e) => rowClick(e, note)}
draggable="true"
ondragstart={(e) => onDragStart(e, note)}
ondragend={() => (dragging = null)}
>
<strong>{note.title || t('notes.untitled')}</strong>
<!-- Dato og begyndelse på samme linje, som Apple Noter gør det: to
linjer pr. note frem for tre, og datoen er dét, man skanner
efter, når man leder efter noget, man skrev i tirsdags. -->
<span class="under">
<!-- Datoen, der svarer til den rækkefølge, listen står i.
Den viste altid `updated_at`, og for tolv hundrede
importerede noter er det i aftes — så en note fra 2017
stod som "i går", og den dato, man sorterede efter, var
ingen steder at se. -->
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
<span class="preview">{plain(note.preview ?? note.body)}</span>
</span>
{#if note.project_id}
<span class="filed">{projectName(note.project_id)}</span>
{/if}
</button>
<!-- De to knapper står oven på hinanden i en smal søjle.
Ved siden af hinanden tog de toogfyrre pixels af en liste,
der er tre hundrede bred — og de bruges sjældent, mens
titlen og uddraget læses hele tiden. -->
<div class="rowactions">
<!-- Én note lagt væk, uden at markere den først. Markeringen er
til flere; det her er til den ene, man står med — og i
arkivet er det vejen tilbage. -->
<button
class="archive-one"
onclick={() => archiveOne(note)}
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button
class="star"
class:on={note.pinned}
onclick={() => favourite(note)}
aria-pressed={note.pinned}
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
>
{note.pinned ? '★' : '☆'}
</button>
{#each groups as group (group.key)}
<!-- Overskriften klæber, mens man ruller. 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. -->
{#if !group.plain}
<h3 class="group">
<!-- Ingen `aria-label`. Den ville erstatte knappens indhold som dens
navn, og knappen *er* overskriften — så overskriften ville hedde
"Fold I dag sammen" i stedet for "I dag". `aria-expanded` siger
tilstanden, som den gør på gruppehovederne i sidebjælken. -->
<button onclick={() => toggleGroup(group.key)} aria-expanded={!folded.has(group.key)}>
{#if group.star}
<span class="mark favmark" aria-hidden="true">★</span>
{:else}
<span class="mark" class:closed={folded.has(group.key)} aria-hidden="true">▾</span>
{/if}
{group.label}
<span class="count">{group.notes.length}</span>
</button>
</h3>
{/if}
{#if !folded.has(group.key)}
<ul>
{#each group.notes as note (note.id)}
<li>
<div class="rowline">
<button
class="row"
class:on={selectedId === note.id}
class:picked={picked.has(note.id)}
onclick={(e) => rowClick(e, note)}
draggable="true"
ondragstart={(e) => onDragStart(e, note)}
ondragend={() => (dragging = null)}
>
<!-- Titel og projekt på linje ét, uddrag på linje to. Datoen står
i gruppens overskrift og ikke her — undtagen når der er sorteret
på navn, for så er der ingen overskrift, der siger den, og så er
den det eneste tilbage, der gør. -->
<span class="head">
<strong>{note.title || t('notes.untitled')}</strong>
{#if order === 'title'}
<span class="when" title={stamp(note)}>{when(shownDate(note))}</span>
{/if}
{#if note.project_id}
<!-- 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. Nu kan Hjem kendes fra
Claude uden at læse ordet. -->
<span class="filed" style="--project: {projectColour(note.project_id)}">
<span class="dot" aria-hidden="true"></span>
{projectName(note.project_id)}
</span>
{/if}
</span>
<span class="preview">{preview(note)}</span>
</button>
<!-- De to knapper står oven på hinanden i en smal søjle.
Ved siden af hinanden tog de toogfyrre pixels af en liste,
der er tre hundrede bred — og de bruges sjældent, mens
titlen og uddraget læses hele tiden. -->
<div class="rowactions">
<!-- Én note lagt væk, uden at markere den først. Markeringen er
til flere; det her er til den ene, man står med — og i
arkivet er det vejen tilbage. -->
<button
class="archive-one"
onclick={() => archiveOne(note)}
aria-label={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
title={note.archived_at ? t('notes.unarchive') : t('notes.archiveOne')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7h18v3H3zM5 10v9h14v-9M10 14h4" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button
class="star"
class:on={note.pinned}
onclick={() => favourite(note)}
aria-pressed={note.pinned}
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
>
{note.pinned ? '★' : '☆'}
</button>
</div>
</div>
</div>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{/if}
{/each}
{/if}
</aside>
@@ -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 {