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} + + {/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 {