feat: an assignee on the row, and the activity log made visible
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6ea87ac19
commit
7da353d87a
@@ -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 |
|
||||
|
||||
@@ -1352,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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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, "anders@example.dk", "Anders")
|
||||
ts.newUser(t, "fremmed@example.dk", "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": "anders@example.dk", "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-2
@@ -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([]);
|
||||
});
|
||||
@@ -757,8 +760,32 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
await andreas.reload();
|
||||
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
||||
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
|
||||
await context.close();
|
||||
|
||||
// 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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -307,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)}`)
|
||||
};
|
||||
|
||||
@@ -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} {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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -353,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">
|
||||
@@ -362,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)}
|
||||
@@ -871,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);
|
||||
|
||||
Reference in New Issue
Block a user