The socket carried task events and nothing else, so a project or a label
created anywhere — another tab, another device, Claude through the
connector — did not appear until a reload. Tasks updated live and the
sidebar did not, which reads as the sidebar being broken rather than as a
gap in what is published.
Projects and labels go to the *user* rather than to a project. A project
that has just been created has no subscribers, because nobody was
watching a thing that did not exist a moment ago.
Labels are announced as "something changed" and re-read, rather than sent
as a payload. The sidebar wants each label's task count, which no single
event carries and which moves whenever a task gains or loses a label —
including through quick add, where "@regnskab" invents the label inside a
task write and no label event is sent at all. Comparing the label lists
on a task change is what catches that, without re-reading on every ticked
checkbox.
And projects now go through an upsert, because the same row arrives twice
— once as the response to the request that created it, once over the
socket. Appending both puts two ids into a keyed {#each}, which throws
and stops the sidebar rendering. The tasks already had that guard; adding
the project event without it reintroduced the identical bug, and the
smoke test caught it on the first run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board has drawn a column per section since it was written and there
was no way to make one. Sections could only be created by the Todoist
importer or a template — so an ordinary project could never have a
heading in it at all.
Add, rename and delete, in the list view where the headings already are.
The controls appear on hover rather than sitting beside the heading
permanently: a heading with two buttons always next to it stops reading
as a heading.
Deleting says how many tasks are in the section and that they will
survive it, because the API keeps them and drops their section — which is
the right behaviour and not what "delete section" sounds like. The list
is re-read afterwards, since those tasks move to the unsectioned group
and nothing else would tell the view they had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things you could not do to a task you had open, and one that had
never worked at all.
**Project, recurrence and assignee** are now fields in the drawer. A task
created in the wrong place could only be fixed by dragging it, a repeat
could only be set by typing it into the title when the task was created,
and delegation had no interface whatsoever despite assignee_id being in
the API from the start. The assignee only appears on a project with
somebody else in it, because on your own there is nobody to choose.
**The title is read the way quick add reads it.** Typing "i morgen kl 14
p1" into a title used to leave it as those words, so where you typed a
sentence decided what it meant. The parse runs on the server — the
grammar is Danish and English and lives there — and applies only when it
consumed something. What it took is said out loud, and the fields on
screen are updated to match: the drawer otherwise seeds its inputs once
per task so a save cannot overwrite what you are typing, and that rule
has to bend exactly here, where the save changed the very fields in front
of you.
**And the member list has never worked.** The share panel, and now the
assignee field, ask for it. Every call errored: the ORDER BY named an
expression, and SQLite only accepts result-set columns across a UNION, so
it failed at query time with "1st ORDER BY term does not match any column
in the result set". Found by the smoke test that opens a task, which is
the second real bug that suite has caught by watching for failed requests
rather than only for what renders.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar was 268px and always there. On a wide monitor that wastes the
room; on a laptop it takes room the list needs.
The width drags from a handle on the border — 7px of target over a 1px
line, because a 1px target is a target you miss — and the whole column
folds away from a button in the topbar. Both are remembered.
In localStorage rather than on the account: this is a property of the
screen you are sitting at, not of you. The same person on a laptop and a
wide monitor wants different answers, and syncing it would make one of
them wrong every time they switch.
The handle is also a slider for the keyboard. Arrow keys move it, Home
puts it back, and so does a double-click — a drag handle with no keyboard
equivalent is a setting some people simply cannot reach.
Folding is desktop-only, and the media query says so rather than leaving
it to chance: below the breakpoint the sidebar is a drawer that opens over
the page, and a collapsed drawer would open to nothing. Clamped to
200–480px in the store, so a stored value from an older build cannot make
it unusable either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.
POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.
Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renaming replaced the project page's h1 with an input, so a page an owner
opened had no heading at all. The sidebar smoke test caught it on CI — and
it was right to: every other view here has a heading, and it is what a
screen reader announces on arrival.
The heading stays and becomes a field when clicked. Same rename, one more
click, and the page keeps its outline.
The more interesting half is why that passed locally. The test clicked a
link and immediately asserted a heading was visible — and `toBeVisible`
was being satisfied by the heading of the page it had just left, before
the SPA swapped it out. On this laptop the old h1 lingered long enough to
answer; on a slower runner it did not. So the test did not check that the
new page rendered, it checked that some page had at some point.
waitForURL closes that. Verified by putting the broken heading back with
the strengthened test in place: it now fails locally with the same message
CI gave.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A project could be created and shared and never renamed or deleted. Both
have worked through the API since the beginning; there was simply nothing
to click.
The heading is now the field — a heading that happens to be editable
rather than a form that looks like one — and saves on blur, with Escape
putting the old name back. Owner only, and the Inbox keeps its plain
heading because it cannot be either renamed away or deleted.
Deleting needed more than a button. It is soft, with thirty days to
change your mind, and restoring has been possible the whole time — from
an id, which is exactly what you no longer have once the project has gone
from the screen. A recovery window nobody can reach is not one. So
GET /trash/projects lists what you have deleted, with how many tasks went
with it and how long is left, and Settings → Data has the button.
Owned, not shared: a member of a project somebody else deleted has no
business bringing it back, and there is a test that says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs with one shape: the interface confidently told you something
that was not so.
Connecting Gmail without a registered OAuth client said "Det er der
allerede." Thirteen different situations answered a plain `conflict`, and
the frontend renders that as "that already exists" — which for four of
them is not vague but wrong. An unconfigured Gmail is not a Gmail that is
already connected; a second factor that is off is not one that is on; the
Inbox is not a project that already exists somewhere else. Those four
have their own codes now, and their own sentences.
The mail-to-task address came out as todo+…@localhost. VERDANDE_SMTP_FROM
defaults to verdande@localhost and the domain was taken from it
unconditionally, so an instance with no mail server handed out an address
that looks real, can never receive anything, and gives no hint which of
the two it is. The default no longer wins over the public hostname, and
the response now carries whether a mail server exists at all — because
even with the right domain, nothing routes to it until one does.
errors.go has always claimed the code list is kept in one place "so the
frontend's mapping can be checked against it". Nothing checked it, and
the mapping was missing bad_request. Now a test reads both files and
fails on a code with no message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vite.config.js.timestamp-*.mjs is written while bundling the config and
removed again — unless the process is killed, which is exactly what
happens during a hung build. One had been committed by accident; five
more appeared while working out why the build was hanging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.
Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.
So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.
It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.
The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite passed on a laptop and failed on the runner with zero links
found. locator.all() is a snapshot and does not auto-wait: calling it
straight after goto() measures how fast the machine is, not what the page
contains.
An assertion on the first link waits for hydration, and the hrefs are
collected once and re-resolved on each pass rather than held as element
handles — navigating re-renders the sidebar, so a handle from before the
click points at an element that no longer exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The brief asked for these under Definition of done and they were never
written. 610 Go tests, none of which opens a browser.
Six flows: create the first account and sign in, quick add and tick a
task off, click every link in the sidebar, open a task and edit it, walk
every settings tab, and check that the manifest's icons and the service
worker can actually be fetched. Every assertion is on rendered content
rather than on a URL — the binary serves the SPA shell for anything it
does not recognise, so toHaveURL passes on precisely the bug this exists
to catch.
The suite builds the frontend, copies it into cmd/verdande/webbuild and
starts the real binary, every run. Merely starting the server would test
whatever frontend was copied there last, which during development is
nearly always the wrong one, and a green report on code that is not
running is worse than no report.
Chromium only. A smoke test catches a build that does not work at all;
running it in three engines finds the same breakage three times.
They found four bugs the first time they ran, fixed in the commits before
this one: concurrent writes returning SQLITE_BUSY, a completed task
keeping its row, the same task landing in the list twice, and a database
failure presenting as everybody being logged out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The server side has been finished for a while — VAPID keys, RFC 8291
encryption, delivery on reminders and assignments — and nothing in the
browser ever asked for a subscription. web/static/ had a manifest and an
icon and no sw.js, so a subscription could not be created at all.
The worker handles push and notificationclick and nothing else. There is
deliberately no offline cache: the app is a live view of a list other
people are editing, and a cached shell serving yesterday's data is worse
than the browser saying it cannot connect. Adding one later means
versioning and invalidating it, which is a feature rather than a few
lines here.
Unsubscribing tells the server first. If the browser unsubscribed and
then the request failed, the server would keep pushing to an endpoint
nothing is listening on, and only a 410 from the push service would ever
clean it up.
Still not verified against a live push service — this is the client half
of a chain whose other half has only ever been tested against the RFC.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board has had it since it was written; the list has not, and
POST /tasks/{id}/move was already tested and working.
Where the board shows the target column, a list has to show the target
gap — the whole decision is "above this one or below it" — so a line is
drawn where the row would land, chosen by which half of the row the
pointer is in rather than by counting rows, which is what keeps it where
the eye expects it when rows have different heights.
The move sends both neighbours. The API's after_id is the task above,
which reads as the opposite of "after" in a vertical list, so the locals
here are named for where they end up on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TaskRow has had an onedit prop that nothing passed. There was no way to
see a task's description, sub-tasks, comments, files or reminders, all of
which the API has supported from the start.
A drawer rather than a page, so the list you came from stays behind it
and ticking a sub-task off and closing lands you where you were rather
than at the top of a re-fetched list. Mounted in the root layout and
driven by an id in the store, so a task opens the same way from Today, a
project, a label or a saved filter — and so an edit arriving over the
socket reaches the drawer without a second source of truth to keep in
step.
Text fields save on blur. A PATCH per keystroke is forty requests for one
sentence, and the last to arrive is not necessarily the last one sent.
The navigation shortcuts are suppressed while it is open: with a task in
front of you, "t" means the letter t.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no /indstillinger route at all. The Gmail OAuth callback
redirects there and had been answering 404 since it was written.
Six pages rather than one long scroll, because the sections have nothing
to do with each other and somebody opening API tokens should not have to
walk past their own password to get there: the account and its second
factor, notifications, integrations, AI, tokens, and data. Everything
listed as having no interface in the handoff is reachable from one of
them — the calendar feed, the mail-to-task address, Gmail, the AI
provider, import, export, templates and the update notice.
Two decisions worth naming. The AI key field starts empty and says why:
a settings page that repopulates a password field is one that will
eventually leak a key into a screenshot, and an empty value means "leave
the stored one alone" rather than "clear it". And the form chrome lives
in the layout as :global rules, because scoped styles do not reach a
child route and six copies of the same twelve selectors is six places for
them to drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticking a task off never removed its row. The optimistic update ran, the
request went out, the server answered 200 — and the view did not move.
The same applied to a change arriving over the websocket, which is how
somebody else's edit was supposed to show up. It had never worked.
The common factor was tasks[i] = value. push and whole-array assignment
both reached the views; index assignment did not. Rather than depend on
which mutations the reactivity happens to see through, every write now
goes through app.replace() or app.upsert(), which build a new array. The
lists are at most a few hundred rows and the copy is not worth a moment's
thought next to the class of bug it removes.
upsert exists because the same task arrives twice: once as the response
to the request that created it, and once over the socket, which publishes
to the whole project including whoever did it. Either can land first, and
appending both puts two rows with one id into a keyed {#each} — not a
duplicated row but a thrown error that stops the view rendering.
complete, reopen and update were three copies of the same twenty lines;
they are now one private helper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every label link in the sidebar was dead. The route file landed in
web/web/src/routes/ because the shell that created it was already inside
web/ — SvelteKit never saw it, so /etiket/<name> fell through to the
app shell and rendered nothing.
The manifest also listed icon-192.png and icon-512.png, and app.html an
apple-touch-icon, none of which were ever generated. A manifest pointing
at missing icons is worse than one listing only what exists, so the dead
references are gone and the SVG — which Chrome and Firefox install from
happily — is what remains.
docs: hand over the project
HANDOFF.md covers what cannot be read out of the code: why the name is
not skuld, the decisions that look strange until you know the reason,
what is tested against a real service and what is only tested against
its specification, the gaps in the order I would take them, and the
traps that cost the most time to find.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The landing page and README now show the app rather than describing it.
The images are captured by tools/shots, which drives a headless Chrome
over the DevTools protocol — Chrome's own --screenshot flag cannot set a
session cookie, so it would only ever photograph the sign-in page. Kept
in the repository so they can be regenerated when the interface changes,
rather than being something somebody did once by hand.
The quick-add example on the site is in English, with the Danish
equivalent shown underneath — the page is read by people who do not
speak Danish, and the point being made is that both work.
Yggdrasil is stated up front rather than left to the footer: being a
Rune is what this was built for, and somebody running the panel should
learn that in the first paragraph instead of the last line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The board is one column per section with native drag-and-drop, which
works with a trackpad, a mouse and a screen reader without any of them
being reimplemented. Dropping is optimistic: a card that snaps back for
200ms reads as a failed drop.
Weeks in the calendar start on Monday. That is ISO 8601 and what every
Danish calendar does; a Sunday-first grid makes a Dane misread the month
at a glance. On a phone the chips give way to coloured bars — at that
width the month's shape is the useful part, not the titles.
Label pages run the filter language rather than a bespoke endpoint, so a
label page and a saved filter can never disagree about what a label
selects.
fix(recurrence): "hver mandag", not "hver uge mandag"
A weekly rule that names its days does not also need the word for the
interval. That is how the phrase is said, and it is what the parser
accepts back — the round trip through Describe now holds for weekday
rules too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interface for phase 1: sign-in and first-run setup, Today, Upcoming,
project views, sharing, Cmd+K search and the keyboard shortcuts.
Quick add highlights what the parser understood underneath the text as
you type — a mirrored layer behind a transparent input, sliced by byte
offset because the server counts bytes and JavaScript counts UTF-16
units, which disagree on exactly the Danish letters this app is full of.
Every local action is optimistic and reconciled afterwards: ticking a
task off strikes it through and animates it out before the request
leaves, and rolls back with a message if the server disagrees.
The Content-Security-Policy is computed at startup by hashing the inline
scripts in the built index.html. Both of them change on every frontend
build, so a hard-coded hash would go stale silently and show up only as
a blank page in production. The alternative, 'unsafe-inline', would drop
the one protection that makes an injected script in a task title inert.
Today is derived from the store by date rather than from the ids that
came back with the first request — filtering against a captured list
meant a task added through quick add never appeared until a reload.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
git does not track empty directories, so COPY web/ ./ failed on a clean
checkout with "/web: not found" — the Dockerfile built locally, where
the directory exists, and only in CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>