Compare commits

...
6 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 6b2cd0f259 merge: engelske skærmbilleder, og otte steder hvor fladen blev ved med at være dansk
Release / Build and publish image (push) Failing after 9m54s
Skærmbillederne er engelske, fjorten i alt, og der er to mennesker i dem. Det
sidste er ikke pynt: halvdelen af det, programmet er til, findes ikke med én
konto — en ansvarlig, et navn i historikken, en medlemsliste, og "Venter på
andre" som andet end en tom side.

Sproget kommer fra kontoens locale og ikke fra strenge skrevet i hånden, hvilket
er dét, der fandt fejlene: otte steder, hvor grænsefladen blev ved med at være
dansk, uanset hvad kontoen sagde. "Forsinket" og "I dag" skrevet ind i markup,
"Uden sektion" som boardets første kolonne, gentagelsesreglerne halvt oversat,
alle nitten navne på hændelser i historikken, og et `da-DK` hårdkodet i Kommende.
Gentagelsesreglerne er flyttet til `when.js`, så menuen og mærket ikke kan blive
uenige igen — det er præcis, hvad den fil er til.

README's billeder havde været brudte siden `37605ba`, som lavede PNG'erne om til
WebP og slettede dem, uden at teksten fulgte med: forsidebilledet og alle seks i
tabellen var 404 på GitHub. Og noterne — hele funktionen — stod ikke nævnt hverken
i README, på forsiden eller i dokumentationen.

Nu skrives PNG til docs og WebP til site, altså præcis dét, de to steder læser.
Før efterlod hver kørsel tolv usporede PNG'er i site, som intet pegede på.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:34:16 +02:00
Kristian WindandClaude Opus 5 3991ec9445 feat(notes): et billede kan indsættes og trækkes ind i en note
Rapporteret fra brug: man kunne ikke indsætte billeder eller trække filer ind i
noter. Det passede — importen kunne tage billeder med, og intet andet kunne.
Indsætningen tog kun `text/plain` og smed resten væk, og der var slet ingen
håndtering af et træk. At indsætte et skærmbillede er det mest almindelige, der
findes i et noteprogram, og det gjorde ingenting overhovedet.

Der manglede også et sted at lægge filen. Vedhæftninger på en note fandtes i
databasen, siden migration 0021, men kun importen kunne skrive dem; der var ingen
rute. Nu er der en, med editor-rettigheder på noten og samme svar for "findes
ikke" og "ikke din", så et id ikke kan afprøves.

Skrevet ind som `![](…)` og ikke som en data-URL: en note er Markdown i en
database, og et fotografi fra en telefon som base64 midt i teksten ville gøre
noten til fire megabyte tekst, der skal læses hver gang listen tegnes.

`execCommand` frem for at bygge noden selv, fordi det er dét, der lægger
handlingen i browserens egen fortryd-historik — ⌘Z tager billedet ud igen,
ligesom det tager et ord ud.

`preventDefault` på både dragover og drop. Uden den første kommer der intet drop;
uden den anden åbner browseren filen i stedet, og man mister det, man var i gang
med at skrive, til en fane med et billede i. Kun filer: et træk inde fra appen
selv bærer vores egen nyttelast og skal falde igennem til den, der lyttede.

