diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 3fa8591..14da1e1 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1225,6 +1225,69 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka * thing this view was before anything was laid over it — and a Google account that * is not there must not take it down with it. */ +test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + // A timed task today at ten, half an hour long. Today, so the week opens on it + // without paging, and ten is inside the hours the grid draws by default. + const today = await page.evaluate(() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }); + const id = await page.evaluate(async (day) => { + const r = await fetch('/api/v1/tasks', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' }, + body: JSON.stringify({ + content: 'Møde med Anders', + due_date: day, + due_time: '10:00', + duration_min: 30 + }) + }); + return (await r.json()).id; + }, today); + + await page.goto('/kalender'); + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + + const chip = page + .locator(`[data-date="${today}"] .tevent.task`) + .filter({ hasText: 'Møde med Anders' }); + await expect(chip).toBeVisible(); + const before = await chip.evaluate((el) => el.getBoundingClientRect().height); + + // Drag the foot of the task down a good stretch. It should grow, both on screen + // and in what the server stores — a resize is a change of length, not of time. + const box = await chip.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 }); + await page.mouse.up(); + + await expect + .poll(async () => + page.evaluate(async (tid) => { + const r = await fetch('/api/v1/tasks/' + tid, { + credentials: 'include', + headers: { 'Sec-Fetch-Site': 'same-origin' } + }); + return (await r.json()).duration_min; + }, id) + ) + .toBeGreaterThan(30); + + // And the chip is taller than it was, so the length is something you can see. + await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before); + + // The time did not move: it is still a ten o'clock task. + await expect(chip.locator('.at')).toHaveText('10:00'); + + expect(trouble).toEqual([]); +}); + test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/kalender'); diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index 872967b..a294532 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -286,6 +286,48 @@ /** The cell lit up under the pointer. */ let over = $state(null); + // A resize in progress: which task, and where its foot is being dragged to, in + // minutes since midnight. Kept apart from the stored length so the task can + // follow the handle live and only be written when the drag ends. + let resizing = $state(null); + + /** + * Dragging the foot of a timed task changes how long it is. + * + * Pointer events rather than the HTML5 drag the body uses: the two must not be + * the same gesture, or every attempt to make a task longer would instead pick it + * up and move it. stopPropagation keeps the handle's press from starting the + * move, and the column it lives in is what the minutes are measured against — + * the same box onDropAt reads a drop time from. + */ + function startResize(event, task, date) { + event.stopPropagation(); + event.preventDefault(); + const col = event.currentTarget.closest('.daycol'); + if (!col || !task.due_datetime) return; + const at = new Date(task.due_datetime); + const from = at.getHours() * 60 + at.getMinutes(); + resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) }; + + const onMove = (e) => { + const box = col.getBoundingClientRect(); + const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1); + const minutes = hours.from * 60 + share * hours.minutes; + // Quarter-hour steps, at least a quarter long, never past midnight. + const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60); + resizing = { ...resizing, to: snapped }; + }; + const onUp = async () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + const current = resizing; + resizing = null; + if (current) await app.resize(current.id, current.to - current.from); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + } + function onDragOver(event, date) { if (!carries(event, TASK)) return; accept(event); @@ -394,7 +436,16 @@ if (!task.due_datetime) continue; const at = new Date(task.due_datetime); const from = at.getHours() * 60 + at.getMinutes(); - out.push({ kind: 'task', ref: task, from, to: from + 30 }); + // The task's own length, not a fixed half hour. A quarter-hour floor so a + // zero-minute task is still something to see and grab, and the end of the + // day is the ceiling. While a resize is in flight this task follows the + // handle instead of its stored length. + let to = + resizing && resizing.id === task.id + ? resizing.to + : from + (task.duration_min || 30); + to = Math.min(Math.max(to, from + 15), 24 * 60); + out.push({ kind: 'task', ref: task, from, to }); } return out.toSorted((a, b) => a.from - b.from || a.to - b.to); @@ -547,12 +598,13 @@ {/snippet} - {#snippet taskChip(task, kind, extra = '', at = '')} + {#snippet taskChip(task, kind, extra = '', at = '', date = null)} {/snippet} @@ -632,7 +694,7 @@ {#if item.kind === 'event'} {@render eventChip(item.ref, date, 'tevent', place(item))} {:else} - {@render taskChip(item.ref, 'tevent task', place(item), clock(item.from))} + {@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)} {/if} {/each} @@ -937,6 +999,29 @@ cursor: grab; } + /* The grip along the foot of a task, for dragging its length. Out of the way + until the task is hovered or being resized, so it never competes with the + words above it. */ + .tevent.task .resize { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 7px; + cursor: ns-resize; + opacity: 0; + touch-action: none; + } + .tevent.task:hover .resize, + .tevent.task.resizing .resize { + opacity: 1; + background: linear-gradient(to bottom, transparent, var(--line-strong)); + } + .tevent.task.resizing { + cursor: ns-resize; + z-index: 5; + } + .tevent.task[data-priority='1'] { border-left-color: var(--p1); } diff --git a/web/src/lib/components/TaskDetail.svelte b/web/src/lib/components/TaskDetail.svelte index bc524cb..136afc6 100644 --- a/web/src/lib/components/TaskDetail.svelte +++ b/web/src/lib/components/TaskDetail.svelte @@ -23,6 +23,9 @@ let priority = $state(4); let dueDate = $state(''); let dueTime = $state(''); + // Length in minutes, as a string for the field. Only meaningful with a time — + // see the field below — so it is seeded and shown alongside the clock. + let duration = $state(''); let labels = $state(''); let projectId = $state(''); let recurrence = $state(''); @@ -57,6 +60,7 @@ priority = task.priority; dueDate = task.due_date ?? ''; dueTime = task.due_datetime ? clockOf(task.due_datetime) : ''; + duration = task.duration_min ? String(task.duration_min) : ''; labels = (task.labels ?? []).join(', '); projectId = task.project_id; recurrence = task.recurrence_rule ?? ''; @@ -436,6 +440,21 @@ disabled={!dueDate} onchange={() => save({ due_date: dueDate, due_time: dueTime })} /> + + + save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })} + /> @@ -731,6 +750,11 @@ width: 7.5rem; } + .due input.dur { + flex: none; + width: 5rem; + } + .due input:disabled { opacity: 0.5; } diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index f24da7f..8ce9897 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -597,6 +597,8 @@ export const da = { 'detail.none': 'Ingen', 'detail.due': 'Forfalder', 'detail.dueTime': 'Klokkeslæt', + 'detail.duration': 'Længde i minutter', + 'detail.durationShort': 'min', 'detail.labels': 'Etiketter', 'detail.labelsPlaceholder': 'adskilt af komma', 'detail.project': 'Projekt', @@ -749,6 +751,7 @@ export const da = { 'cal.settings': 'Indstillinger', 'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.', 'cal.allDay': 'Hele dagen', + 'cal.resize': 'Træk for at ændre længden', 'cal.openInGoogle': 'Åbn i Google Kalender', 'cal.beyondWindow': 'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 87b932a..dab402b 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -593,6 +593,8 @@ export const en = { 'detail.none': 'None', 'detail.due': 'Due', 'detail.dueTime': 'Time', + 'detail.duration': 'Length in minutes', + 'detail.durationShort': 'min', 'detail.labels': 'Labels', 'detail.labelsPlaceholder': 'separated by commas', 'detail.project': 'Project', @@ -742,6 +744,7 @@ export const en = { 'cal.settings': 'Settings', 'cal.noneChosen': 'None of the account’s calendars are chosen yet.', 'cal.allDay': 'All day', + 'cal.resize': 'Drag to change the length', 'cal.openInGoogle': 'Open in Google Calendar', 'cal.beyondWindow': 'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.', diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 84fb5ed..912de8e 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -403,6 +403,19 @@ class AppState { await this.update(id, { due_date: date, due_time: clock }); } + /** + * Sets how long a task is, in minutes. Nothing else about it moves — the date + * and time are left alone, so making a task longer on the calendar does not + * also reschedule it. + */ + async resize(id, minutes) { + const previous = this.get(id); + if (!previous) return; + const rounded = Math.max(15, Math.round(minutes / 15) * 15); + if (previous.duration_min === rounded) return; + await this.update(id, { duration_min: rounded }); + } + async remove(id) { const previous = [...this.tasks]; this.tasks = this.tasks.filter((t) => t.id !== id);