From a2a39c4e17f824cd6e73e3f28037ca370aef79e8 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 13:07:49 +0200 Subject: [PATCH] feat(notes): en flade at skrive i, og noten dukker op i projektet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tredje etape. En liste til venstre, noten til højre — ikke et gitter af kort: en note læses langt oftere på sin første linje end i sin helhed, og en liste viser tyve af dem, hvor et gitter viser seks. Gemmes når man holder op med at skrive. En note er det ene sted i programmet, hvor en times skrivning kan gå tabt på ét fejltryk, og en Gem-knap er en knap, der findes for at blive glemt. Teksten holdes adskilt fra den note, den kom fra, så en gemning, der lander sent, ikke kan overskrive det, der er skrevet siden. Og udbetalingen: skriver man #GarageRisteriet i en note, står den på projektets side. Ingen har filet den derhen — den dukker op, fordi nogen nævnte projektet mens de skrev. Slået op på navnet og ikke på et id, for et #tag skrives i hånden, og et menneske skriver navnet. Markdown i et almindeligt tekstfelt indtil videre. Live preview, hvor mærkerne kun vises i den linje markøren står i, er næste stykke og det dyre; det her er den form, den skal sidde inde i, og alt andet er allerede sandt uden den. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 51 ++++ web/src/lib/api.js | 12 + web/src/lib/components/Sidebar.svelte | 10 + web/src/lib/locales/da.js | 13 + web/src/lib/locales/en.js | 13 + web/src/routes/noter/+page.svelte | 326 +++++++++++++++++++++++ web/src/routes/projekt/[id]/+page.svelte | 82 ++++++ 7 files changed, 507 insertions(+) create mode 100644 web/src/routes/noter/+page.svelte diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 00450e3..6dba626 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1900,3 +1900,54 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature expect(trouble).toEqual([]); }); + +test('en note kan skrives, findes igen, og peger på det den nævner', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('Notetesteriet'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible(); + + await sidebar.getByRole('link', { name: 'Noter' }).click(); + await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible(); + + await page.getByRole('button', { name: 'Ny note' }).click(); + const body = page.getByLabel('Notens tekst'); + await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.'); + // Saved on the way out, so there is no Gem-knap to forget. + await body.blur(); + + // The title comes from the first line, the way Apple Notes does it. Without it + // a list of notes is a list of "Uden titel". + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + + // And the #tag was read out of the text, unasked. This is the whole point: it + // is the same tag a task carries, so the note can be found from the project. + // Scoped to the panel under the editor: the same text is also in the textarea + // and in the list preview, which is three of the same thing and none of them + // the one being asserted. + await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible(); + + // Findable by a word from the body, and in the other Danish spelling too. + await page.getByLabel('Søg i noter').fill('møde'); + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + await page.getByLabel('Søg i noter').fill('mode'); + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + + // It survives a reload, which is the only proof that it was ever written down. + await page.reload(); + await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible(); + + // And the payoff: the note turns up in the project it named, without anybody + // filing it there. This is what a #tag being the same tag actually buys. + await sidebar.getByRole('link', { name: 'Notetesteriet' }).click(); + await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible(); + await expect( + page.locator('.notes').getByRole('link', { name: /Møde om/ }) + ).toBeVisible(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index ab41c91..8846b96 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -360,6 +360,18 @@ export const api = { authorizeGmail: () => post('/gmail/authorize'), syncGmail: () => post('/gmail/sync'), + // Notes. Listing and searching are one endpoint because they answer the same + // question with different words. + notes: (params = {}) => { + const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString(); + return get('/notes' + (q ? `?${q}` : '')); + }, + note: (id) => get(`/notes/${id}`), + createNote: (body) => post('/notes', body), + updateNote: (id, body) => patch(`/notes/${id}`, body), + deleteNote: (id) => del(`/notes/${id}`), + notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`), + // Mailboxes read over IMAP. Each belongs to the person who connected it, so // there is no instance-wide registration behind these the way Gmail has one. mailboxes: () => get('/mailboxes'), diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 0b576e3..fb3c99f 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -379,6 +379,16 @@ {#if app.inbox} {@render projectRow(app.inbox, false)} + + + + + + {t('notes.title')} + + {/if} diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 47b9cce..793a090 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -16,6 +16,19 @@ */ export const da = { // --- the shell --------------------------------------------------------------- + 'notes.title': 'Noter', + 'notes.new': 'Ny note', + 'notes.search': 'Søg i noter', + 'notes.body': 'Notens tekst', + 'notes.placeholder': 'Skriv. #projekt og [[en anden note]] bliver til henvisninger.', + 'notes.untitled': 'Uden titel', + 'notes.noneYet': 'Ingen noter endnu.', + 'notes.noneFound': 'Ingen noter matcher.', + 'notes.pickOne': 'Vælg en note, eller lav en ny.', + 'notes.saving': 'Gemmer …', + 'notes.saved': 'Gemt', + 'notes.delete': 'Slet', + 'notes.deleteNote': 'Slet "{name}"? Den kan hentes tilbage fra papirkurven.', 'nav.today': 'I dag', 'nav.upcoming': 'Kommende', 'nav.delegated': 'Venter på andre', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 69dc5d2..7fb9526 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -12,6 +12,19 @@ */ export const en = { // --- the shell --------------------------------------------------------------- + 'notes.title': 'Notes', + 'notes.new': 'New note', + 'notes.search': 'Search notes', + 'notes.body': "The note's text", + 'notes.placeholder': 'Write. #project and [[another note]] become references.', + 'notes.untitled': 'Untitled', + 'notes.noneYet': 'No notes yet.', + 'notes.noneFound': 'No notes match.', + 'notes.pickOne': 'Pick a note, or write a new one.', + 'notes.saving': 'Saving …', + 'notes.saved': 'Saved', + 'notes.delete': 'Delete', + 'notes.deleteNote': 'Delete "{name}"? It can be brought back from the trash.', 'nav.today': 'Today', 'nav.upcoming': 'Upcoming', 'nav.delegated': 'Waiting on others', diff --git a/web/src/routes/noter/+page.svelte b/web/src/routes/noter/+page.svelte new file mode 100644 index 0000000..c389122 --- /dev/null +++ b/web/src/routes/noter/+page.svelte @@ -0,0 +1,326 @@ + + +{t('notes.title')} · verdande + +
+ + +
+ {#if selected} + + +
+ {saving ? t('notes.saving') : t('notes.saved')} + {#if links.length} + + {#each links as link} + {link.kind === 'project' ? '#' : ''}{link.target_id} + {/each} + + {/if} + +
+ {:else} +

{t('notes.pickOne')}

+ {/if} +
+
+ + diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index c838ae8..4080308 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -43,6 +43,23 @@ load(id); }); + // Notes that mention this project. Looked up by name rather than by id, because + // a #tag is written by hand and a person types the name — the same string the + // quick-add parser reads out of a task. + let notes = $state([]); + + $effect(() => { + const name = project?.name; + if (!name) { + notes = []; + return; + } + api + .notesLinking('project', name) + .then((r) => (notes = r.notes ?? [])) + .catch(() => (notes = [])); + }); + async function load(projectId) { if (!projectId) return; status = 'loading'; @@ -793,6 +810,24 @@ {#if project.role === 'viewer'}

{t('project.readOnly')}

{/if} + + {#if notes.length} +
+

{t('notes.title')}

+ +
+ {/if} {:else if status === 'loading'} @@ -809,6 +844,53 @@