Prøven går begge veje ind, ser at billederne bliver hentet og tegnet, og læser
filen bagefter for at sikre, at der står to bilagsadresser og ingen data-URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:32:03 +02:00
Kristian WindandClaude Opus 5 9d743a5d46 fix(tools): shots skrev PNG'er ned i mappen med WebP
Standardstien var site/screenshots, som siden landingssiden gik over til WebP
kun indeholder WebP. En kørsel lagde tolv PNG'er ved siden af dem, som ingen
tekst peger på. Den skriver nu til docs/, hvor de andre PNG'er ligger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:27:48 +02:00
Kristian WindandClaude Opus 5 71a9fe63d6 docs: noterne findes også i teksten, og hvert billede har et sted at høre til
README'ets billeder havde været brudte siden landingssiden gik over til WebP:
site/screenshots/*.png blev slettet dengang, og markup'en pegede stadig på
dem. Den peger nu på PNG'erne i docs/, som er dem, der findes.

Noter er en af de største ting i appen og stod ikke ét sted i teksten —
hverken i README'et, på landingssiden eller i dokumentationen. Nu gør de,
med de to nye billeder.

Fem billeder pegede ingen tekst på: upcoming, sections, delegated, settings
og admin. De har fået et afsnit i docs/index.md, hvor hvert enkelt beviser
noget, siden siger. Landingssidens gitter viste den lyse udgave to gange —
den ene af dem er blevet til noterne.

Og to billedtekster passede ikke længere på billedet: temaerne viser nu også
de fire udseender, og opgaven i skuffen viser ikke sin samtale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:25:43 +02:00
Kristian WindandClaude Opus 5 eeaaf7506b docs: skærmbillederne er engelske, og der er to mennesker i dem
Landingssiden og dokumentationen læses på engelsk, og billederne var danske
hele vejen — både grænsefladen og indholdet. Kontoen bliver nu oprettet med
engelsk sprog frem for at oversætte noget her: det er sådan appen selv gør
det, og det er også dét, der får en glemt oversættelse til at stå i et
billede i stedet for at vente på en bruger.

Demodataene er skrevet om til engelsk arbejde og husholdning. Og der er en
person mere: en invitation, en konto, tre opgaver hun sidder på og en
kommentar. Halvdelen af det, appen er til for, findes først, når der er to i
den — en ansvarlig på en opgave, et navn i historikken, og "Venter på andre"
som andet end en tom side.

Tre billeder viste ikke det, teksten sagde. `detail` var en tom formular, så
opgaven har fået beskrivelse, undertasks, en fil, en påmindelse og en samtale.
`themes` viste profilfeltet over temaerne, så den ruller nu ned til dem.
`delegated` var en tom tilstand.

Noterne er kommet til — listen med en note åben, og den samme note som kilde.
Fjorten billeder i alt.

WebP'en skrives af den browser, der i forvejen står åben, frem for af cwebp:
et program mere at installere hører ikke til i et script, hvis hele pointe er,
at der ikke er noget at installere. PNG'erne ligger nu kun i docs/ og WebP'en
kun i site/, hvilket er præcis dét, de to steder læser — før lagde et kørsel
tolv usporede PNG'er i site/, som ingen brugte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:24:12 +02:00
Kristian WindandClaude Opus 5 dce8800f93 fix(web): otte steder hvor grænsefladen blev ved med at være dansk
Skærmbillederne skulle tages på engelsk, og det var dét, der fandt dem:
en konto sat til engelsk fik stadig "Forsinket" over de forsinkede opgaver,
"Uden sektion" over den første kolonne på tavlen, "Aldrig" i gentagelses-
menuen, "hver dag" på rækken, "til instansen · inviteret af" på invitationen,
"3 projekter" på kontoen, og hele historikken — nitten hændelser, alle på
dansk. En funktion, der kun virker på det ene sprog, er en funktion, der er i
stykker.

Gentagelsen er den eneste, der ikke bare manglede en nøgle. Serveren skriver
`recurrence_text` på dansk og skal blive ved med det: én besked går over
WebSocket til alle i projektet på én gang, og de læser ikke det samme sprog.
Så de regler, der har et navn, får det i when.js — på den side, der ved, hvis
skærm det er — og alt mere specifikt falder tilbage til serverens ord.

Mellemrummet i historikken er skrevet ud. Svelte fjerner det, der åbner en
blok, så "inviterede" og adressen løb sammen til ét ord.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:23:42 +02:00
51 changed files with 868 additions and 144 deletions
+19 -9
View File
@@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
</p>
---
@@ -35,6 +35,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
lists, quotes, fenced code. A note is shared by filing it in a project;
`#project` and `[[another note]]` are links, and a task shows the notes that
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
the exporter in [`tools/`](tools/) — and comes back out the same shape.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
@@ -90,6 +95,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project templates | Done, tested |
| Nightly backups with rotation | Done, tested |
| Comments, attachments, notifications, Web Push | Built |
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
| Notes in and out: a zip of Markdown, both directions | Done, tested |
| Todoist CSV import and export, account export | Done, tested |
| MCP server for Claude | Done, tested |
| CalDAV server (two-way VTODO) | Done, tested |
@@ -116,17 +123,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
| | |
|---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) |
| ![Board view](docs/screenshots/board.png) | ![Light theme](docs/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. |
| ![A week on Upcoming](site/screenshots/week.png) | ![A task, opened](site/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
| ![The five themes](site/screenshots/themes.png) | ![Instance history](site/screenshots/history.png) |
| Five themes, all measured for contrast. | What has been done on the server. |
| ![A week on Upcoming](docs/screenshots/week.png) | ![A task, opened](docs/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
| ![Notes](docs/screenshots/notes.png) | ![The same note as Markdown](docs/screenshots/notes-source.png) |
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
| ![Themes and looks](docs/screenshots/themes.png) | ![Instance history](docs/screenshots/history.png) |
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
the real binary, creates its own account, fills it with demo data and photographs
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
still works, and is the way to photograph an instance that is already running.)
the real binary, creates its own account and a second one to share with, fills them
with demo data and photographs fourteen surfaces — nothing to set up and nothing to
remember. (`go run ./tools/shots` still works, and is the way to photograph an
instance that is already running.)
## Running it
+30
View File
@@ -22,6 +22,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views, and foldable groups over the projects in the sidebar.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
shared by filing it in a project; `#project` and `[[another note]]` are links,
and a task shows what has been written about it.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
@@ -31,6 +34,33 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
open standards rather than private formats.
## What it looks like
![Upcoming as a month](screenshots/upcoming.png)
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
dragged from one day onto another.*
![A project as a list](screenshots/sections.png)
*The same project as a list, where sections read as bands. As a board they are
columns.*
![Waiting on others](screenshots/delegated.png)
*What you have handed to somebody else, grouped by whoever has it.*
![Settings, integrations](screenshots/settings.png)
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
[mail-to-task address](mail.md) are connected.*
![Settings, people](screenshots/admin.png)
*Inviting somebody to the instance. There is no open registration — everybody
arrives through a link, and picks their own password. See
[signing in](signing-in.md).*
## Getting it running
verdande is built to be deployed as a **Rune** in
+39
View File
@@ -1396,6 +1396,45 @@ paths:
description: The environment variables that are not set.
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/notes/{noteID}/attachments:
post:
tags: [notes]
summary: Put a file into a note.
description: |
A picture pasted or dropped into a note. Until this existed the import
could bring files in and nothing else could, so the most ordinary act in
a notes app — paste a screenshot — did nothing at all.
Editor rights on the note. The same answer for "no such note" and "not
yours", so an id cannot be probed.
The response carries the attachment's URL; the editor writes that into the
note's text as a Markdown image. The file is never inlined as a data URL —
a photograph as base64 would make the note itself megabytes of text.
parameters:
- name: noteID
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
"201":
description: Stored, and hung on the note.
"404":
description: No such note, or not yours to write in.
"413":
description: Larger than 25 MB.
/api/v1/beacon:
post:
tags: [auth]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 180 KiB

+67
View File
@@ -6,7 +6,9 @@ import (
"io"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
@@ -579,3 +581,68 @@ func mimeOf(name string) string {
}
return "application/octet-stream"
}
// handleUploadNoteAttachment takes a file into a note.
//
// The import could already do this, and nothing else could: a note brought in
// from Apple Notes arrived with its pictures, and a note written here could not
// be given one. Pasting a screenshot into a note is the most ordinary thing there
// is, and it did nothing at all.
//
// Editor rights on the note, checked the same way every other write to it is. The
// answer for "no such note" and "not yours" is the same, so an id cannot be
// probed by watching which one comes back.
func (s *Server) handleUploadNoteAttachment(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeFieldErrors(w, map[string]string{"file": "required"})
return
}
defer file.Close()
if header.Size > maxUploadBytes {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, r, "store upload", err)
return
}
user := userFrom(r.Context())
a := &store.Attachment{
NoteID: n.ID,
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, r, "record attachment", err)
return
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
+4
View File
@@ -301,6 +301,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
// A picture pasted or dropped into a note. The import could bring
// files in and nothing else could, which made the most ordinary act
// in a notes app — paste a screenshot — do nothing at all.
r.Post("/{noteID}/attachments", s.handleUploadNoteAttachment)
})
r.Route("/mailboxes", func(r chi.Router) {
+16 -6
View File
@@ -621,13 +621,13 @@
</figure>
<figure class="shot">
<img
src="screenshots/today-light.webp"
alt="The same Today view in the light theme."
src="screenshots/notes.webp"
alt="Notes: a list of notes on the left, one open on the right with headings, bullet lists, a quotation and a shell command in a code block."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>The light theme.</figcaption>
<figcaption>Notes. Rich text on top, Markdown underneath.</figcaption>
</figure>
<figure class="shot">
<img
@@ -642,7 +642,7 @@
<figure class="shot">
<img
src="screenshots/detail.webp"
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
alt="A task open in the detail drawer, with its description, priority, due date, labels, sub-tasks and an attached file."
width="1400"
height="900"
loading="lazy"
@@ -652,12 +652,12 @@
<figure class="shot">
<img
src="screenshots/themes.webp"
alt="The settings page showing the five themes as swatches."
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Five themes, all of them measured for contrast.</figcaption>
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
</figure>
<figure class="shot">
<img
@@ -689,6 +689,16 @@
invite link. Changes appear live.
</p>
</div>
<div class="cell">
<h3>Notes, beside the work</h3>
<p>
Rich text to write in, Markdown on disk. File a note in a project and
everybody in it can read it; <code>#project</code> and
<code>[[another note]]</code> are links, and a task shows what has been
written about it. A zip of Markdown goes in and comes back out the same
shape, so nothing is trapped here.
</p>
</div>
<div class="cell">
<h3>A real CalDAV server</h3>
<p>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+4 -2
View File
@@ -5,7 +5,7 @@
// cookie. Kept in the repository so the images can be regenerated when the
// interface changes, rather than being a thing somebody did once by hand.
//
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out site/screenshots
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out docs/screenshots
package main
import (
@@ -28,7 +28,9 @@ var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
func main() {
base := flag.String("url", "http://localhost:8096", "the running instance")
cookie := flag.String("cookie", "", "session cookie value")
out := flag.String("out", "site/screenshots", "where to write the PNGs")
// docs/, not site/: site/screenshots holds the WebP the landing page reads, and
// PNGs dropped in beside them are files nothing points at.
out := flag.String("out", "docs/screenshots", "where to write the PNGs")
width := flag.Int("width", 1400, "viewport width")
height := flag.Int("height", 900, "viewport height")
flag.Parse()
+65
View File
@@ -2434,3 +2434,68 @@ test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async
// andet skal stadig være stille.
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
});
test('et billede kan indsættes og trækkes ind i en note', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: '# Med billeder\n\nEn linje.\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed).toBeVisible();
// Et rigtigt lille PNG, bygget i browseren, så prøven ikke har brug for en fil
// på disken — og lagt i både en paste og et drop, som er de to veje ind.
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
for (const kind of ['paste', 'drop']) {
await ed.click();
await page.evaluate(
async ({ kind, png }) => {
const bytes = Uint8Array.from(atob(png), (c) => c.charCodeAt(0));
const file = new File([bytes], `${kind}.png`, { type: 'image/png' });
const dt = new DataTransfer();
dt.items.add(file);
const ed = document.querySelector('.page[contenteditable]');
ed.focus();
ed.dispatchEvent(
kind === 'paste'
? new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
: new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })
);
},
{ kind, png }
);
await page.waitForTimeout(1200);
}
// To billeder, begge hentet og tegnet — ikke to stykker tekst med ![]( i.
await expect(ed.locator('img')).toHaveCount(2);
const loaded = await ed.locator('img').evaluateAll((els) =>
els.filter((e) => e.complete && e.naturalWidth > 0).length
);
expect(loaded).toBe(2);
// Og de overlever turen til Markdown: filen skal pege på et bilag, ikke på en
// data-URL, som ville lægge fotografiet i noteteksten.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body.match(/!\[\]\(\/api\/v1\/attachments\/[0-9a-f-]+\)/g)).toHaveLength(2);
expect(body).not.toContain('data:image');
expect(trouble).toEqual([]);
});
+370 -70
View File
@@ -9,17 +9,22 @@
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a
* running instance and a session cookie pasted in by hand — and which therefore
* ran rarely enough that the images fell many rounds behind the app they show.
* This one starts the real binary, creates its own account, fills it with the
* demo data, and takes the pictures. Nothing to set up and nothing to remember.
* This one starts the real binary, creates its own two accounts, fills them with
* the demo data, and takes the pictures. Nothing to set up and nothing to remember.
*
* The binary, not the dev server: the screenshots should show what ships. That
* means building the frontend and copying it into cmd/verdande/webbuild first,
* exactly as the smoke tests do — a capture of a stale embedded build would be a
* picture of an app nobody is running.
*
* English, because the people who read the landing page and the docs read English.
* Not by translating anything here: the account is created with an English locale
* and the app is then in English by itself, which also means a string nobody
* translated shows up in a picture instead of hiding until a user finds it.
*/
import { chromium } from '@playwright/test';
import { spawn, execSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
@@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data');
const PORT = 8098;
const baseURL = `http://127.0.0.1:${PORT}`;
const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' };
// Somebody to share with. Half of what the app is for only exists once there are
// two people in it: an assignee on a task, a name in the history, a face in the
// members list, and "Waiting on others" as something other than an empty page.
const COLLEAGUE = { name: 'Mette', email: 'mette@example.dk', password: 'et andet langt kodeord' };
// 1400×900 is what the landing page's <img> declares. A capture at another size
// would be resampled by the browser and look soft next to the text beside it.
const WIDTH = 1400;
const HEIGHT = 900;
const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')];
// The docs and the README read the PNGs; the landing page reads the WebP, which is
// a fifth of the weight over the wire. Both are written from the same capture, so
// the two sets can never drift apart.
const PNG_DIR = join(repo, 'docs', 'screenshots');
const WEBP_DIR = join(repo, 'site', 'screenshots');
/**
* The pictures, and why each one is here.
@@ -54,31 +67,64 @@ const SHOTS = [
// The same project as a list, which is where sections read as bands rather than
// as columns — and the shape most people actually work in.
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
// By address rather than by clicking the first row: the drawer has to be over
// the one task that was given a description, sub-tasks, a file, a reminder and a
// conversation, or the picture shows an empty form and proves nothing.
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
// Scrolled, because the themes are the second panel on the page and a picture
// captioned "five themes" that shows the profile form is a caption about
// nothing.
{ name: 'themes', path: '/indstillinger', theme: 'dark', prepare: showThemes },
{ name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' },
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' }
];
const NOTE_TITLE = 'Launch meeting — agenda';
async function showList(page) {
await page.getByRole('button', { name: 'Liste', exact: true }).click();
await page.getByRole('button', { name: 'List', exact: true }).click();
await page.waitForTimeout(600);
}
async function showMonth(page) {
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await page.getByRole('button', { name: 'Month', exact: true }).click();
await page.waitForTimeout(800);
}
async function showWeek(page) {
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await page.getByRole('button', { name: 'Week', exact: true }).click();
await page.waitForTimeout(800);
}
async function openFirstTask(page) {
await page.locator('.row').first().click();
async function openMeetingNote(page) {
// By text inside the row rather than by its accessible name: a row's name is its
// title, its date, the first line of its body and the project it is filed in,
// all run together.
await page.locator('button.row', { hasText: NOTE_TITLE }).first().click();
await page.waitForTimeout(800);
}
async function showNoteSource(page) {
await openMeetingNote(page);
await page.getByRole('button', { name: 'Show as code' }).click();
await page.waitForTimeout(600);
}
async function showThemes(page) {
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
// already a few pixels inside the viewport when the page loads, so "if needed"
// decides nothing is needed and the picture is of the profile form above it.
//
// `.first()`, because the looks below the themes carry the same heading — one
// panel, two questions, and both of them called Appearance.
await page
.getByRole('heading', { name: 'Appearance' })
.first()
.evaluate((el) => el.closest('section').scrollIntoView({ block: 'start' }));
await page.waitForTimeout(600);
}
@@ -114,29 +160,42 @@ let browser;
try {
await waitForHealth();
browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: WIDTH, height: HEIGHT },
// The same pinning the smoke tests use, and for the same reason: the sign-up
// form sends navigator.language as the account's locale, and every due date
// is resolved in the account's zone.
locale: 'da-DK',
timezoneId: 'Europe/Copenhagen',
deviceScaleFactor: 2
});
// The browser's own language, which is not the same setting as the context's.
// `<input type="date">` is drawn by Chromium, not by the app, and it takes its
// order from this one — so without it the drawer shows an American date inside
// a day-month app, which is the exact thing i18n.svelte.js warns about.
browser = await chromium.launch({ args: ['--lang=en-GB'] });
const context = await browser.newContext(viewing());
const page = await context.newPage();
await page.goto(baseURL);
// The sign-up form is the one screen that is always Danish: it is drawn before
// there is an account to read a language off, and Danish is the fallback. So
// these labels are Danish on purpose, and the app is English from the moment
// the account exists — which is every screen this script photographs.
await page.getByLabel(/Navn/).fill(USER.name);
await page.getByLabel(/E-mail/).fill(USER.email);
await page.getByLabel(/Adgangskode/).fill(USER.password);
await page.getByRole('button', { name: 'Opret konto' }).click();
await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
console.log('seeding…');
const ids = await page.evaluate(seed);
const ids = await page.evaluate(seed, { colleague: COLLEAGUE.email, noteTitle: NOTE_TITLE });
mkdirSync(OUT[0], { recursive: true });
mkdirSync(OUT[1], { recursive: true });
console.log('adding the second person…');
const ids2 = await addColleague(browser, ids);
Object.assign(ids, ids2);
await page.reload();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
mkdirSync(PNG_DIR, { recursive: true });
mkdirSync(WEBP_DIR, { recursive: true });
// A page of its own for the WebP encoding, on about:blank: the app's pages send
// a content security policy, and an encoder that borrows whichever page was last
// photographed would be one policy change away from silently producing nothing.
const encoder = await context.newPage();
await encoder.goto('about:blank');
for (const shot of SHOTS) {
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
@@ -151,9 +210,9 @@ try {
await page.waitForTimeout(400);
}
const png = await page.screenshot();
writeFileSync(join(OUT[0], `${shot.name}.png`), png);
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
console.log(` ${shot.name}.png`);
writeFileSync(join(PNG_DIR, `${shot.name}.png`), png);
writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png));
console.log(` ${shot.name}`);
}
} finally {
await browser?.close();
@@ -166,6 +225,25 @@ try {
rmSync(dataDir, { recursive: true, force: true });
}
/** The browser settings every context here shares. */
function viewing() {
return {
viewport: { width: WIDTH, height: HEIGHT },
// The locale is not a formatting preference: the sign-up form sends
// navigator.language as the account's language, so this is what makes the
// interface, the Inbox's name and every date English. The zone is pinned for
// the same reason the smoke tests pin it — every due date is resolved in it.
locale: 'en-GB',
timezoneId: 'Europe/Copenhagen',
// Dark, because that is what the shots are forced to below. app.html reads
// prefers-color-scheme before first paint, and Playwright's default is light
// — which left the settings page rendering dark with the *light* theme
// ringed as the chosen one.
colorScheme: 'dark',
deviceScaleFactor: 2
};
}
async function waitForHealth() {
for (let i = 0; i < 240; i++) {
try {
@@ -179,6 +257,92 @@ async function waitForHealth() {
throw new Error('the server never came up');
}
/**
* Encodes a PNG as WebP, in the browser that is already open.
*
* Chromium encodes WebP itself, so the alternative — shelling out to `cwebp` —
* would be something to install first, in a script whose whole point is that there
* is nothing to install. Resized on the way through: the landing page shows these
* 1400 CSS pixels wide, and a full-resolution WebP is twice the bytes for detail
* no screen renders.
*/
async function toWebP(encoder, png) {
const base64 = await encoder.evaluate(
async ({ data, width, quality }) => {
const image = new Image();
image.src = `data:image/png;base64,${data}`;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = Math.round((image.naturalHeight / image.naturalWidth) * width);
const ctx = canvas.getContext('2d');
// The default resampler leaves the interface's hairlines ragged, which on a
// screenshot of a screenshot is the first thing the eye finds.
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/webp', quality).split(',')[1];
},
// 0.75 is cwebp's default and where these stop getting smaller for anything
// the eye finds: 0.85 is a fifth more bytes for a difference that survives
// only under magnification, and the landing page's first image is the one
// somebody waits for.
{ data: png.toString('base64'), width: 1800, quality: 0.75 }
);
return Buffer.from(base64, 'base64');
}
/**
* Signs the second person up through the invite the seed left behind, has them say
* something, and hands their work back to the first.
*
* A context of its own because a session is a cookie: signing up in the page the
* screenshots are taken from would replace the account they are taken as.
*/
async function addColleague(browser, ids) {
const context = await browser.newContext(viewing());
const page = await context.newPage();
try {
await page.goto(ids.invite);
// Danish labels again, and for the same reason: nobody is signed in yet.
await page.getByLabel(/Navn/).fill(COLLEAGUE.name);
await page.getByLabel(/Adgangskode/).fill(COLLEAGUE.password);
await page.getByRole('button', { name: 'Opret konto' }).click();
await page.getByRole('navigation', { name: 'Main menu' }).waitFor();
return await page.evaluate(handOver, ids);
} finally {
await context.close();
}
}
/**
* What the second person does, from inside their own session: answer the task they
* were opened on, and take the three they are being waited for.
*
* The assignments are made here rather than by the owner because an editor may
* assign, and doing it from this side proves it. Written as one function because it
* is passed to the browser as source.
*/
async function handOver(ids) {
const api = async (path, body, method = 'POST') => {
const r = await fetch(`/api/v1${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
return r.status === 204 ? null : r.json();
};
const me = await api('/auth/me', null, 'GET');
for (const id of ids.delegate) await api(`/tasks/${id}`, { assignee_id: me.id }, 'PATCH');
await api(`/tasks/${ids.detail}/comments`, {
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
});
return { colleague: me.id };
}
/**
* The demo data, created through the app's own API from inside the page.
*
@@ -186,11 +350,11 @@ async function waitForHealth() {
* header the API insists on come along without being reconstructed here. Written
* as one function because it is passed to the browser as source.
*
* The content is ordinary Danish household and work tasks. Screenshots of "Task
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
* thing it is for.
* Ordinary English household and work tasks, the same shape the Danish set had.
* Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app
* is any good at the thing it is for.
*/
async function seed() {
async function seed({ colleague, noteTitle }) {
const api = async (path, body, method = 'POST') => {
const r = await fetch(`/api/v1${path}`, {
method,
@@ -210,63 +374,199 @@ async function seed() {
};
// Two headings over the projects, which is what the sidebar grew for.
const arbejde = await api('/project-groups', { name: 'Arbejde' });
const privat = await api('/project-groups', { name: 'Privat' });
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
const work = await api('/project-groups', { name: 'Work' });
const home = await api('/project-groups', { name: 'Home' });
await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH');
await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH');
const work = await api('/projects', {
name: 'Sæsonstart',
const launch = await api('/projects', {
name: 'Autumn launch',
color: 'indigo',
view_mode: 'board',
group_id: arbejde.id
group_id: work.id
});
const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id });
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
const house = await api('/projects', { name: 'House', color: 'amber', group_id: home.id });
const garden = await api('/projects', { name: 'Garden', color: 'green', group_id: home.id });
const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' });
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
const planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' });
const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' });
const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' });
const tasks = [
// One thing that slipped. Today with an overdue band on it is what a Thursday
// actually looks like, and it is the only way to photograph the band at all.
{
content: 'Chase the printer about the labels',
due_date: day(-2),
priority: 2,
project_id: launch.id,
section_id: inFlight.id,
labels: ['waiting']
},
// Today, in the Inbox and across the projects: the Today view is the first
// picture and it has to look like a real day.
{ content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id },
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
{ content: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id },
{
content: 'Skriv oplæg til sæsonmødet',
content: 'Write the opening notes for the launch meeting',
due_date: day(0),
priority: 2,
project_id: work.id,
project_id: launch.id,
section_id: planning.id,
duration_min: 90
duration_min: 90,
labels: ['meeting']
},
{ content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
{ content: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] },
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
// The next couple of weeks, so Kommende and the month grid have something in
// The next couple of weeks, so Upcoming and the month grid have something in
// them rather than a row of empty cells.
{ content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id },
{ content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id },
{ content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
{ content: 'Skift dæk', due_date: day(8), priority: 3 },
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
{ content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id },
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
{ content: 'Cut the beech hedge back', due_date: day(2), project_id: garden.id },
{ content: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id },
{ content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id },
{ content: 'Swap the winter tyres on', due_date: day(8), priority: 3 },
{ content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id },
{ content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 },
{ content: 'Sow winter herbs in the cold frame', due_date: day(15), project_id: garden.id },
{ content: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.id },
{ content: 'Clear the gutters before the autumn rain', due_date: day(26), project_id: house.id },
// A few without dates, so the board has cards that are not all deadlines.
{ content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
{ content: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 },
{ content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id },
{ content: 'Update the price list', project_id: launch.id, section_id: review.id, labels: ['review'] }
];
for (const task of tasks) await api('/tasks', task);
const made = {};
for (const task of tasks) made[task.content] = await api('/tasks', task);
// A saved filter, because the sidebar shows them and an empty heading reads as
// a feature that does not work.
await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' });
await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' });
return { work: work.id, house: house.id, garden: garden.id };
// The one task that is photographed open. Everything the drawer can hold, so the
// picture answers "what is in there" rather than showing an empty form.
const detail = made['Write the opening notes for the launch meeting'];
await api(
`/tasks/${detail.id}`,
{
description:
'Fifteen minutes, no slides. What shipped over the summer, what is left, and what we are deliberately not doing before the first.'
},
'PATCH'
);
for (const content of [
'Pull the numbers from last season',
'Ask Mette for the packaging quotes',
'Book the room'
]) {
await api('/tasks', { content, parent_id: detail.id, project_id: launch.id });
}
await api(`/tasks/${detail.id}/reminders`, { offset_min: 60 });
await api(`/tasks/${detail.id}/comments`, {
body: 'Keeping it to three points. Anything else goes on the list for the week after.'
});
// A file on the task, because "comments, attachments" is a claim and an empty
// Files heading is the opposite of proof.
const form = new FormData();
form.append('file', new Blob([new Uint8Array(9400)], { type: 'application/pdf' }), 'packaging-quote.pdf');
await fetch(`/api/v1/tasks/${detail.id}/attachments`, { method: 'POST', body: form });
// The notes, newest last: the list opens on what was touched most recently, so
// the one the screenshots open is the one written last.
const notes = [
{
body: `# Reading list
- *Shape Up* — the six-week bit, not the pitch bit
- The SQLite WAL paper, again
- Anything at all about pricing`
},
{
project_id: garden.id,
body: `# The bulb order
Two hundred, split three ways, in before the first frost:
1. Narcissus along the south wall
2. Alliums in the bed by the shed
3. Crocus wherever there is room
The bed by the shed is heavy — grit in the hole, or they rot. #Garden`
},
{
body: `# What the roofer said
Ridge tiles have moved, maybe a metre of them. Not urgent, not something to leave
over a winter either.
- Quote by the end of the month
- Two days' work, scaffold on the north side
- Ask whether the gutters can be done at the same time
#House`
},
{
project_id: launch.id,
body: `# Price list — what changes
Nothing has been looked at since March.
- 250 g bag: **79 → 85**
- 1 kg bag: **265 → 279**
- Subscription: **199 → 209**
Subscribers keep the old price for three months. That is the whole of the goodwill
budget and it is worth it.`
},
{
pinned: true,
project_id: launch.id,
body: `# ${noteTitle}
Thursday, fifteen minutes, no slides.
## What shipped over the summer
- The new packaging, minus the labels
- A CalDAV feed nobody asked for and everybody uses
## What is left
- Labels. Still with the printer, chased on Thursday
- The price list: [[Price list — what changes]]
> If it is not on the list by Friday it is not in the launch.
The build we cut the release from:
\`\`\`bash
cd web && npm run build && cd ..
go build -tags embedweb -o verdande ./cmd/verdande
\`\`\`
Room is booked. Coffee is **not** my job this week.
The opening notes are a task, so they can be ticked off: task:${detail.id}`
}
];
for (const note of notes) await api('/notes', note);
// Shared with somebody, and an invite left standing to the instance itself: two
// of the three states the members page has, and the one it never showed before.
const invite = await api(`/projects/${launch.id}/invites`, { email: colleague, role: 'editor' });
await api('/users', { email: 'anders@example.dk' });
return {
work: launch.id,
detail: detail.id,
invite: invite.link,
delegate: [
made['Send the minutes round'].id,
made['Update the price list'].id,
made['Find a new packaging supplier'].id
]
};
}
+1
View File
@@ -337,6 +337,7 @@ export const api = {
deleteComment: (id) => del(`/comments/${id}`),
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
deleteAttachment: (id) => del(`/attachments/${id}`),
attachmentURL: (id) => `/api/v1/attachments/${id}`,
+1 -1
View File
@@ -64,7 +64,7 @@
// The unsectioned column comes first, as it does in the list view: it is where
// quick add puts things, so it is where new work appears.
let columns = $derived([
{ id: '', name: 'Uden sektion' },
{ id: '', name: t('project.noSection') },
...sections.map((s) => ({ id: s.id, name: s.name }))
]);
+93
View File
@@ -17,6 +17,7 @@
import { highlight, guessLanguage } from '$lib/highlight.js';
import { t } from '$lib/i18n.svelte.js';
import { app } from '$lib/stores.svelte.js';
import { api, humanMessage } from '$lib/api.js';
import { colorVar } from '$lib/colors.js';
let { note, onchange, onsave } = $props();
@@ -399,12 +400,92 @@
// Pasted text arrives as whatever the source was — a web page brings its fonts,
// its colours and its layout with it. Only the words are wanted.
/**
* Filer ind i noten — indsat eller trukket.
*
* Importen kunne tage billeder med, og intet andet kunne. At indsætte et
* skærmbillede i en note er det mest almindelige, der findes i et noteprogram,
* og det gjorde ingenting overhovedet: indsætningen tog kun `text/plain` og
* smed resten væk, og der var slet ingen håndtering af et træk.
*
* Filen lægges op og skrives ind som `![](…)`. Ikke som en data-URL: en note
* er Markdown i en database, og et fotografi fra en telefon som base64 midt i
* teksten ville gøre noten til fire megabyte tekst, som skal læses hver gang
* listen tegnes.
*
* En note, der ikke er gemt endnu, kan ikke bære en fil — den har intet id at
* hænge den på. Derfor gemmes den først, og det siges ikke til nogen, fordi
* det er en teknisk detalje og ikke noget, brugeren har bedt om at vide.
*/
async function insertFiles(files) {
const list = [...files].filter((f) => f && f.size > 0);
if (!list.length || !note?.id) return false;
uploading = true;
try {
for (const file of list) {
const saved = await api.uploadNoteFile(note.id, file);
// execCommand frem for at bygge noden selv: det er det, der lægger
// handlingen i browserens egen fortryd-historik, så ⌘Z tager billedet
// ud igen ligesom det tager et ord ud.
editor?.focus();
document.execCommand('insertHTML', false,
`<img src="${saved.url}" alt="">`);
}
changed();
onsave?.();
return true;
} catch (e) {
app.toast(humanMessage(e));
return false;
} finally {
uploading = false;
}
}
let uploading = $state(false);
function onpaste(event) {
// Filer først. Et skærmbillede i udklipsholderen kommer med som en fil og
// som ingenting andet, så hvis der er en, er det den, der skal ind.
const files = event.clipboardData?.files ?? [];
if (files.length) {
event.preventDefault();
insertFiles(files);
return;
}
event.preventDefault();
const text = event.clipboardData?.getData('text/plain') ?? '';
document.execCommand('insertText', false, text);
changed();
}
/**
* Trukket ind udefra.
*
* `preventDefault` på begge: uden den på dragover kommer der ingen drop, og
* uden den på drop åbner browseren filen i stedet — man mister det, man var i
* gang med at skrive, til en fane med et billede i.
*
* Kun filer. Et træk inde fra appen selv — en opgave, en note — bærer vores
* egen nyttelast og skal falde igennem til den, der lyttede efter den.
*/
function ondragover(event) {
if (!event.dataTransfer?.types?.includes('Files')) return;
event.preventDefault();
dropping = true;
}
function ondrop(event) {
const files = event.dataTransfer?.files ?? [];
dropping = false;
if (!files.length) return;
event.preventDefault();
insertFiles(files);
}
let dropping = $state(false);
</script>
<div class="wrap" bind:this={wrap}>
@@ -508,6 +589,10 @@
onmouseup={readState}
{onkeydown}
{onpaste}
{ondragover}
{ondrop}
ondragleave={() => (dropping = false)}
class:dropping
></div>
{#if suggestions.length}
@@ -737,6 +822,14 @@
display: none;
}
/* Mens en fil holdes over noten. En ramme frem for en farvet flade: teksten
under skal stadig kunne læses, så man kan se hvor billedet lander. */
.page.dropping {
outline: 2px dashed var(--accent);
outline-offset: 4px;
border-radius: var(--radius-sm);
}
/* Kildeteksten. Monospace, fordi det er en fil — og fordi indrykningen i en
liste er indhold her og ikke pynt: den skal kunne tælles. */
.source {
+3 -13
View File
@@ -14,6 +14,7 @@
import { app, projectName } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
let { task, onclose } = $props();
@@ -29,17 +30,6 @@
/** Who the task can be given to. Only ever more than one on a shared project. */
let members = $state([]);
// The rules worth a menu entry. Anything more specific is typed into the
// title, where the parser understands far more than a list ever could.
const REPEATS = [
{ rule: '', label: 'Aldrig' },
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
];
let subtasks = $state([]);
let comments = $state([]);
let attachments = $state([]);
@@ -452,13 +442,13 @@
<label for="repeat">{t('detail.repeats')}</label>
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
{#each REPEATS as option (option.rule)}
<option value={option.rule}>{option.label}</option>
<option value={option.rule}>{t(option.label)}</option>
{/each}
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
showing it rather than snapping to the nearest preset is the
difference between a menu and a menu that quietly edits. -->
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
<option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
{/if}
</select>
</div>
+7 -4
View File
@@ -10,9 +10,12 @@
*/
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { repeatLabel } from '$lib/when.js';
let { task, onedit } = $props();
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
let leaving = $state(false);
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
@@ -84,7 +87,7 @@
<span class="description">{task.description}</span>
{/if}
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
<span class="meta">
{#if assignee}
<!-- First in the row, because "who" changes what the rest of the line
@@ -102,15 +105,15 @@
{due.text}{#if time}&nbsp;{time}{/if}
</span>
{/if}
{#if task.recurrence_text}
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
{#if repeats}
<span class="repeat" title={t('task.repeats', { rule: repeats })}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 014-4h14" />
<path d="M7 22l-4-4 4-4" />
<path d="M21 13v2a4 4 0 01-4 4H3" />
</svg>
{task.recurrence_text}
{repeats}
</span>
{/if}
<!-- What is underneath, said as a fraction rather than a total: "3" tells
+32 -24
View File
@@ -1,40 +1,48 @@
/**
* What each recorded activity event is called.
*
* The keys are the server's and they are stable; this is the only place they
* become Danish. It lives in its own module because two surfaces read the same log
* now — a project's own history panel, and the administrator's instance-wide audit
* page — and two copies of nineteen strings is two places for them to drift apart.
* The keys are the server's and they are stable; this is the only place they get a
* name. It lives in its own module because two surfaces read the same log now — a
* project's own history panel, and the administrator's instance-wide audit page —
* and two copies of twenty strings is two places for them to drift apart.
*
* The names themselves are in the dictionaries rather than here. They were written
* out in Danish, which made the whole of both history pages Danish for somebody
* reading the rest of the app in English.
*
* An unknown key falls through to the key itself rather than being hidden: a build
* that records something this table has not learned yet should still show that it
* happened.
*/
import { t } from './i18n.svelte.js';
export const EVENTS = {
'project.created': 'oprettede projektet',
'project.updated': 'ændrede projektet',
'project.deleted': 'slettede projektet',
'project.imported': 'importerede projektet',
'section.created': 'oprettede en sektion',
'section.updated': 'ændrede en sektion',
'section.deleted': 'slettede en sektion',
'member.invited': 'inviterede',
'member.added': 'tilføjede',
'member.removed': 'fjernede',
'member.role_changed': 'ændrede rollen for',
'task.created': 'oprettede en opgave',
'task.updated': 'ændrede en opgave',
'task.completed': 'lukkede en opgave',
'task.reopened': 'genåbnede en opgave',
'task.moved': 'flyttede en opgave',
'task.deleted': 'slettede en opgave',
'task.split': 'delte en opgave op',
'comment.created': 'skrev en kommentar'
'project.created': 'event.projectCreated',
'project.updated': 'event.projectUpdated',
'project.deleted': 'event.projectDeleted',
'project.imported': 'event.projectImported',
'section.created': 'event.sectionCreated',
'section.updated': 'event.sectionUpdated',
'section.deleted': 'event.sectionDeleted',
'member.invited': 'event.memberInvited',
'member.added': 'event.memberAdded',
'member.removed': 'event.memberRemoved',
'member.role_changed': 'event.memberRoleChanged',
'task.created': 'event.taskCreated',
'task.updated': 'event.taskUpdated',
'task.completed': 'event.taskCompleted',
'task.reopened': 'event.taskReopened',
'task.moved': 'event.taskMoved',
'task.deleted': 'event.taskDeleted',
'task.split': 'event.taskSplit',
'comment.created': 'event.commentCreated',
'attachment.added': 'event.attachmentAdded'
};
/** The name of an event, or the raw key when it is one this build does not know. */
export function eventName(event) {
return EVENTS[event] ?? event;
const key = EVENTS[event];
return key ? t(key) : event;
}
/** The bit of context an entry carries, when it has one worth reading. */
+34 -1
View File
@@ -30,7 +30,7 @@ export const da = {
'notes.sortCreated': 'Oprettet',
'notes.sortTitle': 'Navn',
'beacon.title': 'Tælling af installationer',
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes **kun** de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.',
'beacon.send': 'Send en anonym daglig melding',
'beacon.exactly': 'Præcis det her — ikke mere:',
'beacon.lastPing': 'Sidst sendt: {when}',
@@ -318,6 +318,9 @@ export const da = {
'users.taskOne': 'opgave',
'users.taskMany': 'opgaver',
'users.never': 'aldrig set',
'users.toProject': 'til {name}',
'users.toInstance': 'til instansen',
'users.invitedBy': 'inviteret af {name}',
// --- settings: history and errors -----------------------------------------------
'history.title': 'Historik',
@@ -333,6 +336,30 @@ export const da = {
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
'history.nothing': 'Der er ikke registreret noget endnu.',
'history.deletedAccount': 'En slettet konto',
// Hvad hver hændelse i loggen hedder. Nøglerne er serverens og ligger i
// events.js; her står de ord, de bliver til.
'event.projectCreated': 'oprettede projektet',
'event.projectUpdated': 'ændrede projektet',
'event.projectDeleted': 'slettede projektet',
'event.projectImported': 'importerede projektet',
'event.sectionCreated': 'oprettede en sektion',
'event.sectionUpdated': 'ændrede en sektion',
'event.sectionDeleted': 'slettede en sektion',
'event.memberInvited': 'inviterede',
'event.memberAdded': 'tilføjede',
'event.memberRemoved': 'fjernede',
'event.memberRoleChanged': 'ændrede rollen for',
'event.taskCreated': 'oprettede en opgave',
'event.taskUpdated': 'ændrede en opgave',
'event.taskCompleted': 'lukkede en opgave',
'event.taskReopened': 'genåbnede en opgave',
'event.taskMoved': 'flyttede en opgave',
'event.taskDeleted': 'slettede en opgave',
'event.taskSplit': 'delte en opgave op',
'event.commentCreated': 'skrev en kommentar',
'event.attachmentAdded': 'vedhæftede en fil',
'errors.title': 'Serverfejl',
'errors.hint':
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
@@ -512,8 +539,13 @@ export const da = {
'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt',
'detail.repeats': 'Gentages',
'detail.never': 'Aldrig',
'detail.daily': 'Hver dag',
'detail.weekly': 'Hver uge',
'detail.fortnightly': 'Hver anden uge',
'detail.monthly': 'Hver måned',
'detail.yearly': 'Hvert år',
'detail.asWritten': 'Som skrevet',
'detail.assignee': 'Ansvarlig',
'detail.subtasks': 'Undertasks',
'detail.addSubtask': 'Tilføj en undertask',
@@ -703,6 +735,7 @@ export const da = {
'push.upToDate': 'Ingen nyere version fundet.',
'view.nothingMoreToday': 'Ikke mere i dag.',
'view.overdue': 'Forsinket',
'project.roleFor': 'Rolle for {name}',
'project.more': 'Flere handlinger',
+34 -1
View File
@@ -26,7 +26,7 @@ export const en = {
'notes.sortCreated': 'Created',
'notes.sortTitle': 'Name',
'beacon.title': 'Counting installations',
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends **only** the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.',
'beacon.send': 'Send an anonymous daily report',
'beacon.exactly': 'Exactly this — nothing more:',
'beacon.lastPing': 'Last sent: {when}',
@@ -313,6 +313,9 @@ export const en = {
'users.taskOne': 'task',
'users.taskMany': 'tasks',
'users.never': 'never seen',
'users.toProject': 'to {name}',
'users.toInstance': 'to the instance',
'users.invitedBy': 'invited by {name}',
// --- settings: history and errors -----------------------------------------------
'history.title': 'History',
@@ -328,6 +331,30 @@ export const en = {
'history.noMatch': 'Nothing matches the filters.',
'history.nothing': 'Nothing has been recorded yet.',
'history.deletedAccount': 'A deleted account',
// What each recorded event is called. The keys are the server's and live in
// events.js; these are the words they become.
'event.projectCreated': 'created the project',
'event.projectUpdated': 'changed the project',
'event.projectDeleted': 'deleted the project',
'event.projectImported': 'imported the project',
'event.sectionCreated': 'created a section',
'event.sectionUpdated': 'changed a section',
'event.sectionDeleted': 'deleted a section',
'event.memberInvited': 'invited',
'event.memberAdded': 'added',
'event.memberRemoved': 'removed',
'event.memberRoleChanged': 'changed the role of',
'event.taskCreated': 'created a task',
'event.taskUpdated': 'changed a task',
'event.taskCompleted': 'closed a task',
'event.taskReopened': 'reopened a task',
'event.taskMoved': 'moved a task',
'event.taskDeleted': 'deleted a task',
'event.taskSplit': 'split a task up',
'event.commentCreated': 'wrote a comment',
'event.attachmentAdded': 'attached a file',
'errors.title': 'Server errors',
'errors.hint':
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
@@ -508,8 +535,13 @@ export const en = {
'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project',
'detail.repeats': 'Repeats',
'detail.never': 'Never',
'detail.daily': 'Every day',
'detail.weekly': 'Every week',
'detail.fortnightly': 'Every other week',
'detail.monthly': 'Every month',
'detail.yearly': 'Every year',
'detail.asWritten': 'As written',
'detail.assignee': 'Assigned to',
'detail.subtasks': 'Sub-tasks',
'detail.addSubtask': 'Add a sub-task',
@@ -698,6 +730,7 @@ export const en = {
'push.upToDate': 'No newer version found.',
'view.nothingMoreToday': 'Nothing more today.',
'view.overdue': 'Overdue',
'project.roleFor': 'Role for {name}',
'project.more': 'More actions',
+29
View File
@@ -49,3 +49,32 @@ export function fullDate(iso) {
year: 'numeric'
});
}
/**
* The repeat rules worth a menu entry, and what to call them.
*
* Here rather than in the drawer that draws the menu, because the row draws the
* same rule as a badge and the two had already disagreed: the menu said "Hver
* dag" in both languages, and the badge said whatever the server said.
*
* The server sends `recurrence_text` with every task and sends it in Danish. It
* has to: one payload goes over the WebSocket to everybody in the project at
* once, and they do not all read the same language. So the rules that have a name
* are named here, on the side that knows whose screen this is, and anything more
* specific — "hver anden tirsdag", typed into the title — falls back to what the
* server wrote.
*/
export const REPEATS = [
{ rule: '', label: 'detail.never' },
{ rule: 'FREQ=DAILY', label: 'detail.daily' },
{ rule: 'FREQ=WEEKLY', label: 'detail.weekly' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'detail.fortnightly' },
{ rule: 'FREQ=MONTHLY', label: 'detail.monthly' },
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
];
/** What a repeating task says on its row. */
export function repeatLabel(rule, fallback = '') {
const known = REPEATS.find((option) => option.rule && option.rule === rule);
return known ? t(known.label) : fallback;
}
+2 -2
View File
@@ -56,7 +56,7 @@
{#if liveOverdue.length}
<section>
<h2 class="overdue">Forsinket</h2>
<h2 class="overdue">{t('view.overdue')}</h2>
{#each liveOverdue as task (task.id)}
<TaskRow {task} />
{/each}
@@ -65,7 +65,7 @@
<section>
{#if liveOverdue.length}
<h2>I dag</h2>
<h2>{t('nav.today')}</h2>
{/if}
{#each liveToday as task (task.id)}
<TaskRow {task} />
@@ -177,9 +177,11 @@
<div class="what">
<span class="primary-line">{pending.email}</span>
<span class="secondary">
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
{pending.project_name
? t('users.toProject', { name: pending.project_name })
: t('users.toInstance')}
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
{#if pending.invited_by}· {t('users.invitedBy', { name: pending.invited_by })}{/if}
</span>
</div>
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
@@ -209,7 +211,8 @@
</span>
<span class="secondary">
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
{#if user.project_count}·
{count(user.project_count, 'users.projectOne', 'users.projectMany')}{/if}
</span>
</div>
@@ -159,9 +159,12 @@
<span class="who" class:gone={!entry.user_name}
>{entry.user_name || t('history.deletedAccount')}</span
>
<!-- The space is written out. Svelte drops the whitespace that
opens a block, so "invited" and the address ran together. -->
<span class="event"
>{eventName(entry.event)}{#if eventDetail(entry)}
<span class="detail">{eventDetail(entry)}</span>{/if}</span
>{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
>{eventDetail(entry)}</span
>{/if}</span
>
</span>
<span class="secondary">{entry.project_name}</span>
+3 -2
View File
@@ -581,8 +581,9 @@
>{entry.user_name || t('history.deletedAccount')}</span
>
<span class="what">
{eventName(entry.event)}{#if eventDetail(entry)}
<span class="detail">{eventDetail(entry)}</span>{/if}
{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
>{eventDetail(entry)}</span
>{/if}
</span>
<span class="when">{when(entry.created_at)}</span>
</li>
+4 -4
View File
@@ -20,7 +20,7 @@
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
import { t } from '$lib/i18n.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
let days = $state([]);
@@ -48,9 +48,9 @@
const today = new Date(new Date().toDateString());
const diff = Math.round((day - today) / 86400000);
if (diff === 0) return 'I dag';
if (diff === 1) return 'I morgen';
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' });
if (diff === 0) return t('task.today');
if (diff === 1) return t('task.tomorrow');
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
}
// Reading tasks back out of the store keeps a completed row disappearing