Compare commits

...
6 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 ae673086bc feat(tasks): tid på en opgave, og et træk i kalenderen sætter den
Release / Build and publish image (push) Failing after 9m58s
Bedt om: "jeg vil have tid på opgaver, og jeg vil kunne justere dem i kalenderen."

Serveren kunne det hele i forvejen — `due_date` og `due_time` læses sammen og
bliver til et `due_datetime` i kontoens tidszone. Det var fladen, der ikke havde
nogen vej til at sige det.

**Et felt i opgaveruden.** Tomt, indtil nogen sætter det: en opgave uden
klokkeslæt er det almindelige, og et felt, der åbner på 00:00, påstår, at noget
skal ske ved midnat.

**Og en fejl, der lå og ventede.** Serveren læser en dato uden en tid som "ryd
tiden". Så et gem af datoen alene tog klokkeslættet med sig — man rettede dagen og
mistede timen — og det samme gjorde hvert eneste træk i kalenderen. Ingen af
delene sagde noget.

`reschedule` siger nu tiden højt, tre måder, fordi de to første ligner hinanden i
JavaScript og derfor skal skilles ad ét sted frem for at blive forvekslet fire:

    undefined   behold den, opgaven har   — månedens ruder, hvor der ingen tid er at læse
    ''          ryd den                   — heldagsbåndet
    'HH:MM'     sæt den                   — et sted på døgnet

**Hvor på søjlen man slipper, er klokkeslættet.** Målt mod søjlens egen kasse frem
for mod siden, så det bliver rigtigt uanset hvor gitteret sidder og hvor langt der
er rullet. Rundet til kvarter: minuttet under markøren er ikke en oplysning, nogen
har — pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
det blev valgt.

Prøven går hele vejen: sæt tiden, genindlæs og se at serveren tog den, ret datoen
og se at timen bliver, find opgaven på sit klokkeslæt i ugegitteret, og træk den op
i båndet igen. To ting, den lærte undervejs, står skrevet ved den: et træk over
fire hundrede pixels skal gå i skridt, ellers har browseren for få mellemstationer
til at holde gesten i live — og klokken skal være tidlig, for klokken fjorten
ligger under skærmkanten, og at måle noget dernede ruller siden væk under målet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:29:03 +02:00
Kristian WindandClaude Opus 5 e29fd04abd docs: noternes opbygning, skrevet til at blive løftet ud
Hvordan noter er bygget her: modellen, syntaksen, søgningens tre lag,
editorkontrakten, vedhæftninger, ind- og udførsel — og begrundelserne, som er den
del, der ikke kan læses ud af skemaet.

Den begynder med fjorten spørgsmål, der skal besvares før der skrives kode, og
siger det til den, der læser den, med rene ord: gæt ikke en standard. Grunden
står i filen — omtrent en tredjedel af designet her er arvet fra de omgivelser,
noterne bor i, og løftet ind i et program med andre omgivelser uden at afgøre
hvad der skal med, giver noget, der virker en uge og derefter slås med én.

Den siger også, hvad der ikke virker: notekroppe i klartekst i hver
sikkerhedskopi, import der aldrig fletter, ingen sync. En specifikation, der kun
beskriver det, der virker, lyver ved fortielse.

I repoet frem for uden for det, som HANDOFF.md og NOTES-PLAN.md er. De to er
arbejdspapirer om *dette* program; den her er beskrivelsen af en del af det, og
den bliver kun forkert, hvis koden flytter sig uden at den følger med — hvilket
er lettere at opdage, når den ligger ved siden af.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 20:02:59 +02:00
Kristian WindandClaude Opus 5 be581eb8ae fix(search): et ord inde i et ord kunne ikke findes
Release / Build and publish image (push) Failing after 9m57s
Meldt ind som "søgning har ikke det store held": en note med
`gamerule keepInventory true` kunne ikke findes ved at skrive "keep inventory".

Et indeks er bygget af tokens, og et token er det, en tokenizer besluttede var et
ord. `keepInventory` er ét. Så "keep inventory" spørger efter to ord, der ikke
findes nogen steder, og søgningen svarer, at der ikke er noget — mens noten står
lige der.

Præfikssøgningen tager halvdelen af det. `keep*` rammer faktisk
`keepinventory`; `inventory*` kan ikke, for det er ikke et præfiks af noget. Og
de to led er bundet sammen med AND, så den halvdel, der virker, bliver kasseret af
den, der ikke kan.

Det er ikke et sært tilfælde i en notesapp. Ti års noter er fulde af kommandoer,
identifikatorer og filnavne, og hver eneste af dem er flere ord, indekset tror er
ét: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.

Indekset svarer først. Har det ingenting, læses teksten: hvert ord skal stå et
sted i noten, hvor som helst, i hvilket som helst ord. `fold` er
`title || ' ' || body` med de danske bogstaver translittereret, så én søjle dækker
titlen, brødteksten og "gron" for "grøn" på én gang.

Opgavesøgningen har fået den samme arm. At have den i den ene af de to ville være
værre end at have den i ingen af dem: "den finder det i mine noter, men ikke i
mine opgaver" er den næste melding, og svaret på den ville være denne linje.

Hvad det koster, og hvornår formen er forkert, står skrevet ved koden: en LIKE med
et indledende % kan ikke bruge et indeks, så den læser hver note. Ved tolv hundrede
noter er det fire megabyte og et par millisekunder, og den kører kun, når den
hurtige vej allerede kom tomhændet tilbage — altså netop den søgning, der var ved
at skuffe nogen. Ved hundrede tusind er svaret et FTS5-`trigram`-indeks, ikke en
større scanning.

Prøverne er set fejle uden rettelsen, og de dækker begge veje: "inventory" alene,
som ingen præfikssøgning kan nå, og "Bedrock", som stadig skal besvares af
indekset — ellers er rangeringen, der blev bygget til tolv hundrede noter, stille
forsvundet.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 18:33:22 +02:00
Kristian WindandClaude Opus 5 9d34b57166 feat(calendar): ugen er et døgn, og dagene er lige brede
Release / Build and publish image (push) Failing after 9m58s
To ting, meldt ind fra en uge, der ikke svarede på det, man åbnede den for.

**Dagene var ikke lige brede.** Gitteret stod på `repeat(7, 1fr)`, og `1fr` er
`minmax(auto, 1fr)`: `auto` som minimum betyder, at en søjle ikke kan blive
smallere end sit længste ord. Én opgave, der hed "Gårdbutikken på Møllerup Gods —
opfølgning på mail", gjorde onsdag dobbelt så bred som mandag og klemte resten af
ugen sammen om den. `minmax(0, 1fr)` lader søjlen blive så smal, den skal, og så
klipper brikken sin tekst af — hvilket den allerede kunne.

**Ugen var syv punktopstillinger.** Den svarede på "hvad skal der ske torsdag" og
ikke på "hvornår", og det andet er det, man åbner en uge for. To møder klokken ti
er en konflikt, man skal kunne se, ikke læse sig til.

Nu et gitter med en tidsrende og syv dage: timerne ned ad siden, begivenheder
placeret efter klokkeslæt, og et bånd foroven til det, der ikke har et — en
heldagsbegivenhed lagt klokken nul ville påstå, den holdt fra midnat til midnat.

Overlappende ting deler bredden i sammenhængende klynger frem for parvist:
overlapper A med B og B med C, skal alle tre dele, også når A og C ikke rører
hinanden. Ellers ville A og C hver tro, de havde halvdelen, og lægge sig oven på
hver sin halvdel af B.

Hvilke timer der tegnes, kommer fra ugens egne begivenheder med arbejdsdagen som
gulv. Et helt døgn er fireogtyve rækker, hvoraf de otte altid er tomme, og så er
gitteret noget man ruller i frem for noget man ser — men en vagt klokken fem skal
med, uden at alle andre uger betaler for den med tomme rækker.

Måneden er ikke rørt ud over søjlebredden. En dag i en månedsrude er halvanden
centimeter høj; et døgn tegnet i den er ikke en visning, det er en streg.

Brikkerne er flyttet ud i to snippets, som begge visninger tegner med. De viser
de samme to ting og skal vise dem ens; det er placeringen, der er forskellig, og
så er det kun placeringen, der står to steder.

`data-date` følger med over på ugens timesøjler — det er sådan noget uden for
komponenten peger på en dag. Båndet får sit eget `data-allday`, så de to ikke
bliver til det samme svar på ét spørgsmål.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 17:01:58 +02:00
Kristian WindandClaude Opus 5 4b3abc44fb feat(site): hvad der er landet, og hvad man tager på sig ved at køre det
To dele.

**Lately** siger, hvad der er kommet til, fem linjer med versionsnummer i egen
søjle. En landingsside kan påstå, at et projekt er i live; en liste over hvad der
faktisk landede siger det bedre. Ikke en changelog — den ville gå i stå — så den
peger på release-noterne, som skrives alligevel.

**At your own risk** siger det, MIT-licensen siger, og som ingen læser: uden
garanti af nogen art. Dertil det, der er sandt om netop dette: én persons projekt,
version 0.x, du hoster det selv, så data og sikkerhedskopier er dine — og en
sikkerhedskopi, ingen har gendannet, er ikke en sikkerhedskopi endnu. At der er
lavet to gennemgange af koden, at hvert fund er skrevet ned og lukket, og at
**ingen udefra har auditeret det**.

