35d9e3bdac8454af0f1af151b1e57f6105d88f77
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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 <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |