Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 9155e799b1 fix(prøver): markør-prøverne venter på editoren, før de taster
Runde 3 og 4 af de røde End-to-end var samme årsag. Det faste ophold, jeg
fjernede i forrige commit, skjulte den bag en anden påstand.

`page.keyboard.type` kørte, før editoren var tegnet. `click()` på "Ny note"
vender tilbage, så snart klikket er sendt, mens noten først skal oprettes
på serveren og tegnes bagefter. Så gik det tastede ind i den FORRIGE note
eller ingen steder, og noten blev liggende tom:

* runde 3 fejlede på, at noten ikke fandtes efter genindlæsningen
* runde 4 fejlede på, at der ikke kom nogen PATCH at vente på

To forskellige påstande, én årsag.

Og svaret stod skrevet over `nyNote` i samme fil, før prøverne blev
skrevet:

    Et klik på "Ny note" efterfulgt af tastetryk med det samme skriver
    derfor i den forrige note — teksten havner det forkerte sted, og den
    nye note bliver liggende tom. Efter en genindlæsning findes den titel,
    prøven leder efter, slet ikke.

Det er runde 3's fejl ord for ord. Men `nyNote` KLIKKER i feltet, og det er
netop det, markør-prøverne ikke må: de måler, at markøren står der, uden at
nogen har sat den. Derfor `nyTomNote` — samme ventetid, uden klikket.

Ankret på <h1> frem for blot et tomt felt: <h1> skrives af samme effekt,
der sætter markøren, så når den er der, er markøren sat. Et tomt felt kan
være et felt, effekten ikke har rørt endnu.

Kontrolleret i begge retninger med 1500 ms plantet på POST /notes: det rå
klik fejler med runde 4's ordlyd, `nyTomNote` består.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 20:29:59 +02:00
kristianwindandClaude Opus 5 5764271579 fix(prøver): kontrollen venter på gemningen, ikke på et tal
Fjerde røde End-to-end, fjerde reelle årsag, og denne stod skrevet i
forvejen.

"en note med indhold stjæler ikke markøren" brugte `waitForTimeout(1200)`
til at vente på, at noten blev gemt. På en travl runner var 1200 ms ikke
nok: genindlæsningen smed det skrevne væk, og prøven faldt på at noten
ikke FANDTES — `waiting for getByRole({ name: /Allerede skrevet/ })` —
frem for på markøren, som er det den måler.

Kommentaren over `gemt` i toppen af den samme fil siger det ord for ord:

    En fast ventetid er den samme fejl med et tal foran:
    `waitForTimeout(1200)` er et gæt om, hvor hurtig maskinen er, og det
    gæt holder indtil CI er travl.

Jeg skrev præcis det kald, med præcis det tal, under en kommentar der
navngiver begge. Lektien var ikke ny — den var skrevet ned, i filen, lige
over den hjælper der findes for at erstatte det.

Bruger nu `noteGemt(page)`, sat op FØR der skrives: svaret på PATCH'en er
det eneste, der beviser, at serveren har hørt om det.

