Compare commits

..
6 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 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
a8e523b. The source in tools/shots is the thing that matters, and it is
still there.

Ignored as well as removed, because `go build ./tools/shots` without -o
writes the binary to exactly this path — which is how it got committed the
first time, and would be how it got committed the next.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:44:19 +02:00
Kristian WindandClaude Opus 5 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]>
2026-08-17 19:43:49 +02:00
Kristian WindandClaude Opus 5 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]>
2026-08-17 19:36:37 +02:00
Kristian WindandClaude Opus 5 34b718529c fix(auth): the two links sent by email now go somewhere
An invite has always emailed `/invite?token=…` and "Glemt adgangskode?"
has always emailed `/reset?token=…`. Neither address existed. The Go
binary serves the app shell for anything it does not recognise, the shell
found no session, and both links opened the plain sign-in form — asking an
invited person to log into an account that did not exist yet, and asking
somebody who had forgotten their password for their password.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:25:10 +02:00
Kristian WindandClaude Opus 5 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]>
2026-08-17 18:29:56 +02:00
38 changed files with 3149 additions and 118 deletions
+5
View File
@@ -45,3 +45,8 @@ node_modules/
# the process is killed, which during a hung build is exactly what happens. One
# was committed by accident.
/web/vite.config.js.timestamp-*.mjs
# A compiled `go build ./tools/shots` in the repo root. It was committed once by
# accident — 8.9 MB of macOS binary — and `go build ./tools/shots` without -o puts
# it right back.
/shots
+9 -1
View File
@@ -34,7 +34,9 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
@regnskab` becomes a task called "betal moms", due tomorrow at 10:00, priority 1,
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
views.
views, and foldable groups over the projects in the sidebar.
- **Drag where it means something.** Reorder a list or a board, file a project under
a group, drop a task on another project, or drop it on another day.
- **Sharing.** Internal users are created by an admin; anyone else joins through an
invite link sent to their email. Owner, editor and viewer roles.
- **Live sync.** A change made by somebody else appears without a refresh.
@@ -60,12 +62,18 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project roles and permission checks | Done, tested |
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
| Task detail: description, sub-tasks, comments, files, reminders | Done |
| Settings: account, notifications, integrations, AI, tokens, data | Done |
| Device list: where you are signed in, and signing one out | Done, tested |
| Invite and password-reset links that open the right page | Done, smoke-tested |
| Drag to reorder, in both the list and the board | Done |
| Drag a task to another project or another day | Done, smoke-tested |
| Foldable project groups in the sidebar | Done, tested |
| Month view on Upcoming, not only per project | Done, smoke-tested |
| Recurring tasks (RRULE, Danish + English) | Done, tested |
| Reminders, absolute and relative | Done, tested |
| Saved filters with a query language | Done, tested |
+3 -1
View File
@@ -21,7 +21,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
10 p1 #Firma @regnskab` becomes a task due tomorrow at 10:00, priority 1, in
Firma, labelled regnskab. Danish and English, mixed freely in one line.
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
board and calendar views.
board and calendar views, and foldable groups over the projects in the sidebar.
- **Drag where it means something** — reorder a list or a board, file a project
under a group, drop a task on another project, or drop it on another day.
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
joins through an invite link.
- **Live sync** — a change made by somebody else appears without a refresh.
+208
View File
@@ -351,6 +351,44 @@ paths:
"204": { description: Off. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/sessions:
get:
tags: [auth]
summary: Where you are signed in.
description: |
Most recently used first. `id` is the stored hash of the cookie and never
the cookie itself — it cannot be pasted into a browser, and it is what the
delete below takes.
Session only: an API token cannot list the devices of the person it was
stolen from.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
sessions:
type: array
items: { $ref: "#/components/schemas/Session" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/v1/auth/sessions/{sessionID}:
parameters:
- { name: sessionID, in: path, required: true, schema: { type: string } }
delete:
tags: [auth]
summary: Sign one device out.
description: |
Ending the current session is allowed and is simply logging out —
somebody clearing every device wants this one gone too.
responses:
"204": { description: Ended. }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/auth/recovery-codes:
get:
tags: [auth]
@@ -818,6 +856,89 @@ paths:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/project-groups:
get:
tags: [projects]
summary: Your foldable sidebar groups.
description: |
A group is a heading over some of your projects. It is yours rather than
the project's: it has no members and no roles, and a project shared with
you can be filed under one of your groups without the owner seeing it.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items: { $ref: "#/components/schemas/ProjectGroup" }
post:
tags: [projects]
summary: Make a group.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
responses:
"201": { $ref: "#/components/responses/ProjectGroup" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/project-groups/reorder:
post:
tags: [projects]
summary: Set the order groups appear in.
description: The whole list at once, for the same reasons as `/projects/reorder`.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items: { type: string }
description: Group ids, top to bottom.
responses:
"204": { description: Reordered. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/project-groups/{groupID}:
parameters:
- { name: groupID, in: path, required: true, schema: { type: string } }
patch:
tags: [projects]
summary: Rename a group, or fold it.
description: |
`collapsed` is stored on the account rather than in the browser. The
sidebar's width is a property of the screen you are at; a folded group is
a statement about the group, and is true wherever you sign in.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectGroupInput" }
responses:
"200": { $ref: "#/components/responses/ProjectGroup" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Delete a group.
description: |
The heading goes; the projects under it come back out as ungrouped. A
group holds no work of its own, so this is not soft-deleted — there is
nothing in it to recover.
responses:
"204": { description: Deleted. }
"404": { $ref: "#/components/responses/NotFound" }
/api/v1/trash/projects:
get:
tags: [projects]
@@ -1060,6 +1181,40 @@ paths:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/delegated:
get:
tags: [views]
summary: What you are waiting on other people for.
description: |
Everything assigned to somebody who is not you, in the projects you can
see, grouped by the person it is assigned to.
Grouped rather than flat because the question is about people, and a
client grouping it itself would need every assignee's name — which it has
no way to ask for. The names come back with the tasks, so one request is
one screen.
parameters:
- { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
people:
type: array
items:
type: object
properties:
user_id: { type: string }
name: { type: string }
avatar_color: { type: string }
tasks:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/search:
get:
tags: [views]
@@ -2007,6 +2162,11 @@ components:
content:
application/json:
schema: { $ref: "#/components/schemas/Project" }
ProjectGroup:
description: OK
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectGroup" }
FeedURL:
description: OK
content:
@@ -2110,6 +2270,27 @@ components:
type: array
items: { type: string }
Session:
type: object
properties:
id: { type: string }
current:
type: boolean
description: The one making this request.
device:
type: string
description: |
A summary of the user agent — "Chrome på macOS". A handful of
substring tests rather than a parsing library: the question it
answers is "is one of these me?", and the raw string is sent beside
it so anything the summary gets wrong is still visible.
example: Chrome på macOS
user_agent: { type: string }
ip: { type: string }
created_at: { type: string, format: date-time }
last_seen_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
Task:
type: object
properties:
@@ -2202,6 +2383,12 @@ components:
role: { type: string, enum: [owner, editor, viewer] }
member_count: { type: integer }
shared: { type: boolean }
group_id:
type: string
description: |
The sidebar group *you* have filed it under, absent for none. Always
absent on a project shared with you: grouping is a column on the
project row, so it is the owner's filing rather than yours.
ProjectInput:
type: object
@@ -2212,6 +2399,27 @@ components:
view_mode: { type: string, enum: [list, board, calendar] }
archived: { type: boolean }
sort_order: { type: number }
group_id:
type: string
description: |
Files the project under one of your groups. The empty string takes it
out of one — `null` cannot mean that, because absent and null are the
same thing once decoded, and telling "put it nowhere" apart from "I
did not mention it" is what makes this a PATCH.
ProjectGroup:
type: object
properties:
id: { type: string }
name: { type: string }
collapsed: { type: boolean }
sort_order: { type: number }
ProjectGroupInput:
type: object
properties:
name: { type: string }
collapsed: { type: boolean }
Section:
type: object
+134
View File
@@ -761,3 +761,137 @@ func TestUnknownSessionTokenIsUnauthorized(t *testing.T) {
t.Errorf("status %d, want 401", resp.StatusCode)
}
}
// last_seen_at has been written on every request since sessions existed, for
// exactly this list, and nothing read it. A session you cannot see is a session
// you cannot end.
func TestSessionsCanBeListedAndEnded(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// A second sign-in from "another device": same account, its own cookie jar.
other := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, _ := other.do(t, "POST", "/api/v1/auth/login", map[string]string{
"email": "[email protected]", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("second login: status %d", resp.StatusCode)
}
resp, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list sessions: %d %v", resp.StatusCode, body)
}
sessions, _ := body["sessions"].([]any)
if len(sessions) != 2 {
t.Fatalf("want two sessions, got %v", sessions)
}
// Exactly one is this one, or the interface cannot tell somebody which row is
// the browser they are reading it in.
current := ""
for _, raw := range sessions {
s := raw.(map[string]any)
if s["current"] == true {
if current != "" {
t.Fatal("two sessions both claim to be the current one")
}
current = s["id"].(string)
}
if s["device"] == "" {
t.Errorf("no device summary: %v", s)
}
if s["last_seen_at"] == nil {
t.Errorf("no last_seen_at, which is the whole reason the column is written: %v", s)
}
}
if current == "" {
t.Fatal("none of the sessions is marked as the current one")
}
// End the other one.
var otherID string
for _, raw := range sessions {
if s := raw.(map[string]any); s["id"] != current {
otherID = s["id"].(string)
}
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/sessions/"+otherID, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete session: status %d", resp.StatusCode)
}
// That device is signed out, and this one is not.
if r, _ := other.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusUnauthorized {
t.Errorf("the ended session still works: status %d", r.StatusCode)
}
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("ending another session logged this one out: status %d", r.StatusCode)
}
}
// Somebody else's session is not yours to end, even with its id in hand.
func TestASessionCanOnlyBeEndedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, body := ts.do(t, "GET", "/api/v1/auth/sessions", nil)
mine := body["sessions"].([]any)[0].(map[string]any)["id"].(string)
if resp, _ := other.do(t, "DELETE", "/api/v1/auth/sessions/"+mine, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
// And it still works.
if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK {
t.Errorf("somebody else ended my session: status %d", r.StatusCode)
}
}
// An API token is accepted almost everywhere, and must not be accepted here: a
// leaked token that could end its owner's sessions, or even list their devices,
// turns a theft into a lockout.
func TestSessionsAreNotReachableWithAToken(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, user.ID)
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/sessions", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status %d, want 403", resp.StatusCode)
}
}
func TestUserAgentsAreSummarisedForAPerson(t *testing.T) {
cases := []struct {
ua string
want string
}{
{"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Chrome på macOS"},
// An iPhone's user agent says Mac OS X too, so the order of the tests is
// the whole correctness of this function.
{"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "Safari på iPhone"},
// And Edge claims to be Chrome, which claims to be Safari.
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0 Safari/537.36 Edg/131.0", "Edge på Windows"},
{"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0", "Firefox på Linux"},
{"", "Ukendt enhed"},
// Not a browser at all. Its own name beats a word that says the server
// gave up: a CalDAV client is a real session.
{"curl/8.4.0", "curl/8.4.0"},
}
for _, c := range cases {
if got := describeUserAgent(c.ua); got != c.want {
t.Errorf("describeUserAgent(%q) = %q, want %q", c.ua, got, c.want)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
// Groups are the foldable headings over the projects in the sidebar.
//
// They sit outside /projects rather than under it: a group is not a project and
// has no members, no tasks and no roles. Every route here is scoped to the caller
// in the query itself, so there is no permission middleware to forget — a group
// that is not yours is not found.
type projectGroupJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Collapsed bool `json:"collapsed"`
SortOrder float64 `json:"sort_order"`
}
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
return projectGroupJSON{ID: g.ID, Name: g.Name, Collapsed: g.Collapsed, SortOrder: g.SortOrder}
}
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list project groups", err)
return
}
out := make([]projectGroupJSON, 0, len(groups))
for _, g := range groups {
out = append(out, toProjectGroupJSON(g))
}
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
}
type projectGroupRequest struct {
Name *string `json:"name"`
Collapsed *bool `json:"collapsed"`
}
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
var req projectGroupRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
name := ""
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
}
if fields := validateGroupName(name); len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
if req.Collapsed != nil {
g.Collapsed = *req.Collapsed
}
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
s.internal(w, "create project group", err)
return
}
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
writeJSON(w, http.StatusCreated, toProjectGroupJSON(*g))
}
func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request) {
var req projectGroupRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
groupID := chi.URLParam(r, "groupID")
userID := userFrom(r.Context()).ID
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if fields := validateGroupName(name); len(fields) > 0 {
writeFieldErrors(w, fields)
return
}
req.Name = &name
}
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
Name: req.Name, Collapsed: req.Collapsed,
}); err != nil {
s.storeError(w, "update project group", err)
return
}
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
if err != nil {
s.storeError(w, "get project group", err)
return
}
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
}
// handleDeleteProjectGroup removes the heading. The projects under it come back
// out as ungrouped, which is why this is not behind a confirmation about losing
// work: there is none to lose.
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
groupID := chi.URLParam(r, "groupID")
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
s.storeError(w, "delete project group", err)
return
}
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
w.WriteHeader(http.StatusNoContent)
}
// handleReorderProjectGroups takes the whole list, like the projects do — see
// handleReorderProjects for why.
func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
s.internal(w, "reorder project groups", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateGroupName(name string) map[string]string {
switch {
case name == "":
return map[string]string{"name": "required"}
case len([]rune(name)) > 200:
return map[string]string{"name": "must be 200 characters or fewer"}
}
return nil
}
+221
View File
@@ -0,0 +1,221 @@
package httpapi
import (
"net/http"
"testing"
)
func TestAGroupHoldsProjectsAndSurvivesBeingEmptied(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create group: %d %v", resp.StatusCode, group)
}
groupID := group["id"].(string)
if group["collapsed"] != false {
t.Errorf("a new group should be open: %v", group)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
resp, filed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID,
map[string]any{"group_id": groupID})
if resp.StatusCode != http.StatusOK {
t.Fatalf("file project: %d %v", resp.StatusCode, filed)
}
if filed["group_id"] != groupID {
t.Errorf("group_id = %v, want %s", filed["group_id"], groupID)
}
// The listing is what the sidebar reads, so the filing has to survive it.
if got := groupOf(t, ts, projectID); got != groupID {
t.Errorf("group_id in the list = %q, want %s", got, groupID)
}
// An empty group is still a group. This is the whole reason it is a table and
// not a string repeated on every project row.
resp, _ = ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": ""})
if resp.StatusCode != http.StatusOK {
t.Fatalf("ungroup: status %d", resp.StatusCode)
}
if got := groupOf(t, ts, projectID); got != "" {
t.Errorf("group_id = %q after ungrouping, want empty", got)
}
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
if groups, _ := listed["groups"].([]any); len(groups) != 1 {
t.Fatalf("the emptied group is gone: %v", listed)
}
}
// An omitted group_id must not clear the field: that is the difference between a
// PATCH and a PUT, and renaming a project would otherwise tip it out of its group.
func TestRenamingAProjectLeavesItInItsGroup(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
_, renamed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"name": "Bogholderi"})
if renamed["group_id"] != groupID {
t.Errorf("group_id = %v after a rename, want %s", renamed["group_id"], groupID)
}
}
// Deleting the heading is not a request to delete the work filed under it.
func TestDeletingAGroupKeepsItsProjects(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
projectID := project["id"].(string)
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
resp, _ := ts.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete group: status %d", resp.StatusCode)
}
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
found := false
for _, raw := range projects["projects"].([]any) {
p := raw.(map[string]any)
if p["id"] != projectID {
continue
}
found = true
if p["group_id"] != nil && p["group_id"] != "" {
t.Errorf("the project still points at a group that is gone: %v", p["group_id"])
}
}
if !found {
t.Error("the project went with the group it was filed under")
}
}
// Folding is stored on the account rather than in the browser, so it has to come
// back from the server.
func TestFoldingAGroupIsRemembered(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
resp, folded := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
map[string]any{"collapsed": true})
if resp.StatusCode != http.StatusOK || folded["collapsed"] != true {
t.Fatalf("fold: %d %v", resp.StatusCode, folded)
}
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
first := listed["groups"].([]any)[0].(map[string]any)
if first["collapsed"] != true {
t.Errorf("collapsed = %v after re-reading, want true", first["collapsed"])
}
}
// Groups are per person: somebody else's is not yours to see, rename or delete,
// and a group that is not yours is not found rather than forbidden.
func TestGroupsArePerPerson(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
groupID := group["id"].(string)
other := ts.newUser(t, "[email protected]", "Anden")
_, listed := other.do(t, "GET", "/api/v1/project-groups", nil)
if groups, _ := listed["groups"].([]any); len(groups) != 0 {
t.Errorf("somebody else's groups are visible: %v", groups)
}
if resp, _ := other.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
map[string]any{"name": "Mit"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("renaming somebody else's group: status %d, want 404", resp.StatusCode)
}
if resp, _ := other.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil); resp.StatusCode != http.StatusNotFound {
t.Errorf("deleting somebody else's group: status %d, want 404", resp.StatusCode)
}
}
// A project cannot be filed into a group that is not the caller's, even though
// both the project and the group exist and both requests would be well-formed.
func TestAProjectCannotJoinSomebodyElsesGroup(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, group := other.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Deres"})
groupID := group["id"].(string)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
projectID := project["id"].(string)
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404 — a group you cannot see is not one you can file into",
resp.StatusCode)
}
}
func TestGroupsReorderAsAWholeList(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ids := make([]string, 3)
for i, name := range []string{"En", "To", "Tre"} {
_, g := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": name})
ids[i] = g["id"].(string)
}
reversed := []string{ids[2], ids[1], ids[0]}
resp, _ := ts.do(t, "POST", "/api/v1/project-groups/reorder", map[string]any{"ids": reversed})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
got := listed["groups"].([]any)
for i, want := range reversed {
if got[i].(map[string]any)["id"] != want {
t.Fatalf("order = %v, want %v", got, reversed)
}
}
}
// A name is required, the same as a project's. An unnamed heading is a heading
// nobody can tell apart from the next one.
func TestAGroupNeedsAName(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": " "})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, want 422 — body %v", resp.StatusCode, body)
}
}
// groupOf reads a project's group back out of the listing the sidebar uses.
func groupOf(t *testing.T, ts *testServer, projectID string) string {
t.Helper()
_, body := ts.do(t, "GET", "/api/v1/projects", nil)
for _, raw := range body["projects"].([]any) {
p := raw.(map[string]any)
if p["id"] == projectID {
id, _ := p["group_id"].(string)
return id
}
}
t.Fatalf("project %s is not in the listing", projectID)
return ""
}
+21 -1
View File
@@ -24,6 +24,10 @@ type projectJSON struct {
Role string `json:"role"`
MemberCount int `json:"member_count"`
Shared bool `json:"shared"`
// GroupID is the sidebar group the asking user has filed it under, empty for
// none. Always empty on a project shared with them: grouping is a column on
// the project row, so it is the owner's filing and nobody else's.
GroupID string `json:"group_id,omitempty"`
}
func toProjectJSON(p store.Project) projectJSON {
@@ -31,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
Shared: p.MemberCount > 1,
Shared: p.MemberCount > 1, GroupID: p.GroupID,
}
}
@@ -67,6 +71,11 @@ type projectRequest struct {
Icon *string `json:"icon"`
ViewMode *string `json:"view_mode"`
Archived *bool `json:"archived"`
// GroupID files the project under a sidebar group; "" takes it out of one.
// The empty string rather than a JSON null, because null and absent are the
// same nil pointer once decoded — and telling "put it nowhere" apart from
// "I did not mention it" is the whole point of a PATCH.
GroupID *string `json:"group_id"`
}
type reorderProjectsRequest struct {
@@ -162,6 +171,17 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
s.storeError(w, "update project", err)
return
}
// Grouping is written separately because it is scoped differently: the fields
// above belong to the project, and this one belongs to whoever is filing it.
// The route already requires ownership, so the check inside is a second lock
// on the same door rather than a different one.
if req.GroupID != nil {
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
userFrom(r.Context()).ID); err != nil {
s.storeError(w, "set project group", err)
return
}
}
s.activity(r, projectID, "", "project.updated", nil)
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
+22
View File
@@ -151,6 +151,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Post("/totp/disable", s.handleTOTPDisable)
r.Get("/recovery-codes", s.handleRecoveryCodesCount)
r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate)
// Behind requireSession, like the API tokens and for the same
// reason: a leaked token must not be able to end the sessions of
// the person it was stolen from, or list their devices.
r.Group(func(r chi.Router) {
r.Use(s.requireSession)
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
})
})
})
@@ -254,6 +263,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/{labelID}", s.handleDeleteLabel)
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) {
@@ -287,6 +297,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
})
// Groups are a person's own headings over their projects, so there is
// no project permission to check — every query here is scoped to the
// caller.
r.Route("/project-groups", func(r chi.Router) {
r.Get("/", s.handleListProjectGroups)
r.Post("/", s.handleCreateProjectGroup)
// Above /{groupID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjectGroups)
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
})
// The trash is outside the block above: a deleted project is invisible
// to the permission check by design, so these handlers resolve
// ownership themselves.
+147
View File
@@ -0,0 +1,147 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// Where a person is signed in, and how to end one of them.
//
// `last_seen_at` has been written on every request since sessions existed —
// throttled to once a minute, precisely so this list could say "this device, 2
// minutes ago" — and nothing has ever read it. A session you cannot see is a
// session you cannot end.
type sessionJSON struct {
ID string `json:"id"`
// Current marks the one making the request, so the interface can say "denne
// enhed" rather than making somebody work it out from a user agent.
Current bool `json:"current"`
// Device is a summary; UserAgent is what it was summarised from. Both, because
// the summary is what you read and the original is what settles the question
// when the summary is not enough.
Device string `json:"device"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at"`
ExpiresAt string `json:"expires_at"`
}
// handleListSessions lists the caller's live sessions, most recently used first.
//
// The id here is the stored hash of the cookie, never the cookie itself: it cannot
// be pasted into a browser, and it is what the delete below needs.
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
current := sessionFrom(r.Context())
sessions, err := s.db.ListSessions(r.Context(), user.ID)
if err != nil {
s.internal(w, "list sessions", err)
return
}
out := make([]sessionJSON, 0, len(sessions))
for _, sess := range sessions {
out = append(out, sessionJSON{
ID: sess.ID,
Current: current != nil && sess.ID == current.ID,
Device: describeUserAgent(sess.UA),
UserAgent: sess.UA,
IP: sess.IP,
CreatedAt: sess.CreatedAt.Format(time.RFC3339),
LastSeenAt: sess.LastSeenAt.Format(time.RFC3339),
ExpiresAt: sess.ExpiresAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
}
// handleDeleteSession signs one device out. Ending the current one 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.
func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
sessionID := chi.URLParam(r, "sessionID")
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
s.storeError(w, "delete session", err)
return
}
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
s.clearSessionCookie(w)
}
w.WriteHeader(http.StatusNoContent)
}
// describeUserAgent turns a user-agent string into something a person recognises.
//
// Deliberately a handful of substring tests rather than 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. The raw string is sent alongside, so
// anything this gets wrong is still visible.
//
// Order matters: every browser's user agent claims to be several other browsers,
// so the most specific name has to be tested first.
func describeUserAgent(ua string) string {
if strings.TrimSpace(ua) == "" {
return "Ukendt enhed"
}
browser := ""
switch {
case strings.Contains(ua, "Edg/"):
browser = "Edge"
case strings.Contains(ua, "OPR/"), strings.Contains(ua, "Opera"):
browser = "Opera"
case strings.Contains(ua, "Firefox/"):
browser = "Firefox"
case strings.Contains(ua, "Chrome/"):
browser = "Chrome"
case strings.Contains(ua, "Safari/"):
browser = "Safari"
}
system := ""
switch {
// Before "Mac OS X": an iPhone's user agent says both.
case strings.Contains(ua, "iPhone"):
system = "iPhone"
case strings.Contains(ua, "iPad"):
system = "iPad"
case strings.Contains(ua, "Android"):
system = "Android"
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
system = "macOS"
case strings.Contains(ua, "Windows"):
system = "Windows"
case strings.Contains(ua, "Linux"):
system = "Linux"
}
switch {
case browser != "" && system != "":
return browser + " på " + system
case browser != "":
return browser
case system != "":
return system
}
// Nothing recognised: show the string itself rather than "Ukendt". A CalDAV
// client or a script is a real session, and its own name is more useful than a
// word that says the server gave up.
return truncateRunes(ua, 60)
}
func truncateRunes(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max]) + "…"
}
+73
View File
@@ -92,6 +92,79 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": out})
}
type delegatedPerson struct {
UserID string `json:"user_id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
Tasks []taskJSON `json:"tasks"`
}
// handleDelegated is "waiting on others": everything assigned to somebody who is
// not the caller, in the projects the caller can see.
//
// Grouped by person rather than returned flat, because the question this view
// answers is about people — "what is Anders sitting on" — and a client grouping it
// itself would need every assignee's name, which it has no way to ask for. The
// names come back with the tasks, so one request is one screen.
//
// People are ordered by name, and their tasks by whatever the list query decided,
// which puts the overdue and the soonest first.
func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DelegatedBy: user.ID,
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, "delegated", err)
return
}
byPerson := map[string][]taskJSON{}
ids := []string{}
for _, t := range tasks {
if _, seen := byPerson[t.AssigneeID]; !seen {
ids = append(ids, t.AssigneeID)
}
byPerson[t.AssigneeID] = append(byPerson[t.AssigneeID], toTaskJSON(t))
}
people, err := s.db.PeopleByIDs(r.Context(), ids)
if err != nil {
s.internal(w, "delegated people", err)
return
}
out := make([]delegatedPerson, 0, len(ids))
for _, id := range ids {
// A person who no longer has an account still has their id on the task.
// Naming them "Ukendt" beats dropping the tasks, which would make work
// disappear from the one view that exists to stop that happening.
p, ok := people[id]
if !ok {
p = store.Person{ID: id, Name: "Ukendt", AvatarColor: "#8a8f98"}
}
out = append(out, delegatedPerson{
UserID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor, Tasks: byPerson[id],
})
}
sortByName(out)
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
// sortByName orders the groups the way a list of people reads. Insertion sort:
// this is a handful of entries, and it keeps the package free of a dependency on
// sort just for this.
func sortByName(people []delegatedPerson) {
for i := 1; i < len(people); i++ {
for j := i; j > 0 && lower(people[j].Name) < lower(people[j-1].Name); j-- {
people[j], people[j-1] = people[j-1], people[j]
}
}
}
// handleSearch is what Cmd+K calls. It searches across every project the user can
// see, which is the point — the thing you are looking for is usually somewhere you
// were not looking.
+101 -1
View File
@@ -1,6 +1,9 @@
package httpapi
import "testing"
import (
"net/http"
"testing"
)
// A label is how you find a task whose wording you have forgotten. Search
// covered the text only, so "regnskab" found nothing even with the task
@@ -53,3 +56,100 @@ func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
t.Errorf("found somebody else's task: %v", tasks)
}
}
// "Waiting on others" is the other half of an assignee filter, and the half that
// is easy to get backwards: what somebody has handed over, not what they have
// been handed.
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "[email protected]", "Anders")
anders, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
})
// Mine, and unassigned: neither belongs in a view about other people.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "ingen har den", "project_id": projectID,
})
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
}
people, _ := body["people"].([]any)
if len(people) != 1 {
t.Fatalf("want one person, got %v", people)
}
person := people[0].(map[string]any)
if person["name"] != "Anders" {
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
person["name"])
}
if person["avatar_color"] == nil || person["avatar_color"] == "" {
t.Errorf("no avatar colour: %v", person)
}
tasks, _ := person["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("want one task, got %v", tasks)
}
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
t.Errorf("content = %v", tasks[0])
}
}
// The view is per person: Anders is not waiting on himself, and he cannot see
// what somebody has delegated in a project he is not in.
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anders := ts.newUser(t, "[email protected]", "Anders")
andersUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
// A project Anders is not a member of.
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
})
// It is assigned to him, so it is not something he is waiting on.
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := body["people"].([]any); len(people) != 0 {
t.Errorf("Anders is waiting on himself: %v", people)
}
// And somebody with no access to the project sees none of it.
other := ts.newUser(t, "[email protected]", "Tredje")
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := third["people"].([]any); len(people) != 0 {
t.Errorf("a task in a project they cannot see: %v", people)
}
}
@@ -0,0 +1,33 @@
-- Foldable groups over the projects in the sidebar.
--
-- A table rather than a `group_name` column on the project: a group has to be
-- renamable in one write, has to survive being emptied, and has to remember
-- whether it is folded. A repeated string on every project row can do none of
-- those — renaming would touch N rows, and an empty group would simply cease to
-- exist the moment its last project left it.
CREATE TABLE project_groups (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
-- 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 stored per browser. A folded group is not: it is a
-- statement about that group — "this is the part of my work I am not in right
-- now" — and it is true on the laptop and the desktop alike. A group is also
-- owned by exactly one person, so there is nobody else's answer to disagree
-- with.
collapsed INTEGER NOT NULL DEFAULT 0,
sort_order REAL NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_project_groups_owner ON project_groups (owner_id);
-- ON DELETE SET NULL: deleting a group is a decision about the heading, not about
-- the projects underneath it. They fall back to ungrouped, in the order they
-- already had.
ALTER TABLE projects ADD COLUMN group_id TEXT REFERENCES project_groups (id) ON DELETE SET NULL;
CREATE INDEX idx_projects_group ON projects (group_id) WHERE deleted_at IS NULL;
+205
View File
@@ -0,0 +1,205 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// A ProjectGroup is a heading in the sidebar with projects filed under it.
//
// Groups belong to one person. They are not shared and never travel with a
// project: somebody who has a project shared with them files it where they like,
// which is possible precisely because the group is theirs and the membership is
// not. That is also why a shared project can never be in a group — see
// SetProjectGroup.
type ProjectGroup struct {
ID string
OwnerID string
Name string
Collapsed bool
SortOrder float64
CreatedAt time.Time
UpdatedAt time.Time
}
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE owner_id = ?
ORDER BY sort_order, created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
groups := []ProjectGroup{}
for rows.Next() {
var g ProjectGroup
var collapsed int
var created, updated int64
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder,
&created, &updated); err != nil {
return nil, err
}
g.Collapsed = collapsed == 1
g.CreatedAt = time.Unix(created, 0).UTC()
g.UpdatedAt = time.Unix(updated, 0).UTC()
groups = append(groups, g)
}
return groups, rows.Err()
}
// GetProjectGroup is scoped to the owner rather than looked up by id alone, so a
// handler cannot accidentally hand somebody else's group back by forgetting a
// check the query could have made itself.
func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*ProjectGroup, error) {
var g ProjectGroup
var collapsed int
var created, updated int64
err := db.QueryRowContext(ctx,
`SELECT id, owner_id, name, collapsed, sort_order, created_at, updated_at
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
Scan(&g.ID, &g.OwnerID, &g.Name, &collapsed, &g.SortOrder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
g.Collapsed = collapsed == 1
g.CreatedAt = time.Unix(created, 0).UTC()
g.UpdatedAt = time.Unix(updated, 0).UTC()
return &g, nil
}
func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
if g.ID == "" {
g.ID = NewID()
}
now := time.Now().UTC()
g.CreatedAt, g.UpdatedAt = now, now
if g.SortOrder == 0 {
var max sql.NullFloat64
if err := db.QueryRowContext(ctx,
`SELECT max(sort_order) FROM project_groups WHERE owner_id = ?`,
g.OwnerID).Scan(&max); err != nil {
return err
}
g.SortOrder = max.Float64 + 1024
}
_, err := db.ExecContext(ctx,
`INSERT INTO project_groups (id, owner_id, name, collapsed, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
g.ID, g.OwnerID, g.Name, boolToInt(g.Collapsed), g.SortOrder, now.Unix(), now.Unix())
return err
}
type ProjectGroupUpdate struct {
Name *string
Collapsed *bool
SortOrder *float64
}
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
set, args := buildUpdate(map[string]any{
"name": u.Name,
"collapsed": u.Collapsed,
"sort_order": u.SortOrder,
})
if len(set) == 0 {
return nil
}
set = append(set, "updated_at = ?")
args = append(args, time.Now().Unix(), groupID, userID)
res, err := db.ExecContext(ctx,
`UPDATE project_groups SET `+joinComma(set)+` WHERE id = ? AND owner_id = ?`, args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteProjectGroup removes the heading and leaves the projects.
//
// The projects are ungrouped explicitly rather than left to the foreign key's
// ON DELETE SET NULL: the pragma is set per connection, and a schema that quietly
// depends on it would keep the rows pointing at a group that no longer exists on
// any connection that forgot. Hard deleted rather than soft: a group holds no
// work of its own, so there is nothing in it to recover.
func (db *DB) DeleteProjectGroup(ctx context.Context, groupID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`DELETE FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
_, err = tx.ExecContext(ctx,
`UPDATE projects SET group_id = NULL, updated_at = ? WHERE group_id = ?`,
time.Now().Unix(), groupID)
return err
})
}
// ReorderProjectGroups writes the given order, and only for groups the caller
// owns — the same whole-list write the projects use, for the same reason: a
// handful of rows, one transaction, no precision to run out of.
func (db *DB) ReorderProjectGroups(ctx context.Context, userID string, ids []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
now := time.Now().Unix()
for i, id := range ids {
if _, err := tx.ExecContext(ctx,
`UPDATE project_groups SET sort_order = ?, updated_at = ?
WHERE id = ? AND owner_id = ?`,
float64(i), now, id, userID); err != nil {
return err
}
}
return nil
})
}
// SetProjectGroup files a project under a group, or takes it out of one when
// groupID is empty.
//
// Both ends are checked against the caller: the group must be theirs, and so must
// the project. Ownership rather than edit rights, and for the same reason
// sort_order is owner-scoped — the column lives on the project row, so a member
// filing a shared project into a group of their own would move it in the owner's
// sidebar as well. Until grouping is per viewer, a shared project stays where its
// owner has it.
func (db *DB) SetProjectGroup(ctx context.Context, projectID, groupID, userID string) error {
var group any
if groupID != "" {
var exists int
err := db.QueryRowContext(ctx,
`SELECT 1 FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
group = groupID
}
res, err := db.ExecContext(ctx,
`UPDATE projects SET group_id = ?, updated_at = ?
WHERE id = ? AND owner_id = ? AND is_inbox = 0 AND deleted_at IS NULL`,
group, time.Now().Unix(), projectID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+14 -6
View File
@@ -20,6 +20,11 @@ type Project struct {
CreatedAt time.Time
UpdatedAt time.Time
// GroupID is which sidebar group the *asking* user has filed it under, and is
// empty for a project shared with them: the column is on the project row, so
// the owner's filing is not an answer anybody else asked for.
GroupID string
// Role is the asking user's standing, filled in by the list and get calls so
// the frontend can grey out what they cannot do without a second request.
Role Role
@@ -44,7 +49,8 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at,
CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count,
CASE WHEN p.owner_id = ? THEN p.group_id END AS group_id
FROM projects p
LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ?
WHERE p.deleted_at IS NULL
@@ -54,7 +60,7 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b
}
query += ` ORDER BY p.is_inbox DESC, p.sort_order, p.created_at`
rows, err := db.QueryContext(ctx, query, userID, userID, userID)
rows, err := db.QueryContext(ctx, query, userID, userID, userID, userID)
if err != nil {
return nil, err
}
@@ -81,8 +87,9 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
SELECT p.id, p.name, p.color, p.icon, p.view_mode, p.owner_id, p.is_inbox,
p.archived, p.sort_order, p.created_at, p.updated_at,
? AS role,
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), projectID)
(SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1,
CASE WHEN p.owner_id = ? THEN p.group_id END
FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), userID, projectID)
if err != nil {
return nil, err
}
@@ -100,17 +107,18 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec
func scanProject(rows *sql.Rows) (Project, error) {
var p Project
var icon sql.NullString
var icon, groupID sql.NullString
var isInbox, archived int
var created, updated int64
var role string
err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox,
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount)
&archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &groupID)
if err != nil {
return p, err
}
p.Icon = icon.String
p.GroupID = groupID.String
p.IsInbox = isInbox == 1
p.Archived = archived == 1
p.CreatedAt = time.Unix(created, 0).UTC()
+17
View File
@@ -123,6 +123,23 @@ func (db *DB) DeleteSession(ctx context.Context, sessionID string) error {
return err
}
// DeleteUserSession ends one session, and only if it belongs to the caller.
//
// Scoped in the statement rather than checked first: an id read and then deleted
// in two steps is a window, and the whole point of this call is that somebody is
// using it because they think another person is in their account.
func (db *DB) DeleteUserSession(ctx context.Context, sessionID, userID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error {
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID)
return err
+12 -2
View File
@@ -48,8 +48,14 @@ type TaskFilter struct {
// renders them nested under their parents rather than as separate rows.
TopLevelOnly bool
AssigneeID string
LabelID string
Priority int
// DelegatedBy is the other side of AssigneeID: assigned, but not to this
// person. A second field rather than a magic value in AssigneeID, because the
// two are different questions — "what is on my plate" and "what have I handed
// over" — and one string that means an id most of the time and a sentinel the
// rest is the kind of field somebody eventually passes a real id to.
DelegatedBy string
LabelID string
Priority int
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
DueBefore string
@@ -116,6 +122,10 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
where = append(where, `t.assignee_id = ?`)
args = append(args, f.AssigneeID)
}
if f.DelegatedBy != "" {
where = append(where, `t.assignee_id IS NOT NULL AND t.assignee_id <> ?`)
args = append(args, f.DelegatedBy)
}
if f.Priority > 0 {
where = append(where, `t.priority = ?`)
args = append(args, f.Priority)
+41
View File
@@ -122,6 +122,47 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
return &u, nil
}
// Person is the public half of a user: what somebody else is allowed to see when
// their name has to appear next to a task. No address, no hash, no timezone.
type Person struct {
ID string
Name string
AvatarColor string
}
// PeopleByIDs looks up several users at once.
//
// One query rather than a loop of UserByID, because the caller is resolving the
// assignees of a list of tasks and a per-row lookup is a query per row. Unknown
// ids are simply absent from the result; a user who has been deleted is not an
// error for a view that is only trying to put a name on a row.
func (db *DB) PeopleByIDs(ctx context.Context, ids []string) (map[string]Person, error) {
people := map[string]Person{}
if len(ids) == 0 {
return people, nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := db.QueryContext(ctx,
`SELECT id, name, avatar_color FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p Person
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
return nil, err
}
people[p.ID] = p
}
return people, rows.Err()
}
// UserCount is what decides whether the instance still needs its first admin.
func (db *DB) UserCount(ctx context.Context) (int, error) {
var n int
BIN
View File
Binary file not shown.
+1 -5
View File
@@ -7,11 +7,7 @@ import { test as setup, expect } from '@playwright/test';
* or the app shell is broken, nothing below runs and the failure names this file.
*/
export const USER = {
name: 'Kristian',
email: '[email protected]',
password: 'et langt kodeord til test'
};
import { USER } from './user.js';
const AUTH_FILE = 'e2e/.auth/user.json';
+287
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { USER } from './user.js';
/**
* The four flows a broken build must not survive: add a task, tick it off, reach
@@ -227,6 +228,292 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* A group can be made, folded, filled and deleted without taking its projects
* with it.
*
* The fold is the part worth driving in a browser: it is stored on the account
* rather than in localStorage, so it is a round trip, and "the arrow turned" is
* not the same claim as "it came back folded after a reload".
*/
test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne med', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Regnskab');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
await sidebar.getByLabel('Gruppens navn').press('Enter');
const heading = sidebar.getByRole('button', { name: 'Arbejde' });
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
// Dragged in, then dragged back out onto "Projekter" — which is the reason
// that heading is a drop target at all: with every project filed away there
// would otherwise be no loose row left to aim at.
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
await expect(sidebar.getByText('Træk et projekt herop')).toBeHidden();
await sidebar
.getByRole('link', { name: 'Regnskab' })
.dragTo(sidebar.getByRole('heading', { name: 'Projekter' }));
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
// Folded, reloaded, still folded. localStorage would have passed the first
// half of that and failed the second on another machine.
await heading.click();
await expect(heading).toHaveAttribute('aria-expanded', 'false');
await page.reload();
await expect(sidebar.getByRole('button', { name: 'Arbejde' })).toHaveAttribute(
'aria-expanded',
'false'
);
await sidebar.getByRole('button', { name: 'Arbejde' }).click();
// Renaming writes one row rather than one per project, which is the reason a
// group is a table and not a string repeated on every project.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeVisible();
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* A task can be dragged onto another day, and onto another project.
*
* Both are one gesture standing in for a form, and both cross a component
* boundary: the row is dragged out of Kommende and dropped on the sidebar, which
* is a different part of the tree entirely. What decides whether a target will
* take it is the drag's MIME type, and that is only readable while the drag is in
* the air — so this is the one thing here that cannot be checked any other way.
*/
test('en opgave kan trækkes til en anden dag og videre til et projekt', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('hent pakken i dag');
await box.press('Enter');
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
await sidebar.getByLabel('Projektnavn').press('Enter');
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Liste', exact: true }).click();
const day = (name) =>
page.locator('section').filter({ has: page.getByRole('heading', { name, exact: true }) });
const row = page.locator('[draggable="true"]').filter({ hasText: 'hent pakken' });
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
await row.dragTo(day('I morgen'));
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
// And onto a project, which is a move rather than a field: the task has to
// find a place among that project's tasks, and leave its old section behind.
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
await sidebar.getByRole('link', { name: 'Ærinder' }).click();
await expect(page.getByRole('heading', { name: 'Ærinder' })).toBeVisible();
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
// And the same task in the month grid, where a chip is dragged from one cell
// to another. The date is read off the grid rather than worked out here: the
// browser is pinned to Europe/Copenhagen and this process is not, and for two
// hours of every day they disagree about what today is.
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Kalender' }).click();
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
await expect(chip).toBeVisible();
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
.toISOString()
.slice(0, 10);
await chip.dragTo(page.locator(`[data-date="${to}"]`));
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
await expect(page.locator(`[data-date="${from}"]`).getByText('hent pakken')).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* An invite link creates the account it was sent to.
*
* The server has emailed this URL since invites were built and nothing rendered
* for it: the SPA fallback served the shell, the shell found no session, and the
* recipient got a plain sign-in form for an account that did not exist yet. A 200
* all the way down — the same shape as `/mcp` and `/.well-known/`, and invisible
* to every test that does not open the link.
*
* A second browser context, because the invitee is a different person: inheriting
* the signed-in session would test the one case that never happens.
*/
test('et invitationslink opretter kontoen og giver adgang til projektet', async ({
browser,
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Fælleshuset');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
await page.getByRole('button', { name: 'Del' }).click();
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
await page.getByRole('button', { name: 'Inviter' }).click();
// With no mail server the link is shown rather than swallowed, which is also
// the only way this test can get hold of it.
const link = await page.locator('.link-out code').textContent();
expect(link).toContain('/invite?token=');
// Explicitly empty, because a context made through the `browser` fixture picks
// up the project's storageState — and an invitee who is already signed in as
// the person who sent the invite is the one case that never happens.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const invitee = await context.newPage();
const inviteeTrouble = watchForTrouble(invitee);
await invitee.goto(link);
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
await invitee.getByLabel('Navn').fill('Nabo');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
// Signed in, with the project they were invited to.
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(
invitee.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Fælleshuset/ })
).toBeVisible();
// One failure here is not trouble: the page loads before the account exists, so
// the shell's first `me()` is answered 401. That 401 is how the signed-out
// screen appears at all.
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
await context.close();
// Now that there is somebody to delegate to, the inviter can — and the view
// for it appears. It is hidden on an instance with one person, where it could
// never have anything in it.
await page.reload();
await expect(sidebar.getByRole('link', { name: 'Venter på andre' })).toBeVisible();
// A project the inviter owns stays under "Projekter" after sharing it. It used
// to move to "Delt med mig" the moment somebody accepted, and lose its place in
// the order on the way.
await expect(
page.locator('.group').filter({ hasText: 'Projekter' }).getByText('Fælleshuset')
).toBeVisible();
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('males inden fredag', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
await page.keyboard.press('Escape');
await sidebar.getByRole('link', { name: 'Venter på andre' }).click();
await expect(page.getByRole('heading', { name: 'Venter på andre' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nabo' })).toBeVisible();
await expect(page.getByText('males inden fredag')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The reset link renders its own form.
*
* The token is nonsense, so this stops at the form — which is the part that did
* not exist. "Glemt adgangskode?" has always sent a link to this address and the
* API has always accepted the token; what the link opened was the sign-in screen,
* asking for the password the person had just said they had forgotten.
*/
test('nulstillingslinket viser et felt til den nye adgangskode', async ({ browser }) => {
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await context.newPage();
await page.goto('/reset?token=ugyldigt');
await expect(page.getByText('Vælg en ny adgangskode')).toBeVisible();
await page.getByLabel(/adgangskode/i).fill('et langt kodeord til test');
await page.getByRole('button', { name: 'Gem adgangskoden' }).click();
// And it says so rather than pretending: the token is not a real one.
await expect(page.getByRole('alert')).toBeVisible();
await context.close();
});
/**
* The device list, and ending one of them.
*
* `last_seen_at` has been written on every request since sessions existed — once
* a minute, deliberately, so this list could say "lige nu" — and until now
* nothing read it. A session you cannot see is a session you cannot end, which
* matters most in the one situation it exists for: somebody else has your cookie.
*/
test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
// A second sign-in as the same person: a real second session, not a second tab
// sharing this one's cookie.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const elsewhere = await context.newPage();
await elsewhere.goto('/');
await elsewhere.getByLabel('E-mail').fill(USER.email);
await elsewhere.getByLabel('Adgangskode').fill(USER.password);
await elsewhere.getByRole('button', { name: 'Log ind' }).click();
await expect(elsewhere.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await page.goto('/indstillinger');
const panel = page.locator('section.panel').filter({ hasText: 'Enheder' });
await expect(panel.getByText('denne enhed')).toBeVisible();
await expect(panel.locator('li')).toHaveCount(2);
// End the one that is not this browser — the row without the badge.
await panel.locator('li').filter({ hasNot: page.getByText('denne enhed') })
.getByRole('button', { name: 'Log ud' })
.click();
await expect(panel.locator('li')).toHaveCount(1);
// That session is really gone, and this one is untouched.
await elsewhere.reload();
await expect(elsewhere.getByText('Log ind for at fortsætte')).toBeVisible();
await context.close();
await expect(panel.getByText('denne enhed')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The MCP connector address must not fall through to the app shell.
*
+13
View File
@@ -0,0 +1,13 @@
/**
* The account every test runs as.
*
* In its own file rather than exported from auth.setup.js, because Playwright
* refuses to let one test file import another — and both the setup that creates
* this account and the device-list test that signs in as it a second time need
* the same credentials.
*/
export const USER = {
name: 'Kristian',
email: '[email protected]',
password: 'et langt kodeord til test'
};
+13 -5
View File
@@ -22,7 +22,10 @@
--ink: #e8ebe9;
--ink-muted: #9aa39e;
--ink-faint: #6b736f;
/* The quiet tier, and the one that decides whether the interface is
readable: dates, counts and hints all live here. Lifted from #6b736f,
which sat at 3.8:1 against the ground — under AA, and it showed. */
--ink-faint: #828a84;
/* Nordlys-grøn. One accent, used for the checkmark, focus rings, and the
single primary action on a screen — and for nothing else, which is what
@@ -106,7 +109,10 @@
--ink: #e8ebe9;
--ink-muted: #9aa39e;
--ink-faint: #6b736f;
/* The quiet tier, and the one that decides whether the interface is
readable: dates, counts and hints all live here. Lifted from #6b736f,
which sat at 3.8:1 against the ground — under AA, and it showed. */
--ink-faint: #828a84;
--accent: #5fbf8f;
--accent-hover: #6fcf9f;
@@ -247,10 +253,12 @@
--ink: #1c201e;
--ink-muted: #5f6763;
--ink-faint: #8b918d;
/* Was #8b918d at 3.1:1 — the worst of the five, and the theme most likely
to be read in daylight. */
--ink-faint: #6e756f;
--accent: #2f8f63;
--accent-hover: #277a54;
--accent: #257552;
--accent-hover: #1f6a49;
--accent-sunken: #e2f0e9;
--accent-ink: #ffffff;
+10
View File
@@ -167,6 +167,9 @@ export const api = {
totpSetup: () => post('/auth/totp/setup'),
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
endSession: (id) => del(`/auth/sessions/${id}`),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
@@ -180,6 +183,12 @@ export const api = {
listTrashedProjects: () => get('/trash/projects'),
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
listProjectGroups: () => get('/project-groups'),
createProjectGroup: (name) => post('/project-groups', { name }),
updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data),
deleteProjectGroup: (id) => del(`/project-groups/${id}`),
reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }),
listSections: (projectId) => get(`/projects/${projectId}/sections`),
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
updateSection: (id, data) => patch(`/sections/${id}`, data),
@@ -286,5 +295,6 @@ export const api = {
// --- views
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
delegated: () => get('/delegated'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};
+15 -7
View File
@@ -10,6 +10,7 @@
*/
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
import TaskRow from './TaskRow.svelte';
let { project, sections, canEdit } = $props();
@@ -24,23 +25,30 @@
...sections.map((s) => ({ id: s.id, name: s.name }))
]);
// Scoped to this project, not just to the section: the store holds whatever the
// last view loaded, and a task that has left the project would otherwise turn
// up in the "no section" column, which is exactly where a task with no section
// belongs — in some other project.
const tasksIn = (sectionId) =>
app.tasks
.filter((t) => !t.completed && !t.parent_id && (t.section_id ?? '') === sectionId)
.filter(
(t) =>
!t.completed &&
!t.parent_id &&
t.project_id === project.id &&
(t.section_id ?? '') === sectionId
)
.sort((a, b) => a.sort_order - b.sort_order);
function onDragStart(event, task) {
if (!canEdit) return;
draggingId = task.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', task.id);
startDrag(event, TASK, task.id);
}
function onDragOver(event, sectionId) {
if (!canEdit || !draggingId) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (!canEdit || !carries(event, TASK) || !draggingId) return;
accept(event);
overColumn = sectionId;
}
+94 -5
View File
@@ -7,8 +7,32 @@
* misread the whole month at a glance.
*/
import { app } from '$lib/stores.svelte.js';
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
let { onselect } = $props();
let {
/**
* What a chip does when clicked. Opening the task is what it should do
* everywhere, so it is the default rather than something each caller has to
* remember — the project page did not, and its chips were dead for as long
* as the month view has existed.
*/
onselect = (task) => app.openDetail(task.id),
/**
* Told which dates the grid is showing, whenever that changes.
*
* A project's month view needs nothing: the page has already loaded every
* task in the project. Kommende is the other case — it cannot load every
* task anybody has ever dated — so it asks for the month on screen, and
* this is how it learns which one that is.
*/
onrange,
/**
* Narrows the grid to one project. The store holds whatever the last view
* loaded, so a project's month has to say which project it is — Kommende,
* which shows every project, is the one that leaves this out.
*/
projectId = null
} = $props();
let cursor = $state(startOfMonth(new Date()));
@@ -41,10 +65,23 @@
return days;
});
// Reported after the grid is worked out rather than from the cursor, because
// the grid runs whole weeks: a month view nearly always shows a few days of the
// month before it and a few of the one after, and a caller loading only the
// month itself would leave those corners empty.
$effect(() => {
onrange?.({ from: iso(grid[0]), to: iso(grid[grid.length - 1]) });
});
const todayISO = iso(new Date());
const tasksOn = (date) =>
app.tasks.filter((t) => !t.completed && t.due_date === iso(date));
app.tasks.filter(
(t) =>
!t.completed &&
t.due_date === iso(date) &&
(!projectId || t.project_id === projectId)
);
const monthName = $derived(
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
@@ -53,6 +90,25 @@
function step(months) {
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1);
}
// --- dropping a task on a day ---------------------------------------------------
/** The cell lit up under the pointer. */
let over = $state(null);
function onDragOver(event, date) {
if (!carries(event, TASK)) return;
accept(event);
over = iso(date);
}
async function onDrop(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
await app.reschedule(id, iso(date));
}
</script>
<div class="calendar">
@@ -73,11 +129,32 @@
{#each grid as date (date.toISOString())}
{@const tasks = tasksOn(date)}
{@const outside = date.getMonth() !== cursor.getMonth()}
<div class="day" class:outside class:today={iso(date) === todayISO}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day"
data-date={iso(date)}
class:outside
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
>
<span class="number">{date.getDate()}</span>
{#each tasks.slice(0, 3) as task (task.id)}
<button class="chip" data-priority={task.priority} onclick={() => onselect?.(task)}>
<!-- Draggable as well as clickable: within a month, moving something
to another day is the whole reason to be looking at a month. The
browser needs a few pixels of movement before it calls a press a
drag, so the click survives. -->
<button
class="chip"
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
ondragend={() => (over = null)}
onclick={() => onselect?.(task)}
>
{task.content}
</button>
{/each}
@@ -120,7 +197,12 @@
color: var(--ink);
}
.today {
/* Scoped to the header, because `.today` is not only this button: the day cell
for today carries the same class, and an unscoped `margin-left: auto` made a
grid item shrink to its content and sit against the right of its column. The
month always had one cell narrower than the other six, on every project
calendar, and it read as a grid that had not quite laid out yet. */
header .today {
width: auto;
margin-left: auto;
padding: 0 var(--s3);
@@ -178,6 +260,13 @@
color: var(--ink-muted);
}
/* The whole cell, because the whole cell is the target: a day is a box, not a
gap between two boxes. */
.day.over {
background: var(--surface-raised);
box-shadow: inset 0 0 0 1px var(--accent);
}
.day.today .number {
color: var(--accent-ink);
background: var(--accent);
+508 -54
View File
@@ -1,6 +1,7 @@
<script>
import { app, sidebar } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -36,16 +37,78 @@
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
});
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
// Split by who owns it, not by whether anybody else is in it.
//
// The two headings say "Projekter" and "Delt med mig", and the split used to be
// on `shared`, which only means the project has more than one member. So the
// moment you invited somebody to a project of your own it moved under "Delt med
// mig" — where it lost its place in the order, stopped being draggable, and
// could not be filed under a group. Sharing your own project is not the same
// event as somebody sharing theirs with you.
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.role !== 'owner'));
// Sorted here rather than relying on the order the server sent, so a drag
// settles the moment it is dropped instead of after the round trip.
let own = $derived(
app.projects
.filter((p) => !p.is_inbox && !p.shared)
.filter((p) => !p.is_inbox && p.role === 'owner')
.sort((a, b) => a.sort_order - b.sort_order)
);
let current = $derived($page.url.pathname);
// --- groups -------------------------------------------------------------------
let groups = $derived([...app.groups].sort((a, b) => a.sort_order - b.sort_order));
let groupIds = $derived(new Set(groups.map((g) => g.id)));
/**
* Which heading a project renders under — '' for the loose ones at the top.
*
* It is not simply `group_id`, and the difference is not paranoia: a group
* deleted in another tab arrives as one event, without a second one per
* project, so for a moment the projects here still name a heading that no
* longer renders. Filing by the raw id would drop them out of the sidebar
* entirely — under a group that is not drawn, which is nowhere.
*/
const bucketOf = (p) => (p.group_id && groupIds.has(p.group_id) ? p.group_id : '');
let ungrouped = $derived(own.filter((p) => bucketOf(p) === ''));
const inGroup = (id) => own.filter((p) => p.group_id === id);
let addingGroup = $state(false);
let newGroupName = $state('');
let renamingGroup = $state(null);
let groupName = $state('');
async function createGroup(event) {
event.preventDefault();
const name = newGroupName.trim();
if (!name) return;
newGroupName = '';
addingGroup = false;
await app.createGroup(name);
}
async function renameGroup(event, group) {
event.preventDefault();
const name = groupName.trim();
renamingGroup = null;
if (!name || name === group.name) return;
await app.renameGroup(group.id, name);
}
async function removeGroup(group) {
const count = inGroup(group.id).length;
// Says what happens to the projects, because "slet gruppen" reads like it
// might take them with it. It does not: only the heading goes.
const kept =
count === 1
? 'Projektet i den bliver, men ligger bagefter uden gruppe.'
: `De ${count} projekter bliver, men ligger bagefter uden gruppe.`;
if (!confirm(count ? `Slet gruppen "${group.name}"? ${kept}` : `Slet gruppen "${group.name}"?`))
return;
await app.deleteGroup(group.id);
}
// --- resizing -------------------------------------------------------------------
let resizing = $state(false);
@@ -77,40 +140,157 @@
let draggingId = $state(null);
let overId = $state(null);
let overBelow = $state(false);
/** The group whose heading is lit up as a drop target. */
let overGroup = $state(null);
let draggingGroup = $state(null);
function clearDrag() {
draggingId = null;
draggingGroup = null;
overId = null;
overGroup = null;
overProject = null;
}
function onDragStart(event, project) {
draggingId = project.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', project.id);
startDrag(event, PROJECT, project.id);
}
function onDragOver(event, project) {
if (!draggingId || draggingId === project.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (!carries(event, PROJECT) || draggingId === project.id) return;
accept(event);
const box = event.currentTarget.getBoundingClientRect();
overId = project.id;
overGroup = null;
overBelow = event.clientY > box.top + box.height / 2;
}
/**
* Dropping onto a project puts the dragged one in that gap — and into the same
* group, which is what makes dragging between two headings work without a
* separate gesture. The order is one write and the group is another; each is
* meaningful on its own, and neither can leave the other half-applied.
*/
async function onDrop(event, target) {
event.preventDefault();
const id = draggingId;
const id = dragged(event, PROJECT) || draggingId;
const below = overBelow;
draggingId = null;
overId = null;
clearDrag();
if (!id || id === target.id) return;
const moved = own.find((p) => p.id === id);
if (!moved) return;
const without = own.filter((p) => p.id !== id);
const at = without.findIndex((p) => p.id === target.id);
if (at < 0) return;
const ordered = [...without];
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
ordered.splice(below ? at + 1 : at, 0, moved);
await app.setProjectGroup(id, bucketOf(target));
await app.reorderProjects(ordered.map((p) => p.id));
}
/**
* Dropping onto a heading files the project under it and puts it last.
*
* Last rather than first: a heading is a wide target and dropping on it is the
* coarse gesture — "somewhere in here" — where dropping on a row is the precise
* one. Landing at the top would push whatever is already there down, which is a
* rearrangement nobody asked for.
*/
async function onDropInGroup(event, groupID) {
event.preventDefault();
const id = dragged(event, PROJECT) || draggingId;
clearDrag();
if (!id) return;
const moved = own.find((p) => p.id === id);
if (!moved) return;
await app.setProjectGroup(id, groupID);
// Everything else keeps the order it already had; the dragged project is
// reinserted after the last one already under that heading.
const others = own.filter((p) => p.id !== id);
const ordered = [...others];
ordered.splice(others.findLastIndex((p) => bucketOf(p) === groupID) + 1, 0, moved);
await app.reorderProjects(ordered.map((p) => p.id));
}
// --- a task dropped on a project ------------------------------------------------
/** The project row lit up because a task is hovering over it. */
let overProject = $state(null);
/**
* A viewer can open a project but not write to it, so it is not a place a task
* can be dropped. Refusing here rather than letting the server refuse is the
* difference between a row that does not light up and a task that visibly moves
* and then jumps back.
*/
const canReceive = (project) => project.role === 'owner' || project.role === 'editor';
function onTaskDragOver(event, project) {
if (!carries(event, TASK) || !canReceive(project)) return;
accept(event);
overProject = project.id;
}
async function onTaskDrop(event, project) {
event.preventDefault();
const id = dragged(event, TASK);
overProject = null;
if (!id || !canReceive(project)) return;
await app.moveToProject(id, project.id);
}
function onGroupDragStart(event, group) {
draggingGroup = group.id;
startDrag(event, GROUP, group.id);
}
/**
* A heading takes two kinds of drop, so it has to say which one it is showing.
* A project joins the group; another heading reorders against it.
*/
function onGroupDragOver(event, group) {
if (carries(event, GROUP)) {
if (draggingGroup === group.id) return;
accept(event);
const box = event.currentTarget.getBoundingClientRect();
overId = group.id;
overGroup = null;
overBelow = event.clientY > box.top + box.height / 2;
return;
}
if (!carries(event, PROJECT)) return;
accept(event);
overGroup = group.id;
overId = null;
}
async function onGroupDrop(event, group) {
if (carries(event, GROUP)) {
event.preventDefault();
const id = dragged(event, GROUP) || draggingGroup;
const below = overBelow;
clearDrag();
if (!id || id === group.id) return;
const without = groups.filter((g) => g.id !== id);
const at = without.findIndex((g) => g.id === group.id);
if (at < 0) return;
const ordered = [...without];
ordered.splice(below ? at + 1 : at, 0, groups.find((g) => g.id === id));
await app.reorderGroups(ordered.map((g) => g.id));
return;
}
await onDropInGroup(event, group.id);
}
</script>
<nav class="sidebar" aria-label="Hovedmenu">
@@ -131,22 +311,95 @@
<span class="dot" aria-hidden="true"></span>
Kommende
</a>
{#if app.inbox}
<a
href="/projekt/{app.inbox.id}"
class:active={current === `/projekt/${app.inbox.id}`}
onclick={onnavigate}
>
{#if app.projects.some((p) => p.shared)}
<!-- Only where delegating is possible at all. On an instance with one
person this view can never have anything in it, and a permanent empty
entry is a question the sidebar keeps asking and answering "no". -->
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{app.inbox.name}
Venter på andre
</a>
{/if}
{#if app.inbox}
{@render projectRow(app.inbox, false)}
{/if}
</div>
<!-- One row for every project the sidebar shows — your own, the Inbox and the
shared ones — because all three are somewhere a task can be dropped, and
three copies of that would be three chances to fix a bug in two of them.
Only your own reorder: `sort_order` is a column on the project, so a shared
one stays where its owner put it. -->
{#snippet projectRow(project, sortable)}
<a
href="/projekt/{project.id}"
class:sortable
class:active={current === `/projekt/${project.id}`}
class:dragging={draggingId === project.id}
class:drop-above={sortable && overId === project.id && !overBelow}
class:drop-below={sortable && overId === project.id && overBelow}
class:receiving={overProject === project.id}
onclick={onnavigate}
draggable={sortable}
ondragstart={(e) => sortable && onDragStart(e, project)}
ondragend={clearDrag}
ondragover={(e) => {
if (carries(e, TASK)) onTaskDragOver(e, project);
else if (sortable) onDragOver(e, project);
}}
ondragleave={() => {
overId = null;
overProject = null;
}}
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
{#if project.shared}
<span class="count">{project.member_count}</span>
{/if}
</a>
{/snippet}
<div class="group">
<div class="group-head">
<!-- The heading is also the drop target for "no group": without it, a project
dragged into a group could only be got out again by emptying the group,
and with every project filed there would be no loose row left to aim at. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head"
class:over={overGroup === ''}
ondragover={(e) => {
if (!carries(e, PROJECT)) return;
accept(e);
overGroup = '';
overId = null;
}}
ondragleave={() => (overGroup = null)}
ondrop={(e) => onDropInGroup(e, '')}
>
<h2>Projekter</h2>
<button onclick={() => (adding = !adding)} aria-label="Nyt projekt">+</button>
<button
onclick={() => {
adding = !adding;
addingGroup = false;
}}
aria-label="Nyt projekt">+</button
>
<button
class="new-group"
onclick={() => {
addingGroup = !addingGroup;
adding = false;
newGroupName = '';
}}
aria-label="Ny gruppe"
title="Ny gruppe"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
</button>
</div>
{#if adding}
@@ -163,28 +416,22 @@
</form>
{/if}
{#each own as project (project.id)}
<a
href="/projekt/{project.id}"
class="sortable"
class:active={current === `/projekt/${project.id}`}
class:dragging={draggingId === project.id}
class:drop-above={overId === project.id && !overBelow}
class:drop-below={overId === project.id && overBelow}
onclick={onnavigate}
draggable="true"
ondragstart={(e) => onDragStart(e, project)}
ondragend={() => {
draggingId = null;
overId = null;
}}
ondragover={(e) => onDragOver(e, project)}
ondragleave={() => (overId = null)}
ondrop={(e) => onDrop(e, project)}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
</a>
{#if addingGroup}
<form onsubmit={createGroup}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={newGroupName}
autofocus
placeholder="Gruppens navn"
aria-label="Gruppens navn"
onblur={() => !newGroupName.trim() && (addingGroup = false)}
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
/>
</form>
{/if}
{#each ungrouped as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if own.length === 0 && !adding}
@@ -192,19 +439,87 @@
{/if}
</div>
{#each groups as group (group.id)}
{@const inside = inGroup(group.id)}
<div class="group folder">
<!-- Draggable, except while it is being renamed: a draggable ancestor stops
the pointer from selecting text in the field it contains. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-head folder-head"
class:over={overGroup === group.id}
class:drop-above={overId === group.id && !overBelow}
class:drop-below={overId === group.id && overBelow}
class:dragging={draggingGroup === group.id}
draggable={renamingGroup !== group.id}
ondragstart={(e) => onGroupDragStart(e, group)}
ondragend={clearDrag}
ondragover={(e) => onGroupDragOver(e, group)}
ondragleave={() => {
overGroup = null;
overId = null;
}}
ondrop={(e) => onGroupDrop(e, group)}
>
{#if renamingGroup === group.id}
<form onsubmit={(e) => renameGroup(e, group)}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={groupName}
autofocus
aria-label="Gruppens navn"
onblur={() => (renamingGroup = null)}
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
/>
</form>
{:else}
<!-- The button sits inside the heading, not around it, the same way a
project's title does: a group *is* a heading in the sidebar, and
wrapping an h2 in a button both invalidates the markup and takes
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<h2 class="fold">
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
{group.name}
</button>
</h2>
<span class="count">{inside.length}</span>
<button
class="group-action"
onclick={() => {
renamingGroup = group.id;
groupName = group.name;
}}>Omdøb</button
>
<button class="group-action remove" onclick={() => removeGroup(group)}>Slet</button>
{/if}
</div>
{#if !group.collapsed}
{#each inside as project (project.id)}
{@render projectRow(project, true)}
{/each}
{#if !inside.length}
<p class="empty">Tom — træk et projekt herop.</p>
{/if}
{/if}
</div>
{/each}
{#if shared.length}
<div class="group">
<div class="group-head"><h2>Delt med mig</h2></div>
{#each shared as project (project.id)}
<a
href="/projekt/{project.id}"
class:active={current === `/projekt/${project.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
<span class="count">{project.member_count}</span>
</a>
{@render projectRow(project, false)}
{/each}
</div>
{/if}
@@ -390,7 +705,7 @@
color: var(--ink-faint);
}
.group-head button {
.group-head > button {
width: 20px;
height: 20px;
display: grid;
@@ -399,13 +714,143 @@
color: var(--ink-faint);
font-size: var(--text-lg);
line-height: 1;
flex: none;
}
.group-head button:hover {
.group-head > button:hover {
color: var(--ink);
background: var(--surface-raised);
}
.new-group svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* --- groups ------------------------------------------------------------- */
.folder {
margin-top: calc(var(--s3) * -1);
}
.folder-head {
position: relative;
gap: var(--s1);
border-radius: var(--radius);
}
.folder-head.dragging {
opacity: 0.4;
}
/* The heading lights up as a whole, because the whole heading is the target —
unlike a project row, where the target is the gap next to it. Both headings
do it: "Projekter" is where a project goes to leave its group. */
.group-head.over {
background: var(--surface-raised);
box-shadow: inset 0 0 0 1px var(--accent);
border-radius: var(--radius);
}
.folder-head::before {
content: '';
position: absolute;
left: var(--s2);
right: var(--s2);
height: 2px;
background: var(--accent);
opacity: 0;
pointer-events: none;
}
.folder-head.drop-above::before {
top: -1px;
opacity: 1;
}
.folder-head.drop-below::before {
bottom: -1px;
opacity: 1;
}
.fold {
flex: 1;
min-width: 0;
}
.fold button {
display: flex;
align-items: center;
gap: var(--s1);
width: 100%;
min-width: 0;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
color: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fold button:hover {
color: var(--ink);
}
.chevron {
width: 11px;
height: 11px;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--fast) var(--ease);
}
.chevron.collapsed {
transform: rotate(-90deg);
}
/* Hidden until the heading is hovered, like the section actions on a project
page: two buttons permanently beside a label stop it reading as a label. */
.group-action {
font-size: var(--text-xs);
color: var(--ink-faint);
padding: 0 var(--s1);
flex: none;
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.folder-head:hover .group-action,
.group-action:focus-visible {
opacity: 1;
}
.group-action:hover {
color: var(--ink);
}
.group-action.remove:hover {
color: var(--danger);
}
.folder-head .count {
margin-left: 0;
}
.folder-head form {
flex: 1;
min-width: 0;
}
a {
display: flex;
align-items: center;
@@ -464,6 +909,15 @@
opacity: 1;
}
/* A task hovering over a project lights the whole row, where a project hovering
between two rows draws a line in the gap. Two different questions — "into
this one" and "between these two" — so two different marks. */
a.receiving {
background: var(--surface-raised);
box-shadow: inset 0 0 0 1px var(--accent);
color: var(--ink);
}
.dot {
width: 6px;
height: 6px;
+107 -2
View File
@@ -1,17 +1,26 @@
<script>
/**
* Sign-in, first-run setup and the second factor, in one component.
* Sign-in, first-run setup, the second factor, the invite that creates an
* account, and choosing a new password — in one component.
*
* They are one screen because they are one decision tree: an instance with no
* accounts asks you to create one, an instance with accounts asks you to log
* in, and a login with 2FA asks for a code. Splitting them across routes would
* mean redirecting somebody mid-authentication, which is where sessions get
* lost.
*
* The two that arrive by email — /invite and /reset — do have routes, because a
* link has to be an address. Those routes render this component and it reads
* the token out of the URL, so the form is the same form and there is one place
* where signing in is written down.
*/
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let mode = $state('loading'); // loading | login | setup | totp | forgot | sent
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
let email = $state('');
let password = $state('');
let name = $state('');
@@ -20,7 +29,23 @@
let fields = $state({});
let busy = $state(false);
/**
* The token from an emailed link, if this is one.
*
* Read from the URL rather than passed in as a prop: the layout renders this
* component in several places and none of them should have to know about
* tokens.
*/
let token = $derived($page.url.searchParams.get('token') ?? '');
let linkMode = $derived(
$page.url.pathname === '/invite' ? 'invite' : $page.url.pathname === '/reset' ? 'reset' : ''
);
$effect(() => {
if (linkMode) {
mode = linkMode;
return;
}
api
.setupState()
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
@@ -78,6 +103,31 @@
await api.forgotPassword(email);
mode = 'sent';
break;
case 'invite':
// The email address is not asked for and not sent: it comes off the
// invite on the server, which is what stops a link being redirected
// to somebody the inviter did not invite.
await api.signup({
token,
name,
password,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: navigator.language?.startsWith('en') ? 'en' : 'da'
});
// Signup starts the session itself, so there is nothing to log into.
await app.load();
goto('/');
break;
case 'reset':
await api.resetPassword(token, password);
// Not signed in afterwards, on purpose: the reset ends every session
// the account had, and quietly starting a new one here would undo
// the half of that which matters.
password = '';
mode = 'done';
break;
}
});
};
@@ -142,6 +192,60 @@
<button type="submit" disabled={busy}>Send link</button>
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
{:else if mode === 'invite' || mode === 'reset'}
{#if !token}
<!-- A link that lost its token on the way. Saying so beats a form that
cannot succeed and a message that arrives only after it is filled in. -->
<p class="lede">
Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.
</p>
<a class="link" href="/">Til log ind</a>
{:else if app.user}
<!-- Already somebody else. The invite is tied to an address, so using it
from this session would create a second account for a person who is
right here in the first one. -->
<p class="lede">
Du er logget ind som {app.user.name}. Log ud først, og åbn så linket igen.
</p>
<button
type="button"
class="link"
onclick={async () => {
await api.logout();
location.reload();
}}>Log ud</button
>
{:else if mode === 'invite'}
<p class="lede">Du er inviteret. Vælg et navn og en adgangskode.</p>
<label>
Navn
<input bind:value={name} autocomplete="name" required />
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
</label>
<label>
Adgangskode
<input bind:value={password} type="password" autocomplete="new-password" required />
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Opret konto</button>
{:else}
<p class="lede">Vælg en ny adgangskode.</p>
<label>
Ny adgangskode
<input bind:value={password} type="password" autocomplete="new-password" required />
<span class="hint">Mindst 10 tegn. Alle andre enheder bliver logget ud.</span>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Gem adgangskoden</button>
{/if}
{:else if mode === 'done'}
<p class="lede">Adgangskoden er skiftet. Log ind med den nye.</p>
<a class="link" href="/">Til log ind</a>
{:else if mode === 'sent'}
<!-- Deliberately not "we sent an email": that would confirm the address
has an account here to anybody who tried it. -->
@@ -280,6 +384,7 @@
color: var(--ink-faint);
font-size: var(--text-sm);
text-align: center;
text-decoration: none;
}
.link:hover {
+7 -6
View File
@@ -13,6 +13,7 @@
*/
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
import TaskRow from './TaskRow.svelte';
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
@@ -25,15 +26,15 @@
function onDragStart(event, task) {
if (!canEdit) return;
draggingId = task.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', task.id);
// Typed, because the same task can now be dropped somewhere else entirely —
// a project in the sidebar, a day in Kommende — and those targets have to be
// able to tell what is coming while the drag is still in the air.
startDrag(event, TASK, task.id);
}
function onDragOver(event, task) {
if (!canEdit || !draggingId || draggingId === task.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (!canEdit || !carries(event, TASK) || !draggingId || draggingId === task.id) return;
accept(event);
// Which half of the row the pointer is in decides the gap. Comparing against
// the row's own midpoint rather than counting rows is what keeps the line
+43
View File
@@ -0,0 +1,43 @@
/**
* What a drag is carrying.
*
* More than one kind of thing is now dragged onto the same targets: the sidebar
* takes a project (to reorder it, or file it under a group) *and* a task (to move
* it into that project), and Kommende takes a task onto a day.
*
* The distinction has to be a MIME type rather than something read out of the
* payload, because `getData` is deliberately unreadable while a drag is in the
* air — the browser only hands the data over on drop, so that a page cannot read
* what is being dragged across it. During `dragover`, `dataTransfer.types` is all
* a drop target has to decide with, and deciding is exactly what it has to do:
* `preventDefault` on a drag it cannot accept is what turns "no" into a silent,
* wrong drop.
*/
export const TASK = 'application/x-verdande-task';
export const PROJECT = 'application/x-verdande-project';
export const GROUP = 'application/x-verdande-group';
/** Starts a drag carrying one id of the given kind. */
export function startDrag(event, type, id) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(type, id);
// Firefox refuses to start a drag unless text/plain is set as well.
event.dataTransfer.setData('text/plain', id);
}
/** Whether the drag in flight is of this kind. Safe to call during dragover. */
export function carries(event, type) {
return event.dataTransfer?.types?.includes(type) ?? false;
}
/** The id being dragged. Only meaningful in a drop handler. */
export function dragged(event, type) {
return event.dataTransfer?.getData(type) ?? '';
}
/** Marks a drop target as willing, which is what allows the drop to happen at all. */
export function accept(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
+182 -1
View File
@@ -16,6 +16,8 @@ import { api, ApiError, humanMessage } from './api.js';
class AppState {
user = $state(null);
projects = $state([]);
/** The foldable headings over the projects in the sidebar. */
groups = $state([]);
tasks = $state([]);
loading = $state(true);
/** Transient messages: a failed save, a rolled-back change. */
@@ -66,8 +68,12 @@ class AppState {
this.loading = true;
try {
this.user = await api.me();
const { projects } = await api.listProjects();
const [{ projects }, { groups }] = await Promise.all([
api.listProjects(),
api.listProjectGroups()
]);
this.projects = projects;
this.groups = groups;
this.connect();
} catch (e) {
this.user = null;
@@ -133,6 +139,17 @@ class AppState {
this.projects = this.projects.filter((p) => p.id !== task?.id);
break;
case 'project_group.created':
case 'project_group.updated':
this.upsertGroup(task);
break;
case 'project_group.deleted':
// The projects filed under it are not deleted with it; they come back
// out as ungrouped, which the sidebar works out from the group being
// gone rather than from a second event per project.
this.groups = this.groups.filter((g) => g.id !== task?.id);
break;
// The payload is not always the label — a rename says only that
// something changed — and the sidebar wants counts anyway, which no
// single event carries. Asking is cheaper than modelling it.
@@ -252,6 +269,35 @@ class AppState {
}
}
/**
* Moves a task into another project, which is what dropping it on the sidebar
* means.
*
* Through `move` rather than a `project_id` on an update: `sort_order` belongs
* to the project, so a task arriving in a new one needs a place among its
* tasks. The section goes too — sections belong to the project it is leaving,
* and carrying the id across would file it under a heading that is not there.
*/
async moveToProject(id, projectId) {
const previous = this.get(id);
if (!previous || previous.project_id === projectId) return;
this.replace(id, { ...previous, project_id: projectId, section_id: '' });
try {
this.replace(id, await api.moveTask(id, { project_id: projectId, section_id: '' }));
} catch (e) {
this.replace(id, previous);
this.toast(humanMessage(e));
}
}
/** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */
async reschedule(id, date) {
const previous = this.get(id);
if (!previous || previous.due_date === date) return;
await this.update(id, { due_date: date });
}
async remove(id) {
const previous = [...this.tasks];
this.tasks = this.tasks.filter((t) => t.id !== id);
@@ -324,6 +370,112 @@ class AppState {
}
}
// --- project groups -----------------------------------------------------------
/** The same new-array upsert the projects need, and for the same reason. */
upsertGroup(group) {
if (!group?.id) return;
this.groups = this.groups.some((g) => g.id === group.id)
? this.groups.map((g) => (g.id === group.id ? group : g))
: [...this.groups, group];
}
async createGroup(name) {
try {
const group = await api.createProjectGroup(name);
this.upsertGroup(group);
return group;
} catch (e) {
this.toast(humanMessage(e));
return null;
}
}
/**
* Folds or unfolds a group.
*
* Applied first and saved after, like everything else here — a fold that waits
* for a round trip feels like a click that did not land. The rollback matters
* more than usual: this is stored on the account rather than in the browser, so
* a failed save that left the arrow turned would be a lie that survives a
* reload.
*/
async toggleGroup(id) {
const group = this.groups.find((g) => g.id === id);
if (!group) return;
await this.#patchGroup(id, { collapsed: !group.collapsed });
}
async renameGroup(id, name) {
await this.#patchGroup(id, { name });
}
async #patchGroup(id, patch) {
const previous = this.groups.find((g) => g.id === id);
if (!previous) return;
this.upsertGroup({ ...previous, ...patch });
try {
this.upsertGroup(await api.updateProjectGroup(id, patch));
} catch (e) {
this.upsertGroup(previous);
this.toast(humanMessage(e));
}
}
/**
* Deletes the heading. The projects come back out as ungrouped, which is done
* here too rather than waiting for a re-read: the sidebar reads `group_id` to
* decide where a project sits, and a project pointing at a group that is gone
* would otherwise vanish from the list entirely until the next load.
*/
async deleteGroup(id) {
const previousGroups = this.groups;
const previousProjects = this.projects;
this.groups = this.groups.filter((g) => g.id !== id);
this.projects = this.projects.map((p) => (p.group_id === id ? { ...p, group_id: '' } : p));
try {
await api.deleteProjectGroup(id);
} catch (e) {
this.groups = previousGroups;
this.projects = previousProjects;
this.toast(humanMessage(e));
}
}
async reorderGroups(ids) {
const previous = this.groups;
const rank = new Map(ids.map((id, i) => [id, i]));
this.groups = this.groups.map((g) =>
rank.has(g.id) ? { ...g, sort_order: rank.get(g.id) } : g
);
try {
await api.reorderProjectGroups(ids);
} catch (e) {
this.groups = previous;
this.toast(humanMessage(e));
}
}
/** Files a project under a group, or takes it out of one when groupId is ''. */
async setProjectGroup(projectId, groupId) {
const previous = this.projects.find((p) => p.id === projectId);
// `?? ''` because the server omits the field when there is no group, so
// "none" arrives as undefined and is compared against the '' that means the
// same thing.
if (!previous || (previous.group_id ?? '') === groupId) return;
this.upsertProject({ ...previous, group_id: groupId });
try {
this.upsertProject(await api.updateProject(projectId, { group_id: groupId }));
} catch (e) {
this.upsertProject(previous);
this.toast(humanMessage(e));
}
}
// --- toasts -------------------------------------------------------------------
toast(message) {
@@ -393,6 +545,35 @@ class SidebarLayout {
export const sidebar = new SidebarLayout();
/**
* Whether Kommende shows the next seven days as a list, or the month as a grid.
*
* In localStorage rather than on the account, and for the same reason as the
* sidebar's width: a month grid of seven columns needs room, and the answer on a
* phone is not the answer on a wide monitor. A project keeps its `view_mode` on
* the row because that is a fact about the project — this is a fact about the
* screen.
*/
class UpcomingView {
mode = $state(
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:upcoming') === 'calendar'
? 'calendar'
: 'list'
);
set(next) {
if (next !== 'list' && next !== 'calendar') return;
this.mode = next;
try {
localStorage.setItem('verdande:upcoming', next);
} catch {
// Private browsing; the choice simply will not persist.
}
}
}
export const upcomingView = new UpcomingView();
/**
* The themes on offer.
*
+12
View File
@@ -13,6 +13,16 @@
let paletteOpen = $state(false);
let sidebarOpen = $state(false);
/**
* The two pages that arrive by email, and are for somebody who is not signed in.
*
* They render on their own rather than inside the shell or behind the sign-in
* screen. Without this the invite link showed a login form to a person who had
* no account yet, and the reset link showed one to a person who had forgotten
* the password it was asking for.
*/
let standalone = $derived(['/invite', '/reset'].includes($page.url.pathname));
$effect(() => {
app.load();
});
@@ -63,6 +73,8 @@
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
flash of anxiety, not feedback. -->
<div class="booting"></div>
{:else if standalone}
{@render children()}
{:else if !app.user}
<SignIn />
{:else}
+129
View File
@@ -144,6 +144,54 @@
if (!Object.keys(totpErrors).length) app.toast(humanMessage(e));
}
}
// --- sessions --------------------------------------------------------------
let sessions = $state([]);
$effect(() => {
api.listSessions().then((r) => (sessions = r.sessions)).catch(() => {});
});
async function endSession(session) {
if (session.current) {
if (!confirm('Det er denne enhed. Du bliver logget ud.')) return;
await api.endSession(session.id);
// A full reload rather than clearing the store by hand: the session is
// gone, so every open socket and every cached answer in this tab is now
// about an account nobody is signed in to.
location.href = '/';
return;
}
const previous = sessions;
sessions = sessions.filter((s) => s.id !== session.id);
try {
await api.endSession(session.id);
} catch (e) {
sessions = previous;
app.toast(humanMessage(e));
}
}
/**
* "for 2 minutter siden".
*
* Relative rather than a timestamp, because the question is "was that me, just
* now?" and an absolute time makes the reader do the subtraction. Anything
* older than a week gets its date instead: "for 23 dage siden" is a number
* nobody can place.
*/
function ago(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
}
</script>
<section class="panel">
@@ -367,6 +415,35 @@
{/if}
</section>
<section class="panel">
<header>
<h2>Enheder</h2>
<p class="hint">
Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift
adgangskode.
</p>
</header>
<ul class="sessions">
{#each sessions as session (session.id)}
<li>
<div class="what">
<span class="device">
{session.device}
{#if session.current}<span class="badge">denne enhed</span>{/if}
</span>
<!-- The address and the time, small: they are what settles "was that
me?", and they are not what you read first. -->
<span class="when" title={session.user_agent}>
{ago(session.last_seen_at)}{#if session.ip}{' · ' + session.ip}{/if}
</span>
</div>
<button class="secondary" onclick={() => endSession(session)}>Log ud</button>
</li>
{/each}
</ul>
</section>
<style>
form {
display: flex;
@@ -374,6 +451,58 @@
gap: var(--s4);
}
.sessions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s1);
}
.sessions li {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s2) 0;
border-bottom: 1px solid var(--line);
}
.sessions li:last-child {
border-bottom: 0;
}
.what {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.device {
font-size: var(--text-sm);
display: flex;
align-items: center;
gap: var(--s2);
}
.badge {
font-size: var(--text-xs);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius-full);
padding: 0 var(--s2);
}
.when {
font-size: var(--text-xs);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codes {
list-style: none;
margin: 0;
+19
View File
@@ -0,0 +1,19 @@
<script>
/**
* The address an invite link points at.
*
* It exists as a route because the server has been emailing this URL since
* invites were built, and until now nothing answered for it: the SPA fallback
* served the app shell, the shell found no session, and the recipient was shown
* a plain sign-in form for an account they did not have yet. A 200 the whole
* way, which is exactly how that class of bug hides here.
*
* The form is SignIn's: signing up from an invite is one branch of signing in,
* and it reads the token out of the URL itself.
*/
import SignIn from '$lib/components/SignIn.svelte';
</script>
<svelte:head><title>Invitation · verdande</title></svelte:head>
<SignIn />
+8 -2
View File
@@ -63,7 +63,13 @@
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
let isOwner = $derived(project?.role === 'owner');
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
// Filtered by project as well as by state. The store holds whatever the last
// view loaded, and a task can now leave this project while you are looking at
// it — dropped on another project in the sidebar, or moved by somebody else —
// so "what the page asked for" is not the same as "what is in the store".
let open = $derived(
app.tasks.filter((t) => !t.completed && !t.parent_id && t.project_id === project?.id)
);
let unsectioned = $derived(open.filter((t) => !t.section_id));
let editing = $state(false);
@@ -295,7 +301,7 @@
{#if mode === 'board'}
<BoardView {project} {sections} {canEdit} />
{:else if mode === 'calendar'}
<CalendarView />
<CalendarView projectId={project.id} />
{:else}
<section>
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
+15
View File
@@ -0,0 +1,15 @@
<script>
/**
* The address a password-reset link points at.
*
* "Glemt adgangskode?" has always sent one and the API has always accepted the
* token — but nothing rendered here, so the link opened the sign-in screen and
* a person who had forgotten their password was shown the field they could not
* fill in. Same shape as the invite link, and found the same way.
*/
import SignIn from '$lib/components/SignIn.svelte';
</script>
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
<SignIn />
+126
View File
@@ -0,0 +1,126 @@
<script>
/**
* Venter på andre: what you have handed to somebody else and are waiting for.
*
* Grouped by person, because that is the question — "what is Anders sitting
* on" — rather than by date. The server groups it and sends the names with it;
* a client has no way to ask for the name behind an assignee id it has not met
* in a project it happens to have open.
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
let people = $state([]);
let loaded = $state(false);
$effect(() => {
api.delegated().then((data) => {
people = data.people;
app.tasks = data.people.flatMap((p) => p.tasks);
loaded = true;
});
});
// Read back out of the store, so a task that is closed or reassigned from the
// drawer leaves the list at once rather than on the next load. Reassigning to
// yourself is the interesting one: it stops being delegated the moment it
// happens, which is exactly what this view should show.
const live = (person) =>
app.tasks.filter((t) => t.assignee_id === person.user_id && !t.completed);
</script>
<div class="view">
<header><h1>Venter på andre</h1></header>
{#each people as person (person.user_id)}
{@const tasks = live(person)}
{#if tasks.length}
<section>
<h2>
<span class="avatar" style="background: {person.avatar_color}">
{person.name[0]?.toUpperCase() ?? '?'}
</span>
{person.name}
<span class="count">{tasks.length}</span>
</h2>
{#each tasks as task (task.id)}
<TaskRow {task} />
{/each}
</section>
{/if}
{/each}
{#if loaded && !people.some((p) => live(p).length)}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Du venter ikke på nogen.
</p>
{/if}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
h1 {
font-size: var(--text-2xl);
margin-bottom: var(--s5);
}
section {
margin-top: var(--s5);
}
/* The person is the heading, so their face is part of it rather than a
decoration beside it. */
h2 {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
font-weight: 560;
color: var(--ink-muted);
padding: 0 var(--s2) var(--s2);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s2);
}
.avatar {
width: 20px;
height: 20px;
border-radius: var(--radius-full);
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 560;
color: #fff;
flex: none;
}
.count {
margin-left: auto;
font-size: var(--text-xs);
color: var(--ink-faint);
font-weight: 400;
}
.clear {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
padding: var(--s8) var(--s4);
color: var(--ink-faint);
font-size: var(--text-sm);
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
opacity: 0.5;
}
</style>
+149 -19
View File
@@ -1,19 +1,40 @@
<script>
/** Kommende: the next seven days, empty ones included. */
/**
* Kommende: the next seven days as a list, or the month as a grid.
*
* The two views ask for different things and neither is a subset of the other —
* seven days from today, against six whole weeks around a month that can be any
* month — so each loads its own, and switching reloads. Loading both up front
* would mean the list waiting on a month it is not showing.
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { app, upcomingView } from '$lib/stores.svelte.js';
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
let days = $state([]);
$effect(() => {
if (upcomingView.mode !== 'list') return;
api.upcoming().then((data) => {
days = data.days;
app.tasks = data.days.flatMap((d) => d.tasks);
});
});
/**
* Loads the dates the month grid is showing.
*
* The limit is raised well above the default: a busy month across every project
* is more than two hundred rows, and a silently truncated grid is a calendar
* that quietly lies about a day being clear.
*/
function loadMonth({ from, to }) {
app.loadTasks({ due_from: from, due_before: to, limit: 500 });
}
function heading(date) {
const day = new Date(date + 'T00:00:00');
const today = new Date(new Date().toDateString());
@@ -25,28 +46,80 @@
}
// Reading tasks back out of the store keeps a completed row disappearing
// immediately rather than on the next load.
// immediately rather than on the next load. It is also what makes a task
// dragged from one day to another land in its new section straight away: the
// day it belongs to is decided here, not by which list the server put it in.
const live = (date) => app.tasks.filter((t) => t.due_date === date && !t.completed);
// --- dragging a task onto another day -------------------------------------------
/** The day section lit up under the pointer. */
let over = $state(null);
function onDragOver(event, date) {
if (!carries(event, TASK)) return;
accept(event);
over = date;
}
async function onDrop(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
await app.reschedule(id, date);
}
</script>
<div class="view">
<header><h1>Kommende</h1></header>
<div class="view" class:wide={upcomingView.mode === 'calendar'}>
<header>
<h1>Kommende</h1>
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['calendar', 'Kalender']] as [value, label]}
<button
class:active={upcomingView.mode === value}
onclick={() => upcomingView.set(value)}
aria-pressed={upcomingView.mode === value}>{label}</button
>
{/each}
</div>
</header>
<QuickAdd />
{#each days as day (day.date)}
{@const tasks = live(day.date)}
<section>
<h2>{heading(day.date)}</h2>
{#if tasks.length}
{#each tasks as task (task.id)}
<TaskRow {task} />
{/each}
{:else}
<p class="empty">—</p>
{/if}
</section>
{/each}
{#if upcomingView.mode === 'calendar'}
<CalendarView onrange={loadMonth} />
{:else}
{#each days as day (day.date)}
{@const tasks = live(day.date)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class:over={over === day.date}
ondragover={(e) => onDragOver(e, day.date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, day.date)}
>
<h2>{heading(day.date)}</h2>
{#if tasks.length}
{#each tasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="row"
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
ondragend={() => (over = null)}
>
<TaskRow {task} />
</div>
{/each}
{:else}
<!-- The empty day is still a section, and still a target: an em dash
is a small thing to aim at, so the whole section takes the drop. -->
<p class="empty">—</p>
{/if}
</section>
{/each}
{/if}
</div>
<style>
@@ -56,13 +129,70 @@
padding: var(--s6) var(--s4) var(--s8);
}
/* A month grid needs the width a reading column deliberately withholds — the
same allowance the project page makes for its board and calendar. */
.view.wide {
max-width: 1400px;
}
header {
display: flex;
align-items: center;
gap: var(--s4);
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
margin-bottom: var(--s5);
flex: 1;
min-width: 0;
}
.views {
display: flex;
gap: 1px;
flex: none;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.views button {
padding: var(--s1) var(--s3);
font-size: var(--text-sm);
background: var(--ground);
color: var(--ink-muted);
transition:
background var(--fast) var(--ease),
color var(--fast) var(--ease);
}
.views button:hover {
color: var(--ink);
}
.views button.active {
background: var(--surface-raised);
color: var(--ink);
font-weight: 500;
}
section {
margin-top: var(--s5);
border-radius: var(--radius);
}
/* The day lights up as a whole. Its heading is the label on a box, and the box
is what you are dropping into — not the gap between two rows, which is what a
line would mean. */
section.over {
box-shadow: 0 0 0 1px var(--accent);
background: var(--surface-sunken);
}
.row[draggable='true'] {
cursor: grab;
}
h2 {