feat(views): "Venter på andre", and a project you share stays yours

`assignee_id` has been on tasks since sharing was built and delegating has
been possible from the drawer since yesterday. What was missing was
anywhere to see it: work handed to somebody else left your lists and
appeared in nothing.

GET /delegated returns everything assigned to somebody who is not you, in
the projects you can see, grouped by the person. Grouped on the server
rather than flat, because the question is about people — "what is Anders
sitting on" — and a client cannot group it itself: it would need the name
behind an assignee id from a project it does not happen to have open, and
there is no way to ask for that. The names come back with the tasks, so
one request is one screen.

The filter is a field of its own rather than a sentinel in AssigneeID.
"What is on my plate" and "what have I handed over" are different
questions, and one string that means an id most of the time and a magic
word the rest is a field somebody eventually passes a real id to.

An assignee whose account is gone is named "Ukendt" rather than dropped.
Losing the row would make work disappear from the one view that exists to
stop work disappearing.

The sidebar entry only appears where delegating is possible at all. On an
instance with one person it could never have anything in it, and a
permanent empty entry is a question the sidebar keeps asking and answering
"no".

Building it turned up a bug in the split above it. "Projekter" and "Delt
med mig" were divided on `shared`, which only means the project has more
than one member — so inviting somebody to a project of your own moved it
under "Delt med mig", where it lost its place in the order, stopped being
draggable, and could not be filed under a group. It is divided on
ownership now, which is what the two headings have always said. Sharing
your own project is not the same event as somebody sharing theirs with
you.

