diff --git a/internal/quickadd/quickadd.go b/internal/quickadd/quickadd.go index b1ec03c..6afdf2a 100644 --- a/internal/quickadd/quickadd.go +++ b/internal/quickadd/quickadd.go @@ -304,22 +304,28 @@ func extractPriority(input string, spans []Span) (int, []Span) { var ( // An explicit marker: "kl 10", "kl. 10:30", "klokken 16", "at 9.15". reTimeMarked = regexp.MustCompile(`(?i)(?:^|\s)(kl\.?|klokken|at)\s*(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)?(?:\s|$)`) - // A 12-hour suffix carries its own marker: "10am", "7:30 pm". - reTimeMeridiem = regexp.MustCompile(`(?i)(?:^|\s)(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s|$)`) + // A 12-hour suffix carries its own marker: "10am", "7:30 pm" — and may still + // be written after one, "at 10am", "kl 7 pm". The marker is part of the time: + // left behind, "tomorrow at 10am" became the title "… at". + reTimeMeridiem = regexp.MustCompile(`(?i)(?:^|\s)((?:at|kl\.?|klokken)\s*)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s|$)`) // A bare colon time: "16:30". Only ':' — never '.', because "15.3" is a date. reTimeColon = regexp.MustCompile(`(?:^|\s)(\d{1,2}):(\d{2})(?:\s|$)`) ) func extractTime(input string, spans []Span, locale string) (int, int, bool, []Span) { if m := reTimeMeridiem.FindStringSubmatchIndex(input); m != nil { - hour := atoi(input, m[2], m[3]) + hour := atoi(input, m[4], m[5]) minute := 0 - if m[4] >= 0 { - minute = atoi(input, m[4], m[5]) + if m[6] >= 0 { + minute = atoi(input, m[6], m[7]) } - hour = applyMeridiem(hour, strings.ToLower(input[m[6]:m[7]])) + hour = applyMeridiem(hour, strings.ToLower(input[m[8]:m[9]])) if valid(hour, minute) { - return hour, minute, true, append(spans, Span{Start: m[2], End: m[7], Kind: KindTime}) + start := m[4] + if m[2] >= 0 { + start = m[2] + } + return hour, minute, true, append(spans, Span{Start: start, End: m[9], Kind: KindTime}) } } @@ -429,6 +435,37 @@ var ( reNumeric = regexp.MustCompile(`(?:^|\s)(\d{1,2})[/.](\d{1,2})(?:[-/](\d{2,4}))?(?:\s|$)`) ) +// fractionUnits are the words that make "3/4" a quantity rather than a day. +// +// "køb 1/2 liter fløde" is half a litre, not the first of February — and it is +// the ordinary shape of a shopping list or a recipe. Without a unit after it the +// slash form stays a date: "3/4" alone, "møde 3/4 med Anders", and also +// "3/4 kage", because nothing in "kage" tells the third of April from three +// quarters, and a date quietly dropped is worse than one somebody removes. +var fractionUnits = map[string]bool{ + "l": true, "liter": true, "dl": true, "cl": true, "ml": true, + "kg": true, "g": true, "gram": true, "pund": true, + "tsk": true, "spsk": true, "kop": true, "kopper": true, "glas": true, + "stk": true, "stk.": true, "del": true, "dele": true, "pakke": true, "pakker": true, + "time": true, "timer": true, "km": true, "m": true, "cm": true, + "cup": true, "cups": true, "tsp": true, "tbsp": true, "lb": true, "oz": true, +} + +// firstNumericDate is the first "d/m" in the input that is not a quantity — see +// fractionUnits. A written year always makes it a date. +func firstNumericDate(input string) []int { + for _, m := range reNumeric.FindAllStringSubmatchIndex(input, -1) { + if m[6] < 0 { + rest := strings.Fields(input[m[5]:]) + if len(rest) > 0 && fractionUnits[strings.ToLower(rest[0])] { + continue + } + } + return m + } + return nil +} + func extractDate(input string, spans []Span, now time.Time) (time.Time, bool, []Span) { today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) @@ -441,7 +478,7 @@ func extractDate(input string, spans []Span, now time.Time) (time.Time, bool, [] } } - if m := reNumeric.FindStringSubmatchIndex(input); m != nil { + if m := firstNumericDate(input); m != nil { day, mo := atoi(input, m[2], m[3]), atoi(input, m[4], m[5]) year := today.Year() explicitYear := m[6] >= 0 diff --git a/internal/quickadd/quickadd_test.go b/internal/quickadd/quickadd_test.go index 3fe7c7d..ff29d67 100644 --- a/internal/quickadd/quickadd_test.go +++ b/internal/quickadd/quickadd_test.go @@ -784,3 +784,50 @@ func TestImpossibleReminderClockIsNotKept(t *testing.T) { t.Errorf("ReminderOffsetMin = %d, want none", *got.ReminderOffsetMin) } } + +// A marker written before a 12-hour time belongs to the time. Left behind, it +// became the end of the title: "file the VAT return tomorrow at 10am" was the +// task "file the VAT return at". +func TestMarkerBeforeAMeridiemTimeIsConsumed(t *testing.T) { + now := ref(t) + for _, tc := range []struct{ in, content, due, time string }{ + {"file the VAT return tomorrow at 10am", "file the VAT return", "2026-03-11", "10:00"}, + {"call at 7:30 pm", "call", "2026-03-10", "19:30"}, + {"call the dentist tomorrow at 9am remind me an hour before", "call the dentist", "2026-03-11", "09:00"}, + {"møde i morgen kl 7 pm", "møde", "2026-03-11", "19:00"}, + // Control: a meridiem time with no marker still works, and "at" that is + // not in front of a time is a word. + {"standup tomorrow 9am", "standup", "2026-03-11", "09:00"}, + {"look at the report tomorrow", "look at the report", "2026-03-11", ""}, + } { + got := Parse(tc.in, now, "en") + if got.Content != tc.content || got.DueDate != tc.due || got.DueTime != tc.time { + t.Errorf("%q → %q %s %s, want %q %s %s", tc.in, got.Content, got.DueDate, got.DueTime, + tc.content, tc.due, tc.time) + } + } +} + +// "1/2 liter" is half a litre. A day/month followed by a unit is a quantity and +// stays in the title; without a unit it is still a date, because "3/4" alone and +// "møde 3/4 med Anders" are the third of April. +func TestAFractionWithAUnitIsNotADate(t *testing.T) { + now := ref(t) + for _, tc := range []struct{ in, content, due string }{ + {"køb 1/2 liter fløde", "køb 1/2 liter fløde", ""}, + {"tilsæt 3/4 dl mælk", "tilsæt 3/4 dl mælk", ""}, + {"bag med 1/2 kg mel i morgen", "bag med 1/2 kg mel", "2026-03-11"}, + // A quantity first, a real date after it: the date is still found. + {"køb 1/2 liter fløde 15/3", "køb 1/2 liter fløde", "2026-03-15"}, + // Controls: no unit, or a year — a date. + {"møde 3/4", "møde", "2026-04-03"}, + {"møde 3/4 med Anders", "møde med Anders", "2026-04-03"}, + {"halvdelen 3/4 kage", "halvdelen kage", "2026-04-03"}, + {"frist 1/2-2027 liter", "frist liter", "2027-02-01"}, + } { + got := Parse(tc.in, now, "da") + if got.Content != tc.content || got.DueDate != tc.due { + t.Errorf("%q → %q %q, want %q %q", tc.in, got.Content, got.DueDate, tc.content, tc.due) + } + } +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 9496cba..88e5b3f 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -6089,3 +6089,74 @@ test('knappen findes ikke i urd-ansigtet', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible(); }); + +/** Calls the API as the signed-in page does, and returns the parsed body. */ +async function apiCall(page, method, path, body) { + return page.evaluate( + async ({ method, path, body }) => { + const init = { method, credentials: 'include', headers: { 'Sec-Fetch-Site': 'same-origin' } }; + if (body !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + const r = await fetch('/api/v1' + path, init); + return r.status === 204 ? null : r.json(); + }, + { method, path, body } + ); +} + +/** + * Et "#navn", der ikke findes, er klippet ud af titlen af parseren og lagt et + * andet sted af serveren. Uden en besked forsvandt ordet bare. + */ +test('et ukendt projekt i hurtig tilføjelse bliver sagt højt', async ({ page }) => { + await page.goto('/'); + const name = `Findesikke${Date.now()}`; + const box = page.getByLabel('Ny opgave'); + await box.fill(`køb mælk i dag #${name}`); + await box.press('Enter'); + + await expect(page.locator('.toast').filter({ hasText: name })).toContainText( + 'Der er intet projekt' + ); + await expect(page.locator('.toast').filter({ hasText: name })).toContainText('Indbakke'); +}); + +/** + * At skrive i en opgaves titel læses som hurtig tilføjelse læser: dato, + * klokkeslæt, gentagelse, projekt og alarm. Gentagelsen blev engang læst under + * et felt, der ikke findes, og forsvandt. + */ +test('en titel, der rettes i opgaveruden, tager gentagelse, tid, projekt og alarm med', async ({ + page +}) => { + await page.goto('/'); + const project = await apiCall(page, 'POST', '/projects', { name: `Titelprojekt ${Date.now()}` }); + const task = await apiCall(page, 'POST', '/tasks', { content: 'omskriv titel' }); + + await page.goto('/opgave/' + task.id); + const drawer = page.getByRole('complementary', { name: 'Opgave' }); + const title = drawer.getByLabel('Opgavens tekst'); + await expect(title).toHaveValue('omskriv titel'); + + const saved = opgaveGemt(page); + await title.fill(`omskriv titel hver mandag kl 9 #"${project.name}" mind mig en time før`); + await title.press('Enter'); + await saved; + + await expect(title).toHaveValue('omskriv titel'); + await expect(page.locator('.toast').filter({ hasText: 'Læst som' })).toBeVisible(); + + // Målt på serveren, ikke på skærmen: det er dér, det skal stå. + await expect + .poll(async () => (await apiCall(page, 'GET', '/tasks/' + task.id)).recurrence_rule) + .toBe('FREQ=WEEKLY;BYDAY=MO'); + const after = await apiCall(page, 'GET', '/tasks/' + task.id); + expect(after.content).toBe('omskriv titel'); + expect(after.project_id).toBe(project.id); + expect(new Date(after.due_datetime).getHours()).toBe(9); + expect(new Date(after.due_datetime).getDay()).toBe(1); + const { reminders } = await apiCall(page, 'GET', `/tasks/${task.id}/reminders`); + expect(reminders.map((r) => r.offset_min)).toEqual([-60]); +}); diff --git a/web/src/lib/components/TaskDetail.svelte b/web/src/lib/components/TaskDetail.svelte index 94f3375..a3df0f8 100644 --- a/web/src/lib/components/TaskDetail.svelte +++ b/web/src/lib/components/TaskDetail.svelte @@ -14,7 +14,7 @@ import { app, projectName, clockOf } from '$lib/stores.svelte.js'; import { focusOnMount } from '$lib/focus.js'; import { t, tag } from '$lib/i18n.svelte.js'; - import { REPEATS, reminderOffsetLabel } from '$lib/when.js'; + import { REPEATS, reminderOffsetLabel, repeatLabel, shortDate } from '$lib/when.js'; import { readList } from '$lib/pastelist.js'; let { task, onclose } = $props(); @@ -215,24 +215,83 @@ const patch = { content: trimmed }; const took = []; + // Det, der ikke kan stå i en PATCH: alarmen er sin egen række. + let reminder = null; if (parsed && parsed.content && parsed.content !== trimmed) { patch.content = parsed.content; + // Samme felter som hurtig tilføjelse læser, og med de navne serveren + // sender dem under. Feltet hedder `recurrence_rule`; det læste en gang + // `parsed.recurrence`, som aldrig findes, så "hver mandag" i en titel + // forsvandt fra titlen og blev ingen steder. if (parsed.due_date) { patch.due_date = parsed.due_date; - took.push(parsed.due_date); + took.push(shortDate(parsed.due_date)); + } + // Parseren fylder altid datoen ud, når den har læst et klokkeslæt, så + // due_date er sat ovenfor, når due_time er. + if (parsed.due_time) { + patch.due_time = parsed.due_time; + took.push(parsed.due_time); } 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.recurrence_rule) { + patch.recurrence_rule = parsed.recurrence_rule; + took.push(repeatLabel(parsed.recurrence_rule, parsed.recurrence_text)); } if (parsed.labels?.length) { patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])]; took.push(parsed.labels.map((l) => `@${l}`).join(' ')); } + if (parsed.project) { + // Navnet slås op som hurtig tilføjelse gør: uden forskel på store og + // små bogstaver. Et navn, der ikke findes, siges højt — det er klippet + // ud af titlen, og uden en besked var ordet bare væk. + const wanted = parsed.project.toLowerCase(); + const found = app.projects.find((p) => p.name.toLowerCase() === wanted); + if (!found) { + app.toast( + t('task.unknownProject', { + name: parsed.project, + where: projectName(app.projectById(task.project_id)) + }) + ); + } else if (found.id !== task.project_id && !task.parent_id) { + patch.project_id = found.id; + took.push(`#${found.name}`); + } + } + if (parsed.reminder_at) { + // Vægurstid i læserens zone, ligesom feltet nedenfor giver den. + reminder = { remind_at: new Date(parsed.reminder_at).toISOString() }; + } else if (parsed.reminder_offset_min !== undefined && parsed.reminder_offset_min !== null) { + // En forskydning regnes fra klokkeslættet. Uden et ville rækken aldrig + // ringe, og intet bagefter ville afsløre det. + if (patch.due_time || task.due_datetime) { + reminder = { offset_min: parsed.reminder_offset_min }; + } else { + app.toast(t('task.alarmNeedsATime')); + } + } + } + + // Alarmen før rettelsen: får opgaven sit klokkeslæt i samme gemning, lægger + // serveren standardalarmen på — medmindre opgaven allerede har en. Den, + // der blev skrevet, skal være den, og ikke stå oven på standarden. + if (reminder) { + try { + const created = await api.createReminder(task.id, reminder); + reminders = [...reminders, created]; + took.push( + reminder.offset_min !== undefined + ? reminderOffsetLabel(reminder.offset_min) + : parsed.reminder_at.slice(11) + ); + } catch (e) { + app.toast(humanMessage(e)); + } } await save(patch); @@ -244,6 +303,8 @@ if (took.length) { content = patch.content; if (patch.due_date) dueDate = patch.due_date; + if (patch.due_time) dueTime = patch.due_time; + if (patch.project_id) projectId = patch.project_id; if (patch.priority) priority = patch.priority; if (patch.recurrence_rule) recurrence = patch.recurrence_rule; if (patch.labels) labels = patch.labels.join(', '); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 6ffaf8d..bfafabf 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -272,6 +272,9 @@ export const da = { // Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller // feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger // det, fordi en knap med en mikrofon på lover noget andet. + 'task.unknownProject': 'Der er intet projekt, der hedder "{name}". Opgaven ligger i {where}.', + 'task.unknownSection': 'Der er ingen sektion, der hedder "{name}" i {where}. Opgaven ligger uden sektion.', + 'task.subtaskKeepsProject': 'En underopgave ligger i sin opgaves projekt, {where} — "{name}" blev ikke brugt.', 'task.alarmNeedsATime': 'Opgaven er oprettet. Alarmen er ikke — "en time før" skal have et klokkeslæt at regne tilbage fra.', 'task.speak': 'Tal en opgave ind', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 9eb0c4a..2d56ac3 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -266,6 +266,9 @@ export const en = { 'task.pastedAdd': 'Add {n} tasks', 'task.pastedCancel': 'Cancel', 'task.new': 'New task', + 'task.unknownProject': 'There is no project called "{name}". The task is in {where}.', + 'task.unknownSection': 'There is no section called "{name}" in {where}. The task has no section.', + 'task.subtaskKeepsProject': 'A sub-task stays in its task\'s project, {where} — "{name}" was not used.', 'task.alarmNeedsATime': 'The task is created. The alarm is not — "an hour before" needs a time to count back from.', 'task.speak': 'Speak a task', diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 180271a..44d9d59 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -729,6 +729,20 @@ class AppState { // slags påmindelse, hvis fejl intet bagefter afslører — der står ingen // række at savne, og man opdager den først, da den ikke ringede. if (task?.reminder_not_set) this.toast(t('task.alarmNeedsATime')); + // Et "#navn" eller "/navn", der ikke ramte noget, er klippet ud af titlen + // af parseren og lagt et andet sted af serveren. Uden de her linjer + // forsvandt ordet bare, og opgaven lå et sted, ingen havde bedt om. + const where = projectName(this.projectById(task?.project_id)); + if (task?.unknown_project) { + this.toast( + parentId + ? t('task.subtaskKeepsProject', { name: task.unknown_project, where }) + : t('task.unknownProject', { name: task.unknown_project, where }) + ); + } + if (task?.unknown_section) { + this.toast(t('task.unknownSection', { name: task.unknown_section, where })); + } return task; } catch (e) { this.toast(humanMessage(e));