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
+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);
}