diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 01ddc4b..9009a41 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -983,11 +983,11 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ( // slipper der, hvor man kan se, man slipper. await chip.dragTo(page.locator(`[data-date="${to}"]`), { targetPosition: { x: 30, y: 20 } }); - // Sluppet på timesøjlen, landet i heldagsbåndet — og det er det rigtige sted. - // Et træk hen på en dag flytter datoen og sætter ikke et klokkeslæt, så opgaven - // har ingen tid at stå ved. `data-allday` er den anden halvdel af dagen: siden - // ugen blev et døgn, er en dag to kasser, og en prøve skal sige hvilken. - await expect(page.locator(`[data-allday="${to}"]`).getByText('skifte dæk')).toBeVisible(); + // Landet i timesøjlen med et klokkeslæt: hvor på døgnet man slap, *er* tiden. + // Sluppet tyve pixels nede i en søjle, der begynder klokken otte, bliver det + // tidligt på dagen — hvad det præcist runder til, er ikke det, der prøves her. + await expect(page.locator(`[data-date="${to}"] .tevent`).getByText('skifte dæk')).toBeVisible(); + await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden(); await expect(page.locator(`[data-allday="${from}"]`).getByText('skifte dæk')).toBeHidden(); expect(trouble).toEqual([]); @@ -1178,9 +1178,8 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka // få prøven til at bestå: det er også sådan et menneske gør det, fordi man // slipper der, hvor man kan se, man slipper. await task.dragTo(page.locator(`[data-date="${next}"]`), { targetPosition: { x: 30, y: 20 } }); - // Landet i næste dags bånd: et træk hen på en dag flytter datoen og sætter ikke - // et klokkeslæt, så opgaven har ingen tid at stå ved. - await expect(page.locator(`[data-allday="${next}"]`).getByText('vande blomster')).toBeVisible(); + // Landet i næste dags timesøjle, med det klokkeslæt der blev sluppet på. + await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible(); await expect(band.getByText('vande blomster')).toBeHidden(); // The events stayed where they were; a rescheduled task moves nothing else. await expect(cell.locator('.tevent')).toHaveCount(1); @@ -3210,3 +3209,97 @@ test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => { expect(trouble).toEqual([]); }); + +/** + * Tid på en opgave, sat i ruden og justeret i kalenderen. + * + * Tre påstande, og den midterste er den, der var gået galt uden at nogen sagde + * det: serveren læser `due_date` og `due_time` sammen, så en dato sendt uden en + * tid *rydder* tiden. Et gem af datoen alene tog altså klokkeslættet med sig, og + * det samme gjorde et træk i kalenderen. + */ +test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const box = page.getByLabel('Ny opgave'); + // I dag, så den står på den visning, den skrives i — og i den uge, gitteret + // åbner på længere nede. + await box.fill('ringe til tandlægen i dag'); + await box.press('Enter'); + await expect(page.getByText('ringe til tandlægen')).toBeVisible(); + + // Klokkeslættet sættes i ruden. Feltet er tomt til at begynde med: en opgave + // uden tid er det almindelige, og 00:00 ville påstå noget om midnat. + await page.getByText('ringe til tandlægen').click(); + const time = page.getByLabel('Klokkeslæt'); + await expect(time).toHaveValue(''); + // Tidligt på dagen med vilje: gitteret begynder klokken otte, så opgaven står + // lige under båndet og de to kasser er i billedet på én gang. Klokken fjorten + // ligger under skærmkanten, og et træk, der først skal rulle, er et træk, + // browseren taber. + await time.fill('08:30'); + await time.blur(); + await page.waitForTimeout(400); + + // Gemt hos serveren, ikke kun i feltet: genindlæst og åbnet igen. + await page.reload(); + await page.getByText('ringe til tandlægen').click(); + await expect(page.getByLabel('Klokkeslæt'), 'klokkeslættet blev ikke gemt').toHaveValue('08:30'); + + // Og datoen kan rettes bagefter uden at tage timen med sig. + const date = page.locator('#due'); + const day = await date.inputValue(); + await date.fill(day); + await date.blur(); + await page.waitForTimeout(400); + await page.reload(); + await page.getByText('ringe til tandlægen').click(); + await expect( + page.getByLabel('Klokkeslæt'), + 'et gem af datoen ryddede klokkeslættet' + ).toHaveValue('08:30'); + + await page.keyboard.press('Escape'); + + // I ugegitteret står den på sit klokkeslæt frem for i heldagsbåndet. + await page.goto('/upcoming'); + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + const placed = page.locator('.tevent.task').filter({ hasText: 'ringe til tandlægen' }); + await expect(placed, 'opgaven står ikke på døgnet').toBeVisible(); + await expect(placed).toContainText('08:30'); + + // Trukket op i båndet over sin egen dag: så har den en dag og ingen tid. Den + // tomme streng siger "ryd", hvor ingenting siger "behold" — og de to må ikke + // forveksles. + // + // Et bånd, der er tomt. Prøven handler om at krydse mellem de to kasser, og en + // dag, hvis bånd allerede er fyldt af andre prøvers opgaver, gør slippunktet + // til en anden opgave frem for til båndet — hvilket er en anden gest end den, + // der prøves her. + const band = page + .locator('.weekgrid > .allday') + .filter({ hasNot: page.locator('.chip, .event') }) + .first(); + const today = await band.getAttribute('data-allday'); + // Trukket i skridt frem for i ét spring. `dragTo` flytter musen i to træk, og + // over fire hundrede pixels i et travlt gitter giver det browseren for få + // mellemstationer til at holde gesten i live. Et menneske bevæger hånden hele + // vejen, og det er også det, der skal prøves. + const fromBox = await placed.boundingBox(); + const toBox = await band.boundingBox(); + await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + 5); + await page.mouse.down(); + await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 25 }); + await page.mouse.up(); + await expect( + page.locator(`[data-allday="${today}"] .chip`).filter({ hasText: 'ringe til tandlægen' }), + 'opgaven kom ikke op i båndet' + ).toBeVisible(); + await expect( + page.locator(`[data-date="${today}"] .tevent`).filter({ hasText: 'ringe til tandlægen' }), + 'opgaven står stadig på et klokkeslæt' + ).toBeHidden(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index bd44d5f..df291a9 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -272,6 +272,15 @@ over = iso(date); } + /** + * Sluppet på en dag, uden at sige noget om klokkeslættet — så beholder opgaven + * det, den har. Måneden er den her: en rude er en dag, ikke et døgn, og der er + * ingen tid at læse ud af hvor man slap. + * + * Det var ikke sådan før. Trækket sendte kun datoen, og serveren læser en dato + * uden en tid som "ryd tiden" — så en opgave klokken 14 mistede sit + * klokkeslæt ved at blive flyttet til dagen efter, uden at nogen bad om det. + */ async function onDrop(event, date) { event.preventDefault(); const id = dragged(event, TASK); @@ -280,6 +289,44 @@ await app.reschedule(id, iso(date)); } + /** + * Sluppet et sted på døgnet: dér, man slap, er klokkeslættet. + * + * Målt mod søjlens egen kasse frem for mod siden, så det bliver rigtigt, uanset + * hvor gitteret sidder, og uanset hvor langt der er rullet. + * + * Rundet til kvarter. Minuttet under markøren er ikke en oplysning, nogen har — + * pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om + * det blev valgt, hvilket er værre end at være omtrentligt. + */ + async function onDropAt(event, date) { + event.preventDefault(); + const id = dragged(event, TASK); + over = null; + if (!id) return; + + const box = event.currentTarget.getBoundingClientRect(); + const share = Math.min(Math.max((event.clientY - box.top) / box.height, 0), 1); + const minutes = hours.from * 60 + share * hours.minutes; + const snapped = Math.min(Math.round(minutes / 15) * 15, 24 * 60 - 15); + await app.reschedule(id, iso(date), clock(snapped)); + } + + /** + * Sluppet i båndet foroven: opgaven har en dag og ingen tid. + * + * Den tomme streng er ikke det samme som ingenting her — den siger "ryd", hvor + * `undefined` siger "behold". Det er hele grunden til, at de to er skilt ad i + * `reschedule`. + */ + async function onDropAllDay(event, date) { + event.preventDefault(); + const id = dragged(event, TASK); + over = null; + if (!id) return; + await app.reschedule(id, iso(date), ''); + } + // --- ugen som et døgn --------------------------------------------------------- // // En uge, hvor hver dag er en punktopstilling, svarer på "hvad skal der ske @@ -525,7 +572,7 @@ class:over={over === iso(date)} ondragover={(e) => onDragOver(e, date)} ondragleave={() => (over = null)} - ondrop={(e) => onDrop(e, date)} + ondrop={(e) => onDropAllDay(e, date)} > {#each untimedOn(date) as item (item.kind + item.ref.id)} {#if item.kind === 'event'} @@ -556,7 +603,7 @@ class:over={over === iso(date)} ondragover={(e) => onDragOver(e, date)} ondragleave={() => (over = null)} - ondrop={(e) => onDrop(e, date)} + ondrop={(e) => onDropAt(e, date)} > {#each Array(hours.count) as _, i}
diff --git a/web/src/lib/components/TaskDetail.svelte b/web/src/lib/components/TaskDetail.svelte index fd94c79..bc524cb 100644 --- a/web/src/lib/components/TaskDetail.svelte +++ b/web/src/lib/components/TaskDetail.svelte @@ -11,7 +11,7 @@ * necessarily the last one sent. */ import { api, humanMessage } from '$lib/api.js'; - import { app, projectName } from '$lib/stores.svelte.js'; + import { app, projectName, clockOf } from '$lib/stores.svelte.js'; import { focusOnMount } from '$lib/focus.js'; import { t } from '$lib/i18n.svelte.js'; import { REPEATS } from '$lib/when.js'; @@ -22,6 +22,7 @@ let description = $state(''); let priority = $state(4); let dueDate = $state(''); + let dueTime = $state(''); let labels = $state(''); let projectId = $state(''); let recurrence = $state(''); @@ -55,6 +56,7 @@ description = task.description ?? ''; priority = task.priority; dueDate = task.due_date ?? ''; + dueTime = task.due_datetime ? clockOf(task.due_datetime) : ''; labels = (task.labels ?? []).join(', '); projectId = task.project_id; recurrence = task.recurrence_rule ?? ''; @@ -407,14 +409,34 @@ +
- save({ due_date: dueDate })} - /> +
+ save({ due_date: dueDate, due_time: dueTime })} + /> + save({ due_date: dueDate, due_time: dueTime })} + /> +
@@ -692,6 +714,27 @@ padding-bottom: max(var(--s8), env(safe-area-inset-bottom)); } + /* Dato og tid på én linje: de er ét svar på ét spørgsmål, og tiden er den + smalle halvdel af det. */ + .due { + display: flex; + gap: var(--s2); + } + + .due input[type='date'] { + flex: 1; + min-width: 0; + } + + .due input[type='time'] { + flex: none; + width: 7.5rem; + } + + .due input:disabled { + opacity: 0.5; + } + .field { display: flex; flex-direction: column; diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 8bffba1..de2d0d2 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -551,6 +551,7 @@ export const da = { 'detail.p1': 'P1 — haster', 'detail.none': 'Ingen', 'detail.due': 'Forfalder', + 'detail.dueTime': 'Klokkeslæt', 'detail.labels': 'Etiketter', 'detail.labelsPlaceholder': 'adskilt af komma', 'detail.project': 'Projekt', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 52e6d6f..cdbdbea 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -547,6 +547,7 @@ export const en = { 'detail.p1': 'P1 — urgent', 'detail.none': 'None', 'detail.due': 'Due', + 'detail.dueTime': 'Time', 'detail.labels': 'Labels', 'detail.labelsPlaceholder': 'separated by commas', 'detail.project': 'Project', diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 7c02097..6dacab5 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -372,10 +372,35 @@ class AppState { } /** Puts a task on a day — dropping it on one, in Kommende or in a month grid. */ - async reschedule(id, date) { + /** + * Flytter en opgave til en dag — og afgør, hvad der sker med klokkeslættet. + * + * Serveren læser `due_date` og `due_time` sammen: kommer datoen uden en tid, + * *ryddes* tiden. Det er den rigtige regel for et felt, man skriver i, og den + * forkerte for et træk i en kalender — en opgave, der stod klokken 14, mistede + * sit klokkeslæt ved at blive trukket til dagen efter, uden at nogen bad om + * det, og uden at noget sagde det. + * + * Så tiden siges nu højt, tre måder: + * + * time === undefined behold den, opgaven har + * time === '' ryd den — det er heldagsbåndet + * time === 'HH:MM' sæt den — det er et sted på døgnet + * + * `undefined` og `''` er med vilje to forskellige ting her. De ligner hinanden + * i JavaScript, og det er præcis derfor de skal skilles ad ét sted frem for at + * blive forvekslet fire steder. + */ + async reschedule(id, date, time) { const previous = this.get(id); - if (!previous || previous.due_date === date) return; - await this.update(id, { due_date: date }); + if (!previous) return; + + const clock = + time === undefined ? (previous.due_datetime ? clockOf(previous.due_datetime) : '') : time; + if (previous.due_date === date && (previous.due_datetime ? clockOf(previous.due_datetime) : '') === clock) { + return; + } + await this.update(id, { due_date: date, due_time: clock }); } async remove(id) { @@ -729,6 +754,20 @@ class AppState { * Only while it still carries a name the server gave it. Rename it and that is your * name for it, in every language — the same rule as any other project. */ +/** + * Klokkeslættet på en opgave, som "HH:MM" i den maskine, der ser på det. + * + * `Date` og ikke en strengudskæring: `due_datetime` kommer som UTC med et Z på, + * og de fjorten på et stempel er ikke klokken fjorten for den, der læser det. + * TaskRow viser tiden på nøjagtig samme måde, og de to steder skal vise det + * samme tal. + */ +export function clockOf(stamp) { + const at = new Date(stamp); + if (Number.isNaN(at.getTime())) return ''; + return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`; +} + const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']); export function projectName(project) {