Compare commits

...
2 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
Kristian WindandClaude Opus 5 fcd6dca852 fix(push): "der kommer ikke nogen" kunne ikke besvares
Release / Build and publish image (push) Failing after 10m0s
Alt om det var usynligt. Indstillingssiden sagde "til for denne enhed", så snart
der fandtes et abonnement — også når hver eneste push blev afvist. Og en afvist
push gik i en logline på **Debug**, som ikke skrives på en instans, der kører på
standardniveau. Så en levering, der fejler lydløst, og en levering, der aldrig
sker, så ens ud, og forskellen er hele diagnosen.

Tre forskellige problemer havde ét udseende: browseren tilmeldte sig aldrig,
tjenesten afviste den, eller der var ikke noget forfaldent endnu.

**En prøveknap** under Indstillinger → Notifikationer sender én notifikation
gennem den rigtige rute — ikke en genvej, så det, den beviser, er det, der
faktisk skal virke — og siger hvad hver enhed gjorde ved den. Kom den ikke frem,
og afviste ingen den, står der, at det så er browseren eller styresystemet, der
holder den tilbage. Nu peger svaret på ét af de tre steder.

Loglinjen er hævet fra Debug til Warn. Det var den ene linje, der ville have sagt
hvorfor.

Teksten kommer fra klienten, fordi ordbøgerne gør. En server, der bærer sin egen
kopi af "det her er en prøve", bærer den på ét sprog.

