From c14559ed0c95b50603efc2f16d32212753fd68de Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 19:25:10 +0200 Subject: [PATCH] feat: project groups, a month on Kommende, and dragging things where they go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three the handoff named as next. They are one commit because they are one mechanism: all three end in something being dragged, and a drop target cannot tell what is coming without the module they now share. Groups are foldable headings over the projects in the sidebar. A table rather than a `group_name` column on the project, because a group has to be renamable in one write, has to survive being emptied, and has to remember whether it is folded — none of which a string repeated on every project row can do. Folded state lives on the row, not in localStorage. The sidebar's *width* is a property of the screen you are sitting at, which is why that one is per browser; a folded group is a statement about the group — "this is the part of my work I am not in right now" — and it is true on the laptop and on the desktop. There is nobody else's answer to disagree with either: a group belongs to exactly one person. A shared project cannot be filed. `group_id` is a column on the project row, so a member grouping somebody else's project would move it in the owner's sidebar too — the same reason `sort_order` is owner-scoped. The listing reports the column only to the owner, so a viewer is never told where somebody else keeps it. Deleting a group takes the heading and leaves the projects, and the sidebar decides where a project renders by whether its group still exists rather than by the id alone. A group deleted in another tab arrives as one event and not one per project, so for a moment the projects still name a heading that is no longer drawn — filing by the raw id would drop them out of the sidebar entirely. Kommende gains the month grid the project pages already had, behind a Liste/Kalender toggle. The two views ask for different things — seven days from today against six whole weeks around an arbitrary month — so each loads its own, and the grid reports which dates it is showing rather than having the page guess. Which view is remembered in localStorage: a month of seven columns needs room, and the answer on a phone is not the answer on a wide monitor. And a task can now be dropped on a project in the sidebar, on a day in Kommende, or on a cell in any month grid. Onto a project it is a *move*, not a field: `sort_order` belongs to the project, so it has to find a place among that project's tasks and leave its old section behind. Each drag now carries its own MIME type. `getData` is deliberately unreadable while a drag is in the air — the browser hands the payload over on drop and not before, so a page cannot read what is being dragged across it — and `dataTransfer.types` is all a target has to decide with. Deciding is the point: calling preventDefault on a drag you cannot accept is what turns "no" into a silent, wrong drop. The sidebar takes a project and a task on the same row and has to tell them apart. Two things fell out of building it. The month grid has always had one cell narrower than the other six: `.today` styled the "I dag" button in the header, and the day cell for today wears the same class, so `margin-left: auto` was shrinking a grid item to its content. It is scoped to the header now. And a project page listed whatever was in the store rather than what belonged to that project, which did not matter until a task could leave a project while you were looking at it. Covered by eight new API tests and two new smoke tests, and the smoke tests drive the drags for real — the type check they exercise exists only while a drag is in flight, so nothing else can reach it. Co-Authored-By: Claude Opus 5 --- README.md | 7 +- docs/index.md | 4 +- docs/openapi.yaml | 115 ++++ internal/httpapi/projectgroups_handlers.go | 145 +++++ internal/httpapi/projectgroups_test.go | 221 +++++++ internal/httpapi/projects_handlers.go | 22 +- internal/httpapi/server.go | 12 + .../store/migrations/0005_project_groups.sql | 33 ++ internal/store/projectgroups.go | 205 +++++++ internal/store/projects.go | 20 +- web/e2e/smoke.spec.js | 134 +++++ web/src/lib/api.js | 6 + web/src/lib/components/BoardView.svelte | 22 +- web/src/lib/components/CalendarView.svelte | 99 +++- web/src/lib/components/Sidebar.svelte | 543 ++++++++++++++++-- web/src/lib/components/TaskList.svelte | 13 +- web/src/lib/dnd.js | 43 ++ web/src/lib/stores.svelte.js | 183 +++++- web/src/routes/projekt/[id]/+page.svelte | 10 +- web/src/routes/upcoming/+page.svelte | 168 +++++- 20 files changed, 1903 insertions(+), 102 deletions(-) create mode 100644 internal/httpapi/projectgroups_handlers.go create mode 100644 internal/httpapi/projectgroups_test.go create mode 100644 internal/store/migrations/0005_project_groups.sql create mode 100644 internal/store/projectgroups.go create mode 100644 web/src/lib/dnd.js diff --git a/README.md b/README.md index 4c39c65..4df7869 100644 --- a/README.md +++ b/README.md @@ -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. @@ -66,6 +68,9 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector. | Task detail: description, sub-tasks, comments, files, reminders | Done | | Settings: account, notifications, integrations, AI, tokens, data | Done | | 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 | diff --git a/docs/index.md b/docs/index.md index 13e483e..57f26e5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d23ebd1..4a2e3c1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -818,6 +818,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] @@ -2007,6 +2090,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: @@ -2202,6 +2290,12 @@ components: role: { type: string, enum: [owner, editor, viewer] } member_count: { type: integer } shared: { type: boolean } + group_id: + type: string + description: | + The sidebar group *you* have filed it under, absent for none. Always + absent on a project shared with you: grouping is a column on the + project row, so it is the owner's filing rather than yours. ProjectInput: type: object @@ -2212,6 +2306,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 diff --git a/internal/httpapi/projectgroups_handlers.go b/internal/httpapi/projectgroups_handlers.go new file mode 100644 index 0000000..e6cf6dc --- /dev/null +++ b/internal/httpapi/projectgroups_handlers.go @@ -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 +} diff --git a/internal/httpapi/projectgroups_test.go b/internal/httpapi/projectgroups_test.go new file mode 100644 index 0000000..b087536 --- /dev/null +++ b/internal/httpapi/projectgroups_test.go @@ -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, "anden@example.dk", "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, "anden@example.dk", "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 "" +} diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go index c99ec10..8042bbc 100644 --- a/internal/httpapi/projects_handlers.go +++ b/internal/httpapi/projects_handlers.go @@ -24,6 +24,10 @@ type projectJSON struct { Role string `json:"role"` MemberCount int `json:"member_count"` Shared bool `json:"shared"` + // GroupID is the sidebar group the asking user has filed it under, empty for + // none. Always empty on a project shared with them: grouping is a column on + // the project row, so it is the owner's filing and nobody else's. + GroupID string `json:"group_id,omitempty"` } func toProjectJSON(p store.Project) projectJSON { @@ -31,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON { ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode, OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived, SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, - Shared: p.MemberCount > 1, + Shared: p.MemberCount > 1, GroupID: p.GroupID, } } @@ -67,6 +71,11 @@ type projectRequest struct { Icon *string `json:"icon"` ViewMode *string `json:"view_mode"` Archived *bool `json:"archived"` + // GroupID files the project under a sidebar group; "" takes it out of one. + // The empty string rather than a JSON null, because null and absent are the + // same nil pointer once decoded — and telling "put it nowhere" apart from + // "I did not mention it" is the whole point of a PATCH. + GroupID *string `json:"group_id"` } type reorderProjectsRequest struct { @@ -162,6 +171,17 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { s.storeError(w, "update project", err) return } + // Grouping is written separately because it is scoped differently: the fields + // above belong to the project, and this one belongs to whoever is filing it. + // The route already requires ownership, so the check inside is a second lock + // on the same door rather than a different one. + if req.GroupID != nil { + if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID, + userFrom(r.Context()).ID); err != nil { + s.storeError(w, "set project group", err) + return + } + } s.activity(r, projectID, "", "project.updated", nil) p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 44cd2f1..1ff4d73 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -287,6 +287,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. diff --git a/internal/store/migrations/0005_project_groups.sql b/internal/store/migrations/0005_project_groups.sql new file mode 100644 index 0000000..eeb35cd --- /dev/null +++ b/internal/store/migrations/0005_project_groups.sql @@ -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; diff --git a/internal/store/projectgroups.go b/internal/store/projectgroups.go new file mode 100644 index 0000000..bb38a13 --- /dev/null +++ b/internal/store/projectgroups.go @@ -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 +} diff --git a/internal/store/projects.go b/internal/store/projects.go index ded6d1b..fbbd1e8 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -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() diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 8aace00..5f5b322 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -227,6 +227,140 @@ 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([]); +}); + /** * The MCP connector address must not fall through to the app shell. * diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 34f3168..d0f5eab 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -180,6 +180,12 @@ export const api = { listTrashedProjects: () => get('/trash/projects'), restoreProject: (id) => post(`/trash/projects/${id}/restore`), + listProjectGroups: () => get('/project-groups'), + createProjectGroup: (name) => post('/project-groups', { name }), + updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data), + deleteProjectGroup: (id) => del(`/project-groups/${id}`), + reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }), + listSections: (projectId) => get(`/projects/${projectId}/sections`), createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }), updateSection: (id, data) => patch(`/sections/${id}`, data), diff --git a/web/src/lib/components/BoardView.svelte b/web/src/lib/components/BoardView.svelte index 1e11b24..3856816 100644 --- a/web/src/lib/components/BoardView.svelte +++ b/web/src/lib/components/BoardView.svelte @@ -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; } diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index bce1446..4cac559 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -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)); + }
@@ -73,11 +129,32 @@ {#each grid as date (date.toISOString())} {@const tasks = tasksOn(date)} {@const outside = date.getMonth() !== cursor.getMonth()} -
+ +
onDragOver(e, date)} + ondragleave={() => (over = null)} + ondrop={(e) => onDrop(e, date)} + > {date.getDate()} {#each tasks.slice(0, 3) as task (task.id)} - {/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); diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index dfcbabe..efda189 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -1,6 +1,7 @@
+ + {#snippet projectRow(project, sortable)} + 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))} + > + + {project.name} + {#if project.shared} + {project.member_count} + {/if} + + {/snippet} +
-
+ + +
{ + if (!carries(e, PROJECT)) return; + accept(e); + overGroup = ''; + overId = null; + }} + ondragleave={() => (overGroup = null)} + ondrop={(e) => onDropInGroup(e, '')} + >

Projekter

- + +
{#if adding} @@ -163,28 +399,22 @@ {/if} - {#each own as project (project.id)} - onDragStart(e, project)} - ondragend={() => { - draggingId = null; - overId = null; - }} - ondragover={(e) => onDragOver(e, project)} - ondragleave={() => (overId = null)} - ondrop={(e) => onDrop(e, project)} - > - - {project.name} - + {#if addingGroup} +
+ + !newGroupName.trim() && (addingGroup = false)} + onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)} + /> +
+ {/if} + + {#each ungrouped as project (project.id)} + {@render projectRow(project, true)} {/each} {#if own.length === 0 && !adding} @@ -192,19 +422,87 @@ {/if}
+ {#each groups as group (group.id)} + {@const inside = inGroup(group.id)} +
+ + +
onGroupDragStart(e, group)} + ondragend={clearDrag} + ondragover={(e) => onGroupDragOver(e, group)} + ondragleave={() => { + overGroup = null; + overId = null; + }} + ondrop={(e) => onGroupDrop(e, group)} + > + {#if renamingGroup === group.id} +
renameGroup(e, group)}> + + (renamingGroup = null)} + onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)} + /> +
+ {:else} + +

