Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f4de02fc | ||
|
|
4276892590 | ||
|
|
6b6b838167 | ||
|
|
34b718529c | ||
|
|
c14559ed0c | ||
|
|
2300f1dc3b | ||
|
|
1529c87231 | ||
|
|
f15c440942 | ||
|
|
d37d240625 | ||
|
|
3b521b463c | ||
|
|
9056c44c9a | ||
|
|
afc46a5c20 | ||
|
|
8c748c2a88 | ||
|
|
c4e9e1519b |
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+261
-2
@@ -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]
|
||||
@@ -533,7 +571,11 @@ paths:
|
||||
type: string
|
||||
description: Where it goes when the text names no project.
|
||||
responses:
|
||||
"201": { $ref: "#/components/responses/Task" }
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/QuickAddResult" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/tasks/quick-add/preview:
|
||||
@@ -756,6 +798,35 @@ paths:
|
||||
"201": { $ref: "#/components/responses/Project" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/reorder:
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Set the order projects appear in.
|
||||
description: |
|
||||
The whole list at once rather than one move at a time. A person has a
|
||||
handful of projects, so sending the order you want is simpler than
|
||||
midpoints and cannot land half-applied.
|
||||
|
||||
Projects you do not own are skipped: `sort_order` is a column on the
|
||||
project rather than a preference per viewer, so a shared one stays where
|
||||
its owner put it.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ids]
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items: { type: string }
|
||||
description: Project ids, top to bottom.
|
||||
responses:
|
||||
"204": { description: Reordered. }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/projects/{projectID}:
|
||||
parameters:
|
||||
- { name: projectID, in: path, required: true, schema: { type: string } }
|
||||
@@ -785,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]
|
||||
@@ -1027,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]
|
||||
@@ -1974,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:
|
||||
@@ -2077,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:
|
||||
@@ -2106,7 +2320,12 @@ components:
|
||||
TaskInput:
|
||||
type: object
|
||||
properties:
|
||||
project_id: { type: string, description: "Omitted on create, the task goes to the Inbox." }
|
||||
project_id:
|
||||
type: string
|
||||
description: |
|
||||
Omitted on create, the task goes to the Inbox. On update it moves the
|
||||
task — which needs editor rights on the destination.
|
||||
|
||||
section_id: { type: string }
|
||||
parent_id: { type: string }
|
||||
content: { type: string }
|
||||
@@ -2122,6 +2341,19 @@ components:
|
||||
items: { type: string }
|
||||
sort_order: { type: number }
|
||||
|
||||
QuickAddResult:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Task"
|
||||
- type: object
|
||||
properties:
|
||||
unknown_project:
|
||||
type: string
|
||||
description: |
|
||||
Present when the text named a `#project` that does not exist. The
|
||||
task is still created, in the Inbox — a thought is not thrown away
|
||||
over a typo — but the caller is told, because the alternative is
|
||||
that the name vanishes from the title and nothing says why.
|
||||
|
||||
ParsedTask:
|
||||
type: object
|
||||
description: What the quick-add parser made of a line.
|
||||
@@ -2151,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
|
||||
@@ -2161,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.internal(w, "create label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
}
|
||||
|
||||
@@ -263,6 +264,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "update label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -272,5 +274,6 @@ func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "delete label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -983,3 +983,173 @@ func TestWellKnownCalDAVStillRedirects(t *testing.T) {
|
||||
t.Error("CalDAV discovery was caught by the well-known rule")
|
||||
}
|
||||
}
|
||||
|
||||
// --- what the API accepts, it must actually do -------------------------------------
|
||||
|
||||
// project_id on an update used to be validated and then dropped: the destination
|
||||
// was permission-checked, which reads as "understood", and the task never moved
|
||||
// while the answer said OK. Both the REST path and the MCP tool did it.
|
||||
func TestUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "betal moms"})
|
||||
taskID := task["id"].(string)
|
||||
if task["project_id"] == projectID {
|
||||
t.Fatal("the task started in the destination; the test proves nothing")
|
||||
}
|
||||
|
||||
resp, updated := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"project_id": projectID})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("patch: status %d, body %v", resp.StatusCode, updated)
|
||||
}
|
||||
if updated["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %v — the move was accepted and not made",
|
||||
updated["project_id"], projectID)
|
||||
}
|
||||
|
||||
// And it really is there, not merely reported as there.
|
||||
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if reread["project_id"] != projectID {
|
||||
t.Errorf("after re-reading, project_id = %v", reread["project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPUpdateMovesTheTaskToAnotherProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
created := ts.callTool(t, "create_task", map[string]any{"text": "betal moms"})
|
||||
taskID := created["id"].(string)
|
||||
|
||||
moved := ts.callTool(t, "update_task", map[string]any{
|
||||
"task_id": taskID, "project_id": projectID,
|
||||
})
|
||||
if moved["project_id"] != projectID {
|
||||
t.Errorf("project_id = %v, want %v", moved["project_id"], projectID)
|
||||
}
|
||||
}
|
||||
|
||||
// A project you cannot write to is refused rather than quietly ignored.
|
||||
func TestUpdateRefusesAProjectYouCannotWriteTo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min opgave"})
|
||||
taskID := task["id"].(string)
|
||||
mine := task["project_id"]
|
||||
|
||||
resp, _ := ts.do(t, "PATCH", "/api/v1/tasks/"+taskID,
|
||||
map[string]any{"project_id": theirs["id"]})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, reread := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if reread["project_id"] != mine {
|
||||
t.Error("the task moved into somebody else's project")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown #project is still not an error — the thought is kept rather than
|
||||
// thrown away over a typo — but it is no longer silent. It used to vanish from
|
||||
// the title while the task went to the Inbox, with nothing saying why.
|
||||
func TestQuickAddSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status %d, want 201 — the task should still be created", resp.StatusCode)
|
||||
}
|
||||
if body["unknown_project"] != "3Dekoration" {
|
||||
t.Errorf("unknown_project = %v, want %q", body["unknown_project"], "3Dekoration")
|
||||
}
|
||||
if body["content"] != "Lav plakat" {
|
||||
t.Errorf("content = %v", body["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickAddSaysNothingWhenTheProjectExists(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Dekoration"})
|
||||
|
||||
_, body := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Lav plakat #Dekoration"})
|
||||
if _, present := body["unknown_project"]; present {
|
||||
t.Errorf("reported an unknown project for one that exists: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCreateSaysWhenTheProjectDoesNotExist(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
created := ts.callTool(t, "create_task", map[string]any{"text": "Lav plakat #3Dekoration"})
|
||||
note, _ := created["note"].(string)
|
||||
if !strings.Contains(note, "3Dekoration") {
|
||||
t.Errorf("note = %q, want it to name the project that does not exist", note)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-tasks were unreachable over MCP: create_task had no parent_id, so a parent
|
||||
// with seven children arrived as eight unrelated tasks.
|
||||
func TestMCPCreateMakesSubtasks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
parent := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Runen v2", "project_id": project["id"],
|
||||
})
|
||||
parentID := parent["id"].(string)
|
||||
|
||||
child := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Tegn ikonet", "parent_id": parentID,
|
||||
})
|
||||
if child["parent_id"] != parentID {
|
||||
t.Errorf("parent_id = %v, want %v", child["parent_id"], parentID)
|
||||
}
|
||||
// A sub-task joins its parent's project whatever else was asked for.
|
||||
if child["project_id"] != project["id"] {
|
||||
t.Errorf("project_id = %v, want the parent's %v", child["project_id"], project["id"])
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/tasks?parent_id="+parentID, nil)
|
||||
if tasks, _ := listed["tasks"].([]any); len(tasks) != 1 {
|
||||
t.Errorf("the parent has %d sub-tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
parent := ts.callTool(t, "create_task", map[string]any{"content": "Runen v2"})
|
||||
child := ts.callTool(t, "create_task", map[string]any{
|
||||
"content": "Tegn ikonet", "parent_id": parent["id"],
|
||||
})
|
||||
|
||||
// A tool that refuses reports it inside the result with isError, not as a
|
||||
// JSON-RPC error — the call itself was well formed, the arguments were not.
|
||||
response := ts.rpc(t, "tools/call", map[string]any{
|
||||
"name": "create_task",
|
||||
"arguments": map[string]any{"content": "Endnu dybere", "parent_id": child["id"]},
|
||||
})
|
||||
result, _ := response["result"].(map[string]any)
|
||||
if result == nil || result["isError"] != true {
|
||||
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -57,7 +58,8 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
@@ -253,6 +255,7 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
Text string `json:"text"`
|
||||
Content string `json:"content"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
DueDate string `json:"due_date"`
|
||||
Priority int `json:"priority"`
|
||||
Description string `json:"description"`
|
||||
@@ -268,6 +271,11 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
task := &store.Task{CreatedBy: userID, Priority: 4}
|
||||
labels := params.Labels
|
||||
|
||||
// Reported below rather than swallowed. "#Dekoration" used to vanish from the
|
||||
// title while the task went to the Inbox, with nothing anywhere saying so —
|
||||
// invisible in a chat, where nobody sees the row that was written.
|
||||
unknownProject := ""
|
||||
|
||||
if text := strings.TrimSpace(params.Text); text != "" {
|
||||
parsed := quickadd.Parse(text, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
||||
task.Content = parsed.Content
|
||||
@@ -280,6 +288,8 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
if parsed.Project != "" && params.ProjectID == "" {
|
||||
if id, err := s.db.ProjectByName(ctx, userID, parsed.Project); err == nil {
|
||||
task.ProjectID = id
|
||||
} else {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,6 +315,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
if params.ProjectID != "" {
|
||||
task.ProjectID = params.ProjectID
|
||||
}
|
||||
|
||||
// A sub-task belongs to its parent's project, whatever else was asked for:
|
||||
// the two cannot disagree, and the parent is the more specific instruction.
|
||||
// Without this the tools could not build a tree at all — an import of a
|
||||
// parent with seven children arrived as eight unrelated tasks.
|
||||
if params.ParentID != "" {
|
||||
parent, err := s.db.GetTask(ctx, params.ParentID, userID)
|
||||
if err != nil {
|
||||
return nil, mcp.ArgError("no task with id %q to hang this under", params.ParentID)
|
||||
}
|
||||
if parent.ParentID != "" {
|
||||
return nil, mcp.ArgError("sub-tasks are one level deep; %q is already a sub-task", params.ParentID)
|
||||
}
|
||||
task.ParentID = parent.ID
|
||||
task.ProjectID = parent.ProjectID
|
||||
}
|
||||
|
||||
if task.ProjectID == "" {
|
||||
if task.ProjectID, err = s.db.InboxID(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
@@ -319,10 +346,23 @@ func (s *Server) mcpCreateTask(ctx context.Context, userID string, args json.Raw
|
||||
}
|
||||
|
||||
s.hub.Publish(task.ProjectID, "task.created", toTaskJSON(*task))
|
||||
return map[string]any{
|
||||
|
||||
out := map[string]any{
|
||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||
"due_date": task.DueDate, "project_id": task.ProjectID,
|
||||
}, nil
|
||||
}
|
||||
if task.ParentID != "" {
|
||||
out["parent_id"] = task.ParentID
|
||||
}
|
||||
if unknownProject != "" {
|
||||
// Phrased for a model to relay: it says what happened and what to do,
|
||||
// so the person is told rather than left with a task in the wrong place.
|
||||
out["note"] = fmt.Sprintf(
|
||||
"There is no project called %q, so this went to the Inbox. "+
|
||||
"Create the project and move it, or say which project you meant.",
|
||||
unknownProject)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||
@@ -364,8 +404,11 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
||||
if params.Labels != nil {
|
||||
update.SetLabels, update.Labels = true, *params.Labels
|
||||
}
|
||||
// Moved before the field update, and through the same helper the REST handler
|
||||
// uses. This used to check the caller's rights on the destination and then
|
||||
// drop the argument, so the task never moved and the answer still said OK.
|
||||
if params.ProjectID != nil {
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, *params.ProjectID, userID, store.RoleEditor); err != nil {
|
||||
if err := s.moveTaskToProject(ctx, params.TaskID, *params.ProjectID, userID); err != nil {
|
||||
return nil, mcp.ArgError("no project with id %q that you can write to", *params.ProjectID)
|
||||
}
|
||||
}
|
||||
@@ -379,9 +422,13 @@ func (s *Server) mcpUpdateTask(ctx context.Context, userID string, args json.Raw
|
||||
}
|
||||
s.hub.Publish(task.ProjectID, "task.updated", toTaskJSON(*task))
|
||||
|
||||
// project_id is in the answer because it is one of the things that can be
|
||||
// changed here: a caller that asked for a move can see whether it happened
|
||||
// rather than taking "OK" on trust.
|
||||
return map[string]any{
|
||||
"id": task.ID, "content": task.Content, "priority": task.Priority,
|
||||
"due_date": task.DueDate, "labels": task.Labels,
|
||||
"project_id": task.ProjectID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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,44 @@ 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 {
|
||||
// IDs in the order they should appear, top to bottom.
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
// handleReorderProjects sets the sidebar order.
|
||||
//
|
||||
// The whole list rather than one move: a person has a handful of projects, and
|
||||
// sending the order you want is both simpler to reason about and impossible to
|
||||
// get into the half-ordered state a sequence of individual moves can produce if
|
||||
// one of them fails.
|
||||
//
|
||||
// Projects the caller does not own are skipped — sort_order is a column on the
|
||||
// project, so a shared one sits where its owner put it.
|
||||
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// A ceiling rather than a guess at what is reasonable: this is a bound on
|
||||
// the transaction, not an opinion about how many projects somebody may have.
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder projects", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -100,6 +142,7 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||
s.publishToOwner(r, "project.created", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||
}
|
||||
|
||||
@@ -128,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)
|
||||
@@ -135,6 +189,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "get project", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||
}
|
||||
|
||||
@@ -149,6 +204,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "project.deleted", nil)
|
||||
s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -81,3 +81,116 @@ func TestTheTrashIsPerOwner(t *testing.T) {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
var ids []string
|
||||
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
|
||||
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
|
||||
ids = append(ids, body["id"].(string))
|
||||
}
|
||||
|
||||
// Back to front.
|
||||
reversed := []string{ids[2], ids[1], ids[0]}
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
projects, _ := list["projects"].([]any)
|
||||
|
||||
var got []string
|
||||
for _, raw := range projects {
|
||||
p := raw.(map[string]any)
|
||||
// The Inbox sorts ahead of everything by its own rule; it is not part of
|
||||
// what was reordered.
|
||||
if p["is_inbox"] == true {
|
||||
continue
|
||||
}
|
||||
got = append(got, p["id"].(string))
|
||||
}
|
||||
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
|
||||
t.Errorf("order = %v, want %v", got, reversed)
|
||||
}
|
||||
}
|
||||
|
||||
// sort_order is a column on the project, not a per-viewer preference, so
|
||||
// reordering must not rearrange a project somebody else owns.
|
||||
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
|
||||
theirID := theirs["id"].(string)
|
||||
|
||||
_, before := other.do(t, "GET", "/api/v1/projects", nil)
|
||||
var wanted float64
|
||||
for _, raw := range before["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == theirID {
|
||||
wanted = p["sort_order"].(float64)
|
||||
}
|
||||
}
|
||||
|
||||
// Ask, as somebody else, to put their project first.
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
|
||||
"ids": []string{theirID},
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := other.do(t, "GET", "/api/v1/projects", nil)
|
||||
for _, raw := range after["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == theirID {
|
||||
if p["sort_order"] != wanted {
|
||||
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
|
||||
wanted, p["sort_order"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The member list errored on every project — the ORDER BY named an expression,
|
||||
// which SQLite refuses across a UNION — so the share panel had never shown
|
||||
// anybody, not even the owner.
|
||||
func TestListMembersWorksAtAll(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
members, _ := body["members"].([]any)
|
||||
if len(members) != 1 {
|
||||
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
|
||||
}
|
||||
if m := members[0].(map[string]any); m["role"] != "owner" {
|
||||
t.Errorf("role = %v, want owner", m["role"])
|
||||
}
|
||||
}
|
||||
|
||||
// And the Inbox, which is the one every task drawer asks about.
|
||||
func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
var inboxID string
|
||||
for _, raw := range list["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["is_inbox"] == true {
|
||||
inboxID = p["id"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,19 @@ func (s *Server) publish(projectID, event string, payload any) {
|
||||
s.hub.Publish(projectID, event, payload)
|
||||
}
|
||||
|
||||
// publishToOwner sends a change that belongs to a person rather than to a
|
||||
// project: their projects, their labels, their saved filters.
|
||||
//
|
||||
// Project membership is what the socket subscribes to, and a project that has
|
||||
// just been created has no subscribers — the person is not watching a thing that
|
||||
// did not exist a moment ago. So these go to the user instead, which also
|
||||
// reaches their other devices.
|
||||
func (s *Server) publishToOwner(r *http.Request, event string, payload any) {
|
||||
if user := userFrom(r.Context()); user != nil {
|
||||
s.hub.PublishToUser(user.ID, event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebSocket is the live-sync connection.
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
@@ -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,11 +263,14 @@ 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) {
|
||||
r.Get("/", s.handleListProjects)
|
||||
r.Post("/", s.handleCreateProject)
|
||||
// Above /{projectID}, or chi reads "reorder" as an id.
|
||||
r.Post("/reorder", s.handleReorderProjects)
|
||||
|
||||
r.Route("/{projectID}", func(r chi.Router) {
|
||||
// Read access is the floor for the whole subtree; the writes
|
||||
@@ -285,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.
|
||||
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -249,6 +251,16 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A change of project is a move, not a field: it has to renumber the task
|
||||
// among its new neighbours. Done before the rest so a request that both moves
|
||||
// and edits ends up entirely applied or entirely refused.
|
||||
if req.ProjectID != nil {
|
||||
if err := s.moveTaskToProject(r.Context(), taskID, *req.ProjectID, user.ID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
u := store.TaskUpdate{
|
||||
Description: req.Description, Priority: req.Priority,
|
||||
SectionID: req.SectionID, ParentID: req.ParentID,
|
||||
@@ -473,11 +485,18 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
projectID := req.ProjectID
|
||||
// Set when the text named a project that does not exist. The task is still
|
||||
// created — see below — but the caller is told, because the alternative is
|
||||
// what it used to be: "#Dekoration" disappears from the title, the task lands
|
||||
// somewhere else, and nothing anywhere says why.
|
||||
unknownProject := ""
|
||||
if parsed.Project != "" {
|
||||
// An unknown "#name" is not an error: the task still gets created, in the
|
||||
// Inbox, rather than a thought being thrown away over a typo.
|
||||
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
||||
projectID = id
|
||||
} else {
|
||||
unknownProject = parsed.Project
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
@@ -510,6 +529,16 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
t.Labels = parsed.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
}
|
||||
|
||||
@@ -525,6 +554,42 @@ func (s *Server) handleQuickAddPreview(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- helpers --------------------------------------------------------------------
|
||||
|
||||
// moveTaskToProject moves a task to another project, if that is really what was
|
||||
// asked for.
|
||||
//
|
||||
// Shared by the REST handler and the MCP tool, because they had drifted: both
|
||||
// accepted project_id on an update, both checked the caller's rights on the
|
||||
// destination — which reads as "understood" — and then neither passed it on,
|
||||
// because store.TaskUpdate has no such field. The task stayed where it was and
|
||||
// the response said OK. An argument that is validated and then dropped is worse
|
||||
// than one that is refused.
|
||||
//
|
||||
// A move rather than a field assignment: sort_order is per project, so landing
|
||||
// in a new one means being given a position among its tasks.
|
||||
func (s *Server) moveTaskToProject(ctx context.Context, taskID, projectID, userID string) error {
|
||||
if projectID == "" {
|
||||
return nil
|
||||
}
|
||||
current, err := s.db.GetTask(ctx, taskID, userID)
|
||||
if err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
if current.ProjectID == projectID {
|
||||
return nil // already there; not worth a write or a renumber
|
||||
}
|
||||
if _, err := store.RequireProjectRole(ctx, s.db, projectID, userID, store.RoleEditor); err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
|
||||
// No neighbours: it lands at the start of the destination, which is the only
|
||||
// answer available when the caller did not say where. Section is cleared —
|
||||
// a section belongs to the project being left behind.
|
||||
if _, err := s.db.MoveTask(ctx, taskID, projectID, "", "", ""); err != nil {
|
||||
return errors.New("not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTaskContent(content string, priority *int) map[string]string {
|
||||
fields := map[string]string{}
|
||||
switch {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package httpapi
|
||||
|
||||
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
|
||||
// labelled @regnskab in front of you.
|
||||
func TestSearchFindsTasksByLabel(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "ring til revisoren @regnskab"})
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "køb kaffe"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||
tasks, _ := body["tasks"].([]any)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("found %d tasks searching for a label, want 1: %v", len(tasks), body)
|
||||
}
|
||||
if got := tasks[0].(map[string]any)["content"]; got != "ring til revisoren" {
|
||||
t.Errorf("found %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The text search still works, and a label match does not swallow it.
|
||||
func TestSearchStillFindsTasksByText(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": "køb grøn maling"})
|
||||
|
||||
// Diacritic folding still applies to the text side.
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
|
||||
if tasks, _ := body["tasks"].([]any); len(tasks) != 1 {
|
||||
t.Errorf("searching \"gron\" found %d tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody else's label must not pull their task into your results — the label
|
||||
// join has to stay inside the visibility the outer query already applies.
|
||||
func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
other.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "deres hemmelighed @regnskab"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
||||
if tasks, _ := body["tasks"].([]any); len(tasks) != 0 {
|
||||
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;
|
||||
@@ -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
|
||||
}
|
||||
+61
-14
@@ -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()
|
||||
@@ -328,15 +336,27 @@ type Member struct {
|
||||
// project_members row — ownership is the projects table — so they are unioned in
|
||||
// rather than left out of the list of people who can see it.
|
||||
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
||||
// The owner first, then everybody else by name.
|
||||
//
|
||||
// The ordering is a column rather than an expression, and the whole compound
|
||||
// select is wrapped: SQLite only accepts ORDER BY terms from the result set
|
||||
// of a UNION, so `ORDER BY role = 'owner' DESC` — which reads perfectly well
|
||||
// — fails at query time with "1st ORDER BY term does not match any column in
|
||||
// the result set". Every call errored, so the share panel had never listed
|
||||
// anybody.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
|
||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||
WHERE p.id = ?
|
||||
UNION ALL
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
|
||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.project_id = ?
|
||||
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
|
||||
SELECT id, email, name, avatar_color, role, added_at FROM (
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role,
|
||||
p.created_at AS added_at, 0 AS rank
|
||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||
WHERE p.id = ?
|
||||
UNION ALL
|
||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role,
|
||||
m.added_at, 1 AS rank
|
||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.project_id = ?
|
||||
)
|
||||
ORDER BY rank, name`, projectID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -556,3 +576,30 @@ func joinComma(parts []string) string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReorderProjects writes the given order, and only for projects the user owns.
|
||||
//
|
||||
// The whole list at once, rather than the midpoint-between-neighbours dance the
|
||||
// tasks do. A person has a handful of projects, not a thousand, so writing
|
||||
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
|
||||
// failure the task ordering has to respace a section to recover from.
|
||||
//
|
||||
// Scoped to ownership because sort_order lives on the project row rather than
|
||||
// per viewer: a shared project sits where its owner put it, and a member
|
||||
// reordering their sidebar must not rearrange somebody else's. Ids that are not
|
||||
// the caller's are skipped rather than refused, so one shared project in the
|
||||
// list does not fail the whole drag.
|
||||
func (db *DB) ReorderProjects(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 projects SET sort_order = ?, updated_at = ?
|
||||
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
|
||||
float64(i), now, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-4
@@ -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)
|
||||
@@ -136,8 +146,23 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
args = append(args, f.DueFrom)
|
||||
}
|
||||
if expr := MatchExpr(f.Search); expr != "" {
|
||||
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
|
||||
args = append(args, expr)
|
||||
// Text or a label. A label is how you find a task whose wording you have
|
||||
// forgotten — "that thing about the accounts" is a search for @regnskab —
|
||||
// and leaving them out made the box look like it had lost the task.
|
||||
//
|
||||
// The label side is a plain lowercase match rather than another FTS index:
|
||||
// labels are short, and their names are typed by the person searching for
|
||||
// them, so there is no spelling to be generous about. The diacritic
|
||||
// folding that matters for task text does not earn a second index here.
|
||||
where = append(where, `(
|
||||
t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)
|
||||
OR t.id IN (
|
||||
SELECT tl.task_id FROM task_labels tl
|
||||
JOIN labels l ON l.id = tl.label_id
|
||||
WHERE lower(l.name) LIKE ?
|
||||
)
|
||||
)`)
|
||||
args = append(args, expr, "%"+strings.ToLower(strings.TrimSpace(f.Search))+"%")
|
||||
}
|
||||
if f.FilterSQL != "" {
|
||||
where = append(where, "("+f.FilterSQL+")")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
+163
-5
@@ -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
|
||||
@@ -87,7 +90,160 @@
|
||||
--content-max: 720px;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
/*
|
||||
* The dark palette again, by name.
|
||||
*
|
||||
* :root carries it as the default, and :root only ever matches <html> — so a
|
||||
* swatch in the settings picker asking for data-theme="dark" would otherwise
|
||||
* inherit whatever the page is wearing. Repeated rather than restructured: the
|
||||
* alternative is splitting the token block in two and leaving somebody to work
|
||||
* out which half a new token belongs in.
|
||||
*/
|
||||
[data-theme='dark'] {
|
||||
--ground: #111312;
|
||||
--surface: #171a19;
|
||||
--surface-raised: #1d211f;
|
||||
--surface-sunken: #0d0f0e;
|
||||
--line: #262b29;
|
||||
--line-strong: #343a37;
|
||||
|
||||
--ink: #e8ebe9;
|
||||
--ink-muted: #9aa39e;
|
||||
/* 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;
|
||||
--accent-sunken: #1c3329;
|
||||
--accent-ink: #0d1411;
|
||||
|
||||
--p1: #cf6b6b;
|
||||
--p2: #d69f5c;
|
||||
--p3: #6b9ecf;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #cf6b6b;
|
||||
--danger-sunken: #33201f;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
/*
|
||||
* The other themes.
|
||||
*
|
||||
* Each one redefines colour and nothing else: no spacing, no type, no radii.
|
||||
* That is what keeps them themes rather than five separate designs, and it is
|
||||
* why a component that reaches for a raw hex breaks in four places at once
|
||||
* instead of one.
|
||||
*
|
||||
* Every theme states its full palette rather than inheriting the dark one and
|
||||
* patching it. A partial theme looks finished until somebody adds a token, at
|
||||
* which point four themes silently fall back to a colour from a fifth.
|
||||
*/
|
||||
|
||||
/* Skumring — the same dark, turned warm, with amber where the green was. */
|
||||
[data-theme='dusk'] {
|
||||
--ground: #14110f;
|
||||
--surface: #1b1714;
|
||||
--surface-raised: #221d19;
|
||||
--surface-sunken: #100d0b;
|
||||
--line: #2b2521;
|
||||
--line-strong: #3a322c;
|
||||
|
||||
--ink: #ece6df;
|
||||
--ink-muted: #a3988c;
|
||||
/* Light enough to clear 4.5:1 against the ground. The quiet tier is where
|
||||
dates and counts live, and it is the first thing to disappear. */
|
||||
--ink-faint: #8d8377;
|
||||
|
||||
--accent: #d99a4e;
|
||||
--accent-hover: #e8a95c;
|
||||
--accent-sunken: #33261480;
|
||||
--accent-ink: #16110a;
|
||||
|
||||
--p1: #cf6b6b;
|
||||
--p2: #d69f5c;
|
||||
--p3: #6b9ecf;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #cf6b6b;
|
||||
--danger-sunken: #33201f;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.25);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Papir — warm light, closer to paper than to a screen. */
|
||||
[data-theme='paper'] {
|
||||
--ground: #f6f1e7;
|
||||
--surface: #fffcf6;
|
||||
--surface-raised: #fffcf6;
|
||||
--surface-sunken: #ece5d7;
|
||||
--line: #ded5c3;
|
||||
--line-strong: #c8bda6;
|
||||
|
||||
--ink: #241f18;
|
||||
--ink-muted: #665c4c;
|
||||
--ink-faint: #756a55;
|
||||
|
||||
--accent: #8a5a2b;
|
||||
--accent-hover: #74491f;
|
||||
--accent-sunken: #ebdfcb;
|
||||
--accent-ink: #fffcf6;
|
||||
|
||||
--p1: #b0452f;
|
||||
--p2: #96701f;
|
||||
--p3: #3f6b8a;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #b0452f;
|
||||
--danger-sunken: #f3e2dc;
|
||||
|
||||
--shadow: 0 1px 2px rgb(60 40 10 / 0.08);
|
||||
--shadow-lg: 0 8px 32px rgb(60 40 10 / 0.14);
|
||||
}
|
||||
|
||||
/*
|
||||
* Kontrast — for reading in bright sun, or for eyes that the quiet greys of the
|
||||
* other four do not serve. Pure black on white, borders that are actually
|
||||
* visible, and an accent dark enough to pass against white text.
|
||||
*
|
||||
* The muted inks are deliberately much darker than elsewhere: "secondary text"
|
||||
* at 60% grey is the first thing to disappear, and it is where dates live.
|
||||
*/
|
||||
[data-theme='contrast'] {
|
||||
--ground: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #f2f2f2;
|
||||
--surface-sunken: #e8e8e8;
|
||||
--line: #767676;
|
||||
--line-strong: #1a1a1a;
|
||||
|
||||
--ink: #000000;
|
||||
--ink-muted: #2b2b2b;
|
||||
--ink-faint: #4a4a4a;
|
||||
|
||||
--accent: #005c2e;
|
||||
--accent-hover: #00753a;
|
||||
--accent-sunken: #cfe8da;
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--p1: #a10000;
|
||||
--p2: #7a4a00;
|
||||
--p3: #00417a;
|
||||
--p4: var(--ink-faint);
|
||||
|
||||
--danger: #a10000;
|
||||
--danger-sunken: #f7dcdc;
|
||||
|
||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
--ground: #faf9f7;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
@@ -97,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;
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -176,9 +179,16 @@ export const api = {
|
||||
createProject: (data) => post('/projects', data),
|
||||
updateProject: (id, data) => patch(`/projects/${id}`, data),
|
||||
deleteProject: (id) => del(`/projects/${id}`),
|
||||
reorderProjects: (ids) => post('/projects/reorder', { ids }),
|
||||
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),
|
||||
@@ -285,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)}`)
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
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';
|
||||
|
||||
@@ -27,14 +28,269 @@
|
||||
let filters = $state([]);
|
||||
let labels = $state([]);
|
||||
|
||||
// Reads app.labelsChanged so the effect re-runs when a label is created,
|
||||
// renamed or deleted anywhere — including in another tab, or by Claude
|
||||
// through the connector.
|
||||
$effect(() => {
|
||||
app.labelsChanged;
|
||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||
});
|
||||
|
||||
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
|
||||
let own = $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.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);
|
||||
|
||||
/**
|
||||
* Pointer events rather than mouse events, so a trackpad, a mouse and a stylus
|
||||
* all work. Capturing the pointer means the drag keeps following even when it
|
||||
* leaves the handle, which at 4px wide it does constantly.
|
||||
*/
|
||||
function startResize(event) {
|
||||
event.preventDefault();
|
||||
resizing = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function onResize(event) {
|
||||
if (!resizing) return;
|
||||
sidebar.setWidth(event.clientX);
|
||||
}
|
||||
|
||||
function endResize(event) {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
// --- reordering ---------------------------------------------------------------
|
||||
|
||||
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;
|
||||
startDrag(event, PROJECT, project.id);
|
||||
}
|
||||
|
||||
function onDragOver(event, project) {
|
||||
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 = dragged(event, PROJECT) || draggingId;
|
||||
const below = overBelow;
|
||||
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, 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">
|
||||
@@ -55,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}
|
||||
@@ -87,15 +416,22 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#each own 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}
|
||||
</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}
|
||||
@@ -103,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}
|
||||
@@ -182,6 +586,29 @@
|
||||
<span class="signout">Log ud</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||
A drag handle with no keyboard equivalent is a setting some people simply
|
||||
cannot reach. -->
|
||||
<div
|
||||
class="resize"
|
||||
class:resizing
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Bredde på sidebjælken"
|
||||
tabindex="0"
|
||||
onpointerdown={startResize}
|
||||
onpointermove={onResize}
|
||||
onpointerup={endResize}
|
||||
onpointercancel={endResize}
|
||||
ondblclick={() => sidebar.reset()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'ArrowLeft') sidebar.setWidth(sidebar.width - 16);
|
||||
else if (e.key === 'ArrowRight') sidebar.setWidth(sidebar.width + 16);
|
||||
else if (e.key === 'Home') sidebar.reset();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
}}
|
||||
></div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
@@ -196,6 +623,45 @@
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
|
||||
The visible line stays the border; this only widens what the pointer hits. */
|
||||
.resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 3px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.resize:hover::after,
|
||||
.resize:focus-visible::after,
|
||||
.resize.resizing::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resize:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
/* The drawer has a fixed width and no room to drag against. */
|
||||
.resize {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -239,7 +705,7 @@
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.group-head button {
|
||||
.group-head > button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
@@ -248,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;
|
||||
@@ -280,6 +876,48 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Only your own projects reorder. A shared one sits where its owner put it —
|
||||
sort_order is a column on the project, not a preference per viewer. */
|
||||
a.sortable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a.sortable.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A line in the gap rather than a highlighted row: the gap is the target. */
|
||||
a.sortable::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
a.sortable.drop-above::before {
|
||||
top: -1px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
a.sortable.drop-below::before {
|
||||
bottom: -1px;
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
let priority = $state(4);
|
||||
let dueDate = $state('');
|
||||
let labels = $state('');
|
||||
let projectId = $state('');
|
||||
let recurrence = $state('');
|
||||
let assignee = $state('');
|
||||
|
||||
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||
let members = $state([]);
|
||||
|
||||
// The rules worth a menu entry. Anything more specific is typed into the
|
||||
// title, where the parser understands far more than a list ever could.
|
||||
const REPEATS = [
|
||||
{ rule: '', label: 'Aldrig' },
|
||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||
{ rule: 'FREQ=MONTHLY', label: 'Hver måned' },
|
||||
{ rule: 'FREQ=YEARLY', label: 'Hvert år' }
|
||||
];
|
||||
|
||||
let subtasks = $state([]);
|
||||
let comments = $state([]);
|
||||
@@ -46,6 +63,17 @@
|
||||
priority = task.priority;
|
||||
dueDate = task.due_date ?? '';
|
||||
labels = (task.labels ?? []).join(', ');
|
||||
projectId = task.project_id;
|
||||
recurrence = task.recurrence_rule ?? '';
|
||||
assignee = task.assignee_id ?? '';
|
||||
|
||||
members = [];
|
||||
api.listMembers(task.project_id)
|
||||
.then((r) => r.members.length > 1 && (members = r.members))
|
||||
.catch(() => {
|
||||
// A project you are not the owner of may refuse the member list.
|
||||
// Then there is nobody to choose from, which is the same as none.
|
||||
});
|
||||
|
||||
subtasks = [];
|
||||
comments = [];
|
||||
@@ -73,13 +101,83 @@
|
||||
await app.update(task.id, patch);
|
||||
}
|
||||
|
||||
function saveContent() {
|
||||
/**
|
||||
* Saves the title, reading it the way quick add would.
|
||||
*
|
||||
* Typing "i morgen kl. 14" into a title used to leave it as those words. The
|
||||
* same sentence in the quick-add box becomes a date, which made where you
|
||||
* typed it decide what it meant.
|
||||
*
|
||||
* The parse runs on the server — the grammar is Danish and English and lives
|
||||
* there — and is only applied when it actually consumed something. What it
|
||||
* took is said out loud, because a title that edits itself without a word is
|
||||
* worse than one that does nothing.
|
||||
*/
|
||||
async function saveContent() {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || trimmed === task.content) {
|
||||
content = task.content;
|
||||
return;
|
||||
}
|
||||
save({ content: trimmed });
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = await api.quickAddPreview(trimmed);
|
||||
} catch {
|
||||
// Offline, or the parser refused. Saving the words is still right.
|
||||
}
|
||||
|
||||
const patch = { content: trimmed };
|
||||
const took = [];
|
||||
if (parsed && parsed.content && parsed.content !== trimmed) {
|
||||
patch.content = parsed.content;
|
||||
if (parsed.due_date) {
|
||||
patch.due_date = parsed.due_date;
|
||||
took.push(parsed.due_date);
|
||||
}
|
||||
if (parsed.priority && parsed.priority < 4) {
|
||||
patch.priority = parsed.priority;
|
||||
took.push(`P${parsed.priority}`);
|
||||
}
|
||||
if (parsed.recurrence) {
|
||||
patch.recurrence_rule = parsed.recurrence;
|
||||
took.push('gentagelse');
|
||||
}
|
||||
if (parsed.labels?.length) {
|
||||
patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])];
|
||||
took.push(parsed.labels.map((l) => `@${l}`).join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
await save(patch);
|
||||
|
||||
// The fields are seeded once per task so a save cannot overwrite what
|
||||
// somebody is typing. That rule has to bend exactly here: this save
|
||||
// changed the very fields on screen, and leaving them showing what was
|
||||
// typed while the toast reports something else is the worst of both.
|
||||
if (took.length) {
|
||||
content = patch.content;
|
||||
if (patch.due_date) dueDate = patch.due_date;
|
||||
if (patch.priority) priority = patch.priority;
|
||||
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
||||
if (patch.labels) labels = patch.labels.join(', ');
|
||||
app.toast(`Læst som ${took.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
if (projectId === task.project_id) return;
|
||||
await save({ project_id: projectId });
|
||||
}
|
||||
|
||||
async function saveRecurrence() {
|
||||
if (recurrence === (task.recurrence_rule ?? '')) return;
|
||||
await save({ recurrence_rule: recurrence });
|
||||
}
|
||||
|
||||
async function saveAssignee() {
|
||||
if (assignee === (task.assignee_id ?? '')) return;
|
||||
await save({ assignee_id: assignee });
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
@@ -316,8 +414,42 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if task.recurrence_text}
|
||||
<p class="repeats">Gentages {task.recurrence_text}.</p>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="project">Projekt</label>
|
||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="repeat">Gentages</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
{/each}
|
||||
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||
showing it rather than snapping to the nearest preset is the
|
||||
difference between a menu and a menu that quietly edits. -->
|
||||
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
||||
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if members.length > 1}
|
||||
<div class="field">
|
||||
<label for="assignee">Ansvarlig</label>
|
||||
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
|
||||
<option value="">Ingen</option>
|
||||
{#each members as member (member.user_id)}
|
||||
<option value={member.user_id}>{member.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
@@ -567,12 +699,6 @@
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.repeats {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+369
-14
@@ -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. */
|
||||
@@ -29,6 +31,16 @@ class AppState {
|
||||
*/
|
||||
detailId = $state(null);
|
||||
|
||||
/**
|
||||
* Bumped whenever a label changes anywhere.
|
||||
*
|
||||
* A counter rather than the labels themselves: the sidebar wants each label's
|
||||
* task count, which no single event carries and which changes whenever a task
|
||||
* gains or loses one. Whoever is showing labels re-reads them; that is one
|
||||
* small request against modelling a count in two places.
|
||||
*/
|
||||
labelsChanged = $state(0);
|
||||
|
||||
#socket = null;
|
||||
#reconnectDelay = 1000;
|
||||
|
||||
@@ -56,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;
|
||||
@@ -110,6 +126,36 @@ class AppState {
|
||||
case 'task.deleted':
|
||||
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
|
||||
break;
|
||||
|
||||
// Projects and labels belong to a person rather than to a project, so
|
||||
// they arrive on the user's own channel — a project that has just been
|
||||
// created has no subscribers, because nobody was watching a thing that
|
||||
// did not exist a moment ago.
|
||||
case 'project.created':
|
||||
case 'project.updated':
|
||||
this.upsertProject(task);
|
||||
break;
|
||||
case 'project.deleted':
|
||||
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.
|
||||
case 'label.changed':
|
||||
this.labelsChanged++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +182,13 @@ class AppState {
|
||||
* copy is not worth a moment's thought next to the class of bug it removes.
|
||||
*/
|
||||
replace(id, next) {
|
||||
// A task can invent a label just by mentioning one — "@regnskab" in quick
|
||||
// add creates it — and no label event is sent for that, because it happened
|
||||
// inside a task write. Comparing the two lists is what keeps the sidebar
|
||||
// honest without re-reading the labels on every completed checkbox.
|
||||
const before = this.tasks.find((t) => t.id === id)?.labels ?? [];
|
||||
if ((next?.labels ?? []).join() !== before.join()) this.labelsChanged++;
|
||||
|
||||
this.tasks = this.tasks.map((t) => (t.id === id ? next : t));
|
||||
}
|
||||
|
||||
@@ -151,14 +204,33 @@ class AppState {
|
||||
*/
|
||||
upsert(task) {
|
||||
if (!task?.id) return;
|
||||
if (this.tasks.some((t) => t.id === task.id)) this.replace(task.id, task);
|
||||
else this.tasks = [...this.tasks, task];
|
||||
if (this.tasks.some((t) => t.id === task.id)) {
|
||||
this.replace(task.id, task);
|
||||
return;
|
||||
}
|
||||
if (task.labels?.length) this.labelsChanged++;
|
||||
this.tasks = [...this.tasks, task];
|
||||
}
|
||||
|
||||
get(id) {
|
||||
return this.tasks.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a project, or replaces it if the list already has it.
|
||||
*
|
||||
* The same guard the tasks need, and for the same reason: a project arrives
|
||||
* twice, once as the response to the request that created it and once over
|
||||
* the socket. Appending both puts two rows with one id into a keyed `{#each}`,
|
||||
* which throws and stops the sidebar rendering.
|
||||
*/
|
||||
upsertProject(project) {
|
||||
if (!project?.id) return;
|
||||
this.projects = this.projects.some((p) => p.id === project.id)
|
||||
? this.projects.map((p) => (p.id === project.id ? project : p))
|
||||
: [...this.projects, project];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks a task off. The row is struck through and gone before the request
|
||||
* leaves — which is the single most-repeated action in the app, and the one
|
||||
@@ -197,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);
|
||||
@@ -233,7 +334,7 @@ class AppState {
|
||||
async createProject(name) {
|
||||
try {
|
||||
const project = await api.createProject({ name });
|
||||
this.projects = [...this.projects, project];
|
||||
this.upsertProject(project);
|
||||
return project;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
@@ -246,6 +347,135 @@ class AppState {
|
||||
this.projects = projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the projects in the given order.
|
||||
*
|
||||
* The whole list rather than one moved item: it is a handful of rows, and
|
||||
* sending the order you want cannot land in the half-applied state a sequence
|
||||
* of individual moves can. `sort_order` is set here as well as on the server,
|
||||
* so the sidebar settles immediately rather than after a round trip.
|
||||
*/
|
||||
async reorderProjects(ids) {
|
||||
const previous = this.projects;
|
||||
const rank = new Map(ids.map((id, i) => [id, i]));
|
||||
|
||||
this.projects = this.projects.map((p) =>
|
||||
rank.has(p.id) ? { ...p, sort_order: rank.get(p.id) } : p
|
||||
);
|
||||
try {
|
||||
await api.reorderProjects(ids);
|
||||
} catch (e) {
|
||||
this.projects = previous;
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
@@ -263,21 +493,146 @@ class AppState {
|
||||
|
||||
export const app = new AppState();
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
export const theme = {
|
||||
get current() {
|
||||
if (typeof document === 'undefined') return 'dark';
|
||||
return document.documentElement.dataset.theme ?? 'dark';
|
||||
},
|
||||
/**
|
||||
* How wide the sidebar is, and whether it is showing at all.
|
||||
*
|
||||
* Kept in localStorage rather than on the account: it is a property of the
|
||||
* screen you are sitting at, not of you. The same person on a laptop and on a
|
||||
* wide monitor wants different answers, and syncing it would make one of those
|
||||
* two wrong every time they switch.
|
||||
*/
|
||||
const SIDEBAR_MIN = 200;
|
||||
const SIDEBAR_MAX = 480;
|
||||
const SIDEBAR_DEFAULT = 268;
|
||||
|
||||
function storedNumber(key, fallback) {
|
||||
if (typeof localStorage === 'undefined') return fallback;
|
||||
const raw = Number(localStorage.getItem(key));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : fallback;
|
||||
}
|
||||
|
||||
class SidebarLayout {
|
||||
width = $state(storedNumber('verdande:sidebar-width', SIDEBAR_DEFAULT));
|
||||
collapsed = $state(
|
||||
typeof localStorage !== 'undefined' && localStorage.getItem('verdande:sidebar-collapsed') === '1'
|
||||
);
|
||||
|
||||
setWidth(px) {
|
||||
// Clamped here rather than in the drag handler, so every caller gets the
|
||||
// same rule and a stored value from an older build cannot make the
|
||||
// sidebar unusably narrow.
|
||||
this.width = Math.round(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px)));
|
||||
this.#save('verdande:sidebar-width', String(this.width));
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const next = this.current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
this.collapsed = !this.collapsed;
|
||||
this.#save('verdande:sidebar-collapsed', this.collapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setWidth(SIDEBAR_DEFAULT);
|
||||
}
|
||||
|
||||
#save(key, value) {
|
||||
try {
|
||||
localStorage.setItem('verdande:theme', next);
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* `dark` is the tokens on bare :root; the rest are [data-theme] blocks in
|
||||
* app.css. The list lives here so the picker and the stylesheet cannot drift —
|
||||
* an entry with no CSS behind it renders as the default and looks like a bug in
|
||||
* the picker rather than a missing block.
|
||||
*/
|
||||
export const THEMES = [
|
||||
{ id: 'dark', name: 'Nordlys', note: 'Mørk med grøn accent.', dark: true },
|
||||
{ id: 'dusk', name: 'Skumring', note: 'Mørk og varm, med rav.', dark: true },
|
||||
{ id: 'light', name: 'Dagslys', note: 'Lys og køligt neutral.', dark: false },
|
||||
{ id: 'paper', name: 'Papir', note: 'Lys og varm, som papir.', dark: false },
|
||||
{ id: 'contrast', name: 'Kontrast', note: 'Sort på hvidt, til skarpt lys.', dark: false }
|
||||
];
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
class Theme {
|
||||
/**
|
||||
* Mirrors the attribute on <html>, which app.html sets before first paint —
|
||||
* that is the only way to avoid a white flash on load, and it means the
|
||||
* document, not this object, is where the truth lives.
|
||||
*/
|
||||
current = $state(
|
||||
typeof document === 'undefined' ? 'dark' : (document.documentElement.dataset.theme ?? 'dark')
|
||||
);
|
||||
|
||||
set(id) {
|
||||
if (!THEMES.some((t) => t.id === id)) return;
|
||||
this.current = id;
|
||||
document.documentElement.dataset.theme = id;
|
||||
|
||||
// The browser paints its own chrome from this — the address bar on a
|
||||
// phone, the frame of an installed PWA. Left alone it stays the colour of
|
||||
// whichever theme shipped in the markup.
|
||||
const ground = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', ground);
|
||||
|
||||
try {
|
||||
localStorage.setItem('verdande:theme', id);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The topbar button, which flips between light and dark rather than walking
|
||||
* all five. Somebody who has chosen Skumring wants the light one to be Papir,
|
||||
* not to be marched back to the default — so it moves to the other side of
|
||||
* the list and stays warm or stays cool.
|
||||
*/
|
||||
toggle() {
|
||||
const now = THEMES.find((t) => t.id === this.current) ?? THEMES[0];
|
||||
const opposite = THEMES.filter((t) => t.dark !== now.dark);
|
||||
const index = THEMES.filter((t) => t.dark === now.dark).indexOf(now);
|
||||
this.set((opposite[index] ?? opposite[0]).id);
|
||||
}
|
||||
}
|
||||
|
||||
export const theme = new Theme();
|
||||
|
||||
export { ApiError, humanMessage };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { app, theme } from '$lib/stores.svelte.js';
|
||||
import { app, theme, sidebar } from '$lib/stores.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
@@ -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,14 +73,25 @@
|
||||
<!-- 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}
|
||||
<div class="shell" class:sidebar-open={sidebarOpen}>
|
||||
<div
|
||||
class="shell"
|
||||
class:sidebar-open={sidebarOpen}
|
||||
class:sidebar-collapsed={sidebar.collapsed}
|
||||
style="--sidebar-width: {sidebar.width}px"
|
||||
>
|
||||
<Sidebar onnavigate={() => (sidebarOpen = false)} />
|
||||
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<!-- Two controls, not one: on a phone the sidebar is a drawer that
|
||||
opens over the page, on a desktop it is a column that folds away.
|
||||
Same words, different actions, so each is shown only where it
|
||||
applies. -->
|
||||
<button
|
||||
class="menu"
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
@@ -82,6 +103,19 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="fold"
|
||||
onclick={() => sidebar.toggle()}
|
||||
aria-label={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
aria-pressed={sidebar.collapsed}
|
||||
title={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M9 4v16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="search" onclick={() => (paletteOpen = true)}>
|
||||
<span>Søg</span>
|
||||
<kbd>⌘K</kbd>
|
||||
@@ -173,7 +207,32 @@
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.fold {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-faint);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.fold:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Folded away rather than merely narrow: width 0 with the border gone, so
|
||||
nothing of it is left to catch the eye. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-right: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu svg,
|
||||
.fold svg,
|
||||
.theme svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -277,6 +336,11 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/* The drawer is the whole answer here; folding a drawer means nothing. */
|
||||
.fold {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
display: block;
|
||||
}
|
||||
@@ -292,5 +356,15 @@
|
||||
.shell.sidebar-open :global(.sidebar) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* A collapsed desktop sidebar must not follow you to a phone, where the
|
||||
drawer would then open to nothing. */
|
||||
.shell.sidebar-collapsed :global(.sidebar) {
|
||||
width: var(--sidebar-width);
|
||||
padding-left: max(var(--s3), env(safe-area-inset-left));
|
||||
padding-right: var(--s3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||
|
||||
// --- profile ---------------------------------------------------------------
|
||||
|
||||
@@ -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">
|
||||
@@ -204,6 +252,39 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Udseende</h2>
|
||||
<p class="hint">
|
||||
Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved —
|
||||
en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="themes">
|
||||
{#each THEMES as option (option.id)}
|
||||
<button
|
||||
class="theme-card"
|
||||
class:chosen={theme.current === option.id}
|
||||
onclick={() => theme.set(option.id)}
|
||||
aria-pressed={theme.current === option.id}
|
||||
>
|
||||
<!-- data-theme goes on the swatch and not on the card: it redefines
|
||||
--ink too, and a card wearing a dark theme's ink on a light page
|
||||
has a name nobody can read. The swatch shows the colours; the
|
||||
label stays in the page's. -->
|
||||
<span class="swatch" data-theme={option.id} aria-hidden="true">
|
||||
<span class="swatch-line"></span>
|
||||
<span class="swatch-line short"></span>
|
||||
<span class="swatch-dot"></span>
|
||||
</span>
|
||||
<span class="theme-name">{option.name}</span>
|
||||
<span class="theme-note">{option.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Adgangskode</h2>
|
||||
@@ -334,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;
|
||||
@@ -341,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;
|
||||
@@ -353,6 +515,82 @@
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.themes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s1);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.theme-card.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Painted in the card's own theme rather than the page's — that is the whole
|
||||
point of putting data-theme on the button. */
|
||||
.swatch {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--line);
|
||||
padding: var(--s2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.swatch-line {
|
||||
height: 5px;
|
||||
width: 70%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--ink-muted);
|
||||
}
|
||||
|
||||
.swatch-line.short {
|
||||
width: 45%;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.swatch-dot {
|
||||
position: absolute;
|
||||
right: var(--s2);
|
||||
top: var(--s2);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.theme-note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.secret {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
@@ -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 />
|
||||
@@ -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);
|
||||
@@ -101,6 +107,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- sections -------------------------------------------------------------------
|
||||
|
||||
let addingSection = $state(false);
|
||||
let renamingSection = $state(null);
|
||||
let sectionName = $state('');
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
if (!name) return;
|
||||
addingSection = false;
|
||||
sectionName = '';
|
||||
try {
|
||||
sections = [...sections, await api.createSection(project.id, name)];
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSection(event, section) {
|
||||
event.preventDefault();
|
||||
const name = sectionName.trim();
|
||||
renamingSection = null;
|
||||
if (!name || name === section.name) return;
|
||||
|
||||
const previous = sections;
|
||||
sections = sections.map((s) => (s.id === section.id ? { ...s, name } : s));
|
||||
try {
|
||||
await api.updateSection(section.id, { name });
|
||||
} catch (e) {
|
||||
sections = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSection(section) {
|
||||
const count = open.filter((t) => t.section_id === section.id).length;
|
||||
const warning = count
|
||||
? `Slet sektionen "${section.name}"? De ${count} opgaver bliver, men mister deres sektion.`
|
||||
: `Slet sektionen "${section.name}"?`;
|
||||
if (!confirm(warning)) return;
|
||||
|
||||
const previous = sections;
|
||||
sections = sections.filter((s) => s.id !== section.id);
|
||||
try {
|
||||
await api.deleteSection(section.id);
|
||||
// The tasks are not deleted with it; they come back unsectioned, and the
|
||||
// list has to be re-read to show them in the right place.
|
||||
await app.loadTasks({ project_id: project.id });
|
||||
} catch (e) {
|
||||
sections = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function invite(event) {
|
||||
event.preventDefault();
|
||||
inviteError = '';
|
||||
@@ -240,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} />
|
||||
@@ -249,7 +310,34 @@
|
||||
{#each sections as section (section.id)}
|
||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||
<section>
|
||||
<h2>{section.name}</h2>
|
||||
<div class="section-head">
|
||||
{#if renamingSection === section.id}
|
||||
<form onsubmit={(e) => renameSection(e, section)}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={sectionName}
|
||||
autofocus
|
||||
aria-label="Sektionens navn"
|
||||
onblur={() => (renamingSection = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<h2>{section.name}</h2>
|
||||
{#if canEdit}
|
||||
<button
|
||||
class="section-action"
|
||||
onclick={() => {
|
||||
renamingSection = section.id;
|
||||
sectionName = section.name;
|
||||
}}>Omdøb</button
|
||||
>
|
||||
<button class="section-action remove" onclick={() => removeSection(section)}>
|
||||
Slet
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
@@ -257,7 +345,35 @@
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if !open.length}
|
||||
{#if canEdit}
|
||||
<section class="add-section">
|
||||
{#if addingSection}
|
||||
<form onsubmit={addSection}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={sectionName}
|
||||
autofocus
|
||||
placeholder="Sektionens navn"
|
||||
aria-label="Ny sektion"
|
||||
onblur={() => !sectionName.trim() && (addingSection = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingSection = false)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add"
|
||||
onclick={() => {
|
||||
addingSection = true;
|
||||
sectionName = '';
|
||||
}}
|
||||
>
|
||||
+ Tilføj sektion
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if !open.length && !sections.length}
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Ingenting her endnu.
|
||||
@@ -548,6 +664,79 @@
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hidden until the section is hovered or something in it has focus: a heading
|
||||
with two buttons permanently beside it stops reading as a heading. */
|
||||
.section-action {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
padding: 0 var(--s1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.section-head:hover .section-action,
|
||||
.section-action:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-action:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.section-action.remove:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-head form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-head input,
|
||||
.add-section input {
|
||||
width: 100%;
|
||||
padding: var(--s1) var(--s2);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section-head input:focus,
|
||||
.add-section input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.add-section {
|
||||
margin-top: var(--s5);
|
||||
}
|
||||
|
||||
.add-section .add {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--s2);
|
||||
transition: color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.add-section .add:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.readonly {
|
||||
margin: 0;
|
||||
|
||||
@@ -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 />
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user