Sat i samme skriftstørrelse som alt andet på siden. Et forbehold med mindre skrift
end resten er skrevet for at kunne peges på bagefter, og det er den ene ting,
siden ikke skal gøre.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 16:19:44 +02:00
Kristian WindandClaude Opus 5 b87c9ea6e2 fix(auth): en afvist passkey sagde "forkert e-mail eller adgangskode"
Release / Build and publish image (push) Failing after 10m0s
Der er hverken en e-mail eller en adgangskode i et passkey-login. Loginet er
discoverable — enheden ved selv, hvilken konto nøglen hører til, og der bliver
ikke tastet noget. Alligevel fik en afvisning fladens generiske 401-tekst, og den
nævner begge dele.

Det sendte en person ud at lede efter en tastefejl i sin konto, der aldrig havde
været der. Det, der faktisk var sket, var at instansen var flyttet fra http til
https: en passkey hører til præcis den adresse, den blev lavet på — `RPOrigins`
bygges af `VERDANDE_BASE_URL` med skema og alt — så et login på det ene skema mod
en server, der venter det andet, kan ikke verificeres.

Beskeden siger nu det, og siger også hvor man skal kigge. Serveren har hele tiden
svaret "that key was not accepted"; det var oversættelsen i fladen, der gjorde
den til noget andet.

