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);