From c4e9e1519bcfd8d1aea91c1cd2834a9a35926d6e Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 15:49:52 +0200 Subject: [PATCH] feat(projects): drag the sidebar into the order you want MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects have carried sort_order since the first migration and nothing ever set it, so the sidebar was ordered by creation date for good. POST /projects/reorder takes the whole list rather than one move. A person has a handful of projects, so sending the order you want is simpler to reason about than midpoints between neighbours — and it cannot run out of precision, which is the failure the task ordering has to respace a section to recover from. It also cannot land half-applied the way a sequence of individual moves can. Projects you do not own are skipped rather than refused. sort_order is a column on the project, not a preference per viewer, so a shared project sits where its owner put it and a member rearranging their own sidebar does not rearrange somebody else's. The sidebar only makes your own projects draggable, so that rule never shows up as a row that springs back. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 29 +++++++++ internal/httpapi/projects_handlers.go | 33 ++++++++++ internal/httpapi/projects_test.go | 72 ++++++++++++++++++++ internal/httpapi/server.go | 2 + internal/store/projects.go | 27 ++++++++ web/src/lib/api.js | 1 + web/src/lib/components/Sidebar.svelte | 94 ++++++++++++++++++++++++++- web/src/lib/stores.svelte.js | 23 +++++++ 8 files changed, 280 insertions(+), 1 deletion(-) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 076dab6..4784d73 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -756,6 +756,35 @@ paths: "201": { $ref: "#/components/responses/Project" } "422": { $ref: "#/components/responses/Validation" } + /api/v1/projects/reorder: + post: + tags: [projects] + summary: Set the order projects appear in. + description: | + The whole list at once rather than one move at a time. A person has a + handful of projects, so sending the order you want is simpler than + midpoints and cannot land half-applied. + + Projects you do not own are skipped: `sort_order` is a column on the + project rather than a preference per viewer, so a shared one stays where + its owner put it. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ids] + properties: + ids: + type: array + maxItems: 500 + items: { type: string } + description: Project ids, top to bottom. + responses: + "204": { description: Reordered. } + "422": { $ref: "#/components/responses/Validation" } + /api/v1/projects/{projectID}: parameters: - { name: projectID, in: path, required: true, schema: { type: string } } diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go index 91aee6d..78cc74e 100644 --- a/internal/httpapi/projects_handlers.go +++ b/internal/httpapi/projects_handlers.go @@ -69,6 +69,39 @@ type projectRequest struct { Archived *bool `json:"archived"` } +type reorderProjectsRequest struct { + // IDs in the order they should appear, top to bottom. + IDs []string `json:"ids"` +} + +// handleReorderProjects sets the sidebar order. +// +// The whole list rather than one move: a person has a handful of projects, and +// sending the order you want is both simpler to reason about and impossible to +// get into the half-ordered state a sequence of individual moves can produce if +// one of them fails. +// +// Projects the caller does not own are skipped — sort_order is a column on the +// project, so a shared one sits where its owner put it. +func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) { + var req reorderProjectsRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + // A ceiling rather than a guess at what is reasonable: this is a bound on + // the transaction, not an opinion about how many projects somebody may have. + if len(req.IDs) > 500 { + writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"}) + return + } + + if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil { + s.internal(w, "reorder projects", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { var req projectRequest if err := decodeJSON(w, r, &req); err != nil { diff --git a/internal/httpapi/projects_test.go b/internal/httpapi/projects_test.go index 5411354..8da363a 100644 --- a/internal/httpapi/projects_test.go +++ b/internal/httpapi/projects_test.go @@ -81,3 +81,75 @@ func TestTheTrashIsPerOwner(t *testing.T) { t.Errorf("status %d, want 404", resp.StatusCode) } } + +func TestReorderProjectsWritesTheGivenOrder(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + var ids []string + for _, name := range []string{"Alfa", "Bravo", "Charlie"} { + _, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name}) + ids = append(ids, body["id"].(string)) + } + + // Back to front. + reversed := []string{ids[2], ids[1], ids[0]} + resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed}) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("reorder: status %d", resp.StatusCode) + } + + _, list := ts.do(t, "GET", "/api/v1/projects", nil) + projects, _ := list["projects"].([]any) + + var got []string + for _, raw := range projects { + p := raw.(map[string]any) + // The Inbox sorts ahead of everything by its own rule; it is not part of + // what was reordered. + if p["is_inbox"] == true { + continue + } + got = append(got, p["id"].(string)) + } + if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] { + t.Errorf("order = %v, want %v", got, reversed) + } +} + +// sort_order is a column on the project, not a per-viewer preference, so +// reordering must not rearrange a project somebody else owns. +func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + other := ts.newUser(t, "anden@example.dk", "Anden") + _, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"}) + theirID := theirs["id"].(string) + + _, before := other.do(t, "GET", "/api/v1/projects", nil) + var wanted float64 + for _, raw := range before["projects"].([]any) { + if p := raw.(map[string]any); p["id"] == theirID { + wanted = p["sort_order"].(float64) + } + } + + // Ask, as somebody else, to put their project first. + resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{ + "ids": []string{theirID}, + }) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("reorder: status %d", resp.StatusCode) + } + + _, after := other.do(t, "GET", "/api/v1/projects", nil) + for _, raw := range after["projects"].([]any) { + if p := raw.(map[string]any); p["id"] == theirID { + if p["sort_order"] != wanted { + t.Errorf("sort_order changed from %v to %v — somebody else moved it", + wanted, p["sort_order"]) + } + } + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 9fe4cc7..44cd2f1 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -259,6 +259,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Route("/projects", func(r chi.Router) { r.Get("/", s.handleListProjects) r.Post("/", s.handleCreateProject) + // Above /{projectID}, or chi reads "reorder" as an id. + r.Post("/reorder", s.handleReorderProjects) r.Route("/{projectID}", func(r chi.Router) { // Read access is the floor for the whole subtree; the writes diff --git a/internal/store/projects.go b/internal/store/projects.go index 8e5c534..ae45812 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -556,3 +556,30 @@ func joinComma(parts []string) string { } return out } + +// ReorderProjects writes the given order, and only for projects the user owns. +// +// The whole list at once, rather than the midpoint-between-neighbours dance the +// tasks do. A person has a handful of projects, not a thousand, so writing +// 0, 1, 2 … is one small transaction that cannot run out of precision — the +// failure the task ordering has to respace a section to recover from. +// +// Scoped to ownership because sort_order lives on the project row rather than +// per viewer: a shared project sits where its owner put it, and a member +// reordering their sidebar must not rearrange somebody else's. Ids that are not +// the caller's are skipped rather than refused, so one shared project in the +// list does not fail the whole drag. +func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + now := time.Now().Unix() + for i, id := range ids { + if _, err := tx.ExecContext(ctx, + `UPDATE projects SET sort_order = ?, updated_at = ? + WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`, + float64(i), now, id, userID); err != nil { + return err + } + } + return nil + }) +} diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 74749b8..34f3168 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -176,6 +176,7 @@ export const api = { createProject: (data) => post('/projects', data), updateProject: (id, data) => patch(`/projects/${id}`, data), deleteProject: (id) => del(`/projects/${id}`), + reorderProjects: (ids) => post('/projects/reorder', { ids }), listTrashedProjects: () => get('/trash/projects'), restoreProject: (id) => post(`/trash/projects/${id}/restore`), diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 6d5043d..806ea7a 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -33,8 +33,54 @@ }); let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared)); - let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared)); + // Sorted here rather than relying on the order the server sent, so a drag + // settles the moment it is dropped instead of after the round trip. + let own = $derived( + app.projects + .filter((p) => !p.is_inbox && !p.shared) + .sort((a, b) => a.sort_order - b.sort_order) + ); let current = $derived($page.url.pathname); + + // --- reordering --------------------------------------------------------------- + + let draggingId = $state(null); + let overId = $state(null); + let overBelow = $state(false); + + function onDragStart(event, project) { + draggingId = project.id; + event.dataTransfer.effectAllowed = 'move'; + // Firefox refuses to start a drag unless something is set on the transfer. + event.dataTransfer.setData('text/plain', project.id); + } + + function onDragOver(event, project) { + if (!draggingId || draggingId === project.id) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + + const box = event.currentTarget.getBoundingClientRect(); + overId = project.id; + overBelow = event.clientY > box.top + box.height / 2; + } + + async function onDrop(event, target) { + event.preventDefault(); + const id = draggingId; + const below = overBelow; + draggingId = null; + overId = null; + if (!id || id === target.id) return; + + const without = own.filter((p) => p.id !== id); + const at = without.findIndex((p) => p.id === target.id); + if (at < 0) return; + + const ordered = [...without]; + ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id)); + await app.reorderProjects(ordered.map((p) => p.id)); + }