Aflysning bliver behandlet som før — den er ikke en fejl, og en fejl for den ville
læse som om nøglen blev afvist.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 15:59:34 +02:00
15 changed files with 1739 additions and 106 deletions
+437
View File
@@ -0,0 +1,437 @@
# Notes, as verdande builds them
A description of one working notes system: the data model, the syntax, the search,
the rendering contract, the import and export formats, and — more useful than any
of it — the decisions that look arbitrary until you know what they cost.
It is written to be lifted into another program. Nothing here depends on Go,
SQLite or Svelte except where it says so; where an answer was forced by the tool,
that is called out so you can pick a different one on purpose rather than by
accident.
---
## Read this part first: what to ask before you build
**This document is not a drop-in. It describes a notes system that lives inside a
task app with projects, roles, a trash and a search box, and about a third of its
design is inherited from that.** Lifting it into a program with different
surroundings without deciding which parts to keep produces something that works
for a week and then fights you.
**If you are an assistant working from this file: ask these questions and get
answers before writing code.** Do not guess a default. Each one changes what you
build, not just how you build it.
### About the surroundings
1. **Does the host app already have a place to file things** — projects, folders,
notebooks, tags? verdande deliberately gave notes no hierarchy of their own and
filed them under the projects that already existed. If your app has nothing,
you need an answer for where a note lives, and "a folder tree" is the expensive
one.
2. **Is there more than one person?** Sharing, roles and "who wrote this" run
through the whole model below. Single-user changes the schema and removes about
half the permission surface.
3. **Is there already a trash, and does it expire?** Notes lean on one. Without
it, deleting is either permanent or you are building a trash.
4. **Is there an existing full-text index?** The search here is FTS5-shaped. On
Postgres it is `tsvector`, on a client-side app it is probably a scan, and the
ranking section below changes accordingly.
### About the notes themselves
5. **Markdown, rich text, or blocks?** verdande stores Markdown and renders it.
That decision is defended below; it is also the one that most constrains
everything else.
6. **Is the first line the title, or is there a title field?** Both work. Deriving
it means one less thing to keep in step and one less thing a person can leave
blank, and it forces the rule "the first line is a heading" on the editor.
7. **What links to what?** `#project`, `[[note]]` and task references exist here
because the app has projects and tasks. Yours may have none of them, or others.
8. **Do notes carry files?** Attachments bring content-type handling, a store, an
authorisation path and an XSS surface. Skipping them is a legitimate answer.
### About scale and language
9. **How many notes, realistically?** Several choices below flip at around ten
thousand: the list payload, the search fallback, rendering the whole list.
10. **Which languages?** The folding described under *Search* is Danish. Every
language has its own version of that problem and the generic answer (strip
diacritics) is wrong for several of them.
11. **Where does the text end up besides the screen?** Search indexes, AI
features, exports and backups each read it. Plaintext makes all of them cheap
and makes a stolen backup expensive — see *What is still open*.
### About integration, specifically
12. **Who owns the note's identity — you or the other system?** If notes will sync
with something else, ids and timestamps are the whole problem, and the
`updated_at` rule below becomes the first thing you have to change.
13. **Is this a read of somebody else's notes, or the store of record?** A copy
needs conflict rules; a store of record needs export.
14. **What happens to a note whose project, task or link target is deleted?**
Answered here as "the link goes dead and the note is untouched". If your host
app cascades instead, say so before the first delete.
---
## The shape of a note
```
id an opaque string, unique
project_id nullable — where it is filed, or loose
title derived, never set by a caller
body Markdown, the whole note
created_by nullable — survives the account being deleted
pinned favourite; the list puts these first
archived_at nullable timestamp — put away, not thrown away
deleted_at nullable timestamp — the trash
created_at
updated_at
```
### Markdown in a column
Not rich text, and not a format of our own.
Export was a requirement before a line was written, and **if the stored form is
the exported form there is no conversion left to lose anything in.** A note stays
readable by any editor on the day the program is gone. It is also what makes
search and anything an AI does over notes cheap: both read text.
The cost is real and you should know it before choosing: everything the editor can
do has to survive a round trip through Markdown, and anything Markdown cannot say,
the editor must not offer. There is no place to hide a feature.
### The title is the first line
Always, and re-derived on every save — not "when it is empty".
Derived once and then left alone, a title goes stale the moment somebody rewrites
the opening of a note, and the list ends up calling a note by a name that is
nowhere in it. **The column is a cache of the body, not a field beside it**, and no
caller can set it, so every caller gets the same answer.
This forces one editor rule: a new note opens on its title, and the first line is
styled as a heading. Otherwise a note whose first line is a sentence is a note
named by its first sentence.
### Archive and trash are two different bits
The trash says *this was a mistake, and in thirty days it is gone*. The archive
says *this is finished, and I do not want to read past it.*
A twelve-hundred-note import is exactly the case where most notes are worth
keeping and almost none are worth looking at. Without an archive, the list is the
whole archive forever, and finding this week's note means reading past a decade.
Both are nullable timestamps rather than booleans: *when was this put away* answers
questions a flag cannot, and it costs the same byte.
---
## Filing: no hierarchy of its own
A note belongs to a project, or to nobody.
The reasoning is worth repeating because it is easy to talk yourself out of:
projects already exist, are already shared, already have roles, already have a
trash. **A second hierarchy to file things in is one too many**, and a note filed
under a project inherits every one of those answers instead of needing its own.
- A note with a project: everybody who can read the project can read the note; an
editor can change it.
- A note without one: the author's, and nobody else's.
Sharing a note *is* filing it in a project. There is no access list of its own —
a second way to give somebody access is a second place to get it wrong, and the
two would disagree the day one of them changed.
---
## Links: three kinds, parsed out of the text
The body is the truth. Links are an index over it, rebuilt on every save.
| Syntax | Means | Notes |
|---|---|---|
| `#project` | a project | Same characters the task quick-add parser uses, on purpose |
| `[[a note]]` | another note, by title | The spelling every note app has settled on |
| `/opgave/<uuid>` or `task:<uuid>` | a task | What a link pasted out of the app carries |
Patterns as used here:
```
project (?:^|\s)#([\p{L}\p{N}_-]{1,64})
note \[\[([^\]\n]{1,200})\]\]
task (?:/opgave/|task:)([0-9a-fA-F-]{36})
```
Three decisions inside that table:
- **`#Firma` in a note means the same project as `#Firma` in the task box.** Not a
second kind of tag that happens to look alike. That is the whole of "tag a note
and see it in the project".
- **The link table is torn down and rebuilt on save, not diffed.** A body is small
and the set is small; a diff is a second description of the same fact, and it
can disagree with the text. That is the one thing this must never do.
- **The target is not a foreign key.** A link to something deleted is a fact about
the note. It should read as a dead link rather than vanish, and it must not
block the delete.
The index exists so the question can be asked **backwards**: *what has been written
about this task?* Answering that by searching every note's prose would be too slow
to leave a panel open on.
---
## Search
Three layers, in the order they answer.
### 1. Full-text, ranked
FTS5 over `title`, `body` and a folded copy, with `unicode61 remove_diacritics 2`.
Ranking is bm25 with **the title weighted ten times the body**. A word in the title
is what the note is *about*; the same word once in a long body is a mention.
This matters more than it sounds. Ordering by "last touched" is invisible with ten
notes and useless with twelve hundred imported the same evening: searching "Tesla"
answered with a note about nginx first and the note actually called "Tesla Model Y"
second — which reads as a search that does not work, because from where the person
is sitting it is one.
### 2. Folding, for the language you are in
`remove_diacritics` does not touch `ø`, `æ` or `å` — Unicode considers them
letters in their own right, not accented forms. So a Dane cannot find "grøn" by
typing "gron", which is how people type when the keyboard is not theirs.
The answer here is a generated column carrying a transliterated copy:
```
ø,Ø → o æ,Æ → ae å,Å → aa
```
indexed alongside the body and **weighted the same as the body** — otherwise a
folded match outranks an exact one.
> **Ask, do not copy.** This mapping is Danish. German wants `ü → ue`, Swedish
> disagrees with Danish about `ä`, and Turkish has a dotless `ı` that breaks
> naive lowercasing entirely. Get the mapping from somebody who types the
> language.
### 3. A word inside a word
An index is built of tokens, and a token is whatever the tokenizer decided was a
word. `gamerule keepInventory true` is three of them, and the middle one is
`keepinventory` — so a search for "keep inventory" asks for two words that are
nowhere and gets nothing, while the note sits right there.
A prefix query gets half of it: `keep*` matches, `inventory*` cannot, and the two
are ANDed, so the half that works is thrown away by the half that cannot.
**This is not exotic in a notes app.** A decade of notes is full of commands,
identifiers and file names, and every one of them is several words the index
thinks is one.
So: the index answers first; when it has nothing, the text is read, and every term
must appear somewhere in the note, in any word. A `LIKE '%term%'` over the folded
column covers title, body and language folding at once.
What it costs: a leading `%` cannot use an index, so it scans. At twelve hundred
notes that is a few megabytes and a millisecond or two, and it only runs when the
fast path already came back empty — which is exactly the search that was about to
disappoint somebody. **At a hundred thousand notes it is the wrong shape**, and the
answer then is a trigram index, not a bigger scan.
It does not rank. There is no match to rank — that is the whole situation.
---
## The list, and why the body is empty in it
A list showing a title and ninety characters of preview must not fetch whole
notes. Twelve hundred notes came to 3.9 MB, of which 98 % was body nobody could
see.
The important half is *how* it is trimmed: **the body is emptied, not shortened.**
A field called `body` holding a *piece* of the body is a trap. The editor opens
it, the autosave pause writes it back, and the note is now cut down to its own
excerpt — destroyed by nothing more than having been looked at. Send `preview` as
its own field and leave `body` empty; the client sees the empty body and fetches
the whole note.
That costs one more separation, and it is not optional:
> **Which row is selected and which note is loaded are two different things.**
The editor loads its text when the note's id changes. Hand it the excerpt first
and the whole note second and the id is the same both times — so the second one
never renders, and somebody sits looking at a note that had a title a moment ago.
Highlight the row immediately from the list; give the editor the note only when it
is whole.
Rendering cost, separately: with twelve hundred rows, first paint took 2.1 s. CSS
`content-visibility: auto` with an intrinsic-size guess took it to 0.95 s with no
virtual list, no scroll maths, and no list that can fall out of step with itself.
Try that before building windowing.
---
## The editor contract
Rich text on top, Markdown underneath, and a source view that shows the file.
The supported subset — deliberately small, because everything in it has to survive
the round trip:
| Block | Markdown |
|---|---|
| Title | `# ` |
| Heading | `## ` |
| Subheading | `### ` |
| Quote | `> ` |
| Code block | ```` ``` ```` with an optional language |
| Bullet and numbered lists | `- `, `1. `, nested |
Inline: `**bold**`, `*italic*`, `~~strikethrough~~`, `` `code` ``, `<u>underline</u>`,
and images as `![alt](url)`.
Five rules that were each learned the hard way:
1. **Every line becomes exactly one block.** Markdown normally joins consecutive
lines into a paragraph. Here it must not: the person typed a line and expects
to get it back, and an editor that silently reflows what somebody wrote is an
editor they stop trusting.
2. **Load on id change, never while typing.** Writing to the editor's HTML puts
the caret back at the start, which mid-sentence is unusable.
3. **One definition of what a code block contains.** A copy button inside the
`<pre>` and a syntax highlighter reading `textContent` will disagree, and the
difference ends up in the file — a click on "Copy" wrote the word *Copied* into
the code and the next save put it on disk. Have one function that says what a
block holds, used by the highlighter and the Markdown serialiser both.
4. **A note that cannot be rendered must not take the pane with it.** One note
arriving in an unexpected shape threw inside the editor's reactive effect, and
from then on *no* note would open — the previous one just stayed on screen. Put
a guard around the render, fall back to showing the raw text, and let the rest
of the pane keep working.
5. **Escaping between tags and escaping inside an attribute are different jobs.**
An escaper written for text between two tags handles `&`, `<`, `>` — and not
quotes, because between tags they are harmless. Inside an attribute they close
it. `![" onerror="alert(1)](…)` is the whole exploit.
---
## Attachments
Images pasted or dragged into a note, stored content-addressed by hash, referenced
from the body as ordinary Markdown images.
The security shape, which is the part worth copying:
- **Served as `application/octet-stream` with `nosniff` by default.** Attachments
are the one place a user supplies content another user opens. An uploaded SVG or
HTML file rendered inline runs its own script on your origin with the session
cookie attached.
- **One exception, as a named allowlist:** a short list of raster image types
served inline as their exact type — `png`, `jpeg`, `gif`, `webp`, `avif`, `heic`.
A note carries its pictures in the text, and served as octet-stream every one of
them is a broken image.
- **The list is named one type at a time, never matched on an `image/` prefix.**
The prefix lets `image/svg+xml` through, and an SVG is a document that can run
script.
- **The stored path is checked against the files directory again at read time.** A
traversal that somehow got into the database must not become one that reads
`/etc/passwd`.
- **Reaching an attachment follows the note, which follows its project.** Same
chain as everything else. Do not let a client hand you the parent id and take it
on trust.
---
## Import and export
The same format both ways: **a zip of Markdown files**, one per note, with a YAML
front matter block.
```markdown
---
created: 2019-04-02T10:15:00Z
modified: 2024-11-30T21:04:00Z
---
# Møde med Anders
Han vil gerne have kaffe hver uge.
```
- `created` / `created_at` / `date` and `modified` / `updated` / `updated_at` are
all accepted on the way in. Obsidian and Bear write this block; so should you.
- Images travel in the zip beside the notes, and the links in the text are
relative — **a note and its pictures must end up in the same archive**, which
matters the moment an export is split into parts.
- File names collide: two notes called "Møde" are two notes, and a zip with one
entry twice is a zip that loses one.
Limits that a real import taught, all of them cheap to add and expensive to omit:
- A ceiling on the upload, on the number of entries, and on the **unpacked** size.
64 MB of zip expanded to 66 GB is a normal shape for an attack.
- A per-file limit as well as a whole-archive budget: one enormous photograph and
ten thousand small ones are different attacks with the same ending.
- The entry name is never used as a path. Store by hash; the name is only a key
for rewriting links.
- **Count what you skip, and say so.** A note whose name contained an ellipsis was
dropped silently by a filter looking for `..` — one out of 1141, found only by
counting at both ends.
### Timestamps on the way in
An importer needs to write `created_at` and `updated_at` from the file, and the
normal save path must refuse to let it.
`updated_at` means *when did this program last write this note*. Let any caller set
it and the column means whatever the last writer felt like. Give the importer its
own narrow entry point instead, and say in the comment why it exists.
Without those dates, a move of twelve hundred notes is a pile that was all written
the same evening — and the order they were written in is half of what an archive
is.
---
## What is still open here
Written down because a specification that only lists what works is a specification
that lies by omission.
- **Note bodies are plaintext at rest.** They hold API keys and passwords, and
they are in every backup anybody can download. Encrypting them is bigger than it
sounds: the body is in the full-text index and the folded column is generated
from it, so both are a second plaintext copy in the same database. Encrypt and
both have to go, and search has to be rewritten to decrypt in application code —
fine at a thousand notes, a full scan per keystroke at ten thousand, at which
point the answer is a keyed index (an HMAC per word).
- **Import never merges.** Every run creates new notes. Say so out loud in the UI.
- **There is no sync.** Export and the API are the way out.
---
## A short list of the traps, for the person in a hurry
1. A `body` field holding an excerpt will be saved over the real one.
2. Selected row and loaded note are two different pieces of state.
3. The title must be re-derived on every save or it goes stale.
4. Diacritic stripping is not folding, and folding is language-specific.
5. A tokenizer cannot see a word inside a word; decide what you do about it.
6. An attachment served inline is a script on your origin unless you allowlist by
exact type and send `nosniff`.
7. Escaping for text and escaping for attributes are different functions.
8. One render failure must not be allowed to freeze the whole pane.
9. An unpacked-size limit is not optional on an archive import.
10. Let the importer set timestamps; let nobody else.
+40
View File
@@ -6,6 +6,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"testing"
"time"
@@ -1777,3 +1778,42 @@ func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
t.Errorf("content = %v", moved["content"])
}
}
// A word inside a word, on the task side.
//
// The notes search grew this first — a note holding `keepInventory` could not be
// found by typing "keep inventory" — and a task is the same sentence with a
// different table behind it. `SpeedAdmin` is one token, so "speed admin" asks for
// two words that are nowhere.
//
// Having it in one of the two searches would be worse than having it in neither:
// "it finds it in my notes but not in my tasks" is the next report.
func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "Opret kodeord til Kulturskolen (SpeedAdmin)",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Køb kaffe"})
for _, tc := range []struct {
query string
want int
}{
{"speed admin", 1},
{"admin", 1},
{"speedadmin", 1},
{"kulturskolen", 1},
{"vandhane", 0},
} {
resp, body := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(tc.query), nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%q: status %d", tc.query, resp.StatusCode)
}
tasks, _ := body["tasks"].([]any)
if len(tasks) != tc.want {
t.Errorf("%q found %d tasks, want %d", tc.query, len(tasks), tc.want)
}
}
}
+62 -1
View File
@@ -289,12 +289,73 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
//
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
// subquery throws it away.
return db.notesJoin(ctx, noteColumnsQualified, `
found, err := db.notesJoin(ctx, noteColumnsQualified, `
JOIN notes_fts ON notes_fts.rowid = notes.rowid
WHERE notes.deleted_at IS NULL
AND notes_fts MATCH ?
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
LIMIT ?`, expr, limit)
if err != nil || len(found) > 0 {
return found, err
}
return db.searchNotesInside(ctx, query, limit)
}
// searchNotesInside finds a word that lives *inside* another word.
//
// An index is built of tokens, and a token is what a tokenizer decided was a
// word. `gamerule keepInventory true` is three of them, and the middle one is
// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and
// gets nothing. Reported as "søgning har ikke det store held", which from where
// the person is sitting is exactly what it is: the note is right there, and the
// search says there is nothing.
//
// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*`
// cannot, because it is not a prefix of anything — and the two are joined with AND,
// so the half that works is thrown away by the half that cannot.
//
// This is not an exotic case in a notes app. A decade of notes is full of
// commands, identifiers and file names, and every one of them is several words the
// index thinks is one: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`.
//
// So: the index answers first, and when it has nothing, the text is read. Every
// term has to appear somewhere in the note, anywhere, in any word.
//
// # What it costs, and when to stop doing it this way
//
// A LIKE with a leading % cannot use an index; this reads every note. At twelve
// hundred notes that is four megabytes and a millisecond or two, and it only runs
// when the fast path already came back empty — which is the search that was about
// to disappoint somebody. At a hundred thousand notes it would be the wrong shape,
// and the answer then is an FTS5 `trigram` index, not a bigger scan.
//
// It does not rank. bm25 needs a match to rank, and there is no match here — that
// is the whole situation. The rows are the most recently touched first, and the
// set is small, because the index already said there was nothing in it.
func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) {
terms := SearchTerms(query)
if len(terms) == 0 {
return nil, nil
}
// `fold` is `title || ' ' || body` with the Danish letters transliterated, so
// one column covers the title, the body and "gron" for "grøn" at once. LIKE is
// case-insensitive for ASCII in SQLite, which is what makes `keepInventory`
// answer to "inventory".
//
// The needle is safe to interpolate into a pattern because `tokenize` splits on
// everything that is not a letter or a digit: a term cannot contain % or _, so
// there is no wildcard for somebody to smuggle in. It is still a bound
// parameter — that is about SQL, and this is about LIKE.
where := "WHERE deleted_at IS NULL"
args := make([]any, 0, len(terms)+1)
for _, term := range terms {
where += " AND fold LIKE ?"
args = append(args, "%"+FoldDanish(term)+"%")
}
args = append(args, limit)
return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...)
}
// SetNoteTimes writes the dates a note had somewhere else.
+64
View File
@@ -369,3 +369,67 @@ func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
}
}
// A word inside a word.
//
// Reported as "søgning har ikke det store held": a note holding
// `gamerule keepInventory true` could not be found by typing "keep inventory".
// The index is built of tokens, and `keepInventory` is one token — so the two
// words are nowhere, and the search says there is nothing while the note is right
// there.
//
// A decade of notes is full of these: commands, identifiers, file names. Every one
// of them is several words the index thinks is one.
func TestAWordInsideAWordIsStillFound(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Body: "Minecraft Bedrock\n\ngamerule keepInventory true",
}); err != nil {
t.Fatal(err)
}
// A second note, so a fallback that simply returns everything would be caught.
if err := db.SaveNote(ctx, &Note{
CreatedBy: userID,
Body: "Indkøb\n\nKaffe og filtre",
}); err != nil {
t.Fatal(err)
}
for _, q := range []string{
"keep inventory", // the two words, which is how somebody looks for it
"inventory", // the tail on its own — no prefix query can reach this
"keepinventory", // written as one, which the index does hold
"KEEP INVENTORY", // and case is not a question anybody is asking
"vacuum", // not in either note
} {
found, err := db.SearchNotes(ctx, q, 10)
if err != nil {
t.Fatalf("%q: %v", q, err)
}
want := 1
if q == "vacuum" {
want = 0
}
if len(found) != want {
t.Errorf("%q found %d notes, want %d", q, len(found), want)
continue
}
if want == 1 && found[0].Title != "Minecraft Bedrock" {
t.Errorf("%q found %q", q, found[0].Title)
}
}
// The fast path is still the fast path: a word the index does hold must not be
// answered by the scan, or the ranking that was built for twelve hundred notes
// is quietly gone.
found, err := db.SearchNotes(ctx, "Bedrock", 10)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Fatalf("an ordinary word found %d notes", len(found))
}
}
+14
View File
@@ -90,6 +90,20 @@ func MatchExprOver(query string, columns ...string) string {
return b.String()
}
// SearchTerms is the words a query asks about, capped the same way the MATCH
// expression is.
//
// Exported because two searches need the same list: the FTS expression is built
// from it, and so is the substring arm that finds a word living inside another
// word. Two tokenizers would be two answers to "what did they type".
func SearchTerms(query string) []string {
terms := tokenize(query)
if len(terms) > maxSearchTerms {
terms = terms[:maxSearchTerms]
}
return terms
}
// tokenize splits on everything that is not a letter or a digit. Splitting on
// punctuation rather than whitespace alone means "q3/2026" searches for "q3" and
// "2026" instead of for a term FTS5's own tokenizer would never have indexed.
+91
View File
@@ -186,3 +186,94 @@ func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
t.Errorf("the second write did not land: %v", back["provider"])
}
}
// The upgrade path, which is the one nobody runs: an existing database with a row
// in the old shape, opened by a binary that carries 0025.
//
// Every other test here starts empty and runs all the migrations in order, which
// exercises the migration's SQL but not its *situation*. The situation is a table
// that already holds somebody's settings, and the question is whether they come
// through it and whether a second scope can be written afterwards.
func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
user := &User{Email: "[email protected]", Name: "Kristian"}
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
t.Fatal(err)
}
// Wind the database back to how v0.33.3 left it: user_id as the whole primary
// key, and 0025 not yet applied.
for _, stmt := range []string{
`PRAGMA foreign_keys = OFF`,
`CREATE TABLE us_old (
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'general',
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
)`,
`INSERT INTO us_old (user_id, scope, values_json, updated_at)
SELECT user_id, scope, values_json, updated_at FROM user_settings`,
`DROP TABLE user_settings`,
`ALTER TABLE us_old RENAME TO user_settings`,
`DELETE FROM schema_migrations WHERE name = '0025_user_settings_per_scope.sql'`,
`PRAGMA foreign_keys = ON`,
} {
if _, err := db.ExecContext(ctx, stmt); err != nil {
t.Fatalf("rewind (%s): %v", stmt, err)
}
}
// A Gmail connection, written the way the old upsert wrote one.
if _, err := db.ExecContext(ctx, `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', '{"label":"Verdande"}', 0)`, user.ID); err != nil {
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
// And now the new binary opens it.
db, err = Open(path)
if err != nil {
t.Fatalf("the upgrade refused to open the database: %v", err)
}
defer db.Close()
gmail, err := db.UserSettings(ctx, user.ID, "gmail")
if err != nil {
t.Fatal(err)
}
if gmail["label"] != "Verdande" {
t.Errorf("the Gmail settings did not survive the migration: %v", gmail)
}
// The thing that was reported: set up the AI provider, and have it still be
// there afterwards.
if err := db.SetUserSettings(ctx, user.ID, "ai", map[string]any{
"provider": "anthropic", "model": "claude-sonnet-5",
}); err != nil {
t.Fatalf("saving the AI provider after the upgrade: %v", err)
}
ai, err := db.UserSettings(ctx, user.ID, "ai")
if err != nil {
t.Fatal(err)
}
if ai["provider"] != "anthropic" {
t.Errorf("the AI provider is %v after the upgrade, want anthropic", ai["provider"])
}
again, err := db.UserSettings(ctx, user.ID, "gmail")
if err != nil {
t.Fatal(err)
}
if again["label"] != "Verdande" {
t.Errorf("saving the AI provider took the Gmail settings: %v", again)
}
}
+28 -1
View File
@@ -166,15 +166,42 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
// labels are short, and their names are typed by the person searching for
// them, so there is no spelling to be generous about. The diacritic
// folding that matters for task text does not earn a second index here.
//
// The third arm is a word inside a word. An index is built of tokens, and
// `SpeedAdmin` is one of them — so "speed admin" asks for two words that are
// nowhere. A prefix query gets half of it (`speed*` matches, `admin*` cannot)
// and the two are joined with AND, so the half that works is thrown away.
//
// Notes got the same arm, and for the same reason. Having it in one of the
// two would be worse than having it in neither: "it finds it in my notes but
// not in my tasks" is a bug report waiting, and the answer to it would be
// this line.
//
// It scans, and it only ever runs when somebody typed something in the box —
// Today and Kommende do not set `Search`. Every term has to appear, so the
// arms are ANDed with each other while the whole thing is ORed with the two
// above.
inside := make([]string, 0, maxSearchTerms)
insideArgs := make([]any, 0, maxSearchTerms)
for _, term := range SearchTerms(f.Search) {
inside = append(inside, "t.fold LIKE ?")
insideArgs = append(insideArgs, "%"+FoldDanish(term)+"%")
}
text := ""
if len(inside) > 0 {
text = " OR (" + strings.Join(inside, " AND ") + ")"
}
where = append(where, `(
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
OR t.id IN (
SELECT tl.task_id FROM task_labels tl
JOIN labels l ON l.id = tl.label_id
WHERE lower(l.name) LIKE ?
)
)`+text+`
)`)
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
args = append(args, insideArgs...)
}
if f.FilterSQL != "" {
where = append(where, "("+f.FilterSQL+")")
+131
View File
@@ -588,6 +588,65 @@
color: var(--ink-muted);
}
/* Versionsnummeret i sin egen søjle, så øjet kan løbe ned ad dem uden at
læse sætningerne. `ch` frem for pixels: søjlen er lige så bred som de
tal, der står i den, og den følger med, hvis skriften skifter. */
ul.lately {
list-style: none;
margin: 1.5rem 0 0;
padding: 0;
}
ul.lately li {
display: grid;
grid-template-columns: 5ch 1fr;
gap: 0 1rem;
padding: 0.875rem 0;
border-top: 1px solid var(--line);
font-size: 0.9375rem;
color: var(--ink-muted);
}
ul.lately .ver {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--ink-faint);
/* Tallet står på linje med den første tekstlinje og ikke over den. */
line-height: 1.7;
}
@media (max-width: 520px) {
ul.lately li {
grid-template-columns: 1fr;
gap: 0.25rem;
}
}
/* Risikoen står i kort, ikke i småt.
Et forbehold sat med mindre skrift end resten er et forbehold, der er
skrevet for at kunne peges på bagefter — og det er den ene ting, denne
side ikke skal gøre. Samme størrelse som alt andet. */
.risk {
border: 1px solid var(--line-strong);
border-radius: var(--radius-lg);
padding: 1.5rem;
background: var(--surface);
}
.risk p {
margin: 0 0 1rem;
font-size: 0.9375rem;
color: var(--ink-muted);
}
.risk p:last-child {
margin-bottom: 0;
}
.risk strong {
color: var(--ink);
}
/* The non-goals were one paragraph of seven sentences, which is the shape
nobody reads. Each is its own line now, and they can be counted. */
ul.nogo {
@@ -1121,6 +1180,43 @@
</div>
</section>
<section id="recently">
<h2>Lately</h2>
<p class="muted">
A landing page can say a project is alive; a list of what landed says it
better. This is not a changelog — every release has notes, and they are on
<a href="https://github.com/kristianwind/verdande/releases">the releases page</a>.
</p>
<ul class="lately">
<li>
<span class="ver">0.34</span>
Sections can be dragged into the order the work actually happens in. The
sidebar keeps its head still while the projects scroll.
</li>
<li>
<span class="ver">0.33</span>
Notes grew an archive, selection of many at once, pictures you can paste
or drag in, a source view, and a copy button on code blocks. Search
stopped answering by date and started answering by relevance.
</li>
<li>
<span class="ver">0.32</span>
A calendar of its own as a menu item, with a read-only Google calendar
that can be laid over it.
</li>
<li>
<span class="ver">0.31</span>
Mailboxes over IMAP, so flagging a mail in iCloud or Fastmail makes a
task — not only Gmail.
</li>
<li>
<span class="ver">0.30</span>
An installation count that says exactly what it sends, and can be turned
off in one click.
</li>
</ul>
</section>
<section id="running">
<h2>Running it</h2>
@@ -1168,6 +1264,41 @@
</ul>
</section>
<section id="risk">
<h2>At your own risk</h2>
<div class="risk">
<p>
verdande is <strong>MIT licensed and provided as is, without warranty of
any kind</strong>. That sentence is in every MIT licence and it is usually
skipped, so here it is in the open: if you run this, you run it at your
own risk, and nobody is on the hook if it loses your work.
</p>
<p>
It is one person's project, at version 0.x, and 0.x means the shape can
still change. <strong>You host it, so the data and the backups are
yours.</strong> It writes a backup every night and keeps fourteen — and a
backup nobody has ever restored is not yet a backup. Restore one before
you need to.
</p>
<p>
Security is worked on rather than claimed. There have been two full
reviews of the code, every finding is written down and closed, and what is
protected and what is not is spelled out in
<a href="https://github.com/kristianwind/verdande/blob/main/SECURITY.md">SECURITY.md</a>
— including the parts that are a deliberate trade-off. <strong>Nobody
outside the project has audited it.</strong> The last review found two ways
to reach something you should not and closed both; a third review will find
more, which is the point of having one.
</p>
<p>
It is built to sit behind something — a tunnel, a reverse proxy, a VPN —
put there by somebody who knows what they exposed. Found a hole? Mail it
rather than opening an issue; the address is in SECURITY.md and you get an
answer within a week.
</p>
</div>
</section>
<footer>
<!-- The Norn belongs at the end. It was the third paragraph of the hero,
where it stood between somebody arriving and the buttons they came
+221 -10
View File
@@ -976,10 +976,19 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
await expect(chip).toBeVisible();
await chip.dragTo(page.locator(`[data-date="${to}"]`));
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
// slipper der, hvor man kan se, man slipper.
await chip.dragTo(page.locator(`[data-date="${to}"]`), { targetPosition: { x: 30, y: 20 } });
await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible();
// Landet i timesøjlen med et klokkeslæt: hvor på døgnet man slap, *er* tiden.
// Sluppet tyve pixels nede i en søjle, der begynder klokken otte, bliver det
// tidligt på dagen — hvad det præcist runder til, er ikke det, der prøves her.
await expect(page.locator(`[data-date="${to}"] .tevent`).getByText('skifte dæk')).toBeVisible();
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
await expect(page.locator(`[data-allday="${from}"]`).getByText('skifte dæk')).toBeHidden();
expect(trouble).toEqual([]);
});
@@ -1131,10 +1140,14 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
// Both kinds are in the same cell, which is the whole point of the view.
const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' });
const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' });
const task = cell.locator('.chip').filter({ hasText: 'vande blomster' });
// Begge slags på den samme dag, som er hele pointen med visningen — men en dag
// er to kasser nu, siden ugen blev et døgn: timesøjlen bærer det, der har et
// klokkeslæt, og båndet foroven bærer det, der ikke har. En heldagsbegivenhed
// lagt klokken nul ville påstå, den holdt fra midnat til midnat.
const band = page.locator(`[data-allday="${day}"]`);
const event = cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' });
const allDay = band.locator('.event').filter({ hasText: 'Feriedag' });
const task = band.locator('.chip').filter({ hasText: 'vande blomster' });
await expect(event).toBeVisible();
await expect(allDay).toBeVisible();
await expect(task).toBeVisible();
@@ -1159,11 +1172,18 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
// The task still reschedules by drag, in a grid that now has events in it.
await task.dragTo(page.locator(`[data-date="${next}"]`));
await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible();
await expect(cell.getByText('vande blomster')).toBeHidden();
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
// slipper der, hvor man kan se, man slipper.
await task.dragTo(page.locator(`[data-date="${next}"]`), { targetPosition: { x: 30, y: 20 } });
// Landet i næste dags timesøjle, med det klokkeslæt der blev sluppet på.
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
await expect(band.getByText('vande blomster')).toBeHidden();
// The events stayed where they were; a rescheduled task moves nothing else.
await expect(cell.locator('.event')).toHaveCount(2);
await expect(cell.locator('.tevent')).toHaveCount(1);
await expect(band.locator('.event')).toHaveCount(1);
expect(trouble).toEqual([]);
});
@@ -3092,3 +3112,194 @@ test('en note uden krop må ikke låse ruden', async ({ page }) => {
).toHaveText('Beta', { timeout: 5000 });
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
});
/**
* Ugen er et døgn, ikke syv punktopstillinger.
*
* En uge, hvor hver dag er en liste, svarer på "hvad skal der ske torsdag" og ikke
* på "hvornår" — og det andet er det, man åbner en uge for. To møder klokken ti er
* en konflikt, man skal kunne se, ikke læse sig til.
*
* Begivenhederne kommer fra en rute, der svarer i stedet for Google: det, der
* prøves her, er gitteret, og en prøve, der først skal have en OAuth-forbindelse,
* er en prøve, der aldrig kører.
*/
test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
const trouble = watchForTrouble(page);
// Mandag i den uge, siden åbner på.
const monday = (n) => {
const d = new Date();
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
};
const at = (n, from, to, id, summary) => ({
id,
calendar_id: 'c1',
summary,
starts_at: `${monday(n)}T${from}:00+02:00`,
ends_at: `${monday(n)}T${to}:00+02:00`,
start_day: monday(n),
end_day: monday(n),
all_day: false,
calendar_name: 'Privat'
});
await page.route('**/api/v1/calendar/events*', (route) =>
route.fulfill({
json: {
events: [
at(2, '09:00', '10:00', 'e1', 'Morgenmøde'),
at(2, '09:30', '11:00', 'e2', 'Oveni det første'),
at(2, '17:00', '18:00', 'e3', 'Sent på dagen'),
{
id: 'e4',
calendar_id: 'c1',
summary: 'Aalborg',
start_day: monday(1),
end_day: monday(1),
all_day: true,
calendar_name: 'Privat'
}
]
}
})
);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await expect(page.locator('.weekgrid')).toBeVisible();
// Syv lige brede dage. `1fr` er `minmax(auto, 1fr)`, så ét langt ord i én dag
// gjorde den bredere end de seks andre — hvilket er det modsatte af et gitter.
const widths = await page
.locator('.weekgrid .daycol')
.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().width)));
expect(widths, 'ugen har ikke syv søjler').toHaveLength(7);
expect(Math.max(...widths) - Math.min(...widths), 'dagene er ikke lige brede').toBeLessThanOrEqual(1);
// Klokken bestemmer, hvor noget står. Morgenmødet skal ligge over det sene.
const box = async (name) => (await page.locator('.tevent', { hasText: name }).first().boundingBox());
const morning = await box('Morgenmøde');
const evening = await box('Sent på dagen');
expect(morning.y, 'morgenmødet står ikke over det sene møde').toBeLessThan(evening.y);
// To ting på samme tid står ved siden af hinanden. Oven på hinanden er præcis
// den konflikt, visningen findes for at vise.
const overlapping = await box('Oveni det første');
expect(overlapping.x, 'de to overlappende ligger oven på hinanden').toBeGreaterThan(morning.x);
expect(overlapping.x, 'den anden er havnet i næste dag').toBeLessThan(morning.x + morning.width * 2);
// Heldagsting hører til båndet foroven: en heldagsbegivenhed lagt klokken nul
// ville påstå, den holdt fra midnat til midnat.
// `.weekgrid > .allday` er selve cellen; brikken indeni bærer også klassen.
await expect(page.locator('.weekgrid > .allday', { hasText: 'Aalborg' })).toBeVisible();
// Og måneden er stadig lige bred hele vejen rundt.
await page.getByRole('button', { name: 'Måned', exact: true }).click();
const monthWidths = await page
.locator('.grid .day')
.evaluateAll((els) => els.slice(0, 7).map((e) => Math.round(e.getBoundingClientRect().width)));
expect(
Math.max(...monthWidths) - Math.min(...monthWidths),
'månedens dage er ikke lige brede'
).toBeLessThanOrEqual(1);
expect(trouble).toEqual([]);
});
/**
* Tid på en opgave, sat i ruden og justeret i kalenderen.
*
* Tre påstande, og den midterste er den, der var gået galt uden at nogen sagde
* det: serveren læser `due_date` og `due_time` sammen, så en dato sendt uden en
* tid *rydder* tiden. Et gem af datoen alene tog altså klokkeslættet med sig, og
* det samme gjorde et træk i kalenderen.
*/
test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// I dag, så den står på den visning, den skrives i — og i den uge, gitteret
// åbner på længere nede.
await box.fill('ringe til tandlægen i dag');
await box.press('Enter');
await expect(page.getByText('ringe til tandlægen')).toBeVisible();
// Klokkeslættet sættes i ruden. Feltet er tomt til at begynde med: en opgave
// uden tid er det almindelige, og 00:00 ville påstå noget om midnat.
await page.getByText('ringe til tandlægen').click();
const time = page.getByLabel('Klokkeslæt');
await expect(time).toHaveValue('');
// Tidligt på dagen med vilje: gitteret begynder klokken otte, så opgaven står
// lige under båndet og de to kasser er i billedet på én gang. Klokken fjorten
// ligger under skærmkanten, og et træk, der først skal rulle, er et træk,
// browseren taber.
await time.fill('08:30');
await time.blur();
await page.waitForTimeout(400);
// Gemt hos serveren, ikke kun i feltet: genindlæst og åbnet igen.
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(page.getByLabel('Klokkeslæt'), 'klokkeslættet blev ikke gemt').toHaveValue('08:30');
// Og datoen kan rettes bagefter uden at tage timen med sig.
const date = page.locator('#due');
const day = await date.inputValue();
await date.fill(day);
await date.blur();
await page.waitForTimeout(400);
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(
page.getByLabel('Klokkeslæt'),
'et gem af datoen ryddede klokkeslættet'
).toHaveValue('08:30');
await page.keyboard.press('Escape');
// I ugegitteret står den på sit klokkeslæt frem for i heldagsbåndet.
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const placed = page.locator('.tevent.task').filter({ hasText: 'ringe til tandlægen' });
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
await expect(placed).toContainText('08:30');
// Trukket op i båndet over sin egen dag: så har den en dag og ingen tid. Den
// tomme streng siger "ryd", hvor ingenting siger "behold" — og de to må ikke
// forveksles.
//
// Et bånd, der er tomt. Prøven handler om at krydse mellem de to kasser, og en
// dag, hvis bånd allerede er fyldt af andre prøvers opgaver, gør slippunktet
// til en anden opgave frem for til båndet — hvilket er en anden gest end den,
// der prøves her.
const band = page
.locator('.weekgrid > .allday')
.filter({ hasNot: page.locator('.chip, .event') })
.first();
const today = await band.getAttribute('data-allday');
// Trukket i skridt frem for i ét spring. `dragTo` flytter musen i to træk, og
// over fire hundrede pixels i et travlt gitter giver det browseren for få
// mellemstationer til at holde gesten i live. Et menneske bevæger hånden hele
// vejen, og det er også det, der skal prøves.
const fromBox = await placed.boundingBox();
const toBox = await band.boundingBox();
await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + 5);
await page.mouse.down();
await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 25 });
await page.mouse.up();
await expect(
page.locator(`[data-allday="${today}"] .chip`).filter({ hasText: 'ringe til tandlægen' }),
'opgaven kom ikke op i båndet'
).toBeVisible();
await expect(
page.locator(`[data-date="${today}"] .tevent`).filter({ hasText: 'ringe til tandlægen' }),
'opgaven står stadig på et klokkeslæt'
).toBeHidden();
expect(trouble).toEqual([]);
});
+542 -83
View File
@@ -272,6 +272,15 @@
over = iso(date);
}
/**
* Sluppet på en dag, uden at sige noget om klokkeslættet — så beholder opgaven
* det, den har. Måneden er den her: en rude er en dag, ikke et døgn, og der er
* ingen tid at læse ud af hvor man slap.
*
* Det var ikke sådan før. Trækket sendte kun datoen, og serveren læser en dato
* uden en tid som "ryd tiden" — så en opgave klokken 14 mistede sit
* klokkeslæt ved at blive flyttet til dagen efter, uden at nogen bad om det.
*/
async function onDrop(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
@@ -279,6 +288,184 @@
if (!id) return;
await app.reschedule(id, iso(date));
}
/**
* Sluppet et sted på døgnet: dér, man slap, er klokkeslættet.
*
* Målt mod søjlens egen kasse frem for mod siden, så det bliver rigtigt, uanset
* hvor gitteret sidder, og uanset hvor langt der er rullet.
*
* Rundet til kvarter. Minuttet under markøren er ikke en oplysning, nogen har —
* pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
* det blev valgt, hvilket er værre end at være omtrentligt.
*/
async function onDropAt(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
const box = event.currentTarget.getBoundingClientRect();
const share = Math.min(Math.max((event.clientY - box.top) / box.height, 0), 1);
const minutes = hours.from * 60 + share * hours.minutes;
const snapped = Math.min(Math.round(minutes / 15) * 15, 24 * 60 - 15);
await app.reschedule(id, iso(date), clock(snapped));
}
/**
* Sluppet i båndet foroven: opgaven har en dag og ingen tid.
*
* Den tomme streng er ikke det samme som ingenting her — den siger "ryd", hvor
* `undefined` siger "behold". Det er hele grunden til, at de to er skilt ad i
* `reschedule`.
*/
async function onDropAllDay(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
await app.reschedule(id, iso(date), '');
}
// --- ugen som et døgn ---------------------------------------------------------
//
// En uge, hvor hver dag er en punktopstilling, svarer på "hvad skal der ske
// torsdag" og ikke på "hvornår" — og det andet er det, man åbner en uge for.
// To møder klokken ti er en konflikt, man skal kunne *se*, ikke læse sig til.
//
// Måneden bliver som den er. En dag i en månedsrude er halvanden centimeter høj;
// et døgn tegnet i den er ikke en visning, det er en streg.
/** Minutter siden midnat, læst ud af strengen. */
const minutesAt = (stamp) => Number(stamp.slice(11, 13)) * 60 + Number(stamp.slice(14, 16));
const clock = (minutes) =>
`${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
/**
* Det, der har et klokkeslæt på denne dag, med start og slut i minutter.
*
* Begivenheder læses som tekst, af samme grund som `timeOn` gør det: Google
* skriver begivenhedens egen forskydning ind i stemplet, og at give det til
* browseren spørger *denne maskine*, hvad klokken er. Opgaver læses med `Date`,
* fordi det er sådan TaskRow gør det ét skærmbillede væk — en opgaves tid er
* kontoens egen, og de to steder skal vise det samme tal.
*
* En begivenhed hen over midnat klippes til dagen. Uden det ville et møde fra
* fredag aften til lørdag morgen blive tegnet fra 20:00 til 09:00, altså baglæns.
*/
function timedOn(date) {
const day = iso(date);
const out = [];
for (const event of events) {
if (event.all_day || !event.starts_at) continue;
if (event.start_day > day || event.end_day < day) continue;
const from = event.start_day === day ? minutesAt(event.starts_at) : 0;
let to = 24 * 60;
if (event.ends_at && event.end_day === day) to = minutesAt(event.ends_at);
else if (!event.ends_at) to = from + 60;
// Et kvarters gulv: et nulminuts møde er stadig noget, der skal kunne ses
// og klikkes på.
out.push({ kind: 'event', ref: event, from, to: Math.max(to, from + 15) });
}
for (const task of tasksOn(date)) {
if (!task.due_datetime) continue;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
out.push({ kind: 'task', ref: task, from, to: from + 30 });
}
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
}
/** Det uden klokkeslæt: heldagsbegivenheder og opgaver, der kun har en dato. */
function untimedOn(date) {
return [
...eventsOn(date)
.filter((e) => e.all_day || !e.starts_at)
.map((e) => ({ kind: 'event', ref: e })),
...tasksOn(date)
.filter((t) => !t.due_datetime)
.map((t) => ({ kind: 'task', ref: t }))
];
}
/**
* To ting på samme tid står ved siden af hinanden, ikke oven på hinanden.
*
* Sammenhængende klynger frem for parvise sammenligninger: overlapper A med B og
* B med C, skal alle tre dele bredden — også når A og C ikke rører hinanden.
* Ellers ville A og C hver tro, de havde halvdelen, og lægge sig oven på hver sin
* halvdel af B.
*/
function packed(items) {
const out = [];
let cluster = [];
let end = -1;
const flush = () => {
const lanes = [];
for (const item of cluster) {
let lane = lanes.findIndex((free) => free <= item.from);
if (lane === -1) {
lane = lanes.length;
lanes.push(0);
}
lanes[lane] = item.to;
item.lane = lane;
}
for (const item of cluster) item.lanes = lanes.length;
out.push(...cluster);
cluster = [];
end = -1;
};
for (const item of items) {
if (cluster.length && item.from >= end) flush();
cluster.push(item);
end = Math.max(end, item.to);
}
if (cluster.length) flush();
return out;
}
/**
* Hvilke timer der tegnes.
*
* Et helt døgn er fireogtyve rækker, hvoraf de otte altid er tomme, og så er
* gitteret noget man ruller i frem for noget man ser. Arbejdsdagen er gulvet, og
* ugens egne begivenheder skubber kanterne ud, når de ligger uden for den — en
* vagt klokken fem om morgenen kommer med, uden at alle andre uger betaler for
* den med tomme rækker.
*/
let hours = $derived.by(() => {
let from = 8;
let to = 18;
if (span === 'week') {
for (const date of grid) {
for (const item of timedOn(date)) {
from = Math.min(from, Math.floor(item.from / 60));
to = Math.max(to, Math.ceil(item.to / 60));
}
}
}
from = Math.max(0, from);
to = Math.min(24, Math.max(to, from + 1));
return { from, to, count: to - from, minutes: (to - from) * 60 };
});
/** Hvor på døgnet noget står, som procent af de timer, der tegnes. */
function place(item) {
const top = Math.max(0, ((item.from - hours.from * 60) / hours.minutes) * 100);
const height = ((item.to - item.from) / hours.minutes) * 100;
const width = 100 / item.lanes;
return (
`top:${top}%;height:${Math.min(height, 100 - top)}%;` +
`left:${item.lane * width}%;width:${width}%`
);
}
</script>
<div class="calendar" class:week={span === 'week'}>
@@ -296,95 +483,195 @@
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
</header>
<div class="weekdays" aria-hidden="true">
{#each WEEKDAYS as day}
<span>{day}</span>
{/each}
</div>
<!--
Én brik, tegnet ét sted.
<div class="grid">
{#each grid as date (date.toISOString())}
{@const dayEvents = eventsOn(date)}
<!-- Events first, then whatever room is left goes to tasks. An event is
fixed to a time somebody else chose; a task is only dated, and if
something in a full cell has to fall behind "+3 mere", it should be
the one that can be moved. -->
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
{@const hidden =
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
<!-- A week has no outside: every day in it is the week you asked for. -->
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day"
data-date={iso(date)}
class:outside
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
>
<!-- The weekday strip above the grid is enough on a wide screen. A week
stacked into rows on a phone hides it, and a column of bare numbers
is not a week — so the cell carries its own name there. -->
{#if span === 'week'}
<span class="weekday">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
{/if}
<span class="number">{date.getDate()}</span>
Måneden og ugen viser de samme to ting og skal vise dem ens — men de placerer
dem forskelligt: måneden lader dem stå i rækkefølge i en rude, ugen sætter
dem på et klokkeslæt. Det er placeringen, der er forskellig, ikke brikken, så
det er kun placeringen, der står to steder.
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
{@const link = linkOf(event)}
{@const label = t('cal.eventCannotMove', {
name: event.summary,
calendar: event.calendar_name || t('cal.title')
})}
<!-- Not draggable, and it says so twice: the attribute stops the
browser dragging an anchor of its own accord, and the title says
why to anybody who tries. The drop targets would refuse it anyway
— they only accept the task MIME type — but a chip that lifts off
the page and then will not land reads as a bug rather than as a
rule. It is also not a button: there is nothing here to complete,
and a checkbox next to a meeting is an offer verdande cannot keep. -->
<svelte:element
this={link ? 'a' : 'span'}
class="event"
class:allday={event.all_day}
draggable="false"
href={link || undefined}
target={link ? '_blank' : undefined}
rel={link ? 'noreferrer noopener' : undefined}
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
>
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
{event.summary}
</svelte:element>
`extra` er den absolutte placering i ugegitteret og tom i måneden. `at` er
klokkeslættet, når det ikke allerede står i brikken selv.
-->
{#snippet eventChip(event, date, kind, extra = '', at = '')}
{@const link = linkOf(event)}
{@const label = t('cal.eventCannotMove', {
name: event.summary,
calendar: event.calendar_name || t('cal.title')
})}
<!-- Not draggable, and it says so twice: the attribute stops the browser
dragging an anchor of its own accord, and the title says why to anybody
who tries. The drop targets would refuse it anyway — they only accept the
task MIME type — but a chip that lifts off the page and then will not land
reads as a bug rather than as a rule. It is also not a button: there is
nothing here to complete, and a checkbox next to a meeting is an offer
verdande cannot keep. -->
<svelte:element
this={link ? 'a' : 'span'}
class={kind}
class:allday={event.all_day}
draggable="false"
href={link || undefined}
target={link ? '_blank' : undefined}
rel={link ? 'noreferrer noopener' : undefined}
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
style={[swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : '', extra]
.filter(Boolean)
.join(';')}
>
{#if at}<span class="at">{at}</span>{:else if timeOn(event, date)}<span class="at"
>{timeOn(event, date)}</span
>{/if}
{event.summary}
</svelte:element>
{/snippet}
{#snippet taskChip(task, kind, extra = '', at = '')}
<!-- Draggable as well as clickable: moving something to another day is the
whole reason to be looking at a calendar. The browser needs a few pixels
of movement before it calls a press a drag, so the click survives. -->
<button
class={kind}
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
ondragend={() => (over = null)}
onclick={() => onselect?.(task)}
style={extra}
>
{#if at}<span class="at">{at}</span>{/if}
{task.content}
</button>
{/snippet}
{#if span === 'week'}
<!-- Ugen som et døgn, ikke som syv punktopstillinger.
Rullet vandret på en smal skærm frem for stablet til én søjle: der er
ét sæt afleveringsmål her, og et andet sæt til telefonen ville være
endnu et sted, den samme regel kunne være næsten rigtig. -->
<div class="weekwrap">
<div class="weekgrid" style="--hours: {hours.count}">
<div class="corner"></div>
{#each grid as date (date.toISOString())}
<div class="head" class:today={iso(date) === todayISO}>
<span class="wd">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
<span class="num">{date.getDate()}</span>
</div>
{/each}
{#each tasks as task (task.id)}
<!-- Draggable as well as clickable: within a month, moving something
to another day is the whole reason to be looking at a month. The
browser needs a few pixels of movement before it calls a press a
drag, so the click survives. -->
<button
class="chip"
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
ondragend={() => (over = null)}
onclick={() => onselect?.(task)}
<!-- Båndet foroven: det, der ikke har et klokkeslæt og derfor ikke kan
stå ét sted på døgnet. En heldagsbegivenhed lagt klokken nul ville
påstå, den holdt fra midnat til midnat. -->
<div class="gutter">{t('cal.allDay')}</div>
{#each grid as date (date.toISOString())}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="allday"
data-allday={iso(date)}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDropAllDay(e, date)}
>
{task.content}
</button>
{#each untimedOn(date) as item (item.kind + item.ref.id)}
{#if item.kind === 'event'}
{@render eventChip(item.ref, date, 'event')}
{:else}
{@render taskChip(item.ref, 'chip')}
{/if}
{/each}
</div>
{/each}
{#if hidden > 0}
<span class="more">{t('view.more', { n: hidden })}</span>
{/if}
<div class="gutter times">
{#each Array(hours.count) as _, i}
<span class="hour">{clock((hours.from + i) * 60)}</span>
{/each}
</div>
{#each grid as date (date.toISOString())}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- `data-date` er dagens navn udadtil. Månedens ruder har altid haft
det, og ugens søjler skal have det af samme grund: det er sådan
noget uden for komponenten peger på en dag — og det er det, en
prøve slipper en opgave på. Heldagsbåndet får sit eget navn, så
de to ikke bliver til det samme svar på ét spørgsmål. -->
<div
class="daycol"
data-date={iso(date)}
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDropAt(e, date)}
>
{#each Array(hours.count) as _, i}
<div class="hourline" style="top:{(i / hours.count) * 100}%"></div>
{/each}
{#each packed(timedOn(date)) as item (item.kind + item.ref.id)}
{#if item.kind === 'event'}
{@render eventChip(item.ref, date, 'tevent', place(item))}
{:else}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div>
</div>
{:else}
<div class="weekdays" aria-hidden="true">
{#each WEEKDAYS as day}
<span>{day}</span>
{/each}
</div>
<div class="grid">
{#each grid as date (date.toISOString())}
{@const dayEvents = eventsOn(date)}
<!-- Events first, then whatever room is left goes to tasks. An event is
fixed to a time somebody else chose; a task is only dated, and if
something in a full cell has to fall behind "+3 mere", it should be
the one that can be moved. -->
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
{@const hidden =
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
<!-- A week has no outside: every day in it is the week you asked for. -->
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day"
data-date={iso(date)}
class:outside
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
>
<!-- The weekday strip above the grid is enough on a wide screen. A week
stacked into rows on a phone hides it, and a column of bare numbers
is not a week — so the cell carries its own name there. -->
{#if span === 'week'}
<span class="weekday">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
{/if}
<span class="number">{date.getDate()}</span>
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
{@render eventChip(event, date, 'event')}
{/each}
{#each tasks as task (task.id)}
{@render taskChip(task, 'chip')}
{/each}
{#if hidden > 0}
<span class="more">{t('view.more', { n: hidden })}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
@@ -448,9 +735,19 @@
border: 1px solid var(--line);
}
/* `minmax(0, 1fr)` og ikke `1fr`.
*
* `1fr` er `minmax(auto, 1fr)`, og `auto` som minimum betyder, at en søjle ikke
* kan blive smallere end sit længste ord. Én opgave, der hedder "Gårdbutikken på
* Møllerup Gods — opfølgning på mail", gjorde onsdag dobbelt så bred som mandag
* og klemte resten af ugen sammen om den. Syv dage er syv lige store dage; det
* er hele idéen med et gitter.
*
* Nul som minimum lader søjlen blive så smal, den skal — og så er det brikkens
* opgave at klippe sin tekst af, hvilket den allerede gør. */
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
margin-bottom: var(--s2);
}
@@ -465,7 +762,7 @@
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
@@ -473,6 +770,168 @@
overflow: hidden;
}
/* --- ugen som et døgn ---------------------------------------------------
*
* Et gitter med en tidsrende og syv dage, og tre rækker: dagenes navne, båndet
* med det, der ikke har et klokkeslæt, og selve døgnet.
*
* Rullet vandret på en smal skærm frem for stablet til én søjle. Syv timeglas
* ved siden af hinanden kan ikke blive smalle nok til en telefon, og en uge
* stablet til en liste er præcis det, den her visning findes for at holde op
* med at være. */
.weekwrap {
overflow-x: auto;
}
.weekgrid {
display: grid;
grid-template-columns: 3.25rem repeat(7, minmax(0, 1fr));
gap: 1px;
min-width: 640px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.corner,
.head,
.gutter,
.allday,
.daycol {
background: var(--ground);
}
.head {
padding: var(--s2) var(--s1);
text-align: center;
}
.head .wd {
display: block;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
}
.head .num {
font-size: var(--text-sm);
}
.head.today .num {
color: var(--accent);
font-weight: 560;
}
.gutter {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--s1) var(--s2);
font-size: var(--text-xs);
line-height: 1.2;
color: var(--ink-faint);
text-align: right;
}
.allday {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s1);
min-height: 1.75rem;
}
/* Rækken er lige så høj som de timer, den viser. `--hours` sættes på gitteret,
fordi tallet kommer fra ugens egne begivenheder og ikke fra stilarket. */
.times {
display: grid;
align-items: start;
justify-content: stretch;
grid-template-rows: repeat(var(--hours), 1fr);
height: calc(var(--hours) * 3rem);
padding: 0 var(--s2) 0 0;
}
/* Klokkeslættet står ved sin egen streg og ikke midt i timen under den. */
.times .hour {
transform: translateY(-0.55em);
line-height: 1;
}
/* Undtagen det første, som ellers kravler op i heldagsbåndet: der er ingen streg
over den første time — der er gitterets kant, og et tal, der ligger oven på
den, ser ud til at høre til rækken ovenover. */
.times .hour:first-child {
transform: none;
}
.daycol {
position: relative;
height: calc(var(--hours) * 3rem);
}
.hourline {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--line);
pointer-events: none;
}
/* Den første streg er gitterets egen kant. */
.hourline:first-child {
display: none;
}
.allday.over,
.daycol.over {
background: var(--accent-sunken, var(--surface-raised));
box-shadow: inset 0 0 0 1px var(--accent);
}
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
luft, der gør to kasser til to kasser. */
.tevent {
position: absolute;
margin-right: 2px;
overflow: hidden;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour, var(--line-strong));
background: var(--surface-sunken);
color: var(--ink-muted);
font-size: var(--text-xs);
line-height: 1.3;
text-align: left;
text-decoration: none;
}
.tevent.task {
background: var(--surface-raised);
border-left-color: var(--line-strong);
color: var(--ink);
cursor: grab;
}
.tevent.task[data-priority='1'] {
border-left-color: var(--p1);
}
.tevent.task[data-priority='2'] {
border-left-color: var(--p2);
}
.tevent.task[data-priority='3'] {
border-left-color: var(--p3);
}
.tevent .at {
color: var(--ink-faint);
margin-right: 0.3em;
}
.day {
background: var(--ground);
min-height: 92px;
+11 -1
View File
@@ -59,7 +59,17 @@
} catch (e) {
// A cancelled prompt is not a failure. Somebody changed their mind, and
// an error for that reads as though the key was rejected.
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
if (e?.name === 'NotAllowedError' || e?.message === 'cancelled') {
// nothing to say
} else if (e?.code === 'unauthorized') {
// Not "wrong email or password". There is no email here and no
// password — the device signed, and the signature was refused. The
// generic 401 text sent somebody looking through their account for a
// typo that was never there; what had actually happened was that the
// site had moved from http to https, and a passkey is bound to the
// origin it was made on, scheme included.
error = t('passkey.notAccepted');
} else {
error = humanMessage(e);
}
} finally {
+50 -7
View File
@@ -11,7 +11,7 @@
* necessarily the last one sent.
*/
import { api, humanMessage } from '$lib/api.js';
import { app, projectName } from '$lib/stores.svelte.js';
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
@@ -22,6 +22,7 @@
let description = $state('');
let priority = $state(4);
let dueDate = $state('');
let dueTime = $state('');
let labels = $state('');
let projectId = $state('');
let recurrence = $state('');
@@ -55,6 +56,7 @@
description = task.description ?? '';
priority = task.priority;
dueDate = task.due_date ?? '';
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
labels = (task.labels ?? []).join(', ');
projectId = task.project_id;
recurrence = task.recurrence_rule ?? '';
@@ -407,14 +409,34 @@
</select>
</div>
<!--
Dato og klokkeslæt gemmes sammen, altid.
Serveren læser de to felter som ét: kommer datoen uden en tid, *ryddes*
tiden. Så et gem af datoen alene tog klokkeslættet med sig — man rettede
dagen og mistede timen, uden at noget sagde det.
Tiden er tom, indtil nogen sætter den. En opgave uden klokkeslæt er det
almindelige, og et felt, der åbner på 00:00, er et felt, der påstår, at
noget skal ske ved midnat.
-->
<div class="field">
<label for="due">{t('detail.due')}</label>
<input
id="due"
type="date"
bind:value={dueDate}
onchange={() => save({ due_date: dueDate })}
/>
<div class="due">
<input
id="due"
type="date"
bind:value={dueDate}
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
/>
<input
type="time"
aria-label={t('detail.dueTime')}
bind:value={dueTime}
disabled={!dueDate}
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
/>
</div>
</div>
</div>
@@ -692,6 +714,27 @@
padding-bottom: max(var(--s8), env(safe-area-inset-bottom));
}
/* Dato og tid på én linje: de er ét svar på ét spørgsmål, og tiden er den
smalle halvdel af det. */
.due {
display: flex;
gap: var(--s2);
}
.due input[type='date'] {
flex: 1;
min-width: 0;
}
.due input[type='time'] {
flex: none;
width: 7.5rem;
}
.due input:disabled {
opacity: 0.5;
}
.field {
display: flex;
flex-direction: column;
+3
View File
@@ -551,6 +551,7 @@ export const da = {
'detail.p1': 'P1 — haster',
'detail.none': 'Ingen',
'detail.due': 'Forfalder',
'detail.dueTime': 'Klokkeslæt',
'detail.labels': 'Etiketter',
'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt',
@@ -814,6 +815,8 @@ export const da = {
'passkey.hint':
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
'passkey.notAccepted':
'Nøglen blev ikke godtaget. Sker det for en nøgle, der virkede før, er adressen typisk skiftet — en passkey hører til præcis den adresse, den blev lavet på, skema og alt.',
'passkey.none': 'Ingen nøgler endnu.',
'passkey.name': 'Navn på nøglen',
'passkey.namePlaceholder': 'fx min bærbare',
+3
View File
@@ -547,6 +547,7 @@ export const en = {
'detail.p1': 'P1 — urgent',
'detail.none': 'None',
'detail.due': 'Due',
'detail.dueTime': 'Time',
'detail.labels': 'Labels',
'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project',
@@ -807,6 +808,8 @@ export const en = {
'passkey.hint':
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
'passkey.unsupported': 'This browser does not support passkeys.',
'passkey.notAccepted':
'That key was not accepted. If it worked before, the address has usually changed — a passkey belongs to the exact address it was made on, scheme and all.',
'passkey.none': 'No keys yet.',
'passkey.name': 'Name for the key',
'passkey.namePlaceholder': 'e.g. my laptop',
+42 -3
View File
@@ -372,10 +372,35 @@ class AppState {
}
/** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */
async reschedule(id, date) {
/**
* Flytter en opgave til en dag — og afgør, hvad der sker med klokkeslættet.
*
* Serveren læser `due_date` og `due_time` sammen: kommer datoen uden en tid,
* *ryddes* tiden. Det er den rigtige regel for et felt, man skriver i, og den
* forkerte for et træk i en kalender — en opgave, der stod klokken 14, mistede
* sit klokkeslæt ved at blive trukket til dagen efter, uden at nogen bad om
* det, og uden at noget sagde det.
*
* Så tiden siges nu højt, tre måder:
*
* time === undefined behold den, opgaven har
* time === '' ryd den — det er heldagsbåndet
* time === 'HH:MM' sæt den — det er et sted på døgnet
*
* `undefined` og `''` er med vilje to forskellige ting her. De ligner hinanden
* i JavaScript, og det er præcis derfor de skal skilles ad ét sted frem for at
* blive forvekslet fire steder.
*/
async reschedule(id, date, time) {
const previous = this.get(id);
if (!previous || previous.due_date === date) return;
await this.update(id, { due_date: date });
if (!previous) return;
const clock =
time === undefined ? (previous.due_datetime ? clockOf(previous.due_datetime) : '') : time;
if (previous.due_date === date && (previous.due_datetime ? clockOf(previous.due_datetime) : '') === clock) {
return;
}
await this.update(id, { due_date: date, due_time: clock });
}
async remove(id) {
@@ -729,6 +754,20 @@ class AppState {
* Only while it still carries a name the server gave it. Rename it and that is your
* name for it, in every language — the same rule as any other project.
*/
/**
* Klokkeslættet på en opgave, som "HH:MM" i den maskine, der ser på det.
*
* `Date` og ikke en strengudskæring: `due_datetime` kommer som UTC med et Z på,
* og de fjorten på et stempel er ikke klokken fjorten for den, der læser det.
* TaskRow viser tiden på nøjagtig samme måde, og de to steder skal vise det
* samme tal.
*/
export function clockOf(stamp) {
const at = new Date(stamp);
if (Number.isNaN(at.getTime())) return '';
return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
}
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
export function projectName(project) {