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 @@