feat(notes): en flade at skrive i, og noten dukker op i projektet
Release / Build and publish image (push) Failing after 9m55s

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 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-19 13:07:49 +02:00
co-authored by Claude Opus 5
parent fbfc98e217
commit a2a39c4e17
7 changed files with 507 additions and 0 deletions
+51
View File
@@ -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([]);
});
+12
View File
@@ -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'),
+10
View File
@@ -379,6 +379,16 @@
</a>
{#if app.inbox}
{@render projectRow(app.inbox, false)}
<!-- Notes sit with the fixed places rather than among the projects: it is a
place you go, not a thing filed under one. -->
<a href="/noter" class="fixed" onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('notes.title')}
</a>
{/if}
</div>
+13
View File
@@ -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',
+13
View File
@@ -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',
+326
View File
@@ -0,0 +1,326 @@
<script>
/**
* Notes.
*
* A list on the left, the note itself on the right. Not a grid of cards: a note
* is read by its first line far more often than by its whole self, and a list
* shows twenty of those where a grid shows six.
*
* Markdown in a plain textarea for now. The live preview — where the marks show
* only in the line the cursor is in — is the next piece and the expensive one;
* this is the shape it will sit inside, and everything else is already true
* without it.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let notes = $state([]);
let selected = $state(null);
let query = $state('');
let status = $state('loading');
let saving = $state(false);
// The text being edited, kept apart from the note it came from so a save that
// arrives late cannot overwrite what has been typed since.
let draft = $state('');
let timer;
$effect(() => {
load(query);
});
async function load(q) {
try {
notes = (await api.notes(q ? { q } : {})).notes ?? [];
status = 'ready';
if (selected && !notes.some((n) => n.id === selected.id)) open(notes[0] ?? null);
} catch (e) {
status = 'failed';
app.toast(humanMessage(e));
}
}
function open(note) {
clearTimeout(timer);
selected = note;
draft = note?.body ?? '';
}
async function create() {
try {
const note = await api.createNote({ body: '' });
notes = [note, ...notes];
open(note);
document.querySelector('.editor textarea')?.focus();
} catch (e) {
app.toast(humanMessage(e));
}
}
/**
* Saved as you stop typing rather than on a button.
*
* A note is the one place in this program where an hour of writing can be lost
* to one mis-click, and a Gem-knap is a button that exists to be forgotten. The
* delay is long enough that a sentence is one save and not twenty.
*/
function typed() {
clearTimeout(timer);
timer = setTimeout(save, 700);
}
async function save() {
if (!selected) return;
const id = selected.id;
const body = draft;
saving = true;
try {
const saved = await api.updateNote(id, { body });
notes = notes.map((n) => (n.id === id ? saved : n));
// Only if it is still the one on screen: switching notes mid-save must not
// drag the previous one's title back onto this one.
if (selected?.id === id) selected = saved;
} catch (e) {
app.toast(humanMessage(e));
} finally {
saving = false;
}
}
async function remove(note) {
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
try {
await api.deleteNote(note.id);
notes = notes.filter((n) => n.id !== note.id);
if (selected?.id === note.id) open(notes[0] ?? null);
} catch (e) {
app.toast(humanMessage(e));
}
}
// What the note points at, for the panel under the editor.
let links = $derived(selected?.links ?? []);
</script>
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
<div class="notes">
<aside>
<div class="head">
<h1>{t('notes.title')}</h1>
<button class="new" onclick={create} aria-label={t('notes.new')}>+</button>
</div>
<input
class="search"
type="search"
bind:value={query}
placeholder={t('notes.search')}
aria-label={t('notes.search')}
/>
{#if status === 'loading'}
<p class="hint">{t('history.loading')}</p>
{:else if !notes.length}
<p class="hint">{query ? t('notes.noneFound') : t('notes.noneYet')}</p>
{:else}
<ul>
{#each notes as note (note.id)}
<li>
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
<strong>{note.title || t('notes.untitled')}</strong>
<span class="preview">{note.body.slice(0, 80)}</span>
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<section class="editor">
{#if selected}
<textarea
bind:value={draft}
oninput={typed}
onblur={save}
spellcheck="true"
aria-label={t('notes.body')}
placeholder={t('notes.placeholder')}
></textarea>
<footer>
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
{#if links.length}
<span class="links">
{#each links as link}
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
{/each}
</span>
{/if}
<button class="remove" onclick={() => remove(selected)}>{t('notes.delete')}</button>
</footer>
{:else}
<p class="empty">{t('notes.pickOne')}</p>
{/if}
</section>
</div>
<style>
.notes {
display: grid;
grid-template-columns: minmax(220px, 300px) 1fr;
gap: var(--s4);
height: 100%;
min-height: 0;
}
aside {
display: flex;
flex-direction: column;
gap: var(--s2);
min-height: 0;
border-right: 1px solid var(--line);
padding-right: var(--s3);
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
h1 {
font-size: var(--text-xl);
}
.new {
font-size: var(--text-lg);
color: var(--ink-faint);
padding: 0 var(--s2);
}
.new:hover {
color: var(--ink);
}
.search {
width: 100%;
}
ul {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
min-height: 0;
}
.row {
display: block;
width: 100%;
text-align: left;
padding: var(--s2);
border-radius: var(--radius);
color: var(--ink-muted);
}
.row:hover {
background: var(--surface);
}
.row.on {
background: var(--surface-raised);
color: var(--ink);
}
.row strong {
display: block;
font-size: var(--text-sm);
font-weight: 560;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview {
display: block;
font-size: var(--text-xs);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor {
display: flex;
flex-direction: column;
min-height: 0;
}
textarea {
flex: 1;
width: 100%;
min-height: 50vh;
resize: none;
border: 0;
background: none;
color: var(--ink);
font: inherit;
line-height: 1.6;
padding: var(--s2) 0;
}
textarea:focus {
outline: none;
}
footer {
display: flex;
align-items: center;
gap: var(--s3);
padding-top: var(--s2);
border-top: 1px solid var(--line);
font-size: var(--text-xs);
}
.links {
display: flex;
flex-wrap: wrap;
gap: var(--s1);
}
.link {
color: var(--ink-muted);
background: var(--surface);
border-radius: var(--radius-sm);
padding: 0 var(--s1);
font-family: var(--mono, ui-monospace, monospace);
}
.remove {
margin-left: auto;
color: var(--ink-faint);
}
.remove:hover {
color: var(--danger);
}
.empty,
.hint {
color: var(--ink-faint);
font-size: var(--text-sm);
}
@media (max-width: 700px) {
.notes {
grid-template-columns: 1fr;
}
aside {
border-right: 0;
padding-right: 0;
}
}
</style>
+82
View File
@@ -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'}
<p class="readonly">{t('project.readOnly')}</p>
{/if}
<!-- What has been written about this project, which is the payoff for a #tag
in a note being the same tag a task carries: nobody files anything here,
it turns up because somebody mentioned the project while writing. -->
{#if notes.length}
<section class="notes">
<h2>{t('notes.title')}</h2>
<ul>
{#each notes as note (note.id)}
<li>
<a href="/noter?note={note.id}">
<strong>{note.title || t('notes.untitled')}</strong>
<span>{note.body.slice(0, 90)}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
{:else if status === 'loading'}
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
@@ -809,6 +844,53 @@
</div>
<style>
.notes {
margin-top: var(--s4);
padding-top: var(--s3);
border-top: 1px solid var(--line);
}
.notes h2 {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
margin-bottom: var(--s2);
}
.notes ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.notes a {
display: block;
padding: var(--s2);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
}
.notes a:hover {
background: var(--surface);
}
.notes strong {
display: block;
font-size: var(--text-sm);
font-weight: 560;
color: var(--ink);
}
.notes span {
font-size: var(--text-xs);
color: var(--ink-faint);
}
/* Recessed, because it is a record rather than a plan. The rows keep their own
completed styling; this is about the section around them. */
.done {