Commit Graph
7 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 34b718529c fix(auth): the two links sent by email now go somewhere
An invite has always emailed `/invite?token=…` and "Glemt adgangskode?"
has always emailed `/reset?token=…`. Neither address existed. The Go
binary serves the app shell for anything it does not recognise, the shell
found no session, and both links opened the plain sign-in form — asking an
invited person to log into an account that did not exist yet, and asking
somebody who had forgotten their password for their password.

Both APIs were finished. `POST /auth/signup` takes the invite token, reads
the email address off the invite rather than the form — so a link cannot
be redirected to somebody else — creates the account, accepts the invite
and starts the session, all in one transaction. `POST
/auth/password/reset` takes the reset token and ends every session the
account had. Nothing was missing but two pages.

They are branches of SignIn rather than screens of their own, which is
what that component already says it is for: one decision tree, so a person
is never redirected mid-authentication. The routes exist because a link
has to be an address, and each renders the same component; it reads the
token out of the URL itself.

The reset does not sign you in afterwards. The whole point of ending every
session is that somebody may have been in the account, and quietly opening
a new one here would undo the half of that which matters.

Two states that are easy to leave out, and both are reachable in one click
from an old email: a link with no token says so instead of showing a form
that cannot succeed, and a link opened while already signed in as somebody
else offers to log out rather than creating a second account for a person
who is right there in the first one.

The smoke test walks the whole invite: share a project, read the link out
of the panel that shows it when there is no mail server, open it in a
second browser context, create the account, and find the shared project in
the sidebar. A second context and not a second page — inheriting the
session would test the one case that never happens, which is what it did
on the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:30:21 +02:00
Kristian WindandClaude Opus 5 c14559ed0c feat: project groups, a month on Kommende, and dragging things where they go
The three the handoff named as next. They are one commit because they are
one mechanism: all three end in something being dragged, and a drop target
cannot tell what is coming without the module they now share.

Groups are foldable headings over the projects in the sidebar. A table
rather than a `group_name` column on the project, because a group has to
be renamable in one write, has to survive being emptied, and has to
remember whether it is folded — none of which a string repeated on every
project row can do.

Folded state lives on the row, not in localStorage. The sidebar's *width*
is a property of the screen you are sitting at, which is why that one is
per browser; a folded group is a statement about the group — "this is the
part of my work I am not in right now" — and it is true on the laptop and
on the desktop. There is nobody else's answer to disagree with either: a
group belongs to exactly one person.

A shared project cannot be filed. `group_id` is a column on the project
row, so a member grouping somebody else's project would move it in the
owner's sidebar too — the same reason `sort_order` is owner-scoped. The
listing reports the column only to the owner, so a viewer is never told
where somebody else keeps it.

Deleting a group takes the heading and leaves the projects, and the
sidebar decides where a project renders by whether its group still
exists rather than by the id alone. A group deleted in another tab
arrives as one event and not one per project, so for a moment the
projects still name a heading that is no longer drawn — filing by the raw
id would drop them out of the sidebar entirely.

Kommende gains the month grid the project pages already had, behind a
Liste/Kalender toggle. The two views ask for different things — seven days
from today against six whole weeks around an arbitrary month — so each
loads its own, and the grid reports which dates it is showing rather than
having the page guess. Which view is remembered in localStorage: a month
of seven columns needs room, and the answer on a phone is not the answer
on a wide monitor.

And a task can now be dropped on a project in the sidebar, on a day in
Kommende, or on a cell in any month grid. Onto a project it is a *move*,
not a field: `sort_order` belongs to the project, so it has to find a
place among that project's tasks and leave its old section behind.

Each drag now carries its own MIME type. `getData` is deliberately
unreadable while a drag is in the air — the browser hands the payload over
on drop and not before, so a page cannot read what is being dragged across
it — and `dataTransfer.types` is all a target has to decide with. Deciding
is the point: calling preventDefault on a drag you cannot accept is what
turns "no" into a silent, wrong drop. The sidebar takes a project and a
task on the same row and has to tell them apart.

Two things fell out of building it. The month grid has always had one cell
narrower than the other six: `.today` styled the "I dag" button in the
header, and the day cell for today wears the same class, so `margin-left:
auto` was shrinking a grid item to its content. It is scoped to the header
now. And a project page listed whatever was in the store rather than what
belonged to that project, which did not matter until a task could leave a
project while you were looking at it.

Covered by eight new API tests and two new smoke tests, and the smoke tests
drive the drags for real — the type check they exercise exists only while
a drag is in flight, so nothing else can reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:25:10 +02:00
Kristian WindandClaude Opus 5 7ab68b0b58 fix(projects): keep the heading, and make the sidebar test mean what it says
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>
2026-08-17 15:10:00 +02:00
Kristian WindandClaude Opus 5 8a6e1bc39e feat(projects): rename, delete, and a trash you can actually reach
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>
2026-08-17 15:02:41 +02:00
Kristian WindandClaude Opus 5 e63d852847 feat(mcp): let the connector address carry its own key
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>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 25f1e2ff82 fix(e2e): wait for the sidebar before counting its links
Release / Build and publish image (push) Failing after 9m55s
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>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 1424f43a40 test(e2e): smoke tests that drive the real binary
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>
2026-08-17 10:56:33 +02:00