diff --git a/internal/httpapi/projects_test.go b/internal/httpapi/projects_test.go index 8da363a..84b8143 100644 --- a/internal/httpapi/projects_test.go +++ b/internal/httpapi/projects_test.go @@ -153,3 +153,44 @@ func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) { } } } + +// The member list errored on every project — the ORDER BY named an expression, +// which SQLite refuses across a UNION — so the share panel had never shown +// anybody, not even the owner. +func TestListMembersWorksAtAll(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"}) + + resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %v", resp.StatusCode, body) + } + members, _ := body["members"].([]any) + if len(members) != 1 { + t.Fatalf("a project with no invites lists %d members, want its owner", len(members)) + } + if m := members[0].(map[string]any); m["role"] != "owner" { + t.Errorf("role = %v, want owner", m["role"]) + } +} + +// And the Inbox, which is the one every task drawer asks about. +func TestListMembersWorksForTheInbox(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, list := ts.do(t, "GET", "/api/v1/projects", nil) + var inboxID string + for _, raw := range list["projects"].([]any) { + if p := raw.(map[string]any); p["is_inbox"] == true { + inboxID = p["id"].(string) + } + } + + resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %v", resp.StatusCode, body) + } +} diff --git a/internal/store/projects.go b/internal/store/projects.go index ae45812..ded6d1b 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -328,15 +328,27 @@ type Member struct { // project_members row — ownership is the projects table — so they are unioned in // rather than left out of the list of people who can see it. func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) { + // The owner first, then everybody else by name. + // + // The ordering is a column rather than an expression, and the whole compound + // select is wrapped: SQLite only accepts ORDER BY terms from the result set + // of a UNION, so `ORDER BY role = 'owner' DESC` — which reads perfectly well + // — fails at query time with "1st ORDER BY term does not match any column in + // the result set". Every call errored, so the share panel had never listed + // anybody. rows, err := db.QueryContext(ctx, ` - SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at - FROM projects p JOIN users u ON u.id = p.owner_id - WHERE p.id = ? - UNION ALL - SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at - FROM project_members m JOIN users u ON u.id = m.user_id - WHERE m.project_id = ? - ORDER BY role = 'owner' DESC, name`, projectID, projectID) + SELECT id, email, name, avatar_color, role, added_at FROM ( + SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, + p.created_at AS added_at, 0 AS rank + FROM projects p JOIN users u ON u.id = p.owner_id + WHERE p.id = ? + UNION ALL + SELECT u.id, u.email, u.name, u.avatar_color, m.role, + m.added_at, 1 AS rank + FROM project_members m JOIN users u ON u.id = m.user_id + WHERE m.project_id = ? + ) + ORDER BY rank, name`, projectID, projectID) if err != nil { return nil, err } diff --git a/web/src/lib/components/TaskDetail.svelte b/web/src/lib/components/TaskDetail.svelte index d929ce6..694fd90 100644 --- a/web/src/lib/components/TaskDetail.svelte +++ b/web/src/lib/components/TaskDetail.svelte @@ -20,6 +20,23 @@ let priority = $state(4); let dueDate = $state(''); let labels = $state(''); + let projectId = $state(''); + let recurrence = $state(''); + let assignee = $state(''); + + /** Who the task can be given to. Only ever more than one on a shared project. */ + let members = $state([]); + + // The rules worth a menu entry. Anything more specific is typed into the + // title, where the parser understands far more than a list ever could. + const REPEATS = [ + { rule: '', label: 'Aldrig' }, + { rule: 'FREQ=DAILY', label: 'Hver dag' }, + { rule: 'FREQ=WEEKLY', label: 'Hver uge' }, + { rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' }, + { rule: 'FREQ=MONTHLY', label: 'Hver måned' }, + { rule: 'FREQ=YEARLY', label: 'Hvert år' } + ]; let subtasks = $state([]); let comments = $state([]); @@ -46,6 +63,17 @@ priority = task.priority; dueDate = task.due_date ?? ''; labels = (task.labels ?? []).join(', '); + projectId = task.project_id; + recurrence = task.recurrence_rule ?? ''; + assignee = task.assignee_id ?? ''; + + members = []; + api.listMembers(task.project_id) + .then((r) => r.members.length > 1 && (members = r.members)) + .catch(() => { + // A project you are not the owner of may refuse the member list. + // Then there is nobody to choose from, which is the same as none. + }); subtasks = []; comments = []; @@ -73,13 +101,83 @@ await app.update(task.id, patch); } - function saveContent() { + /** + * Saves the title, reading it the way quick add would. + * + * Typing "i morgen kl. 14" into a title used to leave it as those words. The + * same sentence in the quick-add box becomes a date, which made where you + * typed it decide what it meant. + * + * The parse runs on the server — the grammar is Danish and English and lives + * there — and is only applied when it actually consumed something. What it + * took is said out loud, because a title that edits itself without a word is + * worse than one that does nothing. + */ + async function saveContent() { const trimmed = content.trim(); if (!trimmed || trimmed === task.content) { content = task.content; return; } - save({ content: trimmed }); + + let parsed = null; + try { + parsed = await api.quickAddPreview(trimmed); + } catch { + // Offline, or the parser refused. Saving the words is still right. + } + + const patch = { content: trimmed }; + const took = []; + if (parsed && parsed.content && parsed.content !== trimmed) { + patch.content = parsed.content; + if (parsed.due_date) { + patch.due_date = parsed.due_date; + took.push(parsed.due_date); + } + if (parsed.priority && parsed.priority < 4) { + patch.priority = parsed.priority; + took.push(`P${parsed.priority}`); + } + if (parsed.recurrence) { + patch.recurrence_rule = parsed.recurrence; + took.push('gentagelse'); + } + if (parsed.labels?.length) { + patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])]; + took.push(parsed.labels.map((l) => `@${l}`).join(' ')); + } + } + + await save(patch); + + // The fields are seeded once per task so a save cannot overwrite what + // somebody is typing. That rule has to bend exactly here: this save + // changed the very fields on screen, and leaving them showing what was + // typed while the toast reports something else is the worst of both. + if (took.length) { + content = patch.content; + if (patch.due_date) dueDate = patch.due_date; + if (patch.priority) priority = patch.priority; + if (patch.recurrence_rule) recurrence = patch.recurrence_rule; + if (patch.labels) labels = patch.labels.join(', '); + app.toast(`Læst som ${took.join(', ')}.`); + } + } + + async function saveProject() { + if (projectId === task.project_id) return; + await save({ project_id: projectId }); + } + + async function saveRecurrence() { + if (recurrence === (task.recurrence_rule ?? '')) return; + await save({ recurrence_rule: recurrence }); + } + + async function saveAssignee() { + if (assignee === (task.assignee_id ?? '')) return; + await save({ assignee_id: assignee }); } function saveDescription() { @@ -316,8 +414,42 @@ /> - {#if task.recurrence_text} -

Gentages {task.recurrence_text}.

+
+
+ + +
+ +
+ + +
+
+ + {#if members.length > 1} +
+ + +
{/if}