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)); + }
Tom — træk et projekt herop.
+ {/if} + {/if} +—
- {/if} -—
+ {/if} +