f62ffc8d92045a7ca88d13de51356ef3cfc6c512
291
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9cc7df69f0 |
fix(projects): the header stops crushing the project's name
"GarageRisteriet" rendered as "GarageRist / eriet" — broken mid-word. A name split
mid-syllable does not read as a wrap, it reads as a rendering fault.
Two causes. The row carried eight controls beside the heading — colour, show-done,
three view buttons, share, history, delete — and the heading was the only flexible
thing in it, so it was what gave way. And `overflow-wrap: anywhere` breaks at the
first opportunity rather than the last necessary one, so what gave way, gave way
inside a word.
Everything but the view switcher is behind one button now. What stays out is what
you use while working — which view you are in. What moves in is what you do *to* a
project rather than in it, and most of it once ever. The heading gets the row back,
and `break-word` only splits a word that could not fit on a line of its own.
The menu closes on Escape and on a click anywhere else, listened for in the capture
phase so a click whose own handler stops propagation still closes it. A menu you
can only leave by pressing its own button again is one you click twice to get out
of.
Three more untranslated strings turned up on the way: "Fjern {member.name}", "Luk
menu" and "En slettet konto". The guard missed them because none of its words
covered them — it knows fjern, opret, vælg, luk, tilføj, ryd, gem, slet and omdøb
now.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
ab5e3e35a6 |
fix(i18n): three Danish strings that had no æ, ø or å in them
Spotted on the running instance: the version line read "Running v0.11.0. Der bliver ikke set efter opdateringer." — half translated, on one line. The sweep that was supposed to catch these grepped for Danish letters, and none of the three survivors contain one: "Ingen nyere version fundet.", "Ikke mere i dag." and a token hint split across two source lines so neither half looked like prose. So there is a test for it now. It reads the templates and looks for the words that give Danish away whatever letters they are spelled with — a word list rather than a language detector, because this ships as one static binary and thirty common words catch the sentences a person actually writes. It fails on the exact three that got through. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
d6d5bf590b |
fix(sections): a section can hold more than one task
Reported as "sections can only have one task", and that is exactly what it looked like. TaskList read the dragged task from its own `draggingId`, which is only set when the drag *started* in that list. A task dragged in from the unsectioned rows above, or from another section, arrived as null and the handler returned early — after it had already called `stopPropagation`, so the section around it never got the drop either. So a section could only be dropped into while it was empty, because an empty one has no row in the way to swallow the drop. Put one task in and there was no route to a second. The id comes out of `dataTransfer` at drop time now, and `dragover` decides with `carries()` rather than local state — which is the only thing a target can read while a drag is in the air. The stuck frame was the same cause: the section's own cleanup never ran, so the highlight stayed lit after the drop. It is cleared on `dragend` now, which fires once per drag on the source and bubbles to the window — the one place that is true however a drag ends, including one abandoned with Escape, which produces no drop at all and left the frame standing too. The smoke test drops a second task onto the row already in the section, which is the gesture that did nothing, and runs in WebKit as well. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
6853897d00 |
ci: install WebKit as well, for the one test that needs it
The `webkit-forms` project was added to the Playwright config and CI was not told, so it installed Chromium and the one WebKit test died on a missing executable — 26 passed, 1 failed, and the failure said nothing about the app. It is one test on purpose. The suite stays Chromium, because running it in three engines finds the same breakage three times; focus is the exception, and Safari ignoring `autofocus` on anything inserted after load is what made "sections have no function" true from inside Safari. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
726a489a67 |
fix(migrations): say why foreign keys go off, accurately
Release / Build and publish image (push) Failing after 10m0s
The note on 0009 and 0012 said the drop would cascade and take every row with it. It would not: nothing references `activity` or `attachments`, so dropping either takes nothing along. That claim was carried over from 0008, where seven tables do reference `tasks` and it is exactly right. The real reason is the check that comes with the marker. With foreign keys on, the copy is validated a row at a time against a half-rebuilt schema, and the first bad row aborts with no idea how many others are like it. With them off, `PRAGMA foreign_key_check` runs once before the commit and names what is actually wrong — which matters, because these run against a live database on somebody's next restart and a failed migration is a server that does not start. The upgrade test now carries activity rows, an attachment and a group across all five migrations rather than stopping at 0008, and checks that the columns added by ALTER arrived with their defaults rather than as NULL. Co-Authored-By: Claude Opus 5 <[email protected]>v0.11.0 |
||
|
|
626ea9aaec |
feat(tasks): finished work can be looked at, and is out of the way by default
A task list is what is left to do, so closing something has to take it off the plan. But "what did I get done" is a real question and the answer was nowhere: once a task was closed it left the interface entirely. They come back in one list at the bottom rather than back among the open ones in their sections. A closed task has stopped being work and started being a record, and putting the record in the middle of the plan makes the plan longer without making it say more. Off by default, and deliberately: a list that opens full of things already done is a list you have to read past before you can use it. In localStorage rather than on the account, like the sidebar's width and unlike a folded group. It is a way of *looking*, not a statement about the work — somebody reviewing their week wants it on for ten minutes, and a setting that followed them to every device is one they would have to turn off twice. `loadTasks` asks for closed tasks when the setting is on, rather than each caller deciding. A view added later cannot then forget and quietly ignore it. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
4d8b70918b |
feat(i18n): the interface speaks both languages
The account has had a `locale` since the beginning and it only chose which grammar quick add read a line with — so picking English changed nothing anybody could see, and the field said so honestly: "Sprog i hurtig tilføjelse". It says "Sprog" now, because it means it. Every component goes through `t()`. Danish is the source: a key missing from English falls through to Danish rather than to the key, because a Danish sentence in an English screen can be read and `settings.notifications.title` cannot. `tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a dozen components, and that is the half of a translation that gets left behind — an English interface still saying "24. aug." is exactly the shape of a job half done. There is a smoke test that switches to English and refuses to see a Danish month name in the calendar's heading. Colour and theme names are keys now. `colors.js` keeps its ids untouched — they are what the database stores, and `internal/store` reads them out of that file — but `name` is `'color.tomato'`, resolved where it is drawn. The Inbox keeps its name. It is a project row the server creates at signup, not a string in the interface, and it can be renamed like any other project. Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had already drifted: one said "aldrig logget ind", another "aldrig", and two stopped at days where the others carried on to a date. Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and they are in every smoke test; translating them buys nothing and costs both. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
e3c7fc792a |
feat(i18n): the machinery, and the shell translated
The account has had a `locale` since the beginning, and it only chose which
grammar quick add parsed a line with — so setting it to English changed nothing
anybody could see. This is the other half of that field, started.
`t()` reads `i18n.locale`, which is `$state`, so every component that calls it
redraws when the language changes; that is what makes the setting take effect
without a reload, and why the module is `.svelte.js`. Danish is the source and the
fallback: a key missing from English falls through to Danish rather than to the
key, because a Danish sentence in an English screen can be read and
`settings.notifications.title` cannot. `tag()` is the single place `Intl` is
asked, so dates follow the language instead of being `da-DK` in a dozen files.
`internal/i18n` is a Go package that exists only to fail the build when the two
dictionaries drift — the same trick `colors_test.go` plays on the palette. It
checks the placeholders as well as the keys: a translation that spells `{n}` as
`{antal}` leaves `{antal}` on the screen, which is worse than an untranslated
sentence because it looks like a bug in the app rather than a gap in a dictionary.
Converted so far: the shell. Sidebar, layout, quick add, task row, command
palette — fifty-two keys, and the surfaces people look at constantly. Twenty
files remain, the settings pages being the heavy ones.
The language picker is deliberately not wired up yet. It still says "Sprog i
hurtig tilføjelse" and still only changes the parser. Wiring it before the sweep
is done would hand anybody who picks English a bilingual screen — the
half-finished-theme failure `app.css` already taught us. As it stands Danish is
unchanged and English is not offered, which is the honest state for something
half done.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
804963b222 |
feat(groups): a group becomes somewhere you can go
It was a heading over some rows: a name, a colour, and whether it was folded.
Enough to tidy a list, and not enough to be what people mean by it — "Arbejde" is
a body of work with context of its own, and documents that belong to all of it
rather than to any one project inside it.
`/gruppe/{id}` carries three things a heading cannot. The projects in it, each
with what is still open — open tasks, not all of them, because "12" beside a
project somebody finished last year is a number that means nothing. A description,
written in place and saved on blur, the same shape a task's description has. And
the files that govern the whole group: the contract, the drawing, the agreement
everything else rests on.
The name in the sidebar is a link to it and the chevron beside it still folds.
Two intentions, two targets — the single one they used to share could only ever
serve the smaller.
Migration 0012 rebuilds `attachments`, because its CHECK spelled out exactly which
parent columns may be set and SQLite cannot alter a CHECK. The rule is still
"exactly one parent", counted rather than written out as pairwise comparisons:
with three columns that is three clauses, with four it is six, and the version
that is wrong looks exactly like the one that is right.
A group's file is reachable by its owner and nobody else. A group belongs to one
person and is never shared, so `reachAttachment` asks about ownership rather than
a project role — both old callers resolved straight to a task, so a group's
attachment answered 404 for the person who uploaded it.
Three tests also stopped racing their own navigation. Creating a project navigates
and `goto` is a promise nobody was holding, so a quick add fired straight after
could land in the Inbox instead — intermittent, and it read as the quick add
having failed.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
0b467beb3e |
docs: the API and the handover, brought up to what is here
The OpenAPI document gains the audit log, the backups, the Gmail client and the sidebar sections, and loses the paragraph that said deleting an account takes every task the person ever wrote. The handover gains the day: why an account can be deleted without taking other people's work, why the audit log pages with a keyset, why a week view exists at all, why form fields are 16px on touch, and why `autofocus` is gone. And the trap that cost the most, which was not in the code: the build hung on 0% CPU because iCloud had evicted node_modules and a stalled download blocks a read forever. The fix is a repo path outside a synced folder. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
1a6adfd9d3 |
docs: screenshots that regenerate themselves, and eleven of them
They were many rounds behind — no settings, no task detail, no themes, no groups, no colours, no calendar, no admin pages. The reason is in how they were made: `tools/shots` needs a running instance and a session cookie pasted in by hand, so it ran about as often as somebody felt like doing that. `node scripts/generate-screenshots.js` starts the real binary, creates its own account, fills it with Danish demo data and photographs eleven surfaces into both `site/screenshots` and `docs/screenshots`. Nothing to set up and nothing to remember. A script rather than a test, for the same reason `generate-icons.js` is one: CI must never rewrite a tracked binary file. `tools/shots` stays. It is still the way to photograph an instance that is already running rather than a fresh one. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
01d498be20 |
fix(projects): group_id was accepted on create and thrown away
The field was declared on the request type, commented on it, and applied by the update handler — and the create handler never read it. Creating a project into a group answered 201 and put it nowhere. A field that is accepted and discarded is worse than one that is refused: nothing reports it. Found by looking at a screenshot. Both groups stood empty while the projects sat loose above them. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
31bf0557df |
feat(gmail): the OAuth client can be entered instead of deployed
The one click that opens Google's consent screen already existed. What it needs behind it is a registered client, and no app can conjure one: Google issues no Gmail access to an unregistered client, and `gmail.readonly` is a restricted scope, so an id shipped inside a public image would be both extractable from it and unusable without Google's security review of every deployment using it. The registration is a one-off. Having to edit a Rune's manifest and recreate the container to paste the result was not. `VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when set. A value in the environment is a deliberate deployment decision, applied again on every recreate, and a form that could quietly override it would make the manifest a lie — so the page says where the value came from, and PUT answers 409. The redirect URI is spelled out on the page rather than left to be reconstructed. It is derived from VERDANDE_BASE_URL, it has to match Google's copy exactly, and the error Google returns when it does not names neither value. Its own table rather than `user_settings`: an OAuth client is the instance's, not one person's — and `user_settings` has `user_id` as its whole primary key, so a second scope there would silently overwrite the AI provider's key. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
101ca33069 |
feat(tasks): a row says what is underneath it, and what is attached
A task with three sub-tasks looked exactly like one without, and so did a task with a drawing on it. The whole reason to attach something is that somebody should find it. Counted in the same query as the tasks, as correlated subqueries: a list of fifty would otherwise be a hundred more lookups, and the point is that you see this without asking. Said as a fraction rather than a total. "3" tells you something is there; "1/3" tells you whether there is anything left to do, which is the question a list is for. All-done goes accent-coloured, because "3/3" and "1/3" are the two answers you actually look for and only one of them is good news. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
eaff890860 |
fix(forms): Safari focused none of them, and a board could not make a section
Two reports, one sentence between them: "sections have no function, because you cannot create more than one." `autofocus` does not work in Safari for anything inserted after the page has loaded, and every inline form here is inserted after the page has loaded — rename a project, add a section, name a group. In Safari they all opened with no cursor in them and what you typed went nowhere. That does not look like a bug. It looks like the feature does not work. `use:focusOnMount` replaces all seventeen. And the board had no way to add a section at all. The button lived only in the list view, which is the one place nobody looks for it: a column on a board *is* a section, so that is where you go to make one. Chasing this turned up a third. The task drawer's three fields save on blur, and closing the drawer *is* a blur — the input is being unmounted and the browser fires blur on the way out. If the task left the store in the same moment, `task` was already null and the handler threw `Cannot read properties of null`, stopping the page. It only became reproducible once focus actually worked. Fields are 16px on a touch device now, whatever the component asked for. Below 16px, iOS Safari zooms in when a field takes focus and does not zoom back out — so the whole interface is left too large and has to be pinched back by hand, once per field. The type scale tops out at 15px, so every input in the app did it. The sidebar's resize handle sat at `right: -3px`. An absolutely positioned child counts towards its container's scrollable overflow, and `overflow-y: auto` resolves the other axis to `auto` too — so those three pixels gave the whole menu a horizontal scrollbar on every desktop, always. The account name could do it as well: `overflow: hidden` with no `min-width: 0` cannot shrink, so the row grew instead of ellipsing. There is a WebKit pass in the suite now, over one test. The rest stays Chromium on purpose — a smoke test exists to catch a build that does not work, and three engines find the same bug three times — but focus is the exception, and it took a person using the app to find that out. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
1da7ea64bf |
feat(admin): the nightly backup, made visible
It has run since the beginning and nothing showed it. A backup nobody can see is a backup nobody knows has been failing, and the failure is quiet: a volume gone read-only, a disk that filled, a container that has not been up at midnight all week. The first anybody hears of it is the day they need one. `ListBackupRuns` was already in the store and exposed nowhere. Now there is a list, a download, and a button that takes one now — the same work the nightly job does without its "one a day" check, because waiting until tonight to learn whether backups work at all is how somebody finds out on the day they need one, and it is the only way to get a snapshot from *before* something you are about to do. Restoring is deliberately not a button. Swapping a database under a server that is writing to it means stopping every writer, replacing the file and reopening, and a half-done version of that destroys the thing it was restoring. The panel says what to do instead. `RunBackupNow` turns out to have been a second copy of the backup logic, written for a button that did not exist yet, and the two had already drifted: it never created the backups directory and logged nothing. One implementation now. Administrators only, and sessions only. A backup is a complete copy of the database, so this hands out everybody's data in one file. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
04a7ba1024 |
feat(sidebar): headings that fold, a group head that is not indented, and colour where it is edited
Three things reported from use, all in the sidebar. Projekter, Delt med mig, Filtre and Etiketter fold, the way a project group already does. Kept on the account rather than in localStorage, and for the same reason the group's own `collapsed` is: folding a heading says "this is the part of my work I am not in right now", and that is as true on the laptop as on the desktop. The sidebar's *width* is the opposite case and stays per browser. A JSON array of keys rather than a column each, so a renamed heading is not a migration. The group heading is no longer indented — only what is filed under it. The colour dot sat as a sibling before the fold button and pushed the whole row in, and a heading that starts further in than the rows above it says the heading is inside something. And the colour is chosen while renaming. The dot was itself the button, which gave the heading four controls on a line narrow enough to crowd the name; both are edits to the same thing, so they belong in the same moment. The form closes on `focusout` and only when focus has left it entirely, and the swatches refuse focus on mousedown — Safari and Firefox do not focus a button on click, so `relatedTarget` would be null and the form would shut under the pointer. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
b169591209 |
feat(upcoming): a week at a time, so a drag can cross a month boundary
A month grid is anchored to a month, so the two days on either side of its edge sit in different grids — and you cannot page mid-drag, because a drag in flight swallows the click that would page. A week that straddles the 31st and the 1st has both days in the same row. The same component as the month, with `span="week"`, rather than a second one: the part that is easy to get almost right is the same in both. A drop target has to decide from `dataTransfer.types` alone whether it accepts, and two copies of that are two places to be almost right. Seven cells instead of forty-two, so the height a month spends on six rows goes into one and the cells hold a real list rather than a hint. On a phone the row stacks and each day carries its own weekday name, because a column of bare numbers is not a week. `calendar` still means the month in localStorage. It is the value already stored for everybody who chose it, and renaming it would silently reset them. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
85f7b2488d |
feat(admin): what has been done on this server, across every project
The other half of the error log. That one is what broke; this is what was done, and nobody could ask it: `ListActivity` takes one project id, and an administrator is not necessarily a member of the project they need to look into. Paged with a keyset cursor rather than an offset. The log is written to constantly and read rarely, so a page fetched a minute after the one before it would be shifted by everything written in between — one row shown twice and the row behind it skipped, which is the single failure an audit log must not have. Both halves of the cursor matter: the log is written in whole seconds, so a timestamp alone cannot separate two rows written in the same one. Migration 0009 makes `activity.user_id` SET NULL for the same reason 0008 did it to `created_by`. A log whose rows vanish with the account they describe answers "what has happened here" with everything except the part somebody had a reason to remove. `project_id` keeps cascading on purpose: the trash is the undo, and past it the deletion was deliberate. The event names moved to `web/src/lib/events.js`, because two surfaces read the same log now and two copies of nineteen strings is two places for them to drift. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
ebdc8eacf2 |
feat(admin): deleting an account stops deleting other people's work
`tasks.created_by` was ON DELETE CASCADE, so retiring a colleague removed every task they had ever written — including in projects somebody else owns. The interface said so in plain words before you pressed the button, which is a warning rather than a fix. It was the last remaining way to lose other people's work by accident. SET NULL instead: the task stays and loses its author, the same shape `assignee_id` and `completed_by` already have. SQLite cannot alter a foreign key action, so the table is rebuilt — and with foreign keys on, the drop in the middle of a rebuild is a cascading delete that takes every row referencing `tasks` with it. `PRAGMA foreign_keys` is a no-op inside a transaction, so migrations now run on a connection of their own, and a migration can opt out of enforcement with `-- verdande:rebuild-tables`. `PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a dangling reference fails the migration rather than writing it. The test fills a database at 0007, upgrades it, and counts. Without the marker it loses a task, a label link and a comment — which is exactly what it is for. The admin list now says two numbers instead of one: what disappears, and what stays behind unattributed. They are different sentences and the second is a reassurance. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
a29534c811 |
build: lift Vite to 8, and get the tree out of iCloud's way
The frontend build hung on 0% CPU, roughly every other run: blocked in read() inside libuv, no output, forever. `svelte-kit sync` alone did it too, so it was not the bundler. The cause was not the toolchain. `~/Documents` is iCloud-managed, and iCloud evicts what it thinks is cold — which for a dependency tree is nearly all of it: 359 of 400 sampled files under node_modules were `dataless`. Reading an evicted file blocks until it is downloaded again, and a download that stalls hangs the build for good. The tell was a different error entirely: Playwright once reported `ETIMEDOUT: connection timed out, read` while loading jsconfig.json, and ETIMEDOUT on a local file read does not exist. Vite 5 and esbuild 0.21 predate Node 22 and this machine runs Node 26, so they went up anyway — to Vite 8 with @sveltejs/vite-plugin-svelte 7, which SvelteKit 2.70 takes. `package-lock.json` had to be deleted for npm to resolve it; it held plugin 4 and rejected the whole tree with ERESOLVE. That made the build faster. It was not what was hanging. `engines` says Node >= 22.12 now, so the next person is told rather than left watching a process do nothing. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
494640c1a8 |
fix(mobile): the drawer no longer pushes the page sideways
Release / Build and publish image (push) Failing after 10m8s
The task drawer was `width: 100vw`. A viewport unit includes the scrollbar gutter and a percentage does not, so the drawer came out a few pixels wider than the page it was pinned to and the whole document could be shoved sideways. It is `100%` now, and at phone width it is stated as four insets instead — a box defined by the viewport's own edges cannot be a rounding error wider than it. The scrolling body gets `min-width: 0` and `overflow-x: hidden` too, so a pasted URL or a long unbroken word in a comment cannot stretch the flex column and take the page with it. Writing the test for that found another one: creating a project from the sidebar navigated to it and left the drawer covering the screen. Every link in there closes it on the way out; the form did not, because it calls `goto` directly. Now it does the same thing for the same reason. The phone test checks the document cannot be scrolled horizontally, both with the drawer shut and with it open. Co-Authored-By: Claude Opus 5 <[email protected]>v0.10.2 |
||
|
|
6d27d730d2 |
fix(mobile): the project header wraps instead of crushing the title
Release / Build and publish image (push) Failing after 10m4s
On a phone, a project called "Skovvænget" rendered as a vertical column of single letters while the buttons ran off the right edge. It looked like a broken font. The header is a flex row of a title and four controls, and 390px does not have room for them. Nothing wrapped, so the heading was shrunk to its min-content — which with `overflow-wrap: anywhere` is one character. Adding Historik this evening is what tipped it over. It wraps now, and the title keeps a floor of 8ch so flex stops squeezing at the point where wrapping is the better answer. Kommende had the same shape and gets the same fix. Every other smoke test runs at desktop width, which is exactly why this shipped. There is one at 390 now: it measures the heading's box — wide enough to hold a word, short enough not to be a column of letters — and checks the page cannot be scrolled sideways. Also stops an empty project saying both "Uden sektion" and "Ingenting her endnu": that label is a heading for the tasks outside a section, and with no sections there is nothing for them to be outside of. Co-Authored-By: Claude Opus 5 <[email protected]>v0.10.1 |
||
|
|
b0ab6ee5c0 |
fix(docker): build the frontend and the binary once, and cross-compile
Two of the last five releases died at exit 132 — SIGILL, an instruction
QEMU does not implement — partway through `npm run build`. It reads as a
broken build and is a broken emulator.
The cause is that neither builder stage was pinned to the build platform,
so buildx ran both once per target: Node and esbuild under emulation for
arm64, and the entire Go toolchain too. Nothing in either stage needs to be
the target architecture. The frontend produces HTML, CSS and JavaScript,
which are the same bytes wherever the image runs, and Go cross-compiles
natively with CGO already off.
So both are `--platform=$BUILDPLATFORM` now, and the binary is built with
`GOARCH=${TARGETARCH}`. The frontend is built once instead of twice, and
nothing is emulated at all — which should also take most of the fifteen
minutes off a release.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
c2e2d66e76 |
feat(admin): keep server errors where a restart cannot take them
Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at 11:49, twice from another at 10:45 — and there is no way to find out what any of them were. verdande logs the real cause of every 500, but on a Rune the log belongs to the container and the container is replaced on every restart. So an operator is told that something broke and given nothing to act on, which is the worst of both: alarming and useless. Every 500 now writes a row: what the handler was attempting in its own words, the error it hit, the method and path, who was asking, and the request id that ties it back to the log line for whoever still has it. The "what" is the part a status code cannot give — "list projects" and "move task" are the difference between a fault you can find and one you can only count. Written best-effort and never checked. This is the path that is already reporting a failure, and a diagnostic that can itself fail the request would turn one broken screen into two. Threading the request through `internal()` and `storeError()` touched 108 call sites across 19 files, which is the price of not having had it there from the start. It is mechanical and the compiler checked all of it. Kept for thirty days by the nightly sweep: long enough to answer "what happened last week", short enough that a fault loop cannot fill the volume the tasks live on. Read at Indstillinger → Fejl, administrators only — it carries paths, account names and the server's own error strings. The empty state says "Ingen fejl registreret. Det er den rigtige tilstand", because a blank panel should not read as a broken one. The test drops a table and asks for the endpoint that reads it: the same code path a disk error takes, and the row that comes back has to name the operation, the account and the request. Co-Authored-By: Claude Opus 5 <[email protected]>v0.10.0 |
||
|
|
4be934ccc1 |
feat(pwa): the PNG icons the manifest and iOS have always wanted
The manifest pointed at icon.svg and nothing else. Chrome and Firefox install happily on that; Safari does not read the manifest's icons at all and looks for /apple-touch-icon.png by name, so an installed PWA on iOS got a screenshot of the page as its home-screen icon. This was written down as blocked: there is no SVG renderer on this machine, which is why the references were removed rather than left pointing at files that did not exist. Playwright is an SVG renderer, and it has been a dependency the whole time. scripts/generate-icons.js renders the same SVG at 192, 512 and 180. A script rather than a test, so CI never runs it and never rewrites a tracked binary — run it when the icon changes. The smoke test already fetches every icon the manifest names, and now asks for the Apple one by name too, so a reference with no file behind it fails there rather than on somebody's phone. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
7094e05ddd |
feat(sidebar): drop a task on "I dag"
"Do it today" is the most-made rescheduling there is, and the sidebar is where the pointer already is when you have picked a task up. Only that one of the two date views. "Kommende" is a range rather than a day — it starts today and runs a week — so a drop on it would have to invent a date its own label does not name. The month grid inside it takes a drop per cell, which is where a specific day is actually on offer. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
35d9e3bdac |
feat: an assignee on the row, and the activity log made visible
Release / Build and publish image (push) Failing after 10m2s
Two more things the database has been recording and the interface has
never shown.
**Who a task is waiting on, on the row.** Only when it is not you: your own
list would otherwise carry your own face on every line, which is a column
of one initial telling you nothing. The point of showing an assignee is
that it is somebody else.
That needed names, and a task carries only an `assignee_id`. `GET /people`
is the primitive: everybody you share a project with, plus yourself,
fetched once per session. Not per row, and not per project either — a list
would have to know which project each task belongs to before it could ask
for that project's members. Deliberately *not* the instance's user list,
which is `/users` and administrators only: a task list has no business
enumerating everybody with an account here.
It sits first in the meta line, because who changes what the rest of the
line means — a date on somebody else's task is their deadline, not yours.
**And the activity log.** Nineteen kinds of entry have been written since
the beginning — every project, section, member, task and comment event —
and `GET /projects/{id}/activity` has answered honestly the whole time with
nothing on the other end. There is a Historik panel on the project now,
loaded when it is opened rather than with the page, and the event keys
become Danish in exactly one place. An unknown key falls through to itself
rather than being hidden: a build that records something this table has not
learned should still show that it happened.
One test assertion had to change, and it is worth saying why. It matched
"males inden fredag" against a row whose title is "males inden" — quick add
read "fredag" as the due date — so it was really matching the title
concatenated with the metadata beside it. Putting the assignee between them
broke it. The assertion now names the task's own text.
Co-Authored-By: Claude Opus 5 <[email protected]>
v0.9.0
|
||
|
|
b6ea87ac19 |
feat(projects): change a member's role from the member list
An invite sent with the wrong role could not be corrected. The only way back was to remove the person and invite them again — and removing a member unassigns every task they were responsible for, so fixing a dropdown cost somebody their work. This is the other side of "an external user cannot create tasks": the rule was right, the role was wrong, and there was no way to say so. PATCH against a membership that already exists, not the upsert AddMember does. Somebody who is not a member is not silently made one: that is an invite, it belongs to the invite flow, and doing it here would let a typo in a user id add a stranger to a project without anybody being asked. The owner is refused, with the real reason rather than the true but useless one. Ownership lives on the project rather than in project_members, so the plain answer would have been "not a member" — which is correct and tells nobody anything. Ownership is transferred, not granted, and two owners is a state with no way to settle a disagreement about who may remove whom. In the list, the owner's standing is text and everybody else's is a dropdown. A disabled control would be a thing that exists to say no. And the roles are finally in Danish. The API's words are `owner`, `editor` and `viewer`, and they were being printed straight into a Danish page; the list now uses the same three phrases the invite form beside it already did. The smoke test drives both browsers, because the interesting part is that the other one's permissions really change: a viewer is told the project is read-only and has no quick-add box, the owner promotes them from the list, and after a reload the box is there and the notice is gone. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
fc32fea607 |
fix(projects): a section can be dropped into, and a failed load says so
Release / Build and publish image (push) Failing after 9m53s
Two things from the same report — an external member who could see a project but could not get work into it. **A section with no tasks could not be filled by dragging.** Only the task rows accepted a drop, so the target was always another row; a section with none had nothing to aim at, and a section you have just made is empty by definition, which is exactly when you want to put something in it. The board has taken a drop on the column since it was written. The list now does the same: the section is the target, and a drop on it puts the task at the end. Dropping on a row still means "here exactly" and stops there rather than bubbling to the section behind it. The unsectioned block at the top is a target too, which is how a task comes back *out* of a section — previously only possible if some other loose task happened to be there to aim at. **And the page said "Projektet findes ikke, eller du har ikke adgang til det" for three different things.** `project` started null with no loading state, so that sentence was what you saw before the first request returned, what you saw when it failed, and what you saw when you genuinely had no access — and the effect never asked again, so the wrong one stayed until somebody reloaded. That is very likely what was reported: the container was restarted twice while people had the app open, and a request that lands mid-restart leaves the page insisting the project does not exist. Four states now. Loading renders nothing, as the shell does. 404 keeps the old sentence, since the server deliberately answers 404 for both "no such project" and "not yours" and the interface must not guess between them. Anything else says the request failed and offers to try again. What is *not* a bug: a viewer cannot create or move tasks. Checked at the API — an editor invited by link gets 201 on create, 201 on quick add and 200 on a move into a section; a viewer gets 404 on all of them, which is the rule working. If the person reporting this cannot add tasks, their membership is viewer rather than editor. Co-Authored-By: Claude Opus 5 <[email protected]>v0.8.0 |
||
|
|
8b1f374855 |
test(e2e): drive a member opening a project, not just seeing it in the sidebar
The invite test proved the shared project reached the invitee's sidebar and stopped there. A member's project page is a different path from an owner's — different role, different guards — and it was never opened by anything. It also matters that the page shows "Projektet findes ikke, eller du har ikke adgang til det" while it is still loading, because `project` starts null and there is no loading state. A failed load and a slow one look identical, which makes this exactly the kind of thing a person reports and a test suite cannot see. Signs in as the invited account in its own context and clicks through to the project, asserting the heading renders and that message is absent. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
550f420c59 |
feat(sidebar): colours you can choose, and projects indented under their group
`projects.color` has been in the schema since the first migration, has defaulted to 'graphite' on every row ever written, and nothing has ever read it. There was no palette, no name-to-colour mapping, and the dot in the sidebar was hardcoded to a border grey. So this is not "add a picker"; it is implementing colour for the first time. Names, not hex values. A hex in ten thousand rows is a decision nobody can revisit; a name is resolved when the interface paints and can be retuned for a new theme or a new display. It is also the difference between a database that stores a fact and one that stores a rendering. The set is closed and refused at the edge — an unknown name would be stored happily and painted as the default, which looks like a colour that did not save. One palette across all five themes, and this is the one place in app.css that is not redefined per theme. Everything else there is text, a ground or a border and has to be measured against what sits behind it. These are small solid marks beside a label that already carries the meaning: what they owe is to be told apart, and to be visible on both a near-black and a white ground, which is why all ten sit in the mid-tone band. It also keeps them out of the trap the themes are in — a colour added to four blocks and forgotten in the fifth looks finished and falls back silently. There is no fifth block to forget. The list is written down three times: Go for validation, colors.js for the picker, app.css for the tokens that paint. So a test reads the other two — the same small-scanner approach openapi_test.go takes to the spec — and fails if the server accepts a colour nothing can draw, or the picker offers one the server refuses. Groups get a colour of their own (migration 0006) and projects inside a group are indented. The indent is to the group's *name*, not to its dot: the eye follows the text, and lining the projects up under the dot leaves them looking a step short of where the heading starts. The drop line is indented with them, so a drop inside a group reads as landing in the group. The group's dot is the control as well as the preview — you press the thing you are about to change, as on the project page. That was not the first design: a third text action, "Farve", sat beside Omdøb and Slet, and those carry `opacity: 0` rather than `display: none`, so they take their width whether or not you can see them. A group called "Arbejde" rendered as "ARBEJD". Moving the control onto the dot gave the name its room back. The smoke test measures the indent at the content edge rather than the element's, because the indent is padding and the box does not move — which is how the first version of that assertion passed 12 against 12. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
e8ebd0fa78 |
feat(admin): a page for who is on the instance, and how somebody else gets on it
Release / Build and publish image (push) Failing after 10m0s
The README has promised "internal users are created by an admin" since the beginning and there was no such thing. There are exactly two ways an account has ever come into existence: the first admin at setup, and somebody accepting an invite to a project. So the only way to add a person was to share a project with them first. The store has been ready the whole time. CreateInvite already documents that "projectID may be empty, which invites somebody to the instance rather than to a particular project", and AcceptInvite already has the branch that grants no membership when there is none. requireAdmin has existed in the middleware and was used nowhere. This wires those three together and puts a page on them. An invite rather than an account with a password the administrator picks. A password typed here would be known to two people before it was ever used, and would then have to travel to the second one by some channel nobody has thought about. No name is asked for either — the signup form asks the person, and a name an administrator guesses is a name somebody has to correct. Two refusals keep the instance administrable: the last administrator cannot be demoted or deleted, and nobody can delete the account they are signed in as. Both are enforced on the server; the interface simply does not draw the buttons on your own row, because a button whose only purpose is to be refused wastes a click to teach a rule. The last-admin refusal gets its own error code, because "that already exists" is not what happened and this is the one refusal a person needs explained. Deleting an account is the only hard delete in this database, and building the confirmation is what turned up how wide it goes. `projects.owner_id` cascades, which is expected. But `tasks.created_by` cascades too — so removing a colleague deletes every task they ever wrote, including in projects somebody else owns. The count in the list says so, and the confirmation says it in words. Changing that to ON DELETE SET NULL, so the work stays and loses its author, means rebuilding the tasks table and its FTS triggers: worth doing, not worth doing quietly while adding a screen. It is written down in the store instead. Pending invites are listed and can be withdrawn. Only the token's hash is stored, so a link sent to the wrong address cannot be looked up and resent — withdrawing it is the only way to stop it before it expires. Six API tests for the guards and the counts, and two smoke tests: the whole loop from invite to a second browser creating the account and appearing in the list as somebody who is not an administrator, and the absence of buttons on your own row. Co-Authored-By: Claude Opus 5 <[email protected]>v0.7.0 |
||
|
|
a8b453843d |
fix(sidebar): Omdøb og Slet lå oven i hinanden i et gruppehoved
Release / Build and publish image (push) Failing after 9m53s
The "+" beside "Projekter" is a 20px square icon button, and the rule that makes it one was written as `.group-head > button`. A group's heading is also a `.group-head`, and it has two buttons in it that are words rather than icons — so Omdøb and Slet were each squeezed into a 20px box, where they overflowed and were drawn on top of each other. They still worked. Every assertion in the smoke test clicks by accessible name, which is unaffected by a button being the wrong size, so the whole suite passed on a heading that read "0 Om…Slet". It took a screenshot from somebody actually using it. The selector now asks for the class instead of for "a button in a heading", so the next text button added there is safe by default rather than by remembering. And the smoke test measures the row: each button must start at or after the previous one's right edge. A layout assertion is unusual here on purpose — the rest of the file deliberately tests behaviour — but this is a failure mode behaviour cannot see. Co-Authored-By: Claude Opus 5 <[email protected]>v0.6.1 |
||
|
|
a2f4de02fc |
chore: stop tracking the compiled shots binary
Release / Build and publish image (push) Failing after 10m0s
8.9 MB of macOS binary in the repository root, committed by accident in
v0.6.0
|
||
|
|
4276892590 |
feat(auth): see where you are signed in, and end one of them
`last_seen_at` has been written on every request since sessions existed —
throttled to once a minute, deliberately, so a list could one day say
"denne enhed, lige nu" — and nothing has ever read it. The store even had
ListSessions. What was missing was two routes and a panel.
GET /auth/sessions lists them, most recently used first, with the current
one marked so nobody has to work out which row is the browser they are
reading it in. DELETE /auth/sessions/{id} ends one. Ending the current
session is allowed and is simply logging out: refusing it would be a rule
with no reason behind it, and somebody clearing every device wants this
one gone too.
Both are behind requireSession rather than the ambient auth, the same as
the API tokens. A leaked token that could list its owner's devices and end
their sessions turns a theft into a lockout.
The delete is scoped in the statement rather than checked and then run.
An id read in one step and deleted in the next is a window, and the whole
reason somebody is on this screen is that they think another person is in
their account.
The device names are a handful of substring tests, not a parsing library.
The question this answers is "is one of these me?", and for that a wrong
guess between two Chromium forks costs nothing while a dependency that has
to be kept current costs something every year. Order is the whole
correctness of it: an iPhone's user agent says Mac OS X, Edge says Chrome,
and Chrome says Safari. The raw string is sent alongside and sits in the
row's title, so anything the summary gets wrong is still visible — and
something that is not a browser at all shows its own name rather than
"Ukendt", because a CalDAV client is a real session.
Times are relative. The question is "was that me, just now?", and an
absolute timestamp makes the reader do the subtraction; past a week it
switches to a date, because "for 23 dage siden" is a number nobody can
place.
The smoke test signs in a second time in a second browser context, ends
that session from the first, and checks the other browser is really signed
out — and that this one is not.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
||
|
|
6b6b838167 |
feat(views): "Venter på andre", and a project you share stays yours
`assignee_id` has been on tasks since sharing was built and delegating has been possible from the drawer since yesterday. What was missing was anywhere to see it: work handed to somebody else left your lists and appeared in nothing. GET /delegated returns everything assigned to somebody who is not you, in the projects you can see, grouped by the person. Grouped on the server rather than flat, because the question is about people — "what is Anders sitting on" — and a client cannot group it itself: it would need the name behind an assignee id from a project it does not happen to have open, and there is no way to ask for that. The names come back with the tasks, so one request is one screen. The filter is a field of its own rather than a sentinel in AssigneeID. "What is on my plate" and "what have I handed over" are different questions, and one string that means an id most of the time and a magic word the rest is a field somebody eventually passes a real id to. An assignee whose account is gone is named "Ukendt" rather than dropped. Losing the row would make work disappear from the one view that exists to stop work disappearing. The sidebar entry only appears where delegating is possible at all. On an instance with one person it could never have anything in it, and a permanent empty entry is a question the sidebar keeps asking and answering "no". Building it turned up a bug in the split above it. "Projekter" and "Delt med mig" were divided on `shared`, which only means the project has more than one member — so inviting somebody to a project of your own moved it under "Delt med mig", where it lost its place in the order, stopped being draggable, and could not be filed under a group. It is divided on ownership now, which is what the two headings have always said. Sharing your own project is not the same event as somebody sharing theirs with you. The smoke test now follows the whole loop in one go: invite, accept in a second browser, assign a task to the person who just joined, and find it under their name — with the shared project still under "Projekter" on the way past. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
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 <[email protected]> |
||
|
|
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 <[email protected]> |
||
|
|
2300f1dc3b |
fix(theme): the original two themes were the least readable of the five
Adding three themes meant measuring them, and measuring them meant measuring the two that shipped. They came last. --ink-faint is the tier that decides whether an interface is readable: dates, counts, hints and placeholders all live there, and it is the first thing to disappear. Dark sat at 3.8:1 against its ground and light at 3.1:1, where AA asks for 4.5. Light's accent — which is text as often as it is a background, on links and on "open the project" — sat at 3.8:1 too. All five now clear 4.5:1 at every tier. The numbers are in the code beside the colours, because the next person to nudge a hex should measure rather than look: this is exactly the kind of change that looks fine to whoever makes it. Left alone earlier today as a deliberate scope call — recolouring somebody's app as a side effect of adding themes is not a side effect anyone asked for — and done now that it was asked for. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
1529c87231 |
feat(theme): five themes, and a picker that shows them
Release / Build and publish image (push) Failing after 9m58s
Two themes and a toggle between them. Now five, chosen from a grid under Settings → Konto: the two that existed, a warm dark (Skumring) with amber where the green was, a warm light (Papir), and a high-contrast one. Each swatch is painted in its own theme rather than described, so the choice is made by looking. data-theme sits on the swatch and not on the card — it redefines --ink too, and a card wearing a dark theme's ink on a light page has a name nobody can read. Every theme states its full palette rather than inheriting and patching. A partial theme looks finished until somebody adds a token, at which point four themes silently fall back to a colour from a fifth. That is also why the dark palette is repeated under [data-theme='dark']: :root carries it as the default and :root only ever matches <html>, so a swatch asking for it by name would otherwise show whatever the page was wearing. The theme-color meta is updated too, so an installed PWA's frame and a phone's address bar follow rather than staying whichever colour shipped in the markup. Contrast measured for all five. The new ones clear 4.5:1 down to the quietest tier — which is where dates and counts live, and the first thing to disappear. Kontrast reaches 8.9:1 there. The two original themes are left as they are: their faint ink sits at 3.1–3.8, which is worth revisiting, but not as a side effect of adding colours. Co-Authored-By: Claude Opus 5 <[email protected]>v0.5.0 |
||
|
|
f15c440942 |
feat(realtime): keep projects and labels current too, not only tasks
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 <[email protected]>
|
||
|
|
d37d240625 |
feat(search): find a task by its label
Search covered task text only, so "regnskab" found nothing with the task labelled @regnskab sitting in front of you — which reads as the search box having lost it. A label is often the only thing you remember. "That thing about the accounts" is a search for @regnskab, not for any word in the title. The label side is a plain lowercase match rather than a second FTS index. Labels are short, and their names are typed by the same person now searching for them, so there is no spelling to be generous about — the diacritic folding that earns its keep on task text does not earn a second index here. The join sits inside the WHERE the visibility rules already build, so somebody else's label cannot pull their task into your results. There is a test for that, because it is the kind of thing an OR is good at breaking. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
3b521b463c |
feat(projects): add, rename and delete sections
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 <[email protected]> |
||
|
|
9056c44c9a |
feat(tasks): move, repeat, delegate and re-read a task from its drawer
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 <[email protected]> |
||
|
|
afc46a5c20 |
fix(api): do the things the API says yes to
Release / Build and publish image (push) Failing after 9m58s
Three reports from somebody driving the app through the MCP connector, all the same shape: an argument accepted, an OK returned, and nothing done. **project_id on an update never moved the task.** Both the REST handler and the MCP tool read it, both checked the caller's rights on the destination — which reads as "understood" — and then neither passed it on, because store.TaskUpdate has no such field: a project change is a move, since sort_order is per project. So the task stayed where it was and the answer said OK. Moving a task between projects was impossible except by creating it again and closing the old one, which is what the person ended up doing. Both paths now go through one helper, so they cannot drift apart again. **An unknown #project vanished without a word.** The parser reads "#3Dekoration" correctly; the lookup then fails, the task goes to the Inbox, and the name is gone from the title with nothing anywhere saying why. Still not an error — a thought should not be thrown away over a typo — but quick-add now returns unknown_project, and the MCP tool returns a note written for a model to relay. The old behaviour was kind in the web interface, where you see the row appear in the wrong place and fix it. In a chat nobody sees the row. **Sub-tasks were unreachable over MCP.** create_task had no parent_id, though the REST API has had it all along, so importing a parent with seven children produced eight unrelated tasks with the parent's name prefixed onto each. A sub-task joins its parent's project — the two cannot disagree — and a second level is refused rather than silently flattened. Co-Authored-By: Claude Opus 5 <[email protected]>v0.4.0 |
||
|
|
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 <[email protected]> |
||
|
|
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 <[email protected]> |
||
|
|
3f4af6c0ab |
fix: stop the app shell answering for /.well-known/
Release / Build and publish image (push) Failing after 9m56s
Adding the connector to Claude failed with "Couldn't register with Verdande's sign-in service", on a server that has no sign-in service and needs none. Claude probes /.well-known/oauth-authorization-server before connecting to an MCP server. The SPA fallback answered 200 with a page of HTML — which reads as "yes, there is an authorization server here" — so it went on to dynamic client registration and failed there instead of concluding the obvious thing, which is that this server does not use OAuth and the key in the URL is the whole credential. /.well-known/ is reserved by RFC 8615 and is never a route in this app. A 404 is the true answer, and it is also the useful one: the client stops asking and uses the URL as given. This is the third time the catch-all has caused a confusing failure by saying 200 to something that does not exist — /mcp itself was the second. The fallback should hand back the shell for routes the app might have, not for every path on the server. Co-Authored-By: Claude Opus 5 <[email protected]>v0.3.2 |
||
|
|
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 <[email protected]> |