+ +

+ {inside.length} + + + {/if} +
+ + {#if !group.collapsed} + {#each inside as project (project.id)} + {@render projectRow(project, true)} + {/each} + {#if !inside.length} +

Tom — træk et projekt herop.

+ {/if} + {/if} +
+ {/each} + {#if shared.length}

Delt med mig

{#each shared as project (project.id)} - - - {project.name} - {project.member_count} - + {@render projectRow(project, false)} {/each}
{/if} @@ -390,7 +688,7 @@ color: var(--ink-faint); } - .group-head button { + .group-head > button { width: 20px; height: 20px; display: grid; @@ -399,13 +697,143 @@ color: var(--ink-faint); font-size: var(--text-lg); line-height: 1; + flex: none; } - .group-head button:hover { + .group-head > button:hover { color: var(--ink); background: var(--surface-raised); } + .new-group svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.6; + stroke-linecap: round; + stroke-linejoin: round; + } + + /* --- groups ------------------------------------------------------------- */ + + .folder { + margin-top: calc(var(--s3) * -1); + } + + .folder-head { + position: relative; + gap: var(--s1); + border-radius: var(--radius); + } + + .folder-head.dragging { + opacity: 0.4; + } + + /* The heading lights up as a whole, because the whole heading is the target — + unlike a project row, where the target is the gap next to it. Both headings + do it: "Projekter" is where a project goes to leave its group. */ + .group-head.over { + background: var(--surface-raised); + box-shadow: inset 0 0 0 1px var(--accent); + border-radius: var(--radius); + } + + .folder-head::before { + content: ''; + position: absolute; + left: var(--s2); + right: var(--s2); + height: 2px; + background: var(--accent); + opacity: 0; + pointer-events: none; + } + + .folder-head.drop-above::before { + top: -1px; + opacity: 1; + } + + .folder-head.drop-below::before { + bottom: -1px; + opacity: 1; + } + + .fold { + flex: 1; + min-width: 0; + } + + .fold button { + display: flex; + align-items: center; + gap: var(--s1); + width: 100%; + min-width: 0; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + color: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .fold button:hover { + color: var(--ink); + } + + .chevron { + width: 11px; + height: 11px; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform var(--fast) var(--ease); + } + + .chevron.collapsed { + transform: rotate(-90deg); + } + + /* Hidden until the heading is hovered, like the section actions on a project + page: two buttons permanently beside a label stop it reading as a label. */ + .group-action { + font-size: var(--text-xs); + color: var(--ink-faint); + padding: 0 var(--s1); + flex: none; + opacity: 0; + transition: opacity var(--fast) var(--ease); + } + + .folder-head:hover .group-action, + .group-action:focus-visible { + opacity: 1; + } + + .group-action:hover { + color: var(--ink); + } + + .group-action.remove:hover { + color: var(--danger); + } + + .folder-head .count { + margin-left: 0; + } + + .folder-head form { + flex: 1; + min-width: 0; + } + a { display: flex; align-items: center; @@ -464,6 +892,15 @@ opacity: 1; } + /* A task hovering over a project lights the whole row, where a project hovering + between two rows draws a line in the gap. Two different questions — "into + this one" and "between these two" — so two different marks. */ + a.receiving { + background: var(--surface-raised); + box-shadow: inset 0 0 0 1px var(--accent); + color: var(--ink); + } + .dot { width: 6px; height: 6px; diff --git a/web/src/lib/components/TaskList.svelte b/web/src/lib/components/TaskList.svelte index 22a36d1..39bdce1 100644 --- a/web/src/lib/components/TaskList.svelte +++ b/web/src/lib/components/TaskList.svelte @@ -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 diff --git a/web/src/lib/dnd.js b/web/src/lib/dnd.js new file mode 100644 index 0000000..36d583f --- /dev/null +++ b/web/src/lib/dnd.js @@ -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'; +} diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 9e5c32b..6f8e9bf 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -16,6 +16,8 @@ import { api, ApiError, humanMessage } from './api.js'; class AppState { user = $state(null); projects = $state([]); + /** The foldable headings over the projects in the sidebar. */ + groups = $state([]); tasks = $state([]); loading = $state(true); /** Transient messages: a failed save, a rolled-back change. */ @@ -66,8 +68,12 @@ class AppState { this.loading = true; try { this.user = await api.me(); - const { projects } = await api.listProjects(); + const [{ projects }, { groups }] = await Promise.all([ + api.listProjects(), + api.listProjectGroups() + ]); this.projects = projects; + this.groups = groups; this.connect(); } catch (e) { this.user = null; @@ -133,6 +139,17 @@ class AppState { this.projects = this.projects.filter((p) => p.id !== task?.id); break; + case 'project_group.created': + case 'project_group.updated': + this.upsertGroup(task); + break; + case 'project_group.deleted': + // The projects filed under it are not deleted with it; they come back + // out as ungrouped, which the sidebar works out from the group being + // gone rather than from a second event per project. + this.groups = this.groups.filter((g) => g.id !== task?.id); + break; + // The payload is not always the label — a rename says only that // something changed — and the sidebar wants counts anyway, which no // single event carries. Asking is cheaper than modelling it. @@ -252,6 +269,35 @@ class AppState { } } + /** + * Moves a task into another project, which is what dropping it on the sidebar + * means. + * + * Through `move` rather than a `project_id` on an update: `sort_order` belongs + * to the project, so a task arriving in a new one needs a place among its + * tasks. The section goes too — sections belong to the project it is leaving, + * and carrying the id across would file it under a heading that is not there. + */ + async moveToProject(id, projectId) { + const previous = this.get(id); + if (!previous || previous.project_id === projectId) return; + + this.replace(id, { ...previous, project_id: projectId, section_id: '' }); + try { + this.replace(id, await api.moveTask(id, { project_id: projectId, section_id: '' })); + } catch (e) { + this.replace(id, previous); + this.toast(humanMessage(e)); + } + } + + /** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */ + async reschedule(id, date) { + const previous = this.get(id); + if (!previous || previous.due_date === date) return; + await this.update(id, { due_date: date }); + } + async remove(id) { const previous = [...this.tasks]; this.tasks = this.tasks.filter((t) => t.id !== id); @@ -324,6 +370,112 @@ class AppState { } } + // --- project groups ----------------------------------------------------------- + + /** The same new-array upsert the projects need, and for the same reason. */ + upsertGroup(group) { + if (!group?.id) return; + this.groups = this.groups.some((g) => g.id === group.id) + ? this.groups.map((g) => (g.id === group.id ? group : g)) + : [...this.groups, group]; + } + + async createGroup(name) { + try { + const group = await api.createProjectGroup(name); + this.upsertGroup(group); + return group; + } catch (e) { + this.toast(humanMessage(e)); + return null; + } + } + + /** + * Folds or unfolds a group. + * + * Applied first and saved after, like everything else here — a fold that waits + * for a round trip feels like a click that did not land. The rollback matters + * more than usual: this is stored on the account rather than in the browser, so + * a failed save that left the arrow turned would be a lie that survives a + * reload. + */ + async toggleGroup(id) { + const group = this.groups.find((g) => g.id === id); + if (!group) return; + await this.#patchGroup(id, { collapsed: !group.collapsed }); + } + + async renameGroup(id, name) { + await this.#patchGroup(id, { name }); + } + + async #patchGroup(id, patch) { + const previous = this.groups.find((g) => g.id === id); + if (!previous) return; + + this.upsertGroup({ ...previous, ...patch }); + try { + this.upsertGroup(await api.updateProjectGroup(id, patch)); + } catch (e) { + this.upsertGroup(previous); + this.toast(humanMessage(e)); + } + } + + /** + * Deletes the heading. The projects come back out as ungrouped, which is done + * here too rather than waiting for a re-read: the sidebar reads `group_id` to + * decide where a project sits, and a project pointing at a group that is gone + * would otherwise vanish from the list entirely until the next load. + */ + async deleteGroup(id) { + const previousGroups = this.groups; + const previousProjects = this.projects; + + this.groups = this.groups.filter((g) => g.id !== id); + this.projects = this.projects.map((p) => (p.group_id === id ? { ...p, group_id: '' } : p)); + try { + await api.deleteProjectGroup(id); + } catch (e) { + this.groups = previousGroups; + this.projects = previousProjects; + this.toast(humanMessage(e)); + } + } + + async reorderGroups(ids) { + const previous = this.groups; + const rank = new Map(ids.map((id, i) => [id, i])); + + this.groups = this.groups.map((g) => + rank.has(g.id) ? { ...g, sort_order: rank.get(g.id) } : g + ); + try { + await api.reorderProjectGroups(ids); + } catch (e) { + this.groups = previous; + this.toast(humanMessage(e)); + } + } + + /** Files a project under a group, or takes it out of one when groupId is ''. */ + async setProjectGroup(projectId, groupId) { + const previous = this.projects.find((p) => p.id === projectId); + // `?? ''` because the server omits the field when there is no group, so + // "none" arrives as undefined and is compared against the '' that means the + // same thing. + if (!previous || (previous.group_id ?? '') === groupId) return; + + this.upsertProject({ ...previous, group_id: groupId }); + try { + this.upsertProject(await api.updateProject(projectId, { group_id: groupId })); + } catch (e) { + this.upsertProject(previous); + this.toast(humanMessage(e)); + } + } + // --- toasts ------------------------------------------------------------------- toast(message) { @@ -393,6 +545,35 @@ class SidebarLayout { export const sidebar = new SidebarLayout(); +/** + * Whether Kommende shows the next seven days as a list, or the month as a grid. + * + * In localStorage rather than on the account, and for the same reason as the + * sidebar's width: a month grid of seven columns needs room, and the answer on a + * phone is not the answer on a wide monitor. A project keeps its `view_mode` on + * the row because that is a fact about the project — this is a fact about the + * screen. + */ +class UpcomingView { + mode = $state( + typeof localStorage !== 'undefined' && localStorage.getItem('verdande:upcoming') === 'calendar' + ? 'calendar' + : 'list' + ); + + set(next) { + if (next !== 'list' && next !== 'calendar') return; + this.mode = next; + try { + localStorage.setItem('verdande:upcoming', next); + } catch { + // Private browsing; the choice simply will not persist. + } + } +} + +export const upcomingView = new UpcomingView(); + /** * The themes on offer. * diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 73ac469..470f76a 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -63,7 +63,13 @@ let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor'); let isOwner = $derived(project?.role === 'owner'); - let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id)); + // Filtered by project as well as by state. The store holds whatever the last + // view loaded, and a task can now leave this project while you are looking at + // it — dropped on another project in the sidebar, or moved by somebody else — + // so "what the page asked for" is not the same as "what is in the store". + let open = $derived( + app.tasks.filter((t) => !t.completed && !t.parent_id && t.project_id === project?.id) + ); let unsectioned = $derived(open.filter((t) => !t.section_id)); let editing = $state(false); @@ -295,7 +301,7 @@ {#if mode === 'board'} {:else if mode === 'calendar'} - + {:else}
diff --git a/web/src/routes/upcoming/+page.svelte b/web/src/routes/upcoming/+page.svelte index dd193f8..f4942dc 100644 --- a/web/src/routes/upcoming/+page.svelte +++ b/web/src/routes/upcoming/+page.svelte @@ -1,19 +1,40 @@ -
-

Kommende

+
+
+

Kommende

+
+ {#each [['list', 'Liste'], ['calendar', 'Kalender']] as [value, label]} + + {/each} +
+
- {#each days as day (day.date)} - {@const tasks = live(day.date)} -
-

{heading(day.date)}

- {#if tasks.length} - {#each tasks as task (task.id)} - - {/each} - {:else} -

—

- {/if} -
- {/each} + {#if upcomingView.mode === 'calendar'} + + {:else} + {#each days as day (day.date)} + {@const tasks = live(day.date)} + +
onDragOver(e, day.date)} + ondragleave={() => (over = null)} + ondrop={(e) => onDrop(e, day.date)} + > +

{heading(day.date)}

+ {#if tasks.length} + {#each tasks as task (task.id)} + +
startDrag(e, TASK, task.id)} + ondragend={() => (over = null)} + > + +
+ {/each} + {:else} + +

—

+ {/if} +
+ {/each} + {/if}