**Og en lækage, prøven fandt.** Go pakker en transportfejl som `*url.Error`, og
dens tekst er `Post "https://fcm.googleapis.com/fcm/send/dQw4…": dial tcp: …` —
hele endepunktet, som er den legitimation, push-tjenesten udstedte til den enhed.
Den var på vej ud i svaret, i loglinjen og i ethvert skærmbillede af nogen af dem.
Prøven var skrevet for at bevise, at kun værten blev nævnt, og det var sådan den
blev fundet. Fejlen pakkes ud, og adressen skrubbes bagefter alligevel: den næste
fejltype, der bærer den med, kommer ikke med en advarsel.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:41:33 +02:00
10 changed files with 709 additions and 82 deletions
+47
View File
@@ -2079,6 +2079,53 @@ paths:
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/push/test:
post:
tags: [notifications]
summary: Send one notification to your own devices, and say what happened.
description: |
The answer to "no notifications arrive". A push that a service refuses is
the ordinary failure — a stale subscription, a browser that revoked
permission without telling the server — and every one of them looks
exactly like silence from the outside.
It goes through the same path a real notification takes, so what it proves
is the thing that has to work. The title and body come from the caller
because the dictionaries live in the interface.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
title: { type: string, maxLength: 200 }
body: { type: string, maxLength: 500 }
responses:
"200":
description: What each device did with it.
content:
application/json:
schema:
type: object
properties:
subscriptions:
type: integer
description: Devices registered for this account.
sent:
type: integer
failed:
type: array
items:
type: object
properties:
service:
type: string
description: The push service's host — never the endpoint, which is a credential.
reason:
type: string
description: What the service said.
/api/v1/sections/{sectionID}:
parameters:
- { name: sectionID, in: path, required: true, schema: { type: string } }
+61
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@@ -9,6 +10,7 @@ import (
"testing"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/store"
)
// --- MCP -----------------------------------------------------------------------------
@@ -1468,3 +1470,62 @@ func TestTheMailAddressCanBeRotated(t *testing.T) {
t.Error("the second rotation returned the same address")
}
}
// "Der kommer ingen notifikationer" må kunne besvares.
//
// Alt om det var usynligt: indstillingssiden sagde "til for denne enhed", så snart
// der fandtes et abonnement, og en push, tjenesten afviste, gik i en Debug-linje,
// som ikke skrives på en instans, der kører på standardniveau. Man kunne ikke
// skelne "browseren tilmeldte sig aldrig" fra "tjenesten afviste den" fra "der er
// ikke noget forfaldent endnu" — tre forskellige problemer med ét udseende.
func TestATestNotificationSaysWhatHappenedToIt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Ingen enheder: svaret skal sige nul frem for at lade som om det gik godt.
resp, body := ts.do(t, "POST", "/api/v1/push/test", map[string]any{
"title": "Prøve", "body": "kom den frem?",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
if body["subscriptions"] != float64(0) {
t.Errorf("subscriptions = %v uden tilmeldte enheder", body["subscriptions"])
}
// En enhed, der peger på en tjeneste, som ikke findes. Den skal komme tilbage
// som et navngivent afslag og ikke som tavshed.
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
if err := ts.db.SavePushSubscription(t.Context(), &store.PushSubscription{
UserID: user.ID,
Endpoint: "https://push.example.invalid/abc",
P256dh: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
Auth: "aUZmY2xDS2FQUXJJVmJIeA",
}); err != nil {
t.Fatal(err)
}
_, body = ts.do(t, "POST", "/api/v1/push/test", map[string]any{"title": "Prøve"})
if body["subscriptions"] != float64(1) {
t.Fatalf("subscriptions = %v", body["subscriptions"])
}
failed, _ := body["failed"].([]any)
if len(failed) != 1 {
t.Fatalf("failed har %d poster, want 1 — afslaget blev slugt", len(failed))
}
first, _ := failed[0].(map[string]any)
if first["service"] != "push.example.invalid" {
t.Errorf("service = %v", first["service"])
}
if first["reason"] == "" || first["reason"] == nil {
t.Error("afslaget kom uden en grund")
}
// Og aldrig hele adressen: resten af den er den legitimation, tjenesten gav
// enheden, og den hører hverken i et svar, en log eller et skærmbillede.
if strings.Contains(fmt.Sprint(first), "/abc") {
t.Errorf("endepunktet lækkede med ud: %v", first)
}
}
+120 -4
View File
@@ -1,7 +1,9 @@
package httpapi
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
@@ -106,18 +108,44 @@ func (s *Server) PushToUser(userID, title, body, projectID string) {
s.pushToUser(userID, title, body, projectID)
}
func (s *Server) pushToUser(userID, title, body, projectID string) {
// pushOutcome is what happened, per device.
//
// It exists because "nothing arrives" was unanswerable. A push that a service
// refuses is the ordinary failure here — a stale subscription, a VAPID subject the
// service will not take, a browser that revoked permission without telling the
// server — and every one of them looked exactly like silence.
type pushOutcome struct {
Subscriptions int `json:"subscriptions"`
Sent int `json:"sent"`
Failed []pushFailure `json:"failed"`
}
// pushFailure names the service and quotes what it said.
//
// The host and not the endpoint: the rest of that URL is the credential the push
// service issued for this device, and it does not belong in an answer, a log line
// or a screenshot.
type pushFailure struct {
Service string `json:"service"`
Reason string `json:"reason"`
}
func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
defer cancel()
var out pushOutcome
subs, err := s.db.ListPushSubscriptions(ctx, userID)
if err != nil || len(subs) == 0 {
return
return out
}
out.Subscriptions = len(subs)
public, private, err := s.vapidKeys(ctx)
if err != nil {
s.log.Warn("vapid keys", "err", err)
return
out.Failed = append(out.Failed, pushFailure{Service: "verdande", Reason: err.Error()})
return out
}
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
@@ -128,13 +156,101 @@ func (s *Server) pushToUser(userID, title, body, projectID string) {
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
})
if err == nil {
out.Sent++
continue
}
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
// Warn, not Debug.
//
// This was the one line that would have said why no notification ever
// arrived, and at Debug it is not printed on an instance running at the
// default level. A delivery that fails silently and a delivery that does
// not happen look the same from the outside, and the difference is the
// whole diagnosis.
reason := pushReason(sub.Endpoint, err)
s.log.Warn("web push refused", "reason", reason, "service", pushHost(sub.Endpoint), "user", userID)
out.Failed = append(out.Failed, pushFailure{
Service: pushHost(sub.Endpoint),
Reason: reason,
})
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
s.log.Warn("record push failure", "err", recErr)
}
}
return out
}
// pushReason is what went wrong, with the endpoint taken back out of it.
//
// Go wraps a transport failure as `*url.Error`, and its message is `Post
// "https://fcm.googleapis.com/fcm/send/dQw4w9…": dial tcp: …` — the whole
// endpoint, which is the credential the push service issued for that device. It
// would have gone into the answer, the log line and any screenshot of either. A
// test written to prove the host was reported instead is what found it.
//
// Unwrapped first, so the cause is what is quoted. Then the endpoint is scrubbed
// anyway: the next error type to carry it will not come with a warning.
func pushReason(endpoint string, err error) string {
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Err != nil {
err = urlErr.Err
}
reason := err.Error()
if endpoint != "" {
reason = strings.ReplaceAll(reason, endpoint, pushHost(endpoint))
}
return reason
}
// pushHost is the service an endpoint belongs to, and nothing else from it.
func pushHost(endpoint string) string {
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" {
return "ukendt"
}
return u.Host
}
// handlePushTest sends one notification to this account's devices and says what
// happened to it.
//
// Reported as "der kommer ikke nogen". Everything about that was invisible: the
// settings page said "on for this device" as soon as a subscription existed, and a
// refusal from the push service went into a Debug line nobody sees. There was no
// way to tell "the browser never subscribed" from "the service refused it" from
// "nothing has come due yet" — three different problems with one appearance.
//
// It goes through the same path as a real notification rather than a shortcut, so
// what it proves is the thing that actually has to work.
//
// The words come from the client, which is where the dictionaries are. A server
// that carries its own copy of "This is a test" carries it in one language, and
// this one deliberately keeps its prose in the interface — see
// `internal/i18n`. The text is only ever shown back to the person who asked for
// it, on their own devices.
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
title := strings.TrimSpace(req.Title)
if title == "" {
title = "verdande"
}
// Bounded: this is written into a notification on somebody's lock screen, and
// a megabyte of title is not a notification.
if len(title) > 200 {
title = title[:200]
}
body := strings.TrimSpace(req.Body)
if len(body) > 500 {
body = body[:500]
}
user := userFrom(r.Context())
writeJSON(w, http.StatusOK, s.pushToUser(user.ID, title, body, ""))
}
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
+3
View File
@@ -295,6 +295,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/key", s.handlePushKey)
r.Post("/subscribe", s.handleSubscribePush)
r.Post("/unsubscribe", s.handleUnsubscribePush)
// One notification, to this account's own devices, reporting what
// each of them did with it. The answer to "der kommer ikke nogen".
r.Post("/test", s.handlePushTest)
})
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
+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([]);
});
+2
View File
@@ -366,6 +366,8 @@ export const api = {
pushKey: () => get('/push/key'),
subscribePush: (subscription) => post('/push/subscribe', subscription),
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
// Teksten kommer herfra, fordi ordbøgerne gør. Serveren har ingen.
testPush: (title, body) => post('/push/test', { title, body }),
// --- integrations
getMailAddress: () => get('/mail-address'),
+12
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',
@@ -242,6 +247,13 @@ export const da = {
'push.turnOff': 'Slå fra',
'push.turnOn': 'Slå til på denne enhed',
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
'push.test': 'Send en prøve',
'push.testing': 'Sender …',
'push.testTitle': 'Prøve fra verdande',
'push.testBody': 'Kom den frem, virker notifikationer på denne enhed.',
'push.testSent': 'Sendt til {n} enhed(er). Kom den ikke frem, er det browseren eller styresystemet, der holder den tilbage — se om verdande må sende notifikationer.',
'push.testNone': 'Ingen enheder er tilmeldt endnu. Slå notifikationer til her på enheden først.',
'push.testFailed': '{service} afviste den: {reason}',
'push.recent': 'Seneste',
'push.none': 'Ingenting endnu.',
'push.open': 'Åbn',
+12
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',
@@ -238,6 +243,13 @@ export const en = {
'push.turnOff': 'Turn off',
'push.turnOn': 'Turn on for this device',
'push.perBrowser': 'Applies only to the browser you are in right now.',
'push.test': 'Send a test',
'push.testing': 'Sending …',
'push.testTitle': 'Test from verdande',
'push.testBody': 'If this arrived, notifications work on this device.',
'push.testSent': 'Sent to {n} device(s). If nothing arrived, the browser or the operating system is holding it back — check that verdande is allowed to send notifications.',
'push.testNone': 'No devices are registered yet. Turn notifications on here first.',
'push.testFailed': '{service} refused it: {reason}',
'push.recent': 'Recent',
'push.none': 'Nothing yet.',
'push.open': 'Open',
@@ -35,6 +35,43 @@
}
}
let testing = $state(false);
let testResult = $state(null);
/**
* Én notifikation, hele vejen gennem den rigtige rute, og et svar på hvad der
* skete med den.
*
* Den siger ikke "sendt" og lader det være. Kommer den frem, ved man det, fordi
* den ligger på skærmen; kommer den ikke, står der her, hvem der afviste den og
* med hvilke ord — og hvis ingen afviste den, står der, at det så er browseren
* eller styresystemet, der holder den tilbage. Det er de tre steder, den kan
* blive væk, og nu peger svaret på ét af dem.
*/
async function testPush() {
testing = true;
testResult = null;
try {
const r = await api.testPush(t('push.testTitle'), t('push.testBody'));
if (!r.subscriptions) {
testResult = { bad: true, text: t('push.testNone') };
} else if (r.failed?.length) {
testResult = {
bad: true,
text: r.failed
.map((f) => t('push.testFailed', { service: f.service, reason: f.reason }))
.join(' ')
};
} else {
testResult = { bad: false, text: t('push.testSent', { n: r.sent }) };
}
} catch (e) {
testResult = { bad: true, text: humanMessage(e) };
} finally {
testing = false;
}
}
async function disablePush() {
busy = true;
try {
@@ -165,7 +202,18 @@
<p class="hint">{t('push.on')}</p>
<div class="row">
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
<!-- "Til" er ikke det samme som "virker".
Ruden sagde til, så snart der var et abonnement, og en push, tjenesten
afviste, gik i en logline på Debug, som ingen ser. Tre forskellige
problemer — browseren tilmeldte sig aldrig, tjenesten afviste den,
der er ikke noget forfaldent endnu — så ens ud fra den her side. -->
<button class="secondary" onclick={testPush} disabled={busy || testing}>
{testing ? t('push.testing') : t('push.test')}
</button>
</div>
{#if testResult}
<p class="hint" class:bad={testResult.bad}>{testResult.text}</p>
{/if}
{:else}
<div class="row">
<button class="primary" onclick={enablePush} disabled={busy}>
@@ -261,6 +309,10 @@
{/if}
<style>
.bad {
color: var(--danger);
}
.what {
display: flex;
flex-direction: column;
+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 {