The smoke test now follows the whole loop in one go: invite, accept in a
second browser, assign a task to the person who just joined, and find it
under their name — with the shared project still under "Projekter" on the
way past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 19:36:37 +02:00
co-authored by Claude Opus 5
parent 34b718529c
commit 6b6b838167
11 changed files with 436 additions and 5 deletions
+1
View File
@@ -62,6 +62,7 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| Project roles and permission checks | Done, tested |
| REST API: projects, sections, tasks, sub-tasks, ordering | Done, tested |
| Today, Upcoming, search, activity log | Done, tested |
| Waiting on others: what you have delegated, by person | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
+34
View File
@@ -1143,6 +1143,40 @@ paths:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/delegated:
get:
tags: [views]
summary: What you are waiting on other people for.
description: |
Everything assigned to somebody who is not you, in the projects you can
see, grouped by the person it is assigned to.
Grouped rather than flat because the question is about people, and a
client grouping it itself would need every assignee's name — which it has
no way to ask for. The names come back with the tasks, so one request is
one screen.
parameters:
- { name: limit, in: query, schema: { type: integer, default: 200, maximum: 500 } }
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
people:
type: array
items:
type: object
properties:
user_id: { type: string }
name: { type: string }
avatar_color: { type: string }
tasks:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/search:
get:
tags: [views]
+1
View File
@@ -254,6 +254,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Delete("/{labelID}", s.handleDeleteLabel)
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) {
+73
View File
@@ -92,6 +92,79 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": out})
}
type delegatedPerson struct {
UserID string `json:"user_id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
Tasks []taskJSON `json:"tasks"`
}
// handleDelegated is "waiting on others": everything assigned to somebody who is
// not the caller, in the projects the caller can see.
//
// Grouped by person rather than returned flat, because the question this view
// answers is about people — "what is Anders sitting on" — and a client grouping it
// itself would need every assignee's name, which it has no way to ask for. The
// names come back with the tasks, so one request is one screen.
//
// People are ordered by name, and their tasks by whatever the list query decided,
// which puts the overdue and the soonest first.
func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
DelegatedBy: user.ID,
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
})
if err != nil {
s.internal(w, "delegated", err)
return
}
byPerson := map[string][]taskJSON{}
ids := []string{}
for _, t := range tasks {
if _, seen := byPerson[t.AssigneeID]; !seen {
ids = append(ids, t.AssigneeID)
}
byPerson[t.AssigneeID] = append(byPerson[t.AssigneeID], toTaskJSON(t))
}
people, err := s.db.PeopleByIDs(r.Context(), ids)
if err != nil {
s.internal(w, "delegated people", err)
return
}
out := make([]delegatedPerson, 0, len(ids))
for _, id := range ids {
// A person who no longer has an account still has their id on the task.
// Naming them "Ukendt" beats dropping the tasks, which would make work
// disappear from the one view that exists to stop that happening.
p, ok := people[id]
if !ok {
p = store.Person{ID: id, Name: "Ukendt", AvatarColor: "#8a8f98"}
}
out = append(out, delegatedPerson{
UserID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor, Tasks: byPerson[id],
})
}
sortByName(out)
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
// sortByName orders the groups the way a list of people reads. Insertion sort:
// this is a handful of entries, and it keeps the package free of a dependency on
// sort just for this.
func sortByName(people []delegatedPerson) {
for i := 1; i < len(people); i++ {
for j := i; j > 0 && lower(people[j].Name) < lower(people[j-1].Name); j-- {
people[j], people[j-1] = people[j-1], people[j]
}
}
}
// handleSearch is what Cmd+K calls. It searches across every project the user can
// see, which is the point — the thing you are looking for is usually somewhere you
// were not looking.
+101 -1
View File
@@ -1,6 +1,9 @@
package httpapi
import "testing"
import (
"net/http"
"testing"
)
// A label is how you find a task whose wording you have forgotten. Search
// covered the text only, so "regnskab" found nothing even with the task
@@ -53,3 +56,100 @@ func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
t.Errorf("found somebody else's task: %v", tasks)
}
}
// "Waiting on others" is the other half of an assignee filter, and the half that
// is easy to get backwards: what somebody has handed over, not what they have
// been handed.
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.newUser(t, "anders@example.dk", "Anders")
anders, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
if err != nil {
t.Fatal(err)
}
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anders@example.dk", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
})
// Mine, and unassigned: neither belongs in a view about other people.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "ingen har den", "project_id": projectID,
})
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
}
people, _ := body["people"].([]any)
if len(people) != 1 {
t.Fatalf("want one person, got %v", people)
}
person := people[0].(map[string]any)
if person["name"] != "Anders" {
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
person["name"])
}
if person["avatar_color"] == nil || person["avatar_color"] == "" {
t.Errorf("no avatar colour: %v", person)
}
tasks, _ := person["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("want one task, got %v", tasks)
}
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
t.Errorf("content = %v", tasks[0])
}
}
// The view is per person: Anders is not waiting on himself, and he cannot see
// what somebody has delegated in a project he is not in.
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
anders := ts.newUser(t, "anders@example.dk", "Anders")
andersUser, err := ts.db.UserByEmail(t.Context(), "anders@example.dk")
if err != nil {
t.Fatal(err)
}
// A project Anders is not a member of.
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
"email": "anders@example.dk", "role": "editor",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
})
// It is assigned to him, so it is not something he is waiting on.
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := body["people"].([]any); len(people) != 0 {
t.Errorf("Anders is waiting on himself: %v", people)
}
// And somebody with no access to the project sees none of it.
other := ts.newUser(t, "tredje@example.dk", "Tredje")
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
if people, _ := third["people"].([]any); len(people) != 0 {
t.Errorf("a task in a project they cannot see: %v", people)
}
}
+12 -2
View File
@@ -48,8 +48,14 @@ type TaskFilter struct {
// renders them nested under their parents rather than as separate rows.
TopLevelOnly bool
AssigneeID string
LabelID string
Priority int
// DelegatedBy is the other side of AssigneeID: assigned, but not to this
// person. A second field rather than a magic value in AssigneeID, because the
// two are different questions — "what is on my plate" and "what have I handed
// over" — and one string that means an id most of the time and a sentinel the
// rest is the kind of field somebody eventually passes a real id to.
DelegatedBy string
LabelID string
Priority int
// DueBefore and DueFrom bound the calendar day, inclusive, as "YYYY-MM-DD".
DueBefore string
@@ -116,6 +122,10 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
where = append(where, `t.assignee_id = ?`)
args = append(args, f.AssigneeID)
}
if f.DelegatedBy != "" {
where = append(where, `t.assignee_id IS NOT NULL AND t.assignee_id <> ?`)
args = append(args, f.DelegatedBy)
}
if f.Priority > 0 {
where = append(where, `t.priority = ?`)
args = append(args, f.Priority)
+41
View File
@@ -122,6 +122,47 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
return &u, nil
}
// Person is the public half of a user: what somebody else is allowed to see when
// their name has to appear next to a task. No address, no hash, no timezone.
type Person struct {
ID string
Name string
AvatarColor string
}
// PeopleByIDs looks up several users at once.
//
// One query rather than a loop of UserByID, because the caller is resolving the
// assignees of a list of tasks and a per-row lookup is a query per row. Unknown
// ids are simply absent from the result; a user who has been deleted is not an
// error for a view that is only trying to put a name on a row.
func (db *DB) PeopleByIDs(ctx context.Context, ids []string) (map[string]Person, error) {
people := map[string]Person{}
if len(ids) == 0 {
return people, nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := db.QueryContext(ctx,
`SELECT id, name, avatar_color FROM users WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p Person
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
return nil, err
}
people[p.ID] = p
}
return people, rows.Err()
}
// UserCount is what decides whether the instance still needs its first admin.
func (db *DB) UserCount(ctx context.Context) (int, error) {
var n int
+27
View File
@@ -419,6 +419,33 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
// screen appears at all.
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
await context.close();
// Now that there is somebody to delegate to, the inviter can — and the view
// for it appears. It is hidden on an instance with one person, where it could
// never have anything in it.
await page.reload();
await expect(sidebar.getByRole('link', { name: 'Venter på andre' })).toBeVisible();
// A project the inviter owns stays under "Projekter" after sharing it. It used
// to move to "Delt med mig" the moment somebody accepted, and lose its place in
// the order on the way.
await expect(
page.locator('.group').filter({ hasText: 'Projekter' }).getByText('Fælleshuset')
).toBeVisible();
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('males inden fredag', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
await page.keyboard.press('Escape');
await sidebar.getByRole('link', { name: 'Venter på andre' }).click();
await expect(page.getByRole('heading', { name: 'Venter på andre' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nabo' })).toBeVisible();
await expect(page.getByText('males inden fredag')).toBeVisible();
expect(trouble).toEqual([]);
});
+1
View File
@@ -292,5 +292,6 @@ export const api = {
// --- views
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
delegated: () => get('/delegated'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};
+19 -2
View File
@@ -37,12 +37,20 @@
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
});
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
// Split by who owns it, not by whether anybody else is in it.
//
// The two headings say "Projekter" and "Delt med mig", and the split used to be
// on `shared`, which only means the project has more than one member. So the
// moment you invited somebody to a project of your own it moved under "Delt med
// mig" — where it lost its place in the order, stopped being draggable, and
// could not be filed under a group. Sharing your own project is not the same
// event as somebody sharing theirs with you.
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.role !== 'owner'));
// 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)
.filter((p) => !p.is_inbox && p.role === 'owner')
.sort((a, b) => a.sort_order - b.sort_order)
);
let current = $derived($page.url.pathname);
@@ -303,6 +311,15 @@
<span class="dot" aria-hidden="true"></span>
Kommende
</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>
Venter på andre
</a>
{/if}
{#if app.inbox}
{@render projectRow(app.inbox, false)}
{/if}
+126
View File
@@ -0,0 +1,126 @@
<script>
/**
* Venter på andre: what you have handed to somebody else and are waiting for.
*
* Grouped by person, because that is the question — "what is Anders sitting
* on" — rather than by date. The server groups it and sends the names with it;
* a client has no way to ask for the name behind an assignee id it has not met
* in a project it happens to have open.
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
let people = $state([]);
let loaded = $state(false);
$effect(() => {
api.delegated().then((data) => {
people = data.people;
app.tasks = data.people.flatMap((p) => p.tasks);
loaded = true;
});
});
// Read back out of the store, so a task that is closed or reassigned from the
// drawer leaves the list at once rather than on the next load. Reassigning to
// yourself is the interesting one: it stops being delegated the moment it
// happens, which is exactly what this view should show.
const live = (person) =>
app.tasks.filter((t) => t.assignee_id === person.user_id && !t.completed);
</script>
<div class="view">
<header><h1>Venter på andre</h1></header>
{#each people as person (person.user_id)}
{@const tasks = live(person)}
{#if tasks.length}
<section>
<h2>
<span class="avatar" style="background: {person.avatar_color}">
{person.name[0]?.toUpperCase() ?? '?'}
</span>
{person.name}
<span class="count">{tasks.length}</span>
</h2>
{#each tasks as task (task.id)}
<TaskRow {task} />
{/each}
</section>
{/if}
{/each}
{#if loaded && !people.some((p) => live(p).length)}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Du venter ikke på nogen.
</p>
{/if}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
h1 {
font-size: var(--text-2xl);
margin-bottom: var(--s5);
}
section {
margin-top: var(--s5);
}
/* The person is the heading, so their face is part of it rather than a
decoration beside it. */
h2 {
display: flex;
align-items: center;
gap: var(--s2);
font-size: var(--text-sm);
font-weight: 560;
color: var(--ink-muted);
padding: 0 var(--s2) var(--s2);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s2);
}
.avatar {
width: 20px;
height: 20px;
border-radius: var(--radius-full);
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 560;
color: #fff;
flex: none;
}
.count {
margin-left: auto;
font-size: var(--text-xs);
color: var(--ink-faint);
font-weight: 400;
}
.clear {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
padding: var(--s8) var(--s4);
color: var(--ink-faint);
font-size: var(--text-sm);
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
opacity: 0.5;
}
</style>