feat(tasks): move, repeat, delegate and re-read a task from its drawer

Four things you could not do to a task you had open, and one that had
never worked at all.

**Project, recurrence and assignee** are now fields in the drawer. A task
created in the wrong place could only be fixed by dragging it, a repeat
could only be set by typing it into the title when the task was created,
and delegation had no interface whatsoever despite assignee_id being in
the API from the start. The assignee only appears on a project with
somebody else in it, because on your own there is nobody to choose.

**The title is read the way quick add reads it.** Typing "i morgen kl 14
p1" into a title used to leave it as those words, so where you typed a
sentence decided what it meant. The parse runs on the server — the
grammar is Danish and English and lives there — and applies only when it
consumed something. What it took is said out loud, and the fields on
screen are updated to match: the drawer otherwise seeds its inputs once
per task so a save cannot overwrite what you are typing, and that rule
has to bend exactly here, where the save changed the very fields in front
of you.

**And the member list has never worked.** The share panel, and now the
assignee field, ask for it. Every call errored: the ORDER BY named an
expression, and SQLite only accepts result-set columns across a UNION, so
it failed at query time with "1st ORDER BY term does not match any column
in the result set". Found by the smoke test that opens a task, which is
the second real bug that suite has caught by watching for failed requests
rather than only for what renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 17:27:18 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent afc46a5c20
commit 9056c44c9a
3 changed files with 197 additions and 12 deletions
+41
View File
@@ -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)
}
}
+20 -8
View File
@@ -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
}
+136 -4
View File
@@ -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 @@
/>
</div>
{#if task.recurrence_text}
<p class="repeats">Gentages {task.recurrence_text}.</p>
<div class="grid">
<div class="field">
<label for="project">Projekt</label>
<select id="project" bind:value={projectId} onchange={saveProject}>
{#each app.projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
<div class="field">
<label for="repeat">Gentages</label>
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
{#each REPEATS as option (option.rule)}
<option value={option.rule}>{option.label}</option>
{/each}
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
showing it rather than snapping to the nearest preset is the
difference between a menu and a menu that quietly edits. -->
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
{/if}
</select>
</div>
</div>
{#if members.length > 1}
<div class="field">
<label for="assignee">Ansvarlig</label>
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
<option value="">Ingen</option>
{#each members as member (member.user_id)}
<option value={member.user_id}>{member.name}</option>
{/each}
</select>
</div>
{/if}
<section>