Kontrolleret i begge retninger med 2500 ms plantet på PATCH /notes/* —
mere end dobbelt så længe som det gamle ophold ventede. Det gamle kald
fejler med CI's ordlyd; `noteGemt` består.

Der står 24 faste ophold tilbage i filen. De er ældre end denne gren, og
nogle venter måske på noget uden et netværkskald, så de hører i deres egen
rettelse — men mønsteret er nu dokumenteret to gange i samme fil.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 20:12:07 +02:00
kristianwindandClaude Opus 5 8a6329bfc9 fix(prøver): markør-prøverne venter på markøren i stedet for at håbe
CI's End-to-end faldt på "en ny note åbner med markøren i overskriften" med
`Expected: "h1", Received: null`, på en gren hvis eneste ændring siden
forrige grønne kørsel var docs/openapi.yaml. Den kan ikke påvirke en
editor, så fejlen lå i prøven.

Den klikkede "Ny note" og læste markøren i ÉT snapshot med det samme.
`click()` vender tilbage, så snart klikket er sendt, mens markøren sættes
efter en netværksrundtur OG en gentegning. Lokalt er rundturen under et
millisekund, så snapshottet ramte altid; på en travl runner ramte det null.
main er grøn på netop den prøve, så den bestod ved held frem for ved regel
— og den havde ligget og ventet på en travl runner i en fremmed PR, hvor
den ville have lignet dén PRs fejl.

Målt frem for ræsonneret: med 900 ms plantet på POST /notes fejler den
gamle læsning med CI's ordlyd og `expect.poll` består.

Tre af de fire havde samme svaghed. Kontrollen — "en note med indhold
stjæler ikke markøren" — er bevidst IKKE lavet om: den forventer null, så
en poll ville bestå på første læsning uanset hvad koden gjorde. Den ankrer
sig på overskriftens tekst i stedet. Det står som kommentar i filen, så den
ikke bliver "rettet" tilbage.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:53:12 +02:00
kristianwindandClaude Opus 5 fb8abcaa74 fix(docs): nullable er ikke 3.1, og eksemplet hørte til nabofeltet
To fejl i samme blok, begge mine.

`nullable: true` er OpenAPI 3.0. Specen er 3.1, hvor det blev fjernet til
fordel for en type-union, og den havde allerede fortilfældet stående:
`type: [integer, "null"]`. Redocly afviste de to steder som struktur-fejl.

Og min lokale kontrol stillede et svagere spørgsmål end CI's: jeg læste
filen med `yaml.safe_load` og fik "gyldig" tilbage. Den svarede på, om det
er YAML — ikke om det er en gyldig OpenAPI. CI's egen validator kører nu
lokalt, før den kører der.

Den anden fejl fandt kun en ADVARSEL: `example: ["labels"]` tilhørte
sidebar_collapsed, og min indsættelse landede før den, så en strengliste
hang på et heltalsfelt. Ankeret dækkede ikke hele blokken, jeg skød ind
efter. CI fejler kun på fejl, så den var sluppet igennem — på to felter ad
gangen, for sidebar_collapsed stod samtidig uden sit eget eksempel.

Advarsler: 253, som før ændringen. Specen er gyldig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:34:05 +02:00
kristianwindandClaude Opus 5 956e57f234 feat(opgaver): alarmen kan tales ind, og kan være standard
To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.

Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.

SÆTNINGEN

Hurtig tilføjelse forstår nu alarmen:

    ring til tandlægen i morgen kl 9 #hjem mind mig en time før

Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.

Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.

Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.

STANDARDEN

En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.

Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.

Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.

Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.

TRE TING LÆSNINGEN FANGEDE

* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
  (`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
  havde givet en alarm en time for sent, og det er den ene retning en
  påmindelse ikke må tage fejl i. Samme konvention hele vejen —
  indstillingen, parseren og kolonnen — for to fortegnsregler for det
  samme begreb viser sig kun som en alarm på et forkert tidspunkt.

* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
  `AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
  med en dag men ingen klokketid ville have lagt en række, der aldrig
  fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
  Det er den ene slags fejl en påmindelse ikke må have, fordi intet
  bagefter afslører den: der står ingen række at savne.

* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
  de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
  første, der bruger den i praksis. Den skriver nu "1 time før".

PRØVET

Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.

Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.

Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.

Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:16:14 +02:00
Kristian Wind 661e47ebdf Merge pull request #102 from kristianwind/markoer-i-titlen
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 15m22s
CI / Go (push) Failing after 26m42s
Release / Build and publish image (push) Failing after 9s
CI / Docker image builds (push) Successful in 2m36s
fix(noter): en ny note åbner med markøren i overskriften
2026-10-08 10:17:18 +02:00
kristianwindandClaude Opus 5 6178cbbdd8 Merge branch 'main' into markoer-i-titlen
Begge grene føjede prøver til i enden af smoke.spec.js, og begge beholdes:
fire markør-prøver og fire fra tal-en-opgave-ind. Den fælles `/**` ovenfor
dækkede kun den første, så den anden blok har fået sin egen.

Suiten på den løste fletning: 129 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 10:06:42 +02:00
Kristian Wind 43bd405e36 Merge pull request #100 from kristianwind/tal-en-opgave-ind
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m50s
CI / Docker image builds (push) Successful in 3m21s
CI / Go (push) Failing after 26m57s
feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
2026-10-08 09:59:41 +02:00
Kristian Wind b73f55ad0b Merge pull request #101 from kristianwind/afsnit-efter-retur
fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
2026-10-08 09:59:22 +02:00
kristianwind 475ecbe7ef Merge branch 'afsnit-efter-retur' into markoer-i-titlen 2026-10-08 07:55:04 +02:00
kristianwind 159029675c Merge branch 'afsnit-efter-retur' into tal-en-opgave-ind 2026-10-08 07:49:10 +02:00
kristianwindandClaude Opus 5 b18d9a26e6 fix(noter): afsnit skrevet hurtigt mister ikke deres afstand
Retur-handleren udskyder rettelsen ét hak, fordi der før delingen ikke er
nogen ny blok at rette. Men rettelsen spurgte, hvor markøren stod *nu*, og
et hak er nok: skriver man hurtigt, er næste retur nået igennem først, og så
stod markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
blok, rettelsen var ment for, stå tilbage som browserens bare <div>. En
<div> læses som brødtekst og er ikke formateret som en, så afstanden mellem
afsnit forsvandt stille, hos netop de brugere der skriver hurtigst.

Målt frem for gættet: "flere markerede afsnit bliver til én monotypeblok"
faldt på en CI-runner med alle tre linjer skrevet rigtigt, hvor "linje et"
var en <div> og "linje to" og "linje tre" var <p>.

Rettelsen er delt efter hvad der er sikkert at røre:

* Der hvor markøren står, gør execCommand det fortsat. Det er den, der
  lægger ændringen i browserens fortryd-historik, og filen vælger den
  bevidst andre steder af samme grund — ⌘Z skal tage afsnittet tilbage,
  ligesom den tager et ord tilbage.
* Den blok, delingen lige skabte, får samme regel uden markør at låne.
  Den dækker overskriften: retur for enden af en overskrift efterlader
  endnu en overskrift i de fleste browsere.
* Og hver <div> i noten bliver fejet op. En <div> er altid browserens egen
  beholder og aldrig nogens valg, så den kan rettes hvor som helst. En <h1>
  bliver IKKE fejet: menuen har en Titel-stil, så en overskrift midt i en
  note kan være en linje, nogen selv har valgt, og den er deres at beholde.

Fejning frem for et håndtag til blokken, og det er målt: når den første
udskudte rettelse bytter tagget, peger den næstes håndtag på en node uden
for dokumentet, og blokken midt i kæden bliver aldrig rettet. Prøven planter
to efterladte blokke og er kontrolleret i tre retninger — ingen rettelse: 2
efterladte <div>, håndtag til blokken: 1, fejning: 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 07:37:49 +02:00
kristianwindandClaude Opus 5 3768cf946e fix(noter): en ny note åbner med markøren i overskriften
Oprettelse af en note satte ingen markør. Siden kaldte
`document.querySelector('.editor textarea')?.focus()` — og der er ingen
textarea; editoren blev lavet om til en contenteditable, så kaldet har ramt
ingenting lige siden, og `?.` sørgede for, at det aldrig larmede.

Rettelsen ligger i NoteEditor frem for på siden, så begge veje er dækket:
knappen på /noter og den på et projekt, der navigerer til /noter?note=<id>.
Reglen er, at en TOM note sætter markøren i sin overskrift — en note med
indhold rører den ikke, for der er markøren brugerens eget valg.

Fire prøver, og de er kontrolleret i begge retninger: uden kaldet giver den
nye note `Expected: "h1", Received: null`, og med kaldet på enhver note
giver kontrollen `Expected: null, Received: "h1"`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 07:16:22 +02:00
kristianwindandClaude Opus 5 60795126a5 fix(opgaver): skuffens knapper kan ikke tabbes til, mens den er usynlig
`inert` på skuffen, når den er lukket, så dens felt og knapper er ude af
tabrækkefølgen og ude af tilgængelighedstræet.

Det støder mod funktionen selv: et inert element kan ikke fokuseres, og
fokus i samme hug som klikket er præcis det, iOS kræver for at åbne
tastaturet. Attributten sættes reaktivt af `inert={!open}`, og Sveltes
opdateringer er asynkrone, så den er der stadig i det øjeblik handleren
kører.

Derfor fjernes den med hånden i `start()`, før fokus sættes, og den
reaktive binding lægger den tilbage ved lukning. En imperativ DOM-ændring
midt i en deklarativ komponent ser ud som sjusk, og uden en grund skrevet
ved siden af ville den næste læser helt rimeligt rydde den op.

Plantet ved at fjerne linjen: prøven går rød med "markøren skal stå i
skuffens felt efter trykket — Expected: true, Received: false". Linjen
bærer noget.

Luk-knappens `tabindex` er fjernet som overflødig; inert dækker hele træet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 07:04:27 +02:00
kristianwindandClaude Opus 5 664614b924 feat(opgaver): tal en opgave ind — en rund knap i bunden på telefonen
En stor rund knap nederst til højre, inden for tommelfingerens rækkevidde.
Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.

VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
egen diktering kører på enheden på nyere telefoner. En talegenkendelse
bygget ind her ville sende lyden fra et selvhostet program videre til Apple
eller Google — en datavej, der skulle forklares på privatlivssiden, for at
spare ét tryk. Af de tre AI-udbydere, instansen understøtter, har anthropic
i øvrigt slet ingen lyd-API, så serverside-transskription ville også dele
brugerne op efter hvem de har en nøgle hos.

Teksten lander i det felt, der i forvejen viser, hvad parseren forstod, så
"ring til tandlægen i dag kl 14 p1" bliver læst mens man ser på det, og
intet oprettes før man siger ja. Derfor hedder komponenten diktat og ikke
tale: den gør ikke selv noget ved lyden, den stiller feltet frem.

## Det ene, funktionen hænger på

Safari på iOS åbner kun tastaturet, hvis focus() kaldes INDE i den
brugerhandling, der bad om det. QuickAdds `autofocus` sker i en $effect,
altså efter klikket er returneret — det giver feltet en markør og intet
tastatur, og der er ingen fejl nogen steder.

Så skuffen er ALTID monteret og bare skubbet ud af syne med transform og
opacity. Ikke display:none, ikke visibility:hidden, ikke inert — alle tre
gør feltet ufokuserbart. Prøven påstår direkte, at feltet findes FØR
klikket, fordi det er den eneste del af iOS-kravet, en browser kan måles på;
plantet som `{#if open}` går den rød med Expected: 1, Received: 0.

## To kollisioner, hvor jeg kun så den ene

Skuffen lægger et felt mere på siden, og det rammer to ting:

- `data-quickadd`, som skriv-hvor-som-helst-genvejen slår op med
  querySelector — altså FØRSTE match. Set på forhånd og løst med en
  `marked`-prop.

- Det tilgængelige navn. Det så jeg IKKE, og otte prøver gik røde på
  `getByLabel('Ny opgave') resolved to 2 elements`. Det er heller ikke kun
  et prøveproblem: to felter med samme navn læses op to gange af en
  skærmlæser, uden at man kan høre forskel. De to ER forskellige ting — det
  ene er kassen på siden, det andet er det, knappen åbner — så de har hver
  sit navn nu.

## Og en fejl, skærmbilledet fandt

Fremhævningen bag teksten lå på en linje, inputtet ikke havde — en bleg
blok under sætningen. Spejlet stod på `white-space: pre-wrap`, men et
<input type="text"> er enkeltlinje per specifikation og kan ikke ombryde,
uanset hvad reglen siger. Komponentens egen kommentar forudser det ordret:
"same wrapping. Any difference shows up as highlighting that drifts out of
alignment as the line grows."

Sjældent synligt på et bredt felt, konstant på 390px med en dikteret
sætning. Spejlet står nu på `pre` og rulles med inputtets scrollLeft.

Knappen tegnes ikke over 820px — der er feltet fremme hele tiden og et
tastatur at skrive på — og ikke i urd-ansigtet, hvor der ingen opgaver er.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 06:50:56 +02:00
Kristian Wind 807aa83e58 Merge pull request #99 from kristianwind/urd-faar-sin-egen-doer
CI / OpenAPI is valid (push) Successful in 13s
CI / End-to-end (push) Successful in 15m49s
CI / Docker image builds (push) Successful in 2m55s
Release / Build and publish image (push) Failing after 6s
CI / Go (push) Failing after 26m49s
feat(urd): den anden dør — /urd kan installeres som sin egen app
2026-10-07 10:42:20 +02:00
Kristian Wind 1d15cb18c0 Merge pull request #98 from kristianwind/urd-faar-sit-maerke
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Failing after 5m51s
CI / Docker image builds (push) Successful in 3m16s
CI / Go (push) Failing after 27m5s
feat(urd): mærket bliver ð, og frontend-bygget bliver deterministisk
2026-10-07 10:20:28 +02:00
24 changed files with 2022 additions and 48 deletions
+91
View File
@@ -273,6 +273,49 @@ paths:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/default-reminder:
put:
tags: [auth]
summary: The account's standing alarm on tasks that have a clock.
description: |
Reminders have always had to be set one at a time: a task written as
"i morgen kl 9" got a time and no alarm, because nothing creates a reminder
by itself. That is the right default for a task with no clock — there is no
moment to ring at — and an errand you walk every time for a task with one.
`minutes` is relative to the due moment and **negative for before it**, the
same convention `reminders.offset_min` uses because that is the column the
value ends up in: the store adds it to the due time. `0` is exactly at the
due moment. `null` turns the default off, which is where every account
starts — switching it on for everybody in a migration would have meant
alarms nobody asked for, on tasks already lying there.
A positive number is refused. An alarm after the thing it is warning about
is not a reminder, and accepting one here would make the single mistake this
value can make — a sign the wrong way round — look like a choice.
The default never stacks on an alarm the task already has, and a task can
opt out as it is written with "ingen alarm" in the quick-add line.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
minutes:
type: [integer, "null"]
maximum: 0
minimum: -10080
description: |
Minutes relative to the due moment, negative for before it.
Null is off.
example: -10
responses:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/nav-order:
put:
tags: [auth]
@@ -4801,6 +4844,17 @@ components:
never null: a client that has to check for both an absent array and an
empty one will one day check for only one of them.
example: ["labels"]
default_reminder_min:
type: [integer, "null"]
description: |
The account's standing alarm on tasks that have a clock, as minutes
relative to the due moment and negative for before it. Null is off,
which is where every account starts.
A type union rather than a number with a sentinel, because zero is a
real setting here — an alarm exactly at the due moment — so "off" and
"at the time" have to stay different answers.
example: -10
created_at: { type: string, format: date-time }
RecoveryCodes:
@@ -4991,6 +5045,21 @@ components:
task is still created, in the Inbox — a thought is not thrown away
over a typo — but the caller is told, because the alternative is
that the name vanishes from the title and nothing says why.
unknown_section:
type: string
description: |
The same, for a `/section` that no section of the project the task
landed in answers to. The task is created without a section.
reminder_not_set:
type: string
description: |
Present when the line asked to be reminded and the task has
nothing to count back from — "mind mig en time før" on a task with
a day but no clock. The offset form of a reminder fires relative to
the due moment, so without one the row would sit in the table and
never go off. The task is created; the alarm is not, and this says
so. An alarm that silently never rings is the one way a reminder
must not fail, because nothing afterwards reveals it.
ParsedTask:
type: object
@@ -5002,6 +5071,28 @@ components:
due_date: { type: string, format: date }
due_datetime: { type: string, format: date-time }
recurrence: { type: string }
reminder_offset_min:
type: integer
description: |
When to be told, as minutes relative to the due moment: negative for
before it, zero for at it. Absent when the line asked for no alarm.
Relative rather than absolute, so moving the task moves its alarm with
it. Mind the sign — the store adds this to the due time, so "an hour
before" is -60.
no_reminder:
type: boolean
description: |
The line said it wants no alarm on this one — "ingen alarm". It exists
because of the account-wide default: with alarms on for everything that
has a clock, there has to be a way to say "not this one" in the same
breath as writing it, rather than creating the task, opening it and
deleting the alarm just made.
reminder_at:
type: string
description: |
An absolute alarm, `YYYY-MM-DDTHH:MM`, set when the line gave the
reminder a clock of its own. It takes the day the task is due: "mind
mig kl 8" about something due Friday means Friday at eight.
labels:
type: array
items: { type: string }
+44 -2
View File
@@ -33,7 +33,14 @@ type userJSON struct {
// opposite case and stays in localStorage.
SidebarCollapsed []string `json:"sidebar_collapsed"`
NavOrder []string `json:"nav_order"`
CreatedAt string `json:"created_at"`
// DefaultReminderMin is the account's standing alarm on tasks that have a
// clock, as minutes relative to the due moment and negative for before it.
// Null is off, which is where every account starts.
//
// A pointer rather than a number with a sentinel, so that "off" and "at the
// due moment" stay different answers: zero is a real setting here.
DefaultReminderMin *int `json:"default_reminder_min"`
CreatedAt string `json:"created_at"`
}
func toUserJSON(u *store.User) userJSON {
@@ -51,7 +58,8 @@ func toUserJSON(u *store.User) userJSON {
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
DefaultReminderMin: u.DefaultReminderMin,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
@@ -112,6 +120,40 @@ func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleSetDefaultReminder turns the account's standing alarm on or off.
//
// `minutes` is relative to the due moment and negative for before it, which is the
// convention reminders.offset_min uses because that is the column it lands in. Null
// is off. A positive number is refused: an alarm AFTER the thing it is warning
// about is not a reminder, and allowing it here would make the one mistake this
// value can make — a sign the wrong way round — look like a choice.
func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request) {
var req struct {
Minutes *int `json:"minutes"`
}
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Minutes != nil {
switch {
case *req.Minutes > 0:
writeFieldErrors(w, map[string]string{"minutes": "must be zero or negative — an alarm comes before"})
return
// A week is the outer edge of "remind me ahead of this"; beyond it the
// number is far more likely to be a unit mistake than an intention.
case *req.Minutes < -7*24*60:
writeFieldErrors(w, map[string]string{"minutes": "must be within a week of the due time"})
return
}
}
user := userFrom(r.Context())
if err := s.db.SetDefaultReminder(r.Context(), user.ID, req.Minutes); err != nil {
s.storeError(w, r, "set default reminder", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- sign in -----------------------------------------------------------------
type loginRequest struct {
+1
View File
@@ -328,6 +328,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/me", s.handleUpdateProfile)
r.Put("/sidebar-sections", s.handleSetSidebarSections)
r.Put("/nav-order", s.handleSetNavOrder)
r.Put("/default-reminder", s.handleSetDefaultReminder)
r.Post("/logout", s.handleLogout)
r.Post("/password/change", s.handleChangePassword)
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
+88 -2
View File
@@ -243,6 +243,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
// The store wrote the labels; the struct it was handed does not know that, and
// the response has to describe the task that now exists.
t.Labels = req.Labels
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
s.internal(w, r, "default reminder", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
// Lavet til en anden med det samme. Det er den samme besked som at blive
@@ -337,6 +341,12 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
s.storeError(w, r, "get task", err)
return
}
// Et klokkeslu00e6t kan ogsu00e5 komme til EFTER at opgaven er lavet, og det er den
// halvdel, der ellers mangler i et lu00f8fte om "alle opgaver med en dato og tid".
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
s.internal(w, r, "default reminder", err)
return
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
@@ -620,14 +630,55 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
if unknownProject != "" || unknownSection != "" {
// The alarm the line asked for, after the task because a reminder needs its id.
//
// Set when the line asked to be reminded and the task has nothing to count back
// from. An offset is relative to the due moment, and DueReminders selects the
// offset form only `AND t.due_datetime IS NOT NULL` — so on a task with a day
// but no clock there is nothing to be an hour before, and the row would sit in
// the table forever without firing. Reported the same way an unknown project
// is: the task is created either way, and the caller is told what could not be
// honoured. An alarm that silently never rings is the one way a reminder must
// not fail, because nothing afterwards reveals it.
reminderNotSet := ""
switch {
case len(parsed.ReminderAt) == len("2006-01-02T15:04"):
day, clock := parsed.ReminderAt[:10], parsed.ReminderAt[11:]
if _, when, err := resolveDue(day, clock, user.Timezone); err == nil && when != nil {
if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, when, nil); err != nil {
s.internal(w, r, "create reminder", err)
return
}
}
case parsed.ReminderOffsetMin != nil:
if t.DueDatetime == nil {
reminderNotSet = "no due time to count back from"
} else if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, nil, parsed.ReminderOffsetMin); err != nil {
s.internal(w, r, "create reminder", err)
return
}
}
// Standarden til sidst, og efter den udtrykkelige: "mind mig en time fu00f8r" har
// allerede lagt sin, og applyDefaultReminder lu00e6gger ikke en oven pu00e5. "ingen
// alarm" er den anden vej ud u2014 den eneste mu00e5de at sige nej til standarden,
// mens man skriver opgaven, frem for at u00e5bne den bagefter og slette alarmen.
if !parsed.NoReminder {
if err := s.applyDefaultReminder(r.Context(), t, user); err != nil {
s.internal(w, r, "default reminder", err)
return
}
}
if unknownProject != "" || unknownSection != "" || reminderNotSet != "" {
// Alongside the task rather than instead of it: the task was created, and
// this says what could not be honoured while creating it.
writeJSON(w, http.StatusCreated, struct {
taskJSON
UnknownProject string `json:"unknown_project,omitempty"`
UnknownSection string `json:"unknown_section,omitempty"`
}{toTaskJSON(*t), unknownProject, unknownSection})
ReminderNotSet string `json:"reminder_not_set,omitempty"`
}{toTaskJSON(*t), unknownProject, unknownSection, reminderNotSet})
return
}
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
@@ -804,3 +855,38 @@ func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) {
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
// applyDefaultReminder gives a task the alarm the account asks for without being
// asked each time.
//
// Three things make it a no-op, and each of them is the point rather than a guard:
//
// - The account has the default off. That is where every account starts, and it
// stays there until somebody turns it on: switching it on in a migration would
// have given every existing user alarms they never asked for, on tasks already
// lying there, in software other people run themselves.
// - The task has no clock. A task due "tomorrow" has no moment to ring at, and
// DueReminders selects the offset form only `AND t.due_datetime IS NOT NULL` —
// so the row would sit there and never fire.
// - The task already has a reminder. The line may have asked for one itself, or
// the task may be being edited for the second time; either way the default is
// a default and must not stack a second alarm on top of a stated one.
//
// Called on every path a task can gain a clock — created, edited, or quick-added —
// because a default that only applies to one of them is a setting that is true on
// Tuesdays. CalDAV is deliberately not one of them: a task arriving from Apple
// Reminders brings its own alarm, and two alarms for one task is worse than none.
func (s *Server) applyDefaultReminder(ctx context.Context, t *store.Task, user *store.User) error {
if user.DefaultReminderMin == nil || t == nil || t.DueDatetime == nil {
return nil
}
existing, err := s.db.ListReminders(ctx, t.ID)
if err != nil {
return err
}
if len(existing) > 0 {
return nil
}
_, err = s.db.CreateReminder(ctx, t.ID, user.ID, nil, user.DefaultReminderMin)
return err
}
+387
View File
@@ -2080,3 +2080,390 @@ func TestSnoozeSinksAndWakes(t *testing.T) {
t.Errorf("woken task did not return: order = %v", got)
}
}
// --- alarms written into the line ---------------------------------------------
// A dictated sentence sets its own alarm, and the alarm actually goes off.
//
// The assertion is on DueReminders rather than on a row existing, and the
// difference is the whole test: a reminder row with the offset the wrong way round
// exists just as convincingly and rings an hour LATE. So the question asked here is
// "would this fire, and when" — one minute before it is due it must not be in the
// list, one minute after it must.
func TestQuickAddSetsTheAlarmTheLineAskedFor(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "ring til tandlægen i morgen kl 9 mind mig en time før",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
if body["content"] != "ring til tandlægen" {
t.Errorf("content = %v, want %q", body["content"], "ring til tandlægen")
}
if body["reminder_not_set"] != nil {
t.Errorf("reminder_not_set = %v, want absent — the task has a clock", body["reminder_not_set"])
}
taskID, _ := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1", len(rems))
}
if rems[0].OffsetMin == nil {
t.Fatalf("reminder has no offset; it should not be absolute")
}
if *rems[0].OffsetMin != -60 {
t.Errorf("offset = %d, want -60 — the store ADDS this to the due time, so a "+
"positive number is an alarm an hour late", *rems[0].OffsetMin)
}
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
if err != nil {
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
}
early, err := ts.db.DueReminders(ctx, due.Add(-61*time.Minute))
if err != nil {
t.Fatalf("due reminders (early): %v", err)
}
if len(early) != 0 {
t.Errorf("61 minutes before it is due, %d reminders are already due", len(early))
}
ripe, err := ts.db.DueReminders(ctx, due.Add(-59*time.Minute))
if err != nil {
t.Fatalf("due reminders (ripe): %v", err)
}
if len(ripe) != 1 {
t.Fatalf("59 minutes before it is due, got %d reminders, want 1", len(ripe))
}
if ripe[0].TaskContent != "ring til tandlægen" {
t.Errorf("the reminder that came due is for %q", ripe[0].TaskContent)
}
}
// An alarm with nothing to count back from is refused out loud.
//
// "mind mig en time før" on a task with a day but no clock has no moment to be an
// hour before, and DueReminders selects the offset form only when the task has a
// due_datetime — so the row would sit there and never fire. The task is created and
// the caller is told, the same way an unknown #project is reported. An alarm that
// silently never rings is the one way a reminder must not fail.
func TestQuickAddSaysWhenTheAlarmCannotFire(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "køb mælk i morgen mind mig en time før",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
if body["content"] != "køb mælk" {
t.Errorf("content = %v, want %q", body["content"], "køb mælk")
}
if body["reminder_not_set"] == nil {
t.Errorf("reminder_not_set is absent; the alarm could not be set and nothing says so")
}
taskID, _ := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders, want 0 — a reminder that can never fire is worse "+
"than none, because nothing afterwards reveals it", len(rems))
}
}
// A reminder given a clock of its own is kept as an absolute moment.
func TestQuickAddSetsAnAbsoluteAlarm(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "ring til tandlægen i morgen kl 9 mind mig kl 8",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
taskID, _ := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1", len(rems))
}
if rems[0].OffsetMin != nil {
t.Errorf("offset = %d, want none — a clock was written, so it is absolute", *rems[0].OffsetMin)
}
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
if err != nil {
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
}
// Due at nine, reminded at eight: one hour earlier, on the task's own day.
if want := due.Add(-time.Hour); !rems[0].RemindAt.Equal(want) {
t.Errorf("RemindAt = %v, want %v", rems[0].RemindAt, want)
}
}
// --- the account's standing alarm --------------------------------------------
// turnOnDefaultAlarm switches the account's standing alarm on, so many minutes
// before the due moment.
func turnOnDefaultAlarm(t *testing.T, ts *testServer, minutes int) {
t.Helper()
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": minutes})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("set default reminder: %d %v", resp.StatusCode, body)
}
}
// With the setting on, a task that has a clock gets its alarm without being asked.
func TestDefaultAlarmAppliesToATaskWithAClock(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1", len(rems))
}
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
}
}
// And off is where every account starts. This is the control for the test above:
// without it, an alarm appearing for any reason at all reads as the setting working.
func TestNoDefaultAlarmUntilItIsTurnedOn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders on a fresh account, want 0 — turning a notification "+
"on for everybody is not a default anybody chose", len(rems))
}
}
// A task with a day but no clock gets nothing, because there is no moment to ring
// at: DueReminders selects the offset form only when due_datetime is set, so the
// row would be one that never fires.
func TestDefaultAlarmNeedsAClockNotJustADay(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders, want 0 — there is no clock to count back from", len(rems))
}
}
// The default does not stack on top of an alarm the line asked for.
func TestDefaultAlarmDoesNotStackOnAStatedOne(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16 mind mig en time før",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders, want 1 — the stated alarm, and no default on top", len(rems))
}
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -60 {
t.Errorf("offset = %v, want -60 — what the line said, not the default", rems[0].OffsetMin)
}
}
// "ingen alarm" is the way out of the default while writing the task.
//
// Without it the only way to have a timed task that does not ring is to create it,
// open it, and delete the alarm that was just made — which is the omission a
// default quietly creates.
func TestNoAlarmInTheLineBeatsTheDefault(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16 ingen alarm",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
if body["content"] != "hente cyklen" {
t.Errorf("content = %v, want %q — the refusal should leave the title", body["content"], "hente cyklen")
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders, want 0 — the line said no alarm", len(rems))
}
}
// A clock added later gets the alarm too.
//
// This is the half a promise about "every task with a date and time" loses if the
// default is only applied where tasks are created: a task written now and given a
// time this afternoon is the same task.
func TestDefaultAlarmAppliesWhenTheClockComesLater(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
taskID := body["id"].(string)
rems, err := ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Fatalf("got %d reminders before there was a clock, want 0", len(rems))
}
resp, body = ts.do(t, "PATCH", "/api/v1/tasks/"+taskID, map[string]any{
"due_date": time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
"due_time": "16:00",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch task: %d %v", resp.StatusCode, body)
}
rems, err = ts.db.ListReminders(ctx, taskID)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Fatalf("got %d reminders after the clock was added, want 1", len(rems))
}
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
}
}
// A positive offset is refused. An alarm after the thing it warns about is not a
// reminder, and accepting one here would make the single mistake this value can
// make — a sign the wrong way round — look like a choice somebody made.
func TestDefaultAlarmRefusesAnOffsetAfterTheDueTime(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": 10})
if resp.StatusCode != http.StatusUnprocessableEntity && resp.StatusCode != http.StatusBadRequest {
t.Fatalf("set default reminder with +10: %d %v, want a refusal", resp.StatusCode, body)
}
}
// Null turns it off again, and the alarms already made are left alone: a default is
// what happens to the NEXT task, not a statement about the ones already written.
func TestDefaultAlarmCanBeTurnedOffAgain(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ctx := t.Context()
turnOnDefaultAlarm(t, ts, -10)
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente cyklen i morgen kl 16",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
kept := body["id"].(string)
resp, body = ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": nil})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("turn off: %d %v", resp.StatusCode, body)
}
resp, body = ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "hente bilen i morgen kl 17",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
}
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 0 {
t.Errorf("got %d reminders after turning the default off, want 0", len(rems))
}
rems, err = ts.db.ListReminders(ctx, kept)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rems) != 1 {
t.Errorf("the alarm made while it was on is gone; turning a default off is not " +
"a statement about the tasks already written")
}
}
+180 -1
View File
@@ -37,6 +37,7 @@ const (
KindSection Kind = "section"
KindLabel Kind = "label"
KindRepeat Kind = "repeat"
KindReminder Kind = "reminder"
)
// Span is a byte range of the input that was consumed, and what it was read as.
@@ -70,7 +71,28 @@ type Result struct {
Recurrence string `json:"recurrence_rule,omitempty"`
// RecurrenceText is that rule written back out in Danish, for the preview.
RecurrenceText string `json:"recurrence_text,omitempty"`
Spans []Span `json:"spans,omitempty"`
// ReminderOffsetMin is when to be told, as minutes relative to the task's due
// moment: negative for before it, zero for at it. Nil when the line asked for
// no reminder.
//
// Relative rather than absolute, so that moving the task moves its alarm with
// it. Mind the sign: the store ADDS this to the due time
// (`due_datetime + offset_min * 60`), so "an hour before" is -60. Getting that
// backwards gives an alarm an hour late, which is the one direction a reminder
// must never be wrong in.
ReminderOffsetMin *int `json:"reminder_offset_min,omitempty"`
// ReminderAt is an absolute reminder, "YYYY-MM-DDTHH:MM", set when the line
// gave the reminder a clock of its own. It takes the day the task is due:
// "mind mig kl 8" about something due Friday means Friday at eight, not today.
ReminderAt string `json:"reminder_at,omitempty"`
// NoReminder is the line saying it wants no alarm on this one — "ingen alarm".
//
// It exists because of the account-wide default: with alarms on for everything
// that has a clock, there has to be a way to say "not this one" in the same
// breath as writing it. Without that the only way out is to create the task,
// open it, and delete the alarm that was just made.
NoReminder bool `json:"no_reminder,omitempty"`
Spans []Span `json:"spans,omitempty"`
}
// Parse reads input as of the moment `now`, which is also the timezone dates are
@@ -96,6 +118,20 @@ func Parse(input string, now time.Time, locale string) Result {
// mask keeps byte offsets intact, so spans still point into the original input.
masked := mask(input, spans)
// Reminders before the clock and the calendar, and that order is the whole
// trick: "mind mig en time før" has a duration in it and "mind mig kl 8" has a
// clock, and either one read by the time parser first becomes the task's own
// due time instead of its alarm. The phrase is anchored on a keyword the way a
// project is anchored on '#', so consuming it this early cannot take anything
// that was not offered to it.
// "No alarm" before "alarm", or the refusal leaves the very word the stage
// below is looking for standing in the line.
res.NoReminder, spans = extractNoReminder(masked, spans)
masked = mask(masked, spans)
rem, spans := extractReminder(masked, spans)
masked = mask(masked, spans)
// Recurrence before the date parser, and this order is load-bearing: "hver
// mandag" contains a weekday, and a date parser reading it first would claim
// Monday as a one-off due date and leave "hver" stranded in the title.
@@ -137,6 +173,21 @@ func Parse(input string, now time.Time, locale string) Result {
res.DueTime = pad2(hour) + ":" + pad2(minute)
}
// The reminder is resolved last, because an absolute one needs the due date the
// stages above just worked out.
if rem.found {
if rem.hasClock {
day := res.DueDate
if day == "" {
day = now.Format("2006-01-02")
}
res.ReminderAt = day + "T" + pad2(rem.hour) + ":" + pad2(rem.min)
} else {
off := rem.offsetMin
res.ReminderOffsetMin = &off
}
}
sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start })
res.Spans = spans
res.Content = remove(input, spans)
@@ -687,3 +738,131 @@ func extractRecurrence(input string, spans []Span) (string, []Span) {
}
return "", spans
}
// --- reminders ----------------------------------------------------------------
// reReminderLead finds the words that introduce an alarm. A reminder is anchored
// on a keyword the way a project is anchored on '#': nothing becomes an alarm by
// standing near a clock, because "ring til tandlægen kl 9" asks for a task at nine
// and not to be woken before it.
//
// The trailing \b is load-bearing. Without it the pattern matches the first six
// letters of "alarmen" and of "mind migrationen", takes them out of the title, and
// sets an alarm nobody asked for.
var reReminderLead = regexp.MustCompile(
`(?i)(?:^|\s)(mind\s+mig(?:\s+om\s+det)?|påmind(?:\s+mig)?|paamind(?:\s+mig)?` +
`|remind\s+me(?:\s+about\s+it)?|med\s+alarm)\b`)
// reReminderOffset reads the tail as "this long before": "en time før", "10 min
// før", "et kvarter før", "dagen før", "two days before".
var reReminderOffset = regexp.MustCompile(
`(?i)^[\s,]*(?:om\s+)?(\d+|en|et|a|an|to|two|tre|three)?\s*` +
`(halv\s+time|halvtime|kvarter|minutter|minut|minutes|minute|min\.?` +
`|timer|time|hours|hour|dage|dagen|dag|days|day)\s*` +
`(?:før|foer|inden|before)`)
// reReminderClock reads the tail as a clock of its own: "kl 8", "kl. 7.30", "at 7:30".
var reReminderClock = regexp.MustCompile(`(?i)^[\s,]*(?:kl\.?|klokken|at)\s*(\d{1,2})(?:[.:](\d{2}))?`)
// reminder is what the phrase said. OffsetMin is minutes relative to the due
// moment and negative for "before"; zero means at the due moment, which is what a
// bare "mind mig" asks for.
type reminder struct {
found bool
offsetMin int
hour, min int
hasClock bool
}
// extractReminder reads the alarm out of the line.
//
// The lead-in is matched first and the tail interpreted afterwards, rather than in
// one pattern, for the same reason the recurrence stage does it: a single
// expression able to express all three endings — a duration, a clock, or nothing
// at all — matches far more than the three things it is for.
func extractReminder(input string, spans []Span) (reminder, []Span) {
m := reReminderLead.FindStringSubmatchIndex(input)
if m == nil {
return reminder{}, spans
}
rem := reminder{found: true}
start, end := m[2], m[3]
tail := input[end:]
if t := reReminderOffset.FindStringSubmatchIndex(tail); t != nil {
n := 1
if t[2] >= 0 {
n = spokenNumber(tail[t[2]:t[3]])
}
rem.offsetMin = -n * unitMinutes(tail[t[4]:t[5]])
end += t[1]
} else if t := reReminderClock.FindStringSubmatchIndex(tail); t != nil {
hour := atoi(tail, t[2], t[3])
minute := 0
if t[4] >= 0 {
minute = atoi(tail, t[4], t[5])
}
// An impossible clock is not a reminder. Saying nothing is better than
// setting one for 25:00, which the store would happily keep and never fire.
if !valid(hour, minute) {
return reminder{}, spans
}
rem.hour, rem.min, rem.hasClock = hour, minute, true
end += t[1]
}
return rem, append(spans, Span{Start: start, End: end, Kind: KindReminder})
}
// spokenNumber reads the count in front of a unit. People dictating say the word
// rather than the digit, and "mind mig en time før" is the commonest form there is.
func spokenNumber(w string) int {
switch strings.ToLower(w) {
case "en", "et", "a", "an":
return 1
case "to", "two":
return 2
case "tre", "three":
return 3
}
if n, err := strconv.Atoi(w); err == nil {
return n
}
return 1
}
// unitMinutes turns the unit into minutes. "halv time" and "kvarter" carry their
// own count, so they come first: with a leading "en" they multiply by one and come
// out as themselves.
func unitMinutes(unit string) int {
u := strings.ToLower(strings.Join(strings.Fields(unit), " "))
switch {
case strings.HasPrefix(u, "halv"):
return 30
case strings.HasPrefix(u, "kvarter"):
return 15
case strings.HasPrefix(u, "min"):
return 1
case strings.HasPrefix(u, "time"), strings.HasPrefix(u, "hour"):
return 60
case strings.HasPrefix(u, "dag"), strings.HasPrefix(u, "day"):
return 1440
}
return 0
}
// reNoReminder finds the line asking not to be reminded. The account-wide default
// gives every task with a clock an alarm, and this is how one task opts out while
// it is being written rather than afterwards.
var reNoReminder = regexp.MustCompile(
`(?i)(?:^|\s)(ingen\s+(?:alarm|påmindelse|paamindelse)|uden\s+alarm` +
`|no\s+alarm|no\s+reminder|without\s+an?\s+alarm)\b`)
func extractNoReminder(input string, spans []Span) (bool, []Span) {
m := reNoReminder.FindStringSubmatchIndex(input)
if m == nil {
return false, spans
}
return true, append(spans, Span{Start: m[2], End: m[3], Kind: KindReminder})
}
+148
View File
@@ -636,3 +636,151 @@ func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
}
}
}
// --- reminders ----------------------------------------------------------------
// A dictated sentence can set its own alarm.
//
// The offset is negative because the store ADDS it to the due moment
// (`due_datetime + offset_min * 60`). A positive number there is an alarm after
// the thing it was meant to warn about, which is the one direction a reminder must
// never be wrong in — so the sign is asserted here and not assumed.
func TestParseReminderOffset(t *testing.T) {
now := ref(t)
cases := []struct {
in string
want int
}{
{"ring til tandlægen i morgen kl 9 mind mig en time før", -60},
{"ring til tandlægen i morgen kl 9 mind mig 10 min før", -10},
{"ring til tandlægen i morgen kl 9 mind mig 10 minutter før", -10},
{"ring til tandlægen i morgen kl 9 mind mig et kvarter før", -15},
{"ring til tandlægen i morgen kl 9 mind mig en halv time før", -30},
{"møde i morgen kl 9 mind mig dagen før", -1440},
{"møde i morgen kl 9 mind mig to dage før", -2880},
{"møde i morgen kl 9 mind mig 2 timer før", -120},
// Det er talt ind, så kommaet hører til sætningen og ikke til syntaksen.
{"ring til tandlægen i morgen kl 9, mind mig en time før", -60},
{"ring til tandlægen i morgen kl 9, påmind mig en time før", -60},
{"ring til tandlægen i morgen kl 9, mind mig om det en time før", -60},
// Engelsk i samme linje, som resten af parseren gør det.
{"call the dentist tomorrow at 9 remind me an hour before", -60},
{"call the dentist tomorrow at 9 remind me 15 minutes before", -15},
{"call the dentist tomorrow at 9 remind me two days before", -2880},
// En bar påmindelse er på selve forfaldstidspunktet.
{"ring til tandlægen i morgen kl 9 mind mig", 0},
{"ring til tandlægen i morgen kl 9 med alarm", 0},
}
for _, c := range cases {
got := Parse(c.in, now, "da")
if got.ReminderOffsetMin == nil {
t.Errorf("Parse(%q): ReminderOffsetMin = nil, want %d", c.in, c.want)
continue
}
if *got.ReminderOffsetMin != c.want {
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want %d", c.in, *got.ReminderOffsetMin, c.want)
}
}
}
// A reminder with a clock of its own takes the day the task is due.
//
// "mind mig kl 8" about something due Friday means Friday at eight. Resolving it
// against today instead would fire the alarm days early, silently.
func TestParseReminderClockTakesTheDueDay(t *testing.T) {
now := ref(t)
cases := []struct {
in string
want string
}{
{"ring til tandlægen i morgen kl 9 mind mig kl 8", "2026-03-11T08:00"},
{"ring til tandlægen i morgen kl 9 mind mig kl. 7.30", "2026-03-11T07:30"},
{"ring til tandlægen i morgen kl 9, påmind kl 7:15", "2026-03-11T07:15"},
{"call the dentist tomorrow at 9 remind me at 8", "2026-03-11T08:00"},
// Ingen dato i linjen: så er det i dag, som en bar klokketid også er.
{"ring til tandlægen mind mig kl 23", "2026-03-10T23:00"},
}
for _, c := range cases {
got := Parse(c.in, now, "da")
if got.ReminderAt != c.want {
t.Errorf("Parse(%q): ReminderAt = %q, want %q", c.in, got.ReminderAt, c.want)
}
if got.ReminderOffsetMin != nil {
t.Errorf("Parse(%q): also set an offset (%d); a reminder is one or the other",
c.in, *got.ReminderOffsetMin)
}
}
}
// The alarm does not eat the task's own time, and the task's time does not eat the
// alarm. This is the ordering the stage exists for.
func TestReminderDoesNotTakeTheTasksOwnTime(t *testing.T) {
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 8", ref(t), "da")
if got.DueTime != "09:00" {
t.Errorf("DueTime = %q, want 09:00 — the reminder took the task's clock", got.DueTime)
}
if got.DueDate != "2026-03-11" {
t.Errorf("DueDate = %q, want 2026-03-11", got.DueDate)
}
if got.ReminderAt != "2026-03-11T08:00" {
t.Errorf("ReminderAt = %q, want 2026-03-11T08:00", got.ReminderAt)
}
if got.Content != "ring til tandlægen" {
t.Errorf("Content = %q, want %q", got.Content, "ring til tandlægen")
}
}
// Nothing becomes an alarm by standing near a clock.
func TestNoReminderWhereNoneWasAskedFor(t *testing.T) {
now := ref(t)
for _, in := range []string{
"ring til tandlægen i morgen kl 9",
"betal moms i morgen kl 10 p1 #Firma @regnskab",
// The \b cases: these words merely begin with the keywords.
"skift batteri i alarmen",
"mind migrationen om at køre",
"vand planterne hver mandag kl 9",
} {
got := Parse(in, now, "da")
if got.ReminderOffsetMin != nil {
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want none", in, *got.ReminderOffsetMin)
}
if got.ReminderAt != "" {
t.Errorf("Parse(%q): ReminderAt = %q, want none", in, got.ReminderAt)
}
}
}
// The phrase is highlighted, like every other thing the parser claims — see the
// package comment: a parser you cannot see working is one you stop trusting.
func TestReminderIsHighlighted(t *testing.T) {
in := "ring til tandlægen i morgen kl 9 mind mig en time før"
got := Parse(in, ref(t), "da")
var span *Span
for i := range got.Spans {
if got.Spans[i].Kind == KindReminder {
span = &got.Spans[i]
}
}
if span == nil {
t.Fatalf("no %q span in %v", KindReminder, got.Spans)
}
if marked := in[span.Start:span.End]; marked != "mind mig en time før" {
t.Errorf("highlighted %q, want %q", marked, "mind mig en time før")
}
}
// An impossible clock is not a reminder: the store would keep 25:00 and never fire
// it, so saying nothing is the honest answer.
func TestImpossibleReminderClockIsNotKept(t *testing.T) {
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 25", ref(t), "da")
if got.ReminderAt != "" {
t.Errorf("ReminderAt = %q, want none", got.ReminderAt)
}
if got.ReminderOffsetMin != nil {
t.Errorf("ReminderOffsetMin = %d, want none", *got.ReminderOffsetMin)
}
}
@@ -0,0 +1,23 @@
-- Alarm som standard på opgaver med et klokkeslæt.
--
-- Påmindelser har virket længe og skulle sættes én ad gangen: en opgave med
-- "i morgen kl 9" fik en tid og ingen alarm, for intet skaber en påmindelse af sig
-- selv. Det er den rigtige standard for en opgave UDEN klokkeslæt — der er intet
-- tidspunkt at ringe på — men for en med et er det en omvej, man går hver gang.
--
-- Minutter i forhold til forfaldstiden, og NULL betyder slået fra. Én nullable
-- kolonne frem for et flueben plus et tal, så tilstanden "slået til uden
-- forskydning" ikke findes og derfor ikke kan blive uenig med sig selv.
--
-- Fortegnet er det samme som i reminders.offset_min, fordi det er den kolonne,
-- værdien ender i: lageret LÆGGER tallet til forfaldstiden, så en alarm FØR er
-- negativ og 0 er præcis på tidspunktet. To forskellige fortegnsregler for det
-- samme begreb er den slags forskel, der kun viser sig som en alarm, der ringer
-- på det forkerte tidspunkt.
--
-- NULL for alle, der findes i dag, og det er med vilje. At slå den til i en
-- migrering ville give hver eksisterende bruger alarmer, de ikke har bedt om, på
-- opgaver de har liggende — og det her er software, andre selv driver. En
-- indstilling, man selv slår til, er en anden sag end en notifikation, der
-- pludselig kommer.
ALTER TABLE users ADD COLUMN default_reminder_min INTEGER;
+44 -3
View File
@@ -46,7 +46,22 @@ type User struct {
// NavOrder is the order this person wants the fixed views in, by key. Empty
// means the order the program ships with. Unknown keys are ignored and missing
// ones are appended, so adding a view later is not a migration.
NavOrder []string
NavOrder []string
// DefaultReminderMin gives every task that has a clock an alarm without being
// asked, as minutes relative to the due moment: negative for before it, zero
// for at it. Nil is off, which is what every account starts as.
//
// The same sign convention as reminders.offset_min, because this is the column
// the value ends up in — the store ADDS it to the due time. Two sign rules for
// one idea is the kind of difference that only shows up as an alarm going off
// at the wrong moment.
//
// Only tasks with a clock. A task due "tomorrow" with no time has no moment to
// ring at, and DueReminders selects the offset form only when due_datetime is
// set, so an alarm there would be a row that never fires.
DefaultReminderMin *int
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -136,7 +151,8 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
}
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order,
default_reminder_min, created_at, updated_at`
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
var u User
@@ -145,10 +161,12 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
var created, updated int64
var collapsed, navOrder string
var defaultReminder sql.NullInt64
err := db.QueryRowContext(ctx, query, args...).Scan(
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder,
&defaultReminder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -172,6 +190,10 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
u.NavOrder = []string{}
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
if defaultReminder.Valid {
v := int(defaultReminder.Int64)
u.DefaultReminderMin = &v
}
u.CreatedAt = time.Unix(created, 0).UTC()
u.UpdatedAt = time.Unix(updated, 0).UTC()
return &u, nil
@@ -691,3 +713,22 @@ func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) {
}
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token)
}
// SetDefaultReminder records whether tasks with a clock get an alarm unasked, and
// how far ahead of the due moment it rings. Nil turns it off.
func (db *DB) SetDefaultReminder(ctx context.Context, userID string, minutes *int) error {
var value any
if minutes != nil {
value = *minutes
}
res, err := db.ExecContext(ctx,
`UPDATE users SET default_reminder_min = ?, updated_at = ? WHERE id = ?`,
value, time.Now().Unix(), userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+391
View File
@@ -86,6 +86,31 @@ async function nyNote(side) {
return felt;
}
/**
* Som `nyNote`, men UDEN klikket i feltet.
*
* Klikket er netop det, markør-prøverne ikke må gøre: de måler, at markøren
* står i overskriften, uden at nogen har sat den. Ventetiden skal de til
* gengæld have, og manglen på den kostede to røde CI-kørsler.
*
* `click()` på "Ny note" vender tilbage, så snart klikket er sendt, mens noten
* først skal oprettes på serveren og tegnes bagefter. Uden ventetiden går det,
* der tastes, ind i den FORRIGE note eller ingen steder — og så er der heller
* ingen gemning at vente på, så en `waitForResponse` løber tørt i stedet. Det
* er to forskellige påstande, der fejler, med én årsag. Se kommentaren over
* `nyNote`: faren var skrevet ned, med sin løsning, før prøverne blev skrevet.
*
* Overskriften er værnet frem for bare det tomme felt: <h1> skrives af samme
* effekt, der sætter markøren, så når den er der, er markøren sat.
*/
async function nyTomNote(side) {
await side.getByRole('button', { name: 'Ny note' }).click();
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await expect(side.locator('.editor h1')).toHaveCount(1);
return felt;
}
/**
* Kommendes liste kan se længere frem end en uge.
*
@@ -5102,6 +5127,85 @@ test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
*
* Chromium svarer på retur med en bar <div>. Den læses som brødtekst og er ikke
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
* retter den den næste blok to gange og lader denne stå.
*
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
* en <div> og "linje to" og "linje tre" var <p>.
*
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
* beholder, uanset hvor markøren har været i mellemtiden.
*/
test('blokke retur efterlod som <div> bliver brødtekst alligevel', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const ed = await nyNote(page);
await page.keyboard.type('Overskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('første afsnit');
await page.keyboard.press('Enter');
await page.keyboard.type('andet afsnit');
await expect(ed.locator('p')).toHaveCount(2);
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
// når den første ombytning tager dens node ud af dokumentet.
await ed.evaluate((el) => {
for (const para of [...el.querySelectorAll('p')]) {
const div = document.createElement('div');
while (para.firstChild) div.appendChild(para.firstChild);
para.replaceWith(div);
}
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
const range = document.createRange();
range.selectNodeContents(el.lastElementChild);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
await expect(ed.locator('p')).toHaveCount(0);
await expect(ed.locator('> div')).toHaveCount(2);
// Næste retur henter dem begge ind igen, sammen med den nye linje.
await page.keyboard.press('Enter');
await page.keyboard.type('tredje afsnit');
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
await expect(ed.locator('h1')).toHaveCount(1);
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
// rettelse. Den skal også overleve en gemning.
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Overskrift/ }).click();
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
@@ -5620,3 +5724,290 @@ test('the verdande door is unaffected by the urd one', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* En ny note åbner med markøren i sin titel.
*
* Titlen er første linje, som i Apple Notes — der er ikke et selvstændigt felt,
* så "sæt markøren i overskriften" betyder et område placeret inde i <h1> i et
* contenteditable. Derfor måles der på markeringen og ikke på hvad der har fokus:
* editoren kan have fokus med markøren stående et helt andet sted.
*/
async function caretBlock(page) {
return page.evaluate(() => {
const s = window.getSelection();
if (!s || !s.anchorNode) return null;
const node = s.anchorNode.nodeType === 1 ? s.anchorNode : s.anchorNode.parentElement;
const block = node?.closest('h1, h2, h3, p, li, pre');
return block ? block.tagName.toLowerCase() : null;
});
}
test('en ny note åbner med markøren i overskriften', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyTomNote(page);
// `expect.poll` og ikke én læsning: `click()` vender tilbage, så snart klikket er
// sendt, mens markøren sættes efter en netværksrundtur OG en gentegning. Lokalt
// er rundturen under et millisekund, så ét snapshot ramte altid — på en runner
// ramte det `null`, og prøven bestod på main ved held frem for ved regel.
//
// Kontrollen nedenfor (`stjæler ikke markøren`) må IKKE laves om til en poll:
// den forventer null, så en poll ville bestå på første læsning, uanset hvad
// koden gjorde. Den ankrer sig i stedet på overskriftens tekst først.
await expect
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1' })
.toBe('h1');
// Og den står et sted, man kan skrive: det, der tastes, bliver titlen, og
// titlen er det, listen kalder noten.
await page.keyboard.type('Pakkeliste til Island');
await expect(page.locator('.editor h1').first()).toHaveText('Pakkeliste til Island');
await expect(page.locator('.notes button.row strong').first()).toHaveText(
/^Pakkeliste til Island$/
);
expect(trouble).toEqual([]);
});
test('en note med indhold stjæler ikke markøren', async ({ page }) => {
// Kontrollen, og den halvdel der gør det til en regel frem for en vane: åbner
// man en note, man har skrevet i forvejen, skal markøren IKKE hoppe op i
// overskriften og skubbe det, man var i gang med.
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyTomNote(page);
// Lyt FØR der skrives. Her stod `waitForTimeout(1200)`, og kommentaren over
// `gemt` i toppen af filen nævner præcis det tal: et fast ophold er et gæt om,
// hvor hurtig maskinen er, og gættet holder indtil CI er travl. Det holdt indtil
// CI var travl — gemningen var ikke nået, genindlæsningen smed det skrevne væk,
// og prøven faldt på at noten ikke fandtes frem for på markøren, som er det den
// måler. Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om
// det.
const gemt = noteGemt(page);
await page.keyboard.type('Allerede skrevet');
await gemt;
await page.reload();
await page.getByRole('button', { name: /Allerede skrevet/ }).first().click();
await expect(page.locator('.editor h1').first()).toHaveText('Allerede skrevet');
expect(await caretBlock(page), 'ingen markør skal være sat').toBe(null);
expect(trouble).toEqual([]);
});
test('urd-døren opfører sig ens: ny note, markør i overskriften', async ({ page }) => {
// Han bad om "Urd og noter". Det er samme side, men målt gennem døren, fordi
// det er den vej en bruger af urd-appen kommer — og fordi en regel, der kun er
// prøvet ad én vej, er prøvet ét sted.
const trouble = watchForTrouble(page);
await page.goto('/urd');
await expect(page).toHaveURL(/\/noter$/);
await nyTomNote(page);
await expect
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 også i urd' })
.toBe('h1');
expect(trouble).toEqual([]);
});
test('en note oprettet fra et projekt åbner også med markøren i overskriften', async ({ page }) => {
// Den anden vej ind, og den blev påstået dækket før den blev målt: projektets
// "Ny note" opretter noten og sender videre med ?note=<id>, så markøren
// placeres af editoren på den anden side af en navigation frem for i samme
// hug. Reglen sidder i editoren netop for at dække begge — det er værdiløst
// uden en prøve, der går den anden vej.
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Islandsturen');
await sidebar.getByLabel('Projektnavn').press('Enter');
await sidebar.getByRole('link', { name: 'Islandsturen' }).click();
await expect(page.getByRole('heading', { name: 'Islandsturen' })).toBeVisible();
await page.getByRole('button', { name: 'Ny note', exact: true }).click();
await expect(page).toHaveURL(/\/noter\?note=/);
await expect
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 efter navigationen' })
.toBe('h1');
await page.keyboard.type('Dæk og kort');
await expect(page.locator('.editor h1').first()).toHaveText('Dæk og kort');
expect(trouble).toEqual([]);
});
/**
* Tal en opgave ind.
*
* The button does not do speech recognition and is not meant to: it puts the
* quick-add field in front of the thumb so the keyboard's own dictation can be
* used. So what there is to test is the field — that it is reachable, that it ends
* up with the caret in it, and that what lands there becomes a task.
*
* What a browser cannot be asked: whether iOS opened its keyboard. That happens
* only when focus() is called inside the user gesture, and no test can see the
* difference. What a test CAN see is the precondition — that the field is already
* in the document before the button is pressed — and that is asserted below,
* because it is exactly what a well-meaning `{#if open}` would take away.
*/
const PHONE = { width: 390, height: 844 };
test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
const knap = page.getByRole('button', { name: 'Tal en opgave ind' });
await expect(knap).toBeVisible();
// Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes
// synkront, og den eneste del af iOS-kravet en prøve kan se.
//
// `toHaveCount` og ikke `count()`: den første venter, den anden er et
// øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når
// `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde.
await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1);
await knap.click();
// Markøren er i skuffens felt — ikke i sidens eget.
const fokusIskuffen = await page.evaluate(
() => !!document.activeElement?.closest('.sheet')
);
expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true);
// Og det, der dikteres, bliver læst af den samme parser som alt andet.
const felt = page.locator('.sheet input[type="text"]');
await felt.fill('ring til tandlægen i dag p1');
await felt.press('Enter');
// "i dag", af samme grund som de andre prøver her siger det: dette er
// I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt
// fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet.
await expect(page.getByText('ring til tandlægen').first()).toBeVisible();
// Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter.
await expect(page.locator('.sheet.open')).toHaveCount(0);
expect(trouble).toEqual([]);
});
/**
* En talt opgave kan sætte sin egen alarm.
*
* Det var omvejen: en dikteret "i morgen kl 9" fik en tid og INGEN alarm, for
* ingenting skaber en påmindelse af sig selv — `CreateReminder` blev kun kaldt fra
* sit eget endepunkt. Så skulle man bagefter åbne opgaven, finde Påmindelser og
* vælge et klokkeslæt, og den omvej er længere end at skrive opgaven.
*
* Nu siger sætningen det selv, og det gælder uanset hvor stemmen kommer ind:
* tastaturets diktering, en genvej eller noget tredje ender som tekst gennem den
* samme parser.
*
* Prøven går hele vejen til skærmbilledet frem for at stoppe ved svaret, fordi
* forskydningen er negativ i lageret — det lægger den til forfaldstiden — og
* "-60 min." på skærmen er et fortegn, læseren selv skal oversætte.
*/
test('en talt opgave kan sætte sin egen alarm', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
const felt = page.locator('.sheet input[type="text"]');
// Set, før den sendes: alarmen lyser op som et forstået felt, ved siden af
// datoen. Det er hele pointen med fremvisningen — en parser, man ikke kan se
// arbejde, holder man op med at stole på, første gang den tager fejl.
await felt.fill('booke tandrens hos Lisa i morgen kl 9 mind mig en time før');
await expect(page.locator(".sheet mark[data-kind='reminder']")).toHaveText('mind mig en time før');
await felt.press('Enter');
await expect(page.locator('.sheet.open')).toHaveCount(0);
// Opgaven er i morgen, så den står ikke på I dag — se de andre prøver her.
// Et navn ingen anden prøve bruger, og det er ikke pynt: suiten kører med én
// arbejder mod ÉN database, så "ring til tandlægen" findes fire steder i denne
// fil, når denne prøve kommer til. `.first()` ramte en af de andres opgave, som
// ikke har nogen alarm — og kørt alene bestod prøven, fordi der kun var én.
await page.goto('/upcoming');
await page.getByText('booke tandrens hos Lisa').click();
// Og alarmen står der, skrevet som ord og ikke som et fortegn.
await expect(page.getByText('1 time før')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En alarm, der ikke kan ringe, bliver sagt højt.
*
* "mind mig en time før" på en opgave med en dag men ingen klokketid har intet at
* være en time før: `DueReminders` vælger forskydningsformen kun
* `AND t.due_datetime IS NOT NULL`, så rækken ville stå i tabellen og aldrig fyre.
*
* Det er den ene slags påmindelse, hvis fejl intet bagefter afslører — der står
* ingen række at savne, og man opdager den først, da den ikke ringede. Derfor
* bliver opgaven oprettet og beskeden sagt, i stedet for at alarmen tabes i
* stilhed.
*/
test('en alarm uden klokkeslæt at regne tilbage fra bliver sagt højt', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
const felt = page.locator('.sheet input[type="text"]');
await felt.fill('hente pakken på posthuset i morgen mind mig en time før');
await felt.press('Enter');
await expect(page.getByText(/Alarmen er ikke/)).toBeVisible();
// Opgaven er lavet alligevel. Et forkert ord må ikke koste tanken.
await page.goto('/upcoming');
await expect(page.getByText('hente pakken på posthuset')).toBeVisible();
expect(trouble).toEqual([]);
});
test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => {
// Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer
// FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers
// begynder et tastetryk at skrive i en kasse, ingen kan se.
await page.setViewportSize(PHONE);
await page.goto('/');
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1);
const mærketErIskuffen = await page.evaluate(
() => !!document.querySelector('[data-quickadd]')?.closest('.sheet')
);
expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false);
await page.keyboard.press('k');
await expect(page.locator('[data-quickadd]')).toHaveValue('k');
});
test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => {
// Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i
// bunden er til tommelfingeren.
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden();
});
test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
// Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at
// skrue på en variabel, så det er den vej en bruger faktisk kommer.
await page.setViewportSize(PHONE);
await page.goto('/urd');
await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0);
// Kontrollen: samme telefon, den anden dør, og så er den der.
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
});
+3
View File
@@ -241,6 +241,9 @@ export const api = {
// a save button, and this writes on every click of a chevron.
setNavOrder: (order) => put('/auth/nav-order', { order }),
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
// null slu00e5r den fra. Minutter i forhold til forfaldstiden, negativt for fu00f8r —
// samme fortegn som reminders.offset_min, fordi det er den kolonne, den ender i.
setDefaultReminder: (minutes) => put('/auth/default-reminder', { minutes }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
+243
View File
@@ -0,0 +1,243 @@
<script>
/**
* Tal en opgave ind.
*
* En stor rund knap i bunden af skærmen, inden for tommelfingerens rækkevidde.
* Den åbner en skuffe med quick-add-feltet og sætter markøren i det, så
* tastaturet kommer frem — og så trykker man mikrofonen på tastaturet.
*
* VI LAVER INGEN TALEGENKENDELSE, og det er et valg frem for en mangel. iOS'
* egen diktering kører på enheden på nyere telefoner; en talegenkendelse bygget
* ind her ville sende lyden fra et selvhostet program videre til Apple eller
* Google, og det er en datavej, der skal forklares på privatlivssiden for at
* spare ét tryk. Teksten lander i det felt, der i forvejen viser, hvad parseren
* forstod — så "ring til tandlægen i morgen kl 10 p1" bliver læst, mens man ser
* på det, og intet oprettes før man siger ja.
*
* Derfor hedder komponenten diktat og ikke tale: den gør ikke selv noget ved
* lyden, den stiller feltet frem.
*/
import QuickAdd from './QuickAdd.svelte';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let open = $state(false);
let box;
let sheet;
/**
* Åbner og sætter fokus I SAMME HUG som klikket.
*
* Rækkefølgen her er hele funktionen. Safari på iOS åbner kun tastaturet, hvis
* `focus()` kaldes inde i den brugerhandling, der bad om det — og Sveltes
* opdateringer er asynkrone, så feltet kan ikke være betinget tegnet og få
* fokus i samme omgang. Derfor er skuffen ALTID monteret og bare skubbet ud af
* syne med `transform` og `opacity`: elementet findes, så det kan fokuseres nu
* frem for efter næste opdatering.
*
* `display: none` og `visibility: hidden` ville begge gøre feltet ufokuserbart,
* og `inert` ligeså. Det er grunden til, at den lukkede tilstand er gennemsigtig
* og uden pointer-events frem for skjult.
*/
function start() {
// `inert` fjernes med hånden her, og det er ikke sjusk. Attributten sættes
// reaktivt af `inert={!open}` nedenfor, så den er der stadig i det øjeblik,
// klikket kører — Sveltes opdateringer er asynkrone. Og et inert element kan
// ikke fokuseres, så `focusField()` ville ramme ingenting, og iOS ville ikke
// åbne tastaturet.
//
// Den findes, fordi alternativet er værre: uden den kan skuffens knapper
// tabbes til, mens den er usynlig, på hver eneste side.
sheet?.removeAttribute('inert');
open = true;
box?.focusField();
}
function close() {
open = false;
// Tastaturet bliver stående, hvis feltet beholder fokus.
if (sheet?.contains(document.activeElement)) document.activeElement.blur();
}
function onkeydown(event) {
if (event.key === 'Escape' && open) {
event.preventDefault();
close();
}
}
</script>
<svelte:window {onkeydown} />
<!-- Knappen tegnes ikke i urd-ansigtet: der er ingen opgaver at lægge noget i.
Og den er skjult over 820px i CSS frem for her, fordi en skærm, der skifter
bredde, ikke skal kunne tabe et felt, der har fokus. -->
{#if !app.showsUrd}
<button class="fab" class:away={open} onclick={start} aria-label={t('task.speak')}>
<!-- En mikrofon, tegnet i samme streger som mærkerne: kapslen, bøjlen under
den og foden. Ingen tekst i knappen — den er rund og tommelfingerstor,
og navnet står i aria-label. -->
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0" />
<path d="M12 17.5V21" />
</svg>
</button>
{/if}
<div
class="scrim"
class:open
aria-hidden="true"
onclick={close}
role="presentation"
></div>
<div
class="sheet"
class:open
bind:this={sheet}
role="dialog"
aria-modal="false"
aria-label={t('task.speak')}
aria-hidden={!open}
inert={!open}
>
<div class="grab" aria-hidden="true"></div>
<p class="hint">{t('task.speakHint')}</p>
<!-- `marked={false}`: den her må ikke være den, type-anywhere-genvejen finder.
Se kommentaren ved `marked` i QuickAdd. -->
<QuickAdd bind:this={box} marked={false} label={t('task.speak')} onadded={close} />
<button class="done" onclick={close}>{t('detail.close')}</button>
</div>
<style>
.fab {
position: fixed;
right: max(var(--s4), env(safe-area-inset-right));
/* Over hjemmeindikatoren på en iPhone, ikke under den. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 40;
width: 64px;
height: 64px;
border: none;
border-radius: var(--radius-full);
background: var(--accent);
color: var(--accent-ink);
box-shadow: var(--shadow-lg);
display: grid;
place-items: center;
cursor: pointer;
transition:
transform var(--fast) var(--ease-out),
opacity var(--fast) var(--ease-out);
}
.fab svg {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.fab:active {
transform: scale(0.94);
}
/* Væk, mens skuffen er åben: den ville ellers stå oven på feltet. */
.fab.away {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.scrim {
position: fixed;
inset: 0;
z-index: 44;
background: rgb(0 0 0 / 0.4);
opacity: 0;
pointer-events: none;
transition: opacity var(--medium) var(--ease-out);
}
.scrim.open {
opacity: 1;
pointer-events: auto;
}
.sheet {
position: fixed;
inset: auto 0 0 0;
z-index: 45;
padding: var(--s3) max(var(--s3), env(safe-area-inset-left))
calc(var(--s3) + env(safe-area-inset-bottom)) max(var(--s3), env(safe-area-inset-right));
background: var(--surface-raised);
border-top: 1px solid var(--line);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-lg);
display: grid;
gap: var(--s2);
/* Lukket: gennemsigtig og skubbet ned, IKKE display:none — feltet skal kunne
fokuseres i samme hug som klikket. Se `start()`. */
opacity: 0;
transform: translateY(100%);
pointer-events: none;
transition:
transform var(--medium) var(--ease-out),
opacity var(--medium) var(--ease-out);
}
.sheet.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.grab {
width: 36px;
height: 4px;
border-radius: var(--radius-full);
background: var(--line-strong);
justify-self: center;
}
.hint {
margin: 0;
color: var(--ink-muted);
font-size: 0.85rem;
text-align: center;
}
.done {
justify-self: center;
background: none;
border: none;
color: var(--ink-muted);
padding: var(--s2);
cursor: pointer;
}
/* En skrivebordsskærm har feltet fremme hele tiden og et tastatur at skrive
på. Knappen er til tommelfingeren. */
@media (min-width: 821px) {
.fab,
.scrim,
.sheet {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fab,
.scrim,
.sheet {
transition: none;
}
}
</style>
+92 -17
View File
@@ -81,12 +81,55 @@
// fired.
editor.innerHTML = body.trim() ? markdownToHtml(body) : '<h1><br></h1>';
colourCode();
// Og markøren sættes DER.
//
// Indtil nu lavede linjen ovenfor kun titelblokken, så sætningen i
// kommentaren — "opens on its title" — handlede om, hvad der blev tegnet,
// ikke om hvor man kunne skrive. Noter-siden prøvede at råde bod på det
// med `document.querySelector('.editor textarea')?.focus()`, og der er
// ingen textarea: editoren er et contenteditable. Det kald har ramt
// ingenting, stille, siden editoren blev lavet om, og `?.` sørgede for,
// at det aldrig kastede.
//
// Her frem for på siden, så begge veje ind er dækket: noter-sidens egen
// opret-knap, og projektsidens, der sender videre med ?note=<id>.
if (!body.trim()) placeCaretInTitle();
} catch (e) {
editor.textContent = body;
console.error('verdande: noten kunne ikke tegnes', note.id, e);
}
});
/**
* Sætter markøren i titlen på en tom note.
*
* En contenteditable har ikke et felt at fokusere — den har en markør, og den
* skal placeres. `focus()` alene giver elementet tastaturfokus og lader
* markøren stå, hvor browseren synes, hvilket for et tomt element er starten
* på dokumentet frem for inde i overskriften.
*
* Fokus FØRST og så området: at fokusere et contenteditable kan nulstille
* markeringen, så den omvendte rækkefølge sætter et område, der straks bliver
* kasseret igen.
*
* Hvad den IKKE kan, sagt her frem for opdaget på en telefon: åbne tastaturet
* på iOS. Det sker kun, når focus() kaldes inde i den brugerhandling, der bad
* om det — og en ny note skal først oprettes på serveren, så der ligger et
* `await` imellem. Markøren står rigtigt; på en telefon skal man stadig trykke
* én gang for at få tastaturet frem.
*/
function placeCaretInTitle() {
const heading = editor?.querySelector('h1');
if (!heading) return;
editor.focus();
const range = document.createRange();
range.selectNodeContents(heading);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
const SYNTAX = [
{ mark: '#', what: 'notes.syntaxProject' },
{ mark: '[[', what: 'notes.syntaxNote' },
@@ -821,23 +864,54 @@
* the caret in another heading in most browsers, and a note where every line is
* a title is a note with no title at all.
*/
function keepTitleFirst() {
const block = currentBlock();
if (!block) return;
function keepTitleFirst(split) {
if (!editor) return;
// A heading that is not the first line becomes body.
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
document.execCommand('formatBlock', false, 'p');
return;
}
// Der hvor markøren står, gør execCommand det, for det er den, der lægger
// ændringen i browserens egen fortryd-historik — ⌘Z skal tage afsnittet tilbage,
// ligesom den tager et ord tilbage.
const caret = currentBlock();
if (caret && stray(caret)) document.execCommand('formatBlock', false, 'p');
// And the browser's own container becomes a paragraph. Chromium answers Enter
// with a bare <div>, which reads as body but is not styled as one — so the
// spacing between paragraphs quietly disappears the moment somebody presses
// return rather than choosing Brødtekst from the menu.
if (block.tagName === 'DIV') {
document.execCommand('formatBlock', false, 'p');
}
// Retur for enden af en overskrift efterlader endnu en overskrift i de fleste
// browsere, og markøren kan være løbet videre, inden rettelsen når frem. Kun den
// blok, delingen lige skabte: en <h1> andre steder i noten kan være en linje,
// nogen har valgt Titel til i menuen, og den er deres at beholde.
const lavet = split?.nextElementSibling;
if (lavet && lavet !== currentBlock() && stray(lavet)) swapTag(lavet, 'p');
// Og hver <div>, rettelsen ikke nåede.
//
// Den er udskudt ét hak, fordi der før delingen ikke er nogen ny blok at rette.
// Men et hak er nok: skriver man hurtigt, når næste retur igennem først, og så
// står markøren i den NÆSTE blok. Så rettede den den — to gange — og lod den
// blok, rettelsen var ment for, stå tilbage. Målt på en CI-runner, hvor "linje
// et" blev en <div> mens "linje to" og "linje tre" blev <p>: brødtekst uden
// afstand, hos netop de brugere der skriver hurtigst.
//
// Den spørger noten frem for et håndtag. Et håndtag holder ikke: når den første
// udskudte rettelse bytter tagget på en blok, peger den næstes håndtag på en
// node, der ikke længere sidder i dokumentet, og så bliver blokken midt i
// kæden aldrig rettet. En <div> er altid browserens egen beholder og aldrig
// nogens valg, så den kan fejes op hvor som helst uden at tage noget fra nogen.
const her = currentBlock();
for (const block of [...editor.children])
if (block !== her && block.tagName === 'DIV') swapTag(block, 'p');
}
/**
* En blok, der ikke må stå, som den står.
*
* Browserens egen beholder: Chromium svarer på retur med en bar <div>, som læses
* som brødtekst og ikke er formateret som en, så afstanden mellem afsnit stille
* forsvinder. Eller en overskrift, der ikke er den første linje — en note, hvor
* hver linje er en titel, er en note uden titel.
*/
function stray(block) {
if (block.parentNode !== editor) return false;
return (
block.tagName === 'DIV' || (block.tagName === 'H1' && block !== editor.firstElementChild)
);
}
/**
@@ -912,9 +986,10 @@
changed();
return;
}
// After the split, not before it.
// After the split, not before it — and with the block the split started from,
// because by the time this runs the caret may be two blocks further on.
setTimeout(() => {
keepTitleFirst();
keepTitleFirst(block);
changed();
}, 0);
return;
+73 -6
View File
@@ -16,14 +16,53 @@
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { readList } from '$lib/pastelist.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let {
projectId = undefined,
onadded = undefined,
autofocus = false,
/**
* Whether this box carries the `data-quickadd` marker.
*
* The layout's type-anywhere shortcut finds the field with
* `document.querySelector('[data-quickadd]')`, which returns the FIRST match
* in the document. So a second box on the page — the dictate sheet's — has
* to stay unmarked, or the shortcut starts typing into a box nobody can see.
*/
marked = true,
/**
* The field's accessible name.
*
* A prop because the dictate sheet puts a second one of these on the page,
* and two fields answering to "Ny opgave" is a screen reader reading the
* same name twice with no way to tell which is which. They are different
* affordances — one is the box on the page, the other is the thing the
* round button opens — so they say different names.
*/
label = undefined
} = $props();
let fieldLabel = $derived(label ?? t('task.new'));
let text = $state('');
let spans = $state([]);
let submitting = $state(false);
let input;
let mirror;
let focused = $state(false);
/**
* Keeps the tint under the right characters when the line is longer than the
* field.
*
* The input scrolls horizontally and the mirror has to scroll with it. Done on
* both `scroll` and `input` because typing at the end of a long line moves the
* caret — and therefore the scroll — without ever firing a scroll event in some
* browsers.
*/
function syncScroll() {
if (mirror && input) mirror.scrollLeft = input.scrollLeft;
}
// Kept beside the parser's own vocabulary on purpose: a legend that promises
// something internal/quickadd cannot read is worse than no legend. These four
// are what Parse recognises as marks; dates it reads as prose, which is why the
@@ -72,6 +111,20 @@
if (autofocus && input) input.focus();
});
/**
* Focus the field from outside, synchronously.
*
* This exists because of iOS and nothing else. Safari opens the on-screen
* keyboard only when `focus()` is called INSIDE the user gesture that asked for
* it — so the `autofocus` effect above, which runs after the click handler has
* returned, gives the field a caret and no keyboard. For the dictate button that
* is the whole feature failing quietly: the sheet opens, nothing to dictate
* into, and there is no error anywhere.
*/
export function focusField() {
input?.focus();
}
/** Splits the raw text into highlighted and plain runs, by byte offset. */
let segments = $derived.by(() => {
if (!spans.length) return [{ text, kind: null }];
@@ -197,7 +250,7 @@
<!-- The mirror sits behind the input and must match it exactly: same font,
same padding, same wrapping. Any difference shows up as highlighting
that drifts out of alignment as the line grows. -->
<div class="mirror" aria-hidden="true">
<div class="mirror" aria-hidden="true" bind:this={mirror}>
{#each segments as segment}
{#if segment.kind}
<mark data-kind={segment.kind}>{segment.text}</mark>
@@ -211,12 +264,14 @@
bind:this={input}
bind:value={text}
{onkeydown}
onscroll={syncScroll}
oninput={syncScroll}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
type="text"
data-quickadd
data-quickadd={marked ? '' : undefined}
placeholder={t('task.placeholder')}
aria-label={t('task.new')}
aria-label={fieldLabel}
autocomplete="off"
spellcheck="false"
{onpaste}
@@ -385,8 +440,16 @@
letter-spacing: normal;
padding: 0;
border: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
/* `pre`, not `pre-wrap`, and that is the whole alignment.
An <input type="text"> is single-line by specification and cannot wrap
whatever CSS says. So a wrapping mirror behind a non-wrapping input does
not drift a little — it puts the tint on a second line that the input does
not have, and the highlight ends up under empty space. Rare on a wide
desktop field, constant in the dictate sheet on a 390px phone, where a
spoken sentence overflows immediately. */
white-space: pre;
overflow-wrap: normal;
}
.mirror {
@@ -394,6 +457,10 @@
inset: 0;
pointer-events: none;
color: transparent;
/* Scrolled in step with the input by hand — see `syncScroll`. The overflow
has to be hidden rather than visible, or the long tail of a line paints
out past the field's edge. */
overflow: hidden;
}
/* The tint is behind the glyphs, not on them: colouring the text itself would
+4 -2
View File
@@ -14,7 +14,7 @@
import { app, projectName, clockOf } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t, tag } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
import { REPEATS, reminderOffsetLabel } from '$lib/when.js';
import { readList } from '$lib/pastelist.js';
let { task, onclose } = $props();
@@ -757,7 +757,9 @@
{#each reminders as reminder (reminder.id)}
<div class="reminder">
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
<span>{reminder.remind_at
? stamp(reminder.remind_at)
: reminderOffsetLabel(reminder.offset_min)}</span>
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
×
+27
View File
@@ -267,6 +267,13 @@ export const da = {
'task.pastedAdd': 'Opret {n} opgaver',
'task.pastedCancel': 'Fortryd',
'task.new': 'Ny opgave',
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
// det, fordi en knap med en mikrofon på lover noget andet.
'task.alarmNeedsATime':
'Opgaven er oprettet. Alarmen er ikke — "en time før" skal have et klokkeslæt at regne tilbage fra.',
'task.speak': 'Tal en opgave ind',
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
'task.syntaxProject': 'projekt',
'task.syntaxSection': 'sektion',
'task.syntaxLabel': 'etiket',
@@ -398,6 +405,18 @@ export const da = {
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
'account.recoveryLeftMany': '{n} gendannelseskoder tilbage.',
// --- settings: alarm as standard -----------------------------------------------
'alarm.title': 'Alarm som standard',
'alarm.hint':
'Opgaver med bu00e5de en dato og et klokkeslu00e6t fu00e5r en alarm, uden at du beder om den. En opgave uden klokkeslu00e6t fu00e5r ingen — der er intet tidspunkt at ringe pu00e5. Skriv "ingen alarm" i linjen for at springe en enkelt over, eller "mind mig en time fu00f8r" for at give den sin egen.',
'alarm.on': 'Giv opgaver med et klokkeslu00e6t en alarm',
'alarm.when': 'Hvornu00e5r',
'alarm.atDue': 'Pu00e5 tidspunktet',
'alarm.minBefore': '{n} min. fu00f8r',
'alarm.hourBefore': '1 time fu00f8r',
'alarm.hoursBefore': '{n} timer fu00f8r',
'alarm.dayBefore': 'Dagen fu00f8r',
// --- settings: notifications ---------------------------------------------------
'push.title': 'Notifikationer på enheden',
'push.hint':
@@ -757,6 +776,14 @@ export const da = {
'detail.newSubtask': 'Ny undertask',
'detail.files': 'Filer',
'detail.delete': 'Slet',
'detail.remindAtDue': 'På tidspunktet',
'detail.remindBefore': '{when} før',
'detail.remindAfter': '{when} efter',
'when.minuteMany': '{n} min.',
'when.hourOne': '1 time',
'when.hourMany': '{n} timer',
'when.dayOne': '1 dag',
'when.dayMany': '{n} dage',
'detail.reminders': 'Påmindelser',
'detail.newReminder': 'Ny påmindelse',
'detail.sent': 'sendt',
+24
View File
@@ -264,6 +264,10 @@ export const en = {
'task.pastedAdd': 'Add {n} tasks',
'task.pastedCancel': 'Cancel',
'task.new': 'New task',
'task.alarmNeedsATime':
'The task is created. The alarm is not — "an hour before" needs a time to count back from.',
'task.speak': 'Speak a task',
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
'task.syntaxProject': 'project',
'task.syntaxSection': 'section',
'task.syntaxLabel': 'label',
@@ -395,6 +399,18 @@ export const en = {
'account.recoveryLeftOne': '{n} recovery code left.',
'account.recoveryLeftMany': '{n} recovery codes left.',
// --- settings: alarm as standard -----------------------------------------------
'alarm.title': 'Alarms by default',
'alarm.hint':
'Tasks with both a date and a time get an alarm without being asked. A task with no time gets none — there is no moment to ring at. Write "no alarm" in the line to skip a single one, or "remind me an hour before" to give it its own.',
'alarm.on': 'Give tasks with a time an alarm',
'alarm.when': 'When',
'alarm.atDue': 'At the time',
'alarm.minBefore': '{n} min before',
'alarm.hourBefore': '1 hour before',
'alarm.hoursBefore': '{n} hours before',
'alarm.dayBefore': 'The day before',
// --- settings: notifications ---------------------------------------------------
'push.title': 'Notifications on this device',
'push.hint':
@@ -754,6 +770,14 @@ export const en = {
'detail.newSubtask': 'New sub-task',
'detail.files': 'Files',
'detail.delete': 'Delete',
'detail.remindAtDue': 'At the time',
'detail.remindBefore': '{when} before',
'detail.remindAfter': '{when} after',
'when.minuteMany': '{n} min',
'when.hourOne': '1 hour',
'when.hourMany': '{n} hours',
'when.dayOne': '1 day',
'when.dayMany': '{n} days',
'detail.reminders': 'Reminders',
'detail.newReminder': 'New reminder',
'detail.sent': 'sent',
+1
View File
@@ -38,6 +38,7 @@ export const SETTINGS = [
// Notifikationer
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
{ path: '/indstillinger/notifikationer', anchor: 'standard-alarm', key: 'alarm.title', tab: 'settings.tab.notifications', words: 'alarm pu00e5mindelse standard reminder default klokkeslu00e6t' },
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
+23 -2
View File
@@ -86,9 +86,26 @@ class AppState {
urdFace = $state(false);
enterUrd() {
this.urdFace = true;
this.setDoor('urd');
}
leaveUrd() {
this.setDoor('verdande');
}
/**
* Hvilken dør dette vindue sidst kom ind ad.
*
* Symmetrisk med vilje: `/` er også en dør. Uden den halvdel bliver en fane,
* der ÉN gang har set /urd, ved med at hedde urd — også på opgavesiderne, også
* efter et link til forsiden. I den installerede app sker det aldrig, for dens
* start_url er /urd og den anden apps er /; i en browserfane sker det hele
* tiden.
*/
setDoor(door) {
this.urdFace = door === 'urd';
try {
sessionStorage.setItem('verdande:door', 'urd');
sessionStorage.setItem('verdande:door', door);
} catch (e) {
// Privat browsing kan nægte. Ansigtet holder så vinduet ud alligevel,
// fordi det står i $state ovenfor — det er kun genindlæsningen, der
@@ -670,6 +687,10 @@ class AppState {
try {
const task = await api.quickAdd(text, projectId, sectionId, parentId);
this.upsert(task);
// Sagt højt frem for tabt. En alarm, der ikke kunne sættes, er den eneste
// slags påmindelse, hvis fejl intet bagefter afslører — der står ingen
// række at savne, og man opdager den først, da den ikke ringede.
if (task?.reminder_not_set) this.toast(t('task.alarmNeedsATime'));
return task;
} catch (e) {
this.toast(humanMessage(e));
+34
View File
@@ -184,3 +184,37 @@ function every(interval, unit) {
if (interval === 2) return t(`repeat.everyOther.${unit}`);
return t(`repeat.everyN.${unit}`, { n: interval });
}
/**
* En påmindelses forskydning, skrevet som ord.
*
* Lageret LÆGGER tallet til forfaldstiden (`due_datetime + offset_min * 60`), så
* en alarm før er negativ. Rå blev det vist som "-60 min.": et fortegn, læseren
* selv skal oversætte, på netop den oplysning man skal kunne stole på uden at
* regne — og en alarm, man har misforstået, opdager man først, da den ringede på
* det forkerte tidspunkt.
*
* Retningen er skilt fra varigheden, så der kun er ét sæt enheder at vedligeholde.
* En positiv forskydning kan ikke laves hverken i hurtig tilføjelse eller i
* skærmbilledet — kun gennem API'et — men den har et ord her frem for at falde
* tilbage på et tal, for det er den slags gren, der står urørt indtil den en dag
* ikke gør.
*/
export function reminderOffsetLabel(minutes) {
if (minutes === 0) return t('detail.remindAtDue');
const span = durationLabel(Math.abs(minutes));
return minutes < 0 ? t('detail.remindBefore', { when: span }) : t('detail.remindAfter', { when: span });
}
/** Minutter som hele dage, hele timer eller minutter — den største, der går op. */
function durationLabel(minutes) {
if (minutes % 1440 === 0) {
const days = minutes / 1440;
return days === 1 ? t('when.dayOne') : t('when.dayMany', { n: days });
}
if (minutes % 60 === 0) {
const hours = minutes / 60;
return hours === 1 ? t('when.hourOne') : t('when.hourMany', { n: hours });
}
return t('when.minuteMany', { n: minutes });
}
+29
View File
@@ -6,6 +6,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from '$lib/components/Sidebar.svelte';
import DictateSheet from '$lib/components/DictateSheet.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import NoConnection from '$lib/components/NoConnection.svelte';
@@ -57,6 +58,25 @@
app.load();
});
/**
* Hvilken dør dette vindue er inde ad.
*
* To døre, og reglen er én sætning: den, du sidst kom ind ad, bestemmer
* ansigtet. `/urd` sætter urd, `/` sætter verdande, og alt derimellem rører
* det ikke — så et klik videre til en note eller en opgave beholder det navn,
* vinduet åbnede med.
*
* Symmetrien er det, der gør det forudsigeligt. Uden `/`-halvdelen ville en
* fane, der én gang havde set /urd, blive ved med at hedde urd, også på
* opgavesiderne. I de installerede apps sker det aldrig — deres start_url er
* hver sin dør — men en browserfane springer mellem dem hele tiden.
*/
$effect(() => {
const here = $page.url.pathname;
if (here === '/urd') app.enterUrd();
else if (here === '/') app.leaveUrd();
});
/**
* Opgavernes sider findes ikke i noter-udgaven.
*
@@ -295,6 +315,15 @@
tabindex="-1"
></button>
{/if}
<!-- Tal en opgave ind. Kun på telefonen, og kun hvor der er opgaver — se
komponenten.
UBETINGET monteret, og det er ikke en forglemmelse: feltet inde i den
skal kunne få fokus i samme hug som klikket, ellers åbner iOS ikke
tastaturet. Et `{#if}` her ville tage feltet ud af DOM'en og gøre det
umuligt. Menuen dækker den i stedet, fordi den har højere z-index. -->
<DictateSheet />
</div>
<CommandPalette bind:open={paletteOpen} />
@@ -5,6 +5,39 @@
import * as push from '$lib/push.js';
import { t, plural } from '$lib/i18n.svelte.js';
// --- alarm som standard -------------------------------------------------------
// Minutter i forhold til forfaldstiden, negativt for fu00f8r. Samme fortegn som
// reminders.offset_min, fordi det er den kolonne, vu00e6rdien ender i — to
// fortegnsregler for det samme begreb viser sig kun som en alarm pu00e5 et forkert
// tidspunkt.
const ALARM_CHOICES = [0, -5, -10, -15, -30, -60, -120, -1440];
// Valgt, nu00e5r indstillingen slu00e5s til igen. Pu00e5 tidspunktet er sju00e6ldent det, man
// mener med en alarm: der er ingen varsel i at blive mindet om noget, mens det
// sker.
const ALARM_DEFAULT = -10;
function alarmLabel(minutes) {
if (minutes === 0) return t('alarm.atDue');
const n = Math.abs(minutes);
if (n === 1440) return t('alarm.dayBefore');
if (n === 60) return t('alarm.hourBefore');
if (n % 60 === 0) return t('alarm.hoursBefore', { n: n / 60 });
return t('alarm.minBefore', { n });
}
async function saveAlarm(minutes) {
try {
await api.setDefaultReminder(minutes);
// Kontoen genlu00e6ses frem for at blive gu00e6ttet lokalt: serveren er den, der
// afviser et positivt tal, su00e5 det, der vises, skal vu00e6re det, der blev gemt.
app.user = await api.me();
} catch (e) {
app.toast(humanMessage(e));
}
}
// --- web push ----------------------------------------------------------------
let pushState = $state('loading');
@@ -254,6 +287,37 @@
{/if}
</section>
<section class="panel" id="standard-alarm">
<header>
<h2>{t('alarm.title')}</h2>
<p class="hint">{t('alarm.hint')}</p>
</header>
<label class="check">
<input
type="checkbox"
checked={app.user?.default_reminder_min != null}
onchange={(e) => saveAlarm(e.currentTarget.checked ? ALARM_DEFAULT : null)}
/>
<span>{t('alarm.on')}</span>
</label>
{#if app.user?.default_reminder_min != null}
<div class="field">
<label for="alarmwhen">{t('alarm.when')}</label>
<select
id="alarmwhen"
value={app.user.default_reminder_min}
onchange={(e) => saveAlarm(Number(e.currentTarget.value))}
>
{#each ALARM_CHOICES as minutes (minutes)}
<option value={minutes}>{alarmLabel(minutes)}</option>
{/each}
</select>
</div>
{/if}
</section>
<section class="panel" id="push">
<header>
<h2>{t('push.title')}</h2>
+3 -1
View File
@@ -730,8 +730,10 @@
const note = await api.createNote({ body: '' });
notes = [note, ...notes];
// Svaret er hele noten, så den skal ikke hentes igen — se open().
// Markøren sættes af editoren selv, når noten er tom — se
// placeCaretInTitle i NoteEditor. Her stod en focus() på
// `.editor textarea`, og editoren har ingen textarea.
await open(note, { whole: true });
document.querySelector('.editor textarea')?.focus();
} catch (e) {
app.toast(humanMessage(e));
}
+5 -12
View File
@@ -7,25 +7,18 @@
* hvad appen gør, når den så er startet: husker hvilken dør den kom ind ad, og
* går videre til noterne.
*
* `sessionStorage` og ikke `localStorage`, og det er hele grunden til at de to
* apps kan være to apps: de deler origin, så de deler localStorage, cookies og
* service worker. sessionStorage er per vindue — og en installeret PWA er sit
* eget vindue — så urd-appen har sit ansigt og verdande-appen sit, uden at
* træde på hinanden. Det overlever også en genindlæsning, hvilket det skal:
* efter et par klik står adressen på /noter, og en F5 dér går ikke gennem /urd
* igen.
* Ansigtet sættes ikke her, men i layoutet, hvor begge døre står ved siden af
* hinanden — `/urd` sætter urd og `/` sætter verdande. Delt op, fordi en regel
* med to halvdele, der ligger i hver sin fil, er en regel, hvor kun den ene
* halvdel bliver vedligeholdt.
*
* `replaceState`, så telefonens tilbage-gestus ikke fører tilbage til en side,
* der kun omdirigerer.
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
onMount(() => {
app.enterUrd();
goto('/noter', { replaceState: true });
});
onMount(() => goto('/noter', { replaceState: true }));
</script>
<!-- Bevidst tom. Omdirigeringen sker i samme hug som tegningen, så alt her ville