Commit Graph
8 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 8c748c2a88 feat(sidebar): drag it wider, fold it away, and have it stay that way
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>
2026-08-17 16:09:16 +02:00
Kristian WindandClaude Opus 5 c4e9e1519b feat(projects): drag the sidebar into the order you want
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>
2026-08-17 15:59:31 +02:00
Kristian WindandClaude Opus 5 c2a664d891 feat(web): drag to reorder in the list view
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>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 d288606827 feat(web): open a task
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>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 9d9444d0ac feat(web): the settings surface
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>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 fc4dba8549 fix(web): every change to the task list builds a new array
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>
2026-08-17 10:56:33 +02:00
Kristian WindandClaude Opus 5 0ee72d399c feat(web): board and calendar views, saved filters and label pages
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>
2026-08-17 07:10:51 +02:00
Kristian WindandClaude Opus 5 12a9597919 feat(web): SvelteKit PWA with the Nordic design system
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>
2026-08-17 00:03:20 +02:00