diff --git a/README.md b/README.md index 4df7869..5c78547 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4a2e3c1..5964bba 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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] diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 1ff4d73..007b78c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) { diff --git a/internal/httpapi/views_handlers.go b/internal/httpapi/views_handlers.go index 71c04a2..e150a87 100644 --- a/internal/httpapi/views_handlers.go +++ b/internal/httpapi/views_handlers.go @@ -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. diff --git a/internal/httpapi/views_test.go b/internal/httpapi/views_test.go index cf94c0b..960b8ba 100644 --- a/internal/httpapi/views_test.go +++ b/internal/httpapi/views_test.go @@ -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) + } +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 5993e6d..846c4c5 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -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) diff --git a/internal/store/users.go b/internal/store/users.go index 6784b3f..b099dc4 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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 diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index c26a48d..9b46b08 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -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([]); }); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index d0f5eab..1b1eba5 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -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)}`) }; diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index efda189..7d9e2dd 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -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 @@ Kommende + {#if app.projects.some((p) => p.shared)} + + + + Venter på andre + + {/if} {#if app.inbox} {@render projectRow(app.inbox, false)} {/if} diff --git a/web/src/routes/uddelegeret/+page.svelte b/web/src/routes/uddelegeret/+page.svelte new file mode 100644 index 0000000..76a311f --- /dev/null +++ b/web/src/routes/uddelegeret/+page.svelte @@ -0,0 +1,126 @@ + + +
+ + Du venter ikke på nogen. +
+ {/if} +