feat(sidebar): de faste visninger kan sorteres, og rækkefølgen følger kontoen
Release / Build and publish image (push) Failing after 9m57s

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 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-19 17:19:46 +02:00
co-authored by Claude Opus 5
parent 90bdf4b725
commit f557d319ca
9 changed files with 320 additions and 43 deletions
+30
View File
@@ -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]
+30 -1
View File
@@ -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 {
+1
View File
@@ -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)
@@ -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 '[]';
+33 -5
View File
@@ -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
+7
View File
@@ -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.
//
+1
View File
@@ -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 }),
+180 -37
View File
@@ -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 @@
<div class="views">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<a
href="/"
class:active={current === '/'}
class:receiving={overToday}
onclick={onnavigate}
ondragover={(e) => {
if (!carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
ondrop={onDropOnToday}
>
<span class="dot today" aria-hidden="true"></span>
{t('nav.today')}
</a>
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('nav.upcoming')}
</a>
{#if app.projects.some((p) => p.shared)}
<!-- Only where delegating is possible at all. On an instance with one
person this view can never have anything in it, and a permanent empty
entry is a question the sidebar keeps asking and answering "no". -->
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
{t('nav.delegated')}
</a>
{/if}
<!-- Færdige is not in this list any more. It used to sit here on the argument
that somebody hunting a task they closed by mistake does not know which
view they closed it in — which is true, and is also a rare errand next to
four entries that are read every day. It lives under Settings → Data, with
the trash, where the other "where did that go" questions are answered. -->
{#if app.inbox}
{@render projectRow(app.inbox, false)}
{/if}
<!-- The fixed views, in the order this person put them.
Drawn from a list rather than written out, because they can be reordered
now and a hand-written run cannot be. Unknown keys are dropped and new
ones appended, so adding a view later does not strand anybody's order. -->
{#each orderedNav as item (item.key)}
{#if item.key === 'inbox'}
{#if app.inbox}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="navrow"
class:dragging={draggingNav === item.key}
class:over={overNav === item.key}
ondragover={(e) => onNavDragOver(e, item.key)}
ondragleave={() => (overNav = null)}
ondrop={(e) => onNavDrop(e, item.key)}
>
{@render grip(item.key)}
{@render projectRow(app.inbox, false)}
</div>
{/if}
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="navrow"
class:dragging={draggingNav === item.key}
class:over={overNav === item.key}
ondragover={(e) => onNavDragOver(e, item.key)}
ondragleave={() => (overNav = null)}
ondrop={(e) => onNavDrop(e, item.key)}
>
{@render grip(item.key)}
<!-- I dag also takes a task dropped on it — the most-made rescheduling
there is, and the sidebar is where the pointer already is. The
handlers were lost when this run became a list; the test caught
it, which is the second time that drop has earned its test. -->
<a
href={item.href}
class:active={current === item.href}
class:receiving={item.key === 'today' && overToday}
onclick={onnavigate}
ondragover={(e) => {
if (item.key !== 'today' || !carries(e, TASK)) return;
accept(e);
overToday = true;
}}
ondragleave={() => (overToday = false)}
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
>
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
{t(item.label)}
</a>
</div>
{/if}
{/each}
</div>
<!-- Notes stand apart, with a rule above them.
@@ -395,6 +476,21 @@
three copies of that would be three chances to fix a bug in two of them.
Only your own reorder: `sort_order` is a column on the project, so a shared
one stays where its owner put it. -->
<!-- A handle rather than the whole row.
Making the row draggable made everything inside it draggable too, and the
sidebar is already a drop target for tasks — a task dragged onto I dag
stopped landing, because the row had claimed the gesture. A grip claims
nothing until it is grabbed. -->
{#snippet grip(key)}
<span
class="grip"
draggable="true"
ondragstart={(e) => onNavDragStart(e, key)}
ondragend={clearNavDrag}
aria-hidden="true">⠿</span
>
{/snippet}
{#snippet projectRow(project, sortable)}
<a
href="/projekt/{project.id}"
@@ -871,6 +967,53 @@
gap: 1px;
}
.navrow {
position: relative;
display: flex;
align-items: center;
}
/* Takes no room until it is wanted: it sits in the padding the row already has,
so nothing moves when it appears. */
.grip {
position: absolute;
left: -2px;
width: 12px;
text-align: center;
font-size: 10px;
line-height: 1;
color: var(--ink-faint);
cursor: grab;
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.navrow:hover .grip {
opacity: 1;
}
.navrow > :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 {
+25
View File
@@ -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);
}