Compare commits

...
2 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 35d9e3bdac feat: an assignee on the row, and the activity log made visible
Release / Build and publish image (push) Failing after 10m2s
Two more things the database has been recording and the interface has
never shown.

**Who a task is waiting on, on the row.** Only when it is not you: your own
list would otherwise carry your own face on every line, which is a column
of one initial telling you nothing. The point of showing an assignee is
that it is somebody else.

That needed names, and a task carries only an `assignee_id`. `GET /people`
is the primitive: everybody you share a project with, plus yourself,
fetched once per session. Not per row, and not per project either — a list
would have to know which project each task belongs to before it could ask
for that project's members. Deliberately *not* the instance's user list,
which is `/users` and administrators only: a task list has no business
enumerating everybody with an account here.

It sits first in the meta line, because who changes what the rest of the
line means — a date on somebody else's task is their deadline, not yours.

**And the activity log.** Nineteen kinds of entry have been written since
the beginning — every project, section, member, task and comment event —
and `GET /projects/{id}/activity` has answered honestly the whole time with
nothing on the other end. There is a Historik panel on the project now,
loaded when it is opened rather than with the page, and the event keys
become Danish in exactly one place. An unknown key falls through to itself
rather than being hidden: a build that records something this table has not
learned should still show that it happened.

One test assertion had to change, and it is worth saying why. It matched
"males inden fredag" against a row whose title is "males inden" — quick add
read "fredag" as the due date — so it was really matching the title
concatenated with the metadata beside it. Putting the assignee between them
broke it. The assertion now names the task's own text.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:31:43 +02:00
Kristian WindandClaude Opus 5 b6ea87ac19 feat(projects): change a member's role from the member list
An invite sent with the wrong role could not be corrected. The only way
back was to remove the person and invite them again — and removing a member
unassigns every task they were responsible for, so fixing a dropdown cost
somebody their work. This is the other side of "an external user cannot
create tasks": the rule was right, the role was wrong, and there was no way
to say so.

PATCH against a membership that already exists, not the upsert AddMember
does. Somebody who is not a member is not silently made one: that is an
invite, it belongs to the invite flow, and doing it here would let a typo
in a user id add a stranger to a project without anybody being asked.

The owner is refused, with the real reason rather than the true but useless
one. Ownership lives on the project rather than in project_members, so the
plain answer would have been "not a member" — which is correct and tells
nobody anything. Ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom.

In the list, the owner's standing is text and everybody else's is a
dropdown. A disabled control would be a thing that exists to say no.

And the roles are finally in Danish. The API's words are `owner`, `editor`
and `viewer`, and they were being printed straight into a Danish page; the
list now uses the same three phrases the invite form beside it already did.

The smoke test drives both browsers, because the interesting part is that
the other one's permissions really change: a viewer is told the project is
read-only and has no quick-add box, the owner promotes them from the list,
and after a reload the box is there and the notice is gone.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 22:14:33 +02:00
14 changed files with 693 additions and 5 deletions
+3
View File
@@ -65,6 +65,9 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
| 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 |
| Assignee shown on every task that is somebody else's | Done, smoke-tested |
| Activity log, visible per project | Done, smoke-tested |
| Change a member's role without removing them | Done, tested |
| Sharing: invites, roles, members | Done, tested |
| Live sync over WebSocket | Done |
| Web interface: sign-in, views, quick add, ⌘K, shortcuts | Done |
+67
View File
@@ -1211,9 +1211,46 @@ paths:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
- { name: userID, in: path, required: true, schema: { type: string } }
patch:
tags: [projects]
summary: Change what an existing member may do. The project's owner only.
description: |
Editor or viewer. An UPDATE against a membership that already exists —
somebody who is not a member is not silently made one, because that is an
invite and belongs to the invite flow.
Not the owner: ownership is transferred, not granted, and two owners is a
state with no way to settle a disagreement about who may remove whom. The
owner also has no membership row at all — ownership lives on the project —
so this answers 409 with the real reason rather than "not a member".
Correcting a role used to mean removing the person and inviting them
again, which also unassigns every task they were responsible for.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role]
properties:
role: { type: string, enum: [editor, viewer] }
responses:
"204": { description: Changed. }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: The owner's standing cannot be changed.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/Validation" }
delete:
tags: [projects]
summary: Remove somebody from a project. Its owner only.
description: |
Their tasks are unassigned rather than left pointing at somebody who can
no longer see the project — which is why changing a role is a PATCH and
not a remove-and-reinvite.
responses:
"204": { description: Removed. }
"404": { $ref: "#/components/responses/NotFound" }
@@ -1315,6 +1352,36 @@ paths:
type: array
items: { $ref: "#/components/schemas/Task" }
/api/v1/people:
get:
tags: [views]
summary: Everybody you share a project with, and yourself.
description: |
What an interface needs to put a name and a face on an `assignee_id` — in
a task row, in a filter, anywhere a person appears beside work. One
request per session rather than a lookup per row, and per project members
would mean knowing which project every task belongs to first.
Deliberately not the instance's user list: that is
`/users`, administrators only. A task list has no business enumerating
everybody with an account here.
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
people:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
avatar_color: { type: string }
/api/v1/delegated:
get:
tags: [views]
+47
View File
@@ -502,6 +502,53 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed})
}
type memberRoleRequest struct {
Role string `json:"role"`
}
// handleSetMemberRole changes an existing member between editor and viewer.
//
// Until now the only way to correct an invite sent with the wrong role was to
// remove the person and invite them again — which also unassigns everything they
// were responsible for, because removing a member clears their tasks' assignee.
// Fixing a dropdown should not cost somebody their work.
func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
var req memberRoleRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
role := store.Role(req.Role)
if !role.Valid() || role == store.RoleOwner {
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
return
}
// The owner has no project_members row at all — ownership lives on the project
// — so this would report "not a member", which is true and completely
// unhelpful. Say the real reason instead.
owner, err := s.db.ProjectOwner(r.Context(), projectID)
if err != nil {
s.storeError(w, "project owner", err)
return
}
if userID == owner {
writeError(w, http.StatusConflict, CodeConflict,
"the owner's standing cannot be changed; ownership is transferred, not granted")
return
}
if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil {
s.storeError(w, "set member role", err)
return
}
s.activity(r, projectID, "", "member.role_changed",
map[string]any{"user_id": userID, "role": string(role)})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
userID := chi.URLParam(r, "userID")
+89
View File
@@ -194,3 +194,92 @@ func TestListMembersWorksForTheInbox(t *testing.T) {
t.Fatalf("status %d, body %v", resp.StatusCode, body)
}
}
// A role sent by mistake used to be uncorrectable: the only way back was to remove
// the person and invite them again, which unassigns every task they were
// responsible for. Fixing a dropdown should not cost somebody their work.
func TestAMembersRoleCanBeChangedWithoutRemovingThem(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "[email protected]", "Anden")
memberUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
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": "[email protected]", "role": "viewer",
})
// A viewer cannot write. That is the rule, not the bug.
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
map[string]any{"content": "prøv", "project_id": projectID}); resp.StatusCode != http.StatusNotFound {
t.Fatalf("a viewer created a task: status %d", resp.StatusCode)
}
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "editor"})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("promote to editor: status %d", resp.StatusCode)
}
if resp, _ := member.do(t, "POST", "/api/v1/tasks",
map[string]any{"content": "nu kan jeg", "project_id": projectID}); resp.StatusCode != http.StatusCreated {
t.Errorf("an editor could not create a task: status %d", resp.StatusCode)
}
_, members := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
for _, raw := range members["members"].([]any) {
if m := raw.(map[string]any); m["user_id"] == memberUser.ID && m["role"] != "editor" {
t.Errorf("role = %v after the change, want editor", m["role"])
}
}
}
// The owner is not a member row, and ownership is not a role you can hand out.
func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
member := ts.newUser(t, "[email protected]", "Anden")
owner, err := ts.db.UserByEmail(t.Context(), "[email protected]")
if err != nil {
t.Fatal(err)
}
memberUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
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": "[email protected]", "role": "editor",
})
// Demoting the owner would leave a project nobody can administer.
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+owner.ID,
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusConflict {
t.Errorf("changing the owner's role: status %d, want 409", resp.StatusCode)
}
// And no member may promote themselves to owner.
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "owner"}); resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("granting ownership: status %d, want 422", resp.StatusCode)
}
// Nor may an editor change anybody's role: this is the owner's to do.
if resp, _ := member.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID,
map[string]any{"role": "viewer"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("an editor changed a role: status %d, want 404", resp.StatusCode)
}
// Somebody with no membership at all is not quietly given one.
stranger := ts.newUser(t, "[email protected]", "Tredje")
_ = stranger
strangerUser, _ := ts.db.UserByEmail(t.Context(), "[email protected]")
if resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID+"/members/"+strangerUser.ID,
map[string]any{"role": "editor"}); resp.StatusCode != http.StatusNotFound {
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
}
}
+2
View File
@@ -264,6 +264,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
r.Get("/upcoming", s.handleUpcoming)
r.Get("/delegated", s.handleDelegated)
r.Get("/people", s.handlePeople)
r.Get("/search", s.handleSearch)
r.Route("/projects", func(r chi.Router) {
@@ -292,6 +293,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Patch("/", s.handleUpdateProject)
r.Delete("/", s.handleDeleteProject)
r.Post("/invites", s.handleInvite)
r.Patch("/members/{userID}", s.handleSetMemberRole)
r.Delete("/members/{userID}", s.handleRemoveMember)
})
})
+25
View File
@@ -92,6 +92,31 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"days": out})
}
// handlePeople lists everybody the caller shares a project with, so an interface
// can put a name and a face on an assignee id.
//
// One request per session rather than a lookup per row, and not the instance's
// user list — that is the administrator's page. A task list has no business
// enumerating everybody with an account here.
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
if err != nil {
s.internal(w, "list people", err)
return
}
out := make([]personJSON, 0, len(people))
for _, p := range people {
out = append(out, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
writeJSON(w, http.StatusOK, map[string]any{"people": out})
}
type personJSON struct {
ID string `json:"id"`
Name string `json:"name"`
AvatarColor string `json:"avatar_color"`
}
type delegatedPerson struct {
UserID string `json:"user_id"`
Name string `json:"name"`
+47
View File
@@ -153,3 +153,50 @@ func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
t.Errorf("a task in a project they cannot see: %v", people)
}
}
// /people is what puts a name on an assignee id. It must be the people you share
// work with — not the instance's user list, which is the administrator's page.
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
colleague := ts.newUser(t, "[email protected]", "Anders")
ts.newUser(t, "[email protected]", "Fremmed")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites", map[string]any{
"email": "[email protected]", "role": "editor",
})
_, body := ts.do(t, "GET", "/api/v1/people", nil)
names := map[string]bool{}
for _, raw := range body["people"].([]any) {
p := raw.(map[string]any)
names[p["name"].(string)] = true
if p["avatar_color"] == "" {
t.Errorf("no avatar colour for %v — the row cannot draw a face", p["name"])
}
}
if !names["Kristian"] {
t.Error("yourself is missing, so a client cannot tell 'me' from 'somebody else' against one list")
}
if !names["Anders"] {
t.Error("somebody sharing a project is missing")
}
if names["Fremmed"] {
t.Error("an account with no project in common is listed — this is not the user directory")
}
// And it is symmetric: the person invited sees the person who invited them.
_, theirs := colleague.do(t, "GET", "/api/v1/people", nil)
seen := map[string]bool{}
for _, raw := range theirs["people"].([]any) {
seen[raw.(map[string]any)["name"].(string)] = true
}
if !seen["Kristian"] || !seen["Anders"] {
t.Errorf("the member cannot see who they share with: %v", seen)
}
if seen["Fremmed"] {
t.Errorf("a stranger is listed: %v", seen)
}
}
+27
View File
@@ -389,6 +389,33 @@ func (db *DB) AddMember(ctx context.Context, projectID, userID string, role Role
return err
}
// SetMemberRole changes what an existing member may do.
//
// An UPDATE rather than the upsert AddMember does, and the difference is the
// point: this is a PATCH against a membership that exists. Somebody who is not a
// member is not silently made one — that is an invite, it belongs to the invite
// flow, and doing it here would let a typo in a user id add a stranger to a
// project without anybody being asked.
//
// Owner is refused for the same reason AddMember refuses it: ownership is
// transferred, not granted, and two owners is a state with no way to settle a
// disagreement about who may remove whom.
func (db *DB) SetMemberRole(ctx context.Context, projectID, userID string, role Role) error {
if !role.Valid() || role == RoleOwner {
return errors.New("store: members may be editor or viewer")
}
res, err := db.ExecContext(ctx,
`UPDATE project_members SET role = ? WHERE project_id = ? AND user_id = ?`,
string(role), projectID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (db *DB) RemoveMember(ctx context.Context, projectID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
// Anything assigned to them is unassigned rather than left pointing at
+44
View File
@@ -274,6 +274,50 @@ func (db *DB) DeleteUser(ctx context.Context, userID string) error {
return nil
}
// ListPeople returns everybody the caller shares at least one project with, plus
// the caller.
//
// The set the interface needs to put a name on an `assignee_id` — in a task row,
// in a filter, anywhere a person appears next to work. Fetched once rather than
// looked up per row, and deliberately *not* the instance's user list: that is the
// administrator's page, and a task list has no business enumerating everybody with
// an account here.
//
// Themselves included, because a client comparing "is this me" against the same
// list it draws from has one source rather than two.
func (db *DB) ListPeople(ctx context.Context, userID string) ([]Person, error) {
rows, err := db.QueryContext(ctx, `
SELECT DISTINCT u.id, u.name, u.avatar_color
FROM users u
WHERE u.id = ?
-- the owners of projects the caller is a member of
OR u.id IN (SELECT p.owner_id FROM projects p
JOIN project_members m ON m.project_id = p.id
WHERE m.user_id = ? AND p.deleted_at IS NULL)
-- and the members of every project the caller can see
OR u.id IN (SELECT m2.user_id FROM project_members m2
JOIN projects p2 ON p2.id = m2.project_id
WHERE p2.deleted_at IS NULL
AND (p2.owner_id = ?
OR EXISTS (SELECT 1 FROM project_members m3
WHERE m3.project_id = p2.id AND m3.user_id = ?)))
ORDER BY u.name`, userID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
people := []Person{}
for rows.Next() {
var p Person
if err := rows.Scan(&p.ID, &p.Name, &p.AvatarColor); err != nil {
return nil, err
}
people = append(people, 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
+85 -1
View File
@@ -570,7 +570,10 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
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();
// The task's own text, not the whole row's: quick add read "fredag" as the due
// date, so the title is "males inden" and the rest of that line is metadata —
// which now has the assignee in it too.
await expect(page.getByText('males inden', { exact: true })).toBeVisible();
expect(trouble).toEqual([]);
});
@@ -705,6 +708,87 @@ test('den sidste administrator kan hverken fjernes eller slette sig selv', async
await expect(mine.getByRole('button')).toHaveCount(0);
});
/**
* A member's role can be corrected without removing them.
*
* This is what a viewer who cannot add tasks looks like from the other side. The
* rule itself is right — a viewer may not write — but until now the only way to
* fix an invite sent with the wrong role was to remove the person and invite them
* again, which unassigns every task they were responsible for on the way past.
*
* Driven end to end because the interesting part is that the *other* browser's
* permissions actually change: the server decides, and the page has to agree.
*/
test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Fælles');
await sidebar.getByLabel('Projektnavn').press('Enter');
await page.getByRole('button', { name: 'Del' }).click();
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
await page.getByLabel('Rolle').selectOption('viewer');
await page.getByRole('button', { name: 'Inviter' }).click();
const link = await page.locator('.link-out code').textContent();
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
await andreas.getByLabel('Navn').fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await andreas
.getByRole('navigation', { name: 'Hovedmenu' })
.getByRole('link', { name: /Fælles/ })
.click();
// A viewer sees the project and is told, plainly, that it is read-only.
await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible();
await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0);
// The owner promotes them from the member list. The owner's own row is text,
// not a dropdown: ownership is transferred, not granted.
await page.reload();
await page.getByRole('button', { name: /Delt/ }).click();
await expect(page.getByText('Ejer')).toBeVisible();
await page.getByLabel('Rolle for andreas').selectOption('editor');
await andreas.reload();
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
// Now that they can edit, hand them something — and the row says whose it is.
// Only when it is somebody else's: your own list would otherwise carry your own
// face on every line, which is a column of one initial telling you nothing.
await page.getByLabel('Ny opgave').fill('brænde kaffe');
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('brænde kaffe', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
await page.keyboard.press('Escape');
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
// And not on a task that is nobody's.
await page.getByLabel('Ny opgave').fill('min egen');
await page.getByLabel('Ny opgave').press('Enter');
await expect(
page.locator('.row').filter({ hasText: 'min egen' }).locator('.assignee')
).toHaveCount(0);
// The log has recorded all of this since the beginning and nothing showed it.
await page.getByRole('button', { name: 'Historik' }).click();
await expect(page.getByText('oprettede projektet')).toBeVisible();
await expect(page.getByText('ændrede rollen for')).toBeVisible();
await context.close();
expect(trouble).toEqual([]);
});
/**
* The MCP connector address must not fall through to the app shell.
*
+3
View File
@@ -205,6 +205,8 @@ export const api = {
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
activity: (projectId) => get(`/projects/${projectId}/activity`),
@@ -305,5 +307,6 @@ export const api = {
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
delegated: () => get('/delegated'),
people: () => get('/people'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};
+33 -1
View File
@@ -48,6 +48,8 @@
};
}
// Only when it is somebody else's: see app.assigneeOf.
let assignee = $derived(app.assigneeOf(task));
let due = $derived(dueLabel(task.due_date));
let time = $derived(
task.due_datetime
@@ -81,8 +83,19 @@
<span class="description">{task.description}</span>
{/if}
{#if due || task.labels?.length || task.recurrence_text}
{#if assignee || due || task.labels?.length || task.recurrence_text}
<span class="meta">
{#if assignee}
<!-- First in the row, because "who" changes what the rest of the line
means: a date on somebody else's task is their deadline, not
yours. -->
<span class="assignee" title="Ansvarlig: {assignee.name}">
<span class="face" style="background: {assignee.avatar_color}">
{assignee.name[0]?.toUpperCase() ?? '?'}
</span>
{assignee.name}
</span>
{/if}
{#if due}
<span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if}
@@ -259,6 +272,25 @@
stroke-linejoin: round;
}
.assignee {
display: inline-flex;
align-items: center;
gap: var(--s1);
color: var(--ink-muted);
}
.face {
width: 14px;
height: 14px;
border-radius: var(--radius-full);
display: inline-grid;
place-items: center;
font-size: 9px;
font-weight: 560;
color: #fff;
flex: none;
}
.label {
color: var(--ink-muted);
background: var(--surface-raised);
+29 -2
View File
@@ -18,6 +18,14 @@ class AppState {
projects = $state([]);
/** The foldable headings over the projects in the sidebar. */
groups = $state([]);
/**
* Everybody you share a project with, and yourself.
*
* Loaded once so a task row can put a name on an `assignee_id` without a
* request per row — and without every list having to know which project each
* task belongs to in order to ask for its members.
*/
people = $state([]);
tasks = $state([]);
loading = $state(true);
/** Transient messages: a failed save, a rolled-back change. */
@@ -52,6 +60,23 @@ class AppState {
return this.projects.find((p) => p.id === id);
}
personById(id) {
return this.people.find((p) => p.id === id) ?? null;
}
/**
* The person a task is waiting on, or null when it is nobody or yourself.
*
* Yourself is nobody, on purpose: every row in your own list would otherwise
* carry your own face, which is a column of the same initial telling you
* nothing. The point of showing an assignee is that it is *not* you.
*/
assigneeOf(task) {
const id = task?.assignee_id;
if (!id || id === this.user?.id) return null;
return this.personById(id) ?? { id, name: 'Ukendt', avatar_color: '#8a8f98' };
}
get detailTask() {
return this.tasks.find((t) => t.id === this.detailId) ?? null;
}
@@ -68,12 +93,14 @@ class AppState {
this.loading = true;
try {
this.user = await api.me();
const [{ projects }, { groups }] = await Promise.all([
const [{ projects }, { groups }, { people }] = await Promise.all([
api.listProjects(),
api.listProjectGroups()
api.listProjectGroups(),
api.people()
]);
this.projects = projects;
this.groups = groups;
this.people = people;
this.connect();
} catch (e) {
this.user = null;
+192 -1
View File
@@ -26,6 +26,8 @@
let sections = $state([]);
let members = $state([]);
let showShare = $state(false);
let showLog = $state(false);
let activity = $state([]);
let inviteEmail = $state('');
let inviteRole = $state('editor');
let inviteLink = $state('');
@@ -65,6 +67,63 @@
}
});
// Loaded when it is opened rather than with the page: it is a record you go
// looking for, not something every visit should pay for.
$effect(() => {
if (showLog && project) {
api.activity(project.id).then((r) => (activity = r.activity)).catch(() => {});
}
});
/**
* What each recorded event is called.
*
* The log has been written since the beginning — nineteen kinds of entry — and
* nothing has ever read it. The keys are the server's and they are stable; this
* is the only place they become Danish.
*
* An unknown key falls through to the key itself rather than being hidden: a
* build that records something this table has not learned yet should still show
* that it happened.
*/
const EVENTS = {
'project.created': 'oprettede projektet',
'project.updated': 'ændrede projektet',
'project.deleted': 'slettede projektet',
'project.imported': 'importerede projektet',
'section.created': 'oprettede en sektion',
'section.updated': 'ændrede en sektion',
'section.deleted': 'slettede en sektion',
'member.invited': 'inviterede',
'member.added': 'tilføjede',
'member.removed': 'fjernede',
'member.role_changed': 'ændrede rollen for',
'task.created': 'oprettede en opgave',
'task.updated': 'ændrede en opgave',
'task.completed': 'lukkede en opgave',
'task.reopened': 'genåbnede en opgave',
'task.moved': 'flyttede en opgave',
'task.deleted': 'slettede en opgave',
'task.split': 'delte en opgave op',
'comment.created': 'skrev en kommentar'
};
/** The bit of context an entry carries, when it has one worth reading. */
function detail(entry) {
const p = entry.payload ?? {};
return p.name ?? p.email ?? p.role ?? '';
}
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
}
// The chosen view is kept locally as well as on the project, so switching is
// instant and only persists once the request lands.
let view = $state(null);
@@ -245,6 +304,33 @@
}
}
/**
* What each standing is called in the interface.
*
* The API's words are `owner`, `editor` and `viewer`, and they were being
* printed straight into a Danish page. These are the same three words the
* invite form already uses, so the list and the form agree.
*/
const ROLES = { owner: 'Ejer', editor: 'Kan redigere', viewer: 'Kan kun se' };
/**
* Changes what somebody may do, without removing them.
*
* Removing and re-inviting was the only way to correct a role, and it
* unassigns every task they were responsible for on the way past — so fixing a
* dropdown cost somebody their work.
*/
async function setMemberRole(member, role) {
const previous = members;
members = members.map((m) => (m.user_id === member.user_id ? { ...m, role } : m));
try {
await api.setMemberRole(project.id, member.user_id, role);
} catch (e) {
members = previous;
app.toast(humanMessage(e));
}
}
async function invite(event) {
event.preventDefault();
inviteError = '';
@@ -326,6 +412,12 @@
</button>
{/if}
{#if !project.is_inbox}
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
Historik
</button>
{/if}
{#if isOwner && !project.is_inbox}
<button class="remove" onclick={remove} aria-label="Slet projektet">
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -335,6 +427,26 @@
{/if}
</header>
{#if showLog}
<div class="panel">
<ul class="log">
{#each activity as entry (entry.id)}
<li>
<span class="who">{entry.user_name}</span>
<span class="what">
{EVENTS[entry.event] ?? entry.event}{#if detail(entry)}
<span class="detail">{detail(entry)}</span>{/if}
</span>
<span class="when">{when(entry.created_at)}</span>
</li>
{/each}
</ul>
{#if !activity.length}
<p class="empty">Ingenting er sket her endnu.</p>
{/if}
</div>
{/if}
{#if choosingColor}
<div class="panel swatches" role="group" aria-label="Vælg farve">
{#each COLORS as color (color.id)}
@@ -360,7 +472,22 @@
{member.name[0]?.toUpperCase()}
</span>
<span class="member-name">{member.name}</span>
<span class="role">{member.role}</span>
<!-- The owner's standing is not a choice: ownership is transferred,
not granted, and the server refuses it. A disabled dropdown
would be a control that exists to say no. -->
{#if isOwner && member.role !== 'owner'}
<select
class="role-picker"
value={member.role}
aria-label="Rolle for {member.name}"
onchange={(e) => setMemberRole(member, e.currentTarget.value)}
>
<option value="editor">{ROLES.editor}</option>
<option value="viewer">{ROLES.viewer}</option>
</select>
{:else}
<span class="role">{ROLES[member.role] ?? member.role}</span>
{/if}
{#if isOwner && member.role !== 'owner'}
<button
class="remove"
@@ -764,6 +891,24 @@
font-size: var(--text-xs);
}
.role-picker {
flex: none;
padding: var(--s1) var(--s2);
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-muted);
font-size: var(--text-xs);
outline: none;
transition: border-color var(--fast) var(--ease);
}
.role-picker:hover,
.role-picker:focus {
border-color: var(--accent);
color: var(--ink);
}
.remove {
color: var(--ink-faint);
width: 20px;
@@ -811,6 +956,52 @@
font-weight: 550;
}
.log {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
/* Capped and scrolled: a busy project's log is thousands of lines, and a
panel that pushes the tasks off the screen is a panel you close again. */
max-height: 320px;
overflow-y: auto;
}
.log li {
display: flex;
align-items: baseline;
gap: var(--s2);
padding: var(--s2) 0;
border-bottom: 1px solid var(--line);
font-size: var(--text-sm);
}
.log li:last-child {
border-bottom: 0;
}
.who {
font-weight: 500;
flex: none;
}
.what {
color: var(--ink-muted);
flex: 1;
min-width: 0;
}
.detail {
color: var(--ink);
}
.when {
color: var(--ink-faint);
font-size: var(--text-xs);
flex: none;
}
.link-out {
margin: 0;
font-size: var(--text-xs);