From f557d319ca70013e357f83a0460a2347f4285e96 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 17:19:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(sidebar):=20de=20faste=20visninger=20kan?= =?UTF-8?q?=20sorteres,=20og=20r=C3=A6kkef=C3=B8lgen=20f=C3=B8lger=20konto?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken rækkefølge man vil have dem i, er en kendsgerning om personen. En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige forlod menuen. To ting, prøverne fandt undervejs, og begge var mine: Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det. Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet var en menuflytning. Migration 0019. Co-Authored-By: Claude Opus 5 --- docs/openapi.yaml | 30 +++ internal/httpapi/auth_handlers.go | 31 ++- internal/httpapi/server.go | 1 + internal/store/migrations/0019_nav_order.sql | 13 ++ internal/store/users.go | 38 +++- web/e2e/smoke.spec.js | 7 + web/src/lib/api.js | 1 + web/src/lib/components/Sidebar.svelte | 217 +++++++++++++++---- web/src/lib/stores.svelte.js | 25 +++ 9 files changed, 320 insertions(+), 43 deletions(-) create mode 100644 internal/store/migrations/0019_nav_order.sql diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a668e17..a0a806d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -263,6 +263,36 @@ paths: "204": { description: Saved. } "400": { $ref: "#/components/responses/BadRequest" } + /api/v1/auth/nav-order: + put: + tags: [auth] + summary: The order of the fixed views in the sidebar. + description: | + I dag, Kommende, Venter and the inbox, in whatever order this person wants + them. On the account rather than in the browser: which order you want them + in is a fact about you and should be the same on the phone as on the laptop. + + A list of keys rather than a number per view. The set of views changes with + the program, and a key that no longer exists costs nothing — the sidebar + draws what it recognises and appends anything new at the end, which is what + makes adding a view later not a migration. An empty list means the order the + program ships with. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + order: + type: array + maxItems: 20 + items: { type: string } + example: ["inbox", "today", "upcoming", "delegated"] + responses: + "204": { description: Saved. } + "400": { $ref: "#/components/responses/BadRequest" } + /api/v1/auth/me: get: tags: [auth] diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go index 8dce7bf..97773fd 100644 --- a/internal/httpapi/auth_handlers.go +++ b/internal/httpapi/auth_handlers.go @@ -32,6 +32,7 @@ type userJSON struct { // about the work and not about the screen — the sidebar's width is the // opposite case and stays in localStorage. SidebarCollapsed []string `json:"sidebar_collapsed"` + NavOrder []string `json:"nav_order"` CreatedAt string `json:"created_at"` } @@ -42,10 +43,14 @@ func toUserJSON(u *store.User) userJSON { if collapsed == nil { collapsed = []string{} } + order := u.NavOrder + if order == nil { + order = []string{} + } return userJSON{ ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor, Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin, - TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, + TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order, CreatedAt: u.CreatedAt.Format(time.RFC3339), } } @@ -83,6 +88,30 @@ func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusNoContent) } +// handleSetNavOrder records the order of the fixed views in the sidebar. +// +// Its own route for the same reason as the folds: this writes on every drop, and +// sharing the profile's path would make a reorder send a name and a timezone it +// was never asked to change. +func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) { + var req struct { + Order []string `json:"order"` + } + if err := decodeJSON(w, r, &req); err != nil { + return + } + if len(req.Order) > 20 { + writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries") + return + } + user := userFrom(r.Context()) + if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil { + s.storeError(w, r, "set nav order", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + // --- sign in ----------------------------------------------------------------- type loginRequest struct { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index a3b2b48..729cf68 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -178,6 +178,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/me", s.handleMe) r.Patch("/me", s.handleUpdateProfile) r.Put("/sidebar-sections", s.handleSetSidebarSections) + r.Put("/nav-order", s.handleSetNavOrder) r.Post("/logout", s.handleLogout) r.Post("/password/change", s.handleChangePassword) r.Post("/totp/setup", s.handleTOTPSetup) diff --git a/internal/store/migrations/0019_nav_order.sql b/internal/store/migrations/0019_nav_order.sql new file mode 100644 index 0000000..aa04bbc --- /dev/null +++ b/internal/store/migrations/0019_nav_order.sql @@ -0,0 +1,13 @@ +-- The order of the fixed views in the sidebar. +-- +-- On the account rather than in the browser, and for the reason the sidebar's +-- width is the other way round: a width is a fact about the screen you are at, and +-- the answer on a phone is not the answer on a monitor. Which order you want I dag, +-- Kommende, Venter and Indbakken in is a fact about you, and it should be the same +-- on the laptop as on the phone. +-- +-- A list of keys rather than a number per row, for the same reason sidebar_collapsed +-- is: the set of views changes with the program, and a key that no longer exists +-- costs nothing — the sidebar draws what it recognises and appends anything new at +-- the end. That is what makes adding a view later not a migration. +ALTER TABLE users ADD COLUMN nav_order TEXT NOT NULL DEFAULT '[]'; diff --git a/internal/store/users.go b/internal/store/users.go index 58ec5fd..c0c836d 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -36,8 +36,13 @@ type User struct { // is as true on the laptop as on the desktop. The sidebar's *width* is the // opposite case and is stored per browser. SidebarCollapsed []string - CreatedAt time.Time - UpdatedAt time.Time + + // NavOrder is the order this person wants the fixed views in, by key. Empty + // means the order the program ships with. Unknown keys are ignored and missing + // ones are appended, so adding a view later is not a migration. + NavOrder []string + CreatedAt time.Time + UpdatedAt time.Time } // NormalizeEmail is the one place an address is canonicalised. Addresses arrive @@ -104,7 +109,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) { } const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled, - avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at` + avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at` func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) { var u User @@ -112,11 +117,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e var totpEnabled, isAdmin int var created, updated int64 - var collapsed string + var collapsed, navOrder string err := db.QueryRowContext(ctx, query, args...).Scan( &u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled, - &u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated) + &u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -131,11 +136,34 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e // worst case is a sidebar that opens fully, which is the state it shipped in. u.SidebarCollapsed = []string{} _ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed) + u.NavOrder = []string{} + _ = json.Unmarshal([]byte(navOrder), &u.NavOrder) u.CreatedAt = time.Unix(created, 0).UTC() u.UpdatedAt = time.Unix(updated, 0).UTC() return &u, nil } +// SetNavOrder records the order of the fixed views. +func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error { + if order == nil { + order = []string{} + } + raw, err := json.Marshal(order) + if err != nil { + return err + } + res, err := db.ExecContext(ctx, + `UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`, + string(raw), time.Now().Unix(), userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + // SetSidebarCollapsed records which sidebar headings are folded. // // Its own method rather than a field on UpdateProfile: that one backs a form with diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 15677c3..71aa91a 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1875,6 +1875,13 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature await box.fill('første i dag'); await box.press('Enter'); + // In the project itself before looking at the sidebar. Without this the count + // can be read while the task is still on its way, or — worse — after it landed + // in the inbox because the box that took the text belonged to the previous + // view. Then the assertion below fails for a reason that has nothing to do with + // what it is testing. + await expect(page.getByText('første', { exact: true })).toBeVisible(); + // Reloaded before looking. What is asserted is that the number is open tasks // and comes from the server, which holds on any load. // diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 8846b96..e7cae3e 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -188,6 +188,7 @@ export const api = { updateProfile: (data) => patch('/auth/me', data), // Its own route rather than a field on the profile: that one backs a form with // a save button, and this writes on every click of a chevron. + setNavOrder: (order) => put('/auth/nav-order', { order }), setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }), signup: (data) => post('/auth/signup', data), forgotPassword: (email) => post('/auth/password/forgot', { email }), diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 7a88928..63df0d7 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -117,6 +117,70 @@ } + // --- the fixed views --------------------------------------------------------- + + // Delegated is only offered where delegating is possible at all: on an instance + // with one person that view can never have anything in it, and a permanent empty + // entry is a question the sidebar keeps asking and answering "no". + let navItems = $derived( + [ + { key: 'today', href: '/', label: 'nav.today' }, + { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' }, + app.projects.some((p) => p.shared) + ? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' } + : null, + // No href: the inbox is a project and is drawn by projectRow, which knows + // its id. It is in this list only so it can be dragged with the others. + { key: 'inbox', label: 'nav.inbox' } + ].filter(Boolean) + ); + + let orderedNav = $derived( + app + .navOrder(navItems.map((i) => i.key)) + .map((key) => navItems.find((i) => i.key === key)) + .filter(Boolean) + ); + + let draggingNav = $state(null); + let overNav = $state(null); + + function onNavDragStart(event, key) { + draggingNav = key; + event.dataTransfer.effectAllowed = 'move'; + // A payload, because Firefox refuses to start a drag without one. + event.dataTransfer.setData('text/plain', key); + } + + function onNavDragOver(event, key) { + if (!draggingNav || key === draggingNav) return; + event.preventDefault(); + overNav = key; + } + + function clearNavDrag() { + draggingNav = null; + overNav = null; + } + + async function onNavDrop(event, key) { + const moved = draggingNav; + // Checked before anything is swallowed. Stopping the event first meant a task + // dragged onto I dag never reached the handler that gives it a date — the row + // took the drop and then discovered it was not for it. + if (!moved || moved === key) { + clearNavDrag(); + return; + } + event.preventDefault(); + event.stopPropagation(); + clearNavDrag(); + + const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved); + keys.splice(keys.indexOf(key), 0, moved); + await app.setNavOrder(keys); + } + // --- resizing ------------------------------------------------------------------- let resizing = $state(false); @@ -340,43 +404,60 @@
- { - if (!carries(e, TASK)) return; - accept(e); - overToday = true; - }} - ondragleave={() => (overToday = false)} - ondrop={onDropOnToday} - > - - {t('nav.today')} - - - - {t('nav.upcoming')} - - {#if app.projects.some((p) => p.shared)} - - - - {t('nav.delegated')} - - {/if} - - {#if app.inbox} - {@render projectRow(app.inbox, false)} - {/if} + + {#each orderedNav as item (item.key)} + {#if item.key === 'inbox'} + {#if app.inbox} + + + {/if} + {:else} + + + {/if} + {/each}
+ + {#snippet grip(key)} + onNavDragStart(e, key)} + ondragend={clearNavDrag} + aria-hidden="true">⠿ + {/snippet} + {#snippet projectRow(project, sortable)} :global(a) { + flex: 1; + min-width: 0; + } + + .navrow.dragging { + opacity: 0.4; + } + + /* A line where it will land, not a box around what it will land on: the first + reads as a position and the second as a target. */ + .navrow.over::before { + content: ''; + position: absolute; + top: -1px; + left: var(--s2); + right: var(--s2); + height: 2px; + background: var(--accent); + border-radius: 1px; + } + /* A rule and a little air, which is the whole of "separate". Notes are the other half of the program, not a fifth way of looking at the same tasks. */ .apart { diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 96f0f78..70b5b35 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -562,6 +562,31 @@ class AppState { } } + /** + * The order of the fixed views, as keys. + * + * Unknown keys are dropped and missing ones appended, so the stored order never + * has to be migrated when a view is added or taken away — which has already + * happened once, when Færdige left the sidebar. + */ + navOrder(keys) { + const saved = this.user?.nav_order ?? []; + const known = new Set(keys); + const ordered = saved.filter((k) => known.has(k)); + return [...ordered, ...keys.filter((k) => !ordered.includes(k))]; + } + + async setNavOrder(order) { + const before = this.user?.nav_order ?? []; + this.user = { ...this.user, nav_order: order }; + try { + await api.setNavOrder(order); + } catch (e) { + this.user = { ...this.user, nav_order: before }; + this.toast(humanMessage(e)); + } + } + sectionCollapsed(key) { return (this.user?.sidebar_collapsed ?? []).includes(key); }