diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go index d0f1f8f..c009a11 100644 --- a/internal/httpapi/tasks_handlers.go +++ b/internal/httpapi/tasks_handlers.go @@ -475,6 +475,10 @@ type quickAddRequest struct { // ProjectID is the view the user was looking at. A "#project" in the text // wins over it: what somebody typed beats where they happened to be standing. ProjectID string `json:"project_id,omitempty"` + // SectionID is the section the box was opened in, for the field that sits at + // the foot of one. Same rule as the project: a "/section" in the text wins, + // because typing it is a choice and standing there is a circumstance. + SectionID string `json:"section_id,omitempty"` } func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) { @@ -518,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) { return } + // Resolved after the project, because a section belongs to one project and the + // same name can exist in several. A name that matches nothing is reported the + // same way an unknown project is: the task is still created, in the project + // without a section, and the caller is told which word did not land. + sectionID, unknownSection := "", "" + // The section the box belongs to, unless the text names one. Checked against + // the project the task actually landed in, so a "#project" that moved it + // elsewhere cannot drag a section id from another project along with it. + if req.SectionID != "" { + if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs { + sectionID = req.SectionID + } + } + if parsed.Section != "" { + if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil { + sectionID = id + } else { + unknownSection = parsed.Section + } + } + t := &store.Task{ - ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority, - CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence, + ProjectID: projectID, SectionID: sectionID, Content: parsed.Content, + Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate, + RecurrenceRule: parsed.Recurrence, } if parsed.DueDate != "" { _, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone) @@ -537,13 +563,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) { s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content}) s.publish(t.ProjectID, "task.created", toTaskJSON(*t)) - if unknownProject != "" { + if unknownProject != "" || unknownSection != "" { // Alongside the task rather than instead of it: the task was created, and // this says what could not be honoured while creating it. writeJSON(w, http.StatusCreated, struct { taskJSON - UnknownProject string `json:"unknown_project"` - }{toTaskJSON(*t), unknownProject}) + UnknownProject string `json:"unknown_project,omitempty"` + UnknownSection string `json:"unknown_section,omitempty"` + }{toTaskJSON(*t), unknownProject, unknownSection}) return } writeJSON(w, http.StatusCreated, toTaskJSON(*t)) diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index 06b632e..c92babd 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) { t.Errorf("unread = %v after marking everything read", after["unread"]) } } + +// Quick add can put a task straight into a section. +// +// A section belongs to one project, so the name is resolved after the project is +// decided — and two projects may both have a "Produktion", which are different +// sections. A name that matches nothing is reported the way an unknown project is: +// the task is still created, and the caller is told which word did not land. +func TestQuickAddPutsATaskInASection(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"}) + projectID := project["id"].(string) + _, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections", + map[string]any{"name": "Kunder/Ordrer"}) + sectionID := section["id"].(string) + + // The section name has a slash in it, which is also the sigil. The bare form is + // greedy, so the whole name comes across. + resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", + map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task) + } + if task["section_id"] != sectionID { + t.Errorf("section_id = %v, want %v", task["section_id"], sectionID) + } + if task["content"] != "Karpenhøj kaffe bestilling" { + t.Errorf("content = %v — the sigils should be gone", task["content"]) + } + + // Case-insensitive, like the project sigil. + _, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add", + map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`}) + if lower["section_id"] != sectionID { + t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"]) + } + + // A name that matches nothing still creates the task, and says so. + _, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add", + map[string]any{"text": "noget andet #Risteriet /Findesikke"}) + if missing["section_id"] != nil && missing["section_id"] != "" { + t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"]) + } + if missing["unknown_section"] != "Findesikke" { + t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"]) + } + if missing["content"] != "noget andet" { + t.Errorf("content = %v", missing["content"]) + } + + // A section in another project is a different section, and must not be found. + _, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"}) + ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections", + map[string]any{"name": "Egen"}) + _, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add", + map[string]any{"text": "forkert sted #Risteriet /Egen"}) + if crossed["unknown_section"] != "Egen" { + t.Errorf("a section from another project was found: %v", crossed["section_id"]) + } +} + +// The field at the foot of a section passes the section it sits in, and a +// "#project" in the same line can move the task out from under it. +func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"}) + hereID := here["id"].(string) + _, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections", + map[string]any{"name": "Produktion"}) + sectionID := section["id"].(string) + + // Standing in the section is enough; no sigil needed. + _, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "male gavlen", "project_id": hereID, "section_id": sectionID, + }) + if plain["section_id"] != sectionID { + t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID) + } + + // A "#project" that moves the task must not drag the section id with it: that + // section belongs to the project being left behind. + ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"}) + _, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID, + }) + if moved["section_id"] != nil && moved["section_id"] != "" { + t.Errorf("the section came along to another project: %v", moved["section_id"]) + } + if moved["content"] != "male gavlen" { + t.Errorf("content = %v", moved["content"]) + } +} diff --git a/internal/quickadd/quickadd.go b/internal/quickadd/quickadd.go index 15bcf14..6c18b2d 100644 --- a/internal/quickadd/quickadd.go +++ b/internal/quickadd/quickadd.go @@ -34,6 +34,7 @@ const ( KindTime Kind = "time" KindPriority Kind = "priority" KindProject Kind = "project" + KindSection Kind = "section" KindLabel Kind = "label" KindRepeat Kind = "repeat" ) @@ -52,7 +53,11 @@ type Result struct { Content string `json:"content"` // Project is the name written after '#', not an id: the caller resolves it // against the user's projects and decides what to do when it matches nothing. - Project string `json:"project,omitempty"` + Project string `json:"project,omitempty"` + // Section is the name written after '/', resolved by the caller against the + // sections of whichever project the task lands in. A section belongs to one + // project, so it cannot be looked up until the project is known. + Section string `json:"section,omitempty"` Labels []string `json:"labels,omitempty"` // Priority is 1 (highest) to 4. 4 is both the default and "none stated". Priority int `json:"priority"` @@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result { // Sigils first. '#' and '@' are unambiguous, and consuming them stops a project // called "Fredag" from later being read as a weekday. res.Project, spans = extractProject(input, spans) + res.Section, spans = extractSection(input, spans) res.Labels, spans = extractLabels(input, spans) res.Priority, spans = extractPriority(input, spans) @@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result { var ( reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`) reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`) + // A section, written '/Produktion' or '/"Kunder og ordrer"'. + // + // The name may itself contain a slash — "Kunder/Ordrer" is a real section + // name — so the bare form is greedy and swallows the lot. + // + // The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash + // inside a word never matches, so "km/t" and "3/4" are safe, and the name has + // to start immediately after the slash, so "20 kr / stk" is safe too. Those + // three are the shapes a slash actually takes in a Danish task line. + reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`) ) func extractProject(input string, spans []Span) (string, []Span) { @@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) { return name, spans } +// Last one wins, as with the project: retyping is a correction, not two sections. +func extractSection(input string, spans []Span) (string, []Span) { + all := reSection.FindAllStringSubmatchIndex(input, -1) + if len(all) == 0 { + return "", spans + } + var name string + for _, m := range all { + name = submatch(input, m) + spans = append(spans, sigilSpan(input, m, KindSection)) + } + return name, spans +} + func extractLabels(input string, spans []Span) ([]string, []Span) { var labels []string seen := map[string]bool{} diff --git a/internal/quickadd/quickadd_test.go b/internal/quickadd/quickadd_test.go index 6c2c8b8..c6dedde 100644 --- a/internal/quickadd/quickadd_test.go +++ b/internal/quickadd/quickadd_test.go @@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) { t.Error("no span of kind repeat") } } + +// A section, and the three shapes a slash takes in ordinary text. +// +// The sigil is the one that needed thinking about: '#' and '@' never occur in a +// Danish task line by accident, and '/' occurs constantly. What keeps it apart is +// that the slash must follow whitespace and the name must follow the slash with +// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail. +func TestASectionIsWrittenWithASlash(t *testing.T) { + for _, c := range []struct { + in string + section string + content string + }{ + {"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"}, + {"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"}, + // A real section name with a slash in it. The bare form is greedy, so the + // whole thing comes across rather than stopping at the second slash. + {"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"}, + {`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"}, + // Retyping is a correction, not two sections. + {"nye poser /Drift /Produktion", "Produktion", "nye poser"}, + // And alongside everything else. + {"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"}, + + // The ways a slash shows up when nobody meant a section. None of them may + // produce one: the slash has to follow whitespace, and the name has to + // follow the slash with nothing between. + {"kør 80 km/t forbi", "", "kør 80 km/t forbi"}, + {"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"}, + // "3/4" is a section to nobody and a date to a Dane — the 3rd of April — + // and the date parser is right to claim it. What matters here is only that + // it did not become a section. + {"læs 3/4 af rapporten", "", "læs af rapporten"}, + } { + got := Parse(c.in, ref(t), "da") + if got.Section != c.section { + t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section) + } + if got.Content != c.content { + t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content) + } + } +} diff --git a/internal/store/projects.go b/internal/store/projects.go index 095a950..169e60f 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -630,3 +630,36 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string) return nil }) } + +// SectionByName resolves a "/name" from quick add within one project. +// +// Case-insensitive, and scoped to the project the task is landing in — a section +// belongs to exactly one project, so the name cannot be looked up before that is +// decided. Two projects may both have a "Produktion" and they are different +// sections; asking without the project would be asking the wrong question. +func (db *DB) SectionByName(ctx context.Context, projectID, name string) (string, error) { + var id string + err := db.QueryRowContext(ctx, ` + SELECT id FROM sections + WHERE project_id = ? AND lower(name) = lower(?) AND deleted_at IS NULL + ORDER BY sort_order + LIMIT 1`, projectID, name).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return id, err +} + +// SectionInProject reports whether a section id belongs to the given project. +// +// Quick add takes a section id from the client — the field at the foot of a +// section passes the one it sits in — and a "#project" in the same line can move +// the task somewhere else entirely. Without this the id would follow it, and the +// task would land in a section belonging to a project it is not in. +func (db *DB) SectionInProject(ctx context.Context, sectionID, projectID string) (bool, error) { + var n int + err := db.QueryRowContext(ctx, + `SELECT count(*) FROM sections WHERE id = ? AND project_id = ? AND deleted_at IS NULL`, + sectionID, projectID).Scan(&n) + return n == 1, err +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 7edd9f4..b04e5c3 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1336,6 +1336,62 @@ test('projektets navn brydes ikke midt i et ord', async ({ page }) => { expect(trouble).toEqual([]); }); +/** + * A task can be written straight into a section, two ways. + * + * The box at the top of the page puts things in the project with no section, which + * is right for capturing — but a project has sections because the work belongs + * somewhere, and dragging every new task down into the right one is a second + * gesture for something you already knew when you typed it. + * + * The field at the foot of a section passes the section it sits in. The sigil is + * for the box at the top, and for Today and Upcoming, where you are not standing + * in a section at all. + */ +test('en opgave kan skrives direkte i en sektion @forms', async ({ 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('Risteriet'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible(); + + // A section whose name contains the sigil, because that is the one that would + // break: the bare form has to be greedy enough to swallow the whole name. + await page.getByRole('button', { name: '+ Tilføj sektion' }).click(); + await page.getByLabel('Ny sektion').fill('Kunder/Ordrer'); + await page.getByLabel('Ny sektion').press('Enter'); + await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible(); + + const section = page.locator('section').filter({ + has: page.getByRole('heading', { name: 'Kunder/Ordrer' }) + }); + + // The field at the foot of the section. No sigil — standing there is enough. + await section.getByRole('button', { name: '+ Tilføj opgave' }).click(); + const field = page.getByLabel('Ny opgave i Kunder/Ordrer'); + await field.fill('Karpenhøj bestilling i morgen p1'); + await field.press('Enter'); + + const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' }); + await expect(landed).toBeVisible(); + // It went through quick add, so the date and the priority were read as well. + await expect(landed.getByText('I morgen')).toBeVisible(); + + // And the sigil, from the box at the top, which is not standing anywhere. + await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer'); + await page.getByLabel('Ny opgave').press('Enter'); + await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible(); + // The sigil is consumed, not left in the title. + await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0); + + await expect(section.locator('.row')).toHaveCount(2); + + expect(trouble).toEqual([]); +}); + /** * The sidebar never scrolls sideways. * diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 52f9b07..22fb8a4 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -267,7 +267,8 @@ export const api = { reopenTask: (id) => post(`/tasks/${id}/reopen`), moveTask: (id, data) => post(`/tasks/${id}/move`, data), - quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }), + quickAdd: (text, projectId, sectionId) => + post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }), quickAddPreview: (text, signal) => get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }), diff --git a/web/src/lib/components/BoardView.svelte b/web/src/lib/components/BoardView.svelte index 6c62a41..53c215c 100644 --- a/web/src/lib/components/BoardView.svelte +++ b/web/src/lib/components/BoardView.svelte @@ -28,6 +28,23 @@ let adding = $state(false); let name = $state(''); + /** Same as the list view's: through quick add, so a date or a p1 still parses. */ + let addingIn = $state(null); + let newInSection = $state(''); + + async function addToSection(event, sectionId) { + event.preventDefault(); + const text = newInSection.trim(); + if (!text) return; + newInSection = ''; + addingIn = null; + try { + app.upsert(await api.quickAdd(text, project.id, sectionId)); + } catch (e) { + app.toast(humanMessage(e)); + } + } + async function addSection(event) { event.preventDefault(); const trimmed = name.trim(); @@ -147,6 +164,35 @@

{t('view.emptySection')}

{/if} + + + {#if canEdit} + + {#if addingIn === column.id} +
addToSection(e, column.id)}> + !newInSection.trim() && (addingIn = null)} + onkeydown={(e) => e.key === 'Escape' && (addingIn = null)} + /> +
+ {:else} + + {/if} + {/if} {/each} @@ -305,6 +351,36 @@ background: transparent; } + .add-task-open { + align-self: flex-start; + padding: var(--s2); + font-size: var(--text-sm); + color: var(--ink-faint); + opacity: 0; + transition: opacity var(--fast) var(--ease); + } + + .column:hover .add-task-open, + .add-task-open:focus-visible { + opacity: 1; + } + + .add-task-open:hover { + color: var(--ink-muted); + } + + .add-task input { + width: 100%; + padding: var(--s2); + background: var(--ground); + border: 1px solid var(--accent); + border-radius: var(--radius); + font: inherit; + font-size: var(--text-sm); + color: var(--ink); + outline: none; + } + .empty { margin: 0; padding: var(--s3) var(--s2); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 321d9ad..5700d0f 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -601,5 +601,9 @@ export const da = { 'project.more': 'Flere handlinger', 'project.removeMember': 'Fjern {name}', - 'nav.closeMenu': 'Luk menu' + 'nav.closeMenu': 'Luk menu', + + 'view.addTask': '+ Tilføj opgave', + 'view.addTaskHere': 'Skriv en opgave — dato, p1 og @etiket virker her også', + 'view.addTaskIn': 'Ny opgave i {name}' }; diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index f405f07..2228ac2 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -596,5 +596,9 @@ export const en = { 'project.more': 'More actions', 'project.removeMember': 'Remove {name}', - 'nav.closeMenu': 'Close menu' + 'nav.closeMenu': 'Close menu', + + 'view.addTask': '+ Add a task', + 'view.addTaskHere': 'Write a task — a date, p1 and @label work here too', + 'view.addTaskIn': 'New task in {name}' }; diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 3924d8c..ca35620 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -277,6 +277,36 @@ let renamingSection = $state(null); let sectionName = $state(''); + /** + * A task written straight into a section. + * + * The quick-add box at the top of the page puts things in the project with no + * section, which is right for "capture it now" — but a project with sections has + * them because the work belongs somewhere, and dragging every new task down into + * the right one is a second gesture for something you already knew when you + * typed it. + * + * It goes through quick add rather than createTask, so a line written here still + * parses its dates, priorities and labels. The section is passed rather than + * written, so `/Produktion` is not needed when you are already standing in it. + */ + let addingIn = $state(null); + let newInSection = $state(''); + + async function addToSection(event, sectionId) { + event.preventDefault(); + const text = newInSection.trim(); + if (!text) return; + newInSection = ''; + addingIn = null; + try { + const created = await api.quickAdd(text, project.id, sectionId); + app.upsert(created); + } catch (e) { + app.toast(humanMessage(e)); + } + } + async function addSection(event) { event.preventDefault(); const name = sectionName.trim(); @@ -684,6 +714,29 @@ {#if !tasks.length}

{t('view.emptySection')}

{/if} + + {#if canEdit} + {#if addingIn === section.id} +
addToSection(e, section.id)}> + !newInSection.trim() && (addingIn = null)} + onkeydown={(e) => e.key === 'Escape' && (addingIn = null)} + /> +
+ {:else} + + {/if} + {/if} {/each} @@ -1267,6 +1320,38 @@ border-color: var(--accent); } + /* Quiet until wanted. A section that is not being added to should read as a + heading and its rows, not as a form. */ + .add-task-open { + align-self: flex-start; + padding: var(--s2) 0 var(--s2) var(--s2); + font-size: var(--text-sm); + color: var(--ink-faint); + opacity: 0; + transition: opacity var(--fast) var(--ease); + } + + section:hover .add-task-open, + .add-task-open:focus-visible { + opacity: 1; + } + + .add-task-open:hover { + color: var(--ink-muted); + } + + .add-task input { + width: 100%; + padding: var(--s2); + background: transparent; + border: 0; + border-bottom: 1px solid var(--accent); + font: inherit; + font-size: var(--text-sm); + color: var(--ink); + outline: none; + } + .add-section { margin-top: var(--s5); }