Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f25905d81 | ||
|
|
e3eeb3037c |
@@ -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
|
* 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.
|
* 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 }) => {
|
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/kalender');
|
await page.goto('/kalender');
|
||||||
|
|||||||
@@ -286,6 +286,48 @@
|
|||||||
/** The cell lit up under the pointer. */
|
/** The cell lit up under the pointer. */
|
||||||
let over = $state(null);
|
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) {
|
function onDragOver(event, date) {
|
||||||
if (!carries(event, TASK)) return;
|
if (!carries(event, TASK)) return;
|
||||||
accept(event);
|
accept(event);
|
||||||
@@ -394,7 +436,16 @@
|
|||||||
if (!task.due_datetime) continue;
|
if (!task.due_datetime) continue;
|
||||||
const at = new Date(task.due_datetime);
|
const at = new Date(task.due_datetime);
|
||||||
const from = at.getHours() * 60 + at.getMinutes();
|
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);
|
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
|
||||||
@@ -547,12 +598,13 @@
|
|||||||
</svelte:element>
|
</svelte:element>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet taskChip(task, kind, extra = '', at = '')}
|
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
|
||||||
<!-- Draggable as well as clickable: moving something to another day is the
|
<!-- Draggable as well as clickable: moving something to another day is the
|
||||||
whole reason to be looking at a calendar. The browser needs a few pixels
|
whole reason to be looking at a calendar. The browser needs a few pixels
|
||||||
of movement before it calls a press a drag, so the click survives. -->
|
of movement before it calls a press a drag, so the click survives. -->
|
||||||
<button
|
<button
|
||||||
class={kind}
|
class={kind}
|
||||||
|
class:resizing={resizing?.id === task.id}
|
||||||
data-priority={task.priority}
|
data-priority={task.priority}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||||
@@ -562,6 +614,16 @@
|
|||||||
>
|
>
|
||||||
{#if at}<span class="at">{at}</span>{/if}
|
{#if at}<span class="at">{at}</span>{/if}
|
||||||
{task.content}
|
{task.content}
|
||||||
|
<!-- Only a task standing at a time can be made longer, and only where the
|
||||||
|
grid has room to show it — the timed column, which passes the date. -->
|
||||||
|
{#if date && task.due_datetime}
|
||||||
|
<span
|
||||||
|
class="resize"
|
||||||
|
title={t('cal.resize')}
|
||||||
|
aria-hidden="true"
|
||||||
|
onpointerdown={(e) => startResize(e, task, date)}
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -632,7 +694,7 @@
|
|||||||
{#if item.kind === 'event'}
|
{#if item.kind === 'event'}
|
||||||
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
{@render eventChip(item.ref, date, 'tevent', place(item))}
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -937,6 +999,29 @@
|
|||||||
cursor: grab;
|
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'] {
|
.tevent.task[data-priority='1'] {
|
||||||
border-left-color: var(--p1);
|
border-left-color: var(--p1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
let priority = $state(4);
|
let priority = $state(4);
|
||||||
let dueDate = $state('');
|
let dueDate = $state('');
|
||||||
let dueTime = $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 labels = $state('');
|
||||||
let projectId = $state('');
|
let projectId = $state('');
|
||||||
let recurrence = $state('');
|
let recurrence = $state('');
|
||||||
@@ -57,6 +60,7 @@
|
|||||||
priority = task.priority;
|
priority = task.priority;
|
||||||
dueDate = task.due_date ?? '';
|
dueDate = task.due_date ?? '';
|
||||||
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
dueTime = task.due_datetime ? clockOf(task.due_datetime) : '';
|
||||||
|
duration = task.duration_min ? String(task.duration_min) : '';
|
||||||
labels = (task.labels ?? []).join(', ');
|
labels = (task.labels ?? []).join(', ');
|
||||||
projectId = task.project_id;
|
projectId = task.project_id;
|
||||||
recurrence = task.recurrence_rule ?? '';
|
recurrence = task.recurrence_rule ?? '';
|
||||||
@@ -436,6 +440,21 @@
|
|||||||
disabled={!dueDate}
|
disabled={!dueDate}
|
||||||
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
onchange={() => save({ due_date: dueDate, due_time: dueTime })}
|
||||||
/>
|
/>
|
||||||
|
<!-- Length in minutes, and only with a time to hang it on: a duration
|
||||||
|
without a start is a length of nothing, and the calendar has
|
||||||
|
nowhere to draw it. Empty clears it. -->
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="dur"
|
||||||
|
min="0"
|
||||||
|
step="15"
|
||||||
|
aria-label={t('detail.duration')}
|
||||||
|
placeholder={t('detail.durationShort')}
|
||||||
|
bind:value={duration}
|
||||||
|
disabled={!dueTime}
|
||||||
|
onchange={() =>
|
||||||
|
save({ duration_min: duration === '' ? 0 : Math.max(0, Math.round(Number(duration))) })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -731,6 +750,11 @@
|
|||||||
width: 7.5rem;
|
width: 7.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.due input.dur {
|
||||||
|
flex: none;
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.due input:disabled {
|
.due input:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -597,6 +597,8 @@ export const da = {
|
|||||||
'detail.none': 'Ingen',
|
'detail.none': 'Ingen',
|
||||||
'detail.due': 'Forfalder',
|
'detail.due': 'Forfalder',
|
||||||
'detail.dueTime': 'Klokkeslæt',
|
'detail.dueTime': 'Klokkeslæt',
|
||||||
|
'detail.duration': 'Længde i minutter',
|
||||||
|
'detail.durationShort': 'min',
|
||||||
'detail.labels': 'Etiketter',
|
'detail.labels': 'Etiketter',
|
||||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||||
'detail.project': 'Projekt',
|
'detail.project': 'Projekt',
|
||||||
@@ -749,6 +751,7 @@ export const da = {
|
|||||||
'cal.settings': 'Indstillinger',
|
'cal.settings': 'Indstillinger',
|
||||||
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
||||||
'cal.allDay': 'Hele dagen',
|
'cal.allDay': 'Hele dagen',
|
||||||
|
'cal.resize': 'Træk for at ændre længden',
|
||||||
'cal.openInGoogle': 'Åbn i Google Kalender',
|
'cal.openInGoogle': 'Åbn i Google Kalender',
|
||||||
'cal.beyondWindow':
|
'cal.beyondWindow':
|
||||||
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
||||||
|
|||||||
@@ -593,6 +593,8 @@ export const en = {
|
|||||||
'detail.none': 'None',
|
'detail.none': 'None',
|
||||||
'detail.due': 'Due',
|
'detail.due': 'Due',
|
||||||
'detail.dueTime': 'Time',
|
'detail.dueTime': 'Time',
|
||||||
|
'detail.duration': 'Length in minutes',
|
||||||
|
'detail.durationShort': 'min',
|
||||||
'detail.labels': 'Labels',
|
'detail.labels': 'Labels',
|
||||||
'detail.labelsPlaceholder': 'separated by commas',
|
'detail.labelsPlaceholder': 'separated by commas',
|
||||||
'detail.project': 'Project',
|
'detail.project': 'Project',
|
||||||
@@ -742,6 +744,7 @@ export const en = {
|
|||||||
'cal.settings': 'Settings',
|
'cal.settings': 'Settings',
|
||||||
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
|
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
|
||||||
'cal.allDay': 'All day',
|
'cal.allDay': 'All day',
|
||||||
|
'cal.resize': 'Drag to change the length',
|
||||||
'cal.openInGoogle': 'Open in Google Calendar',
|
'cal.openInGoogle': 'Open in Google Calendar',
|
||||||
'cal.beyondWindow':
|
'cal.beyondWindow':
|
||||||
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
||||||
|
|||||||
@@ -403,6 +403,19 @@ class AppState {
|
|||||||
await this.update(id, { due_date: date, due_time: clock });
|
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) {
|
async remove(id) {
|
||||||
const previous = [...this.tasks];
|
const previous = [...this.tasks];
|
||||||
this.tasks = this.tasks.filter((t) => t.id !== id);
|
this.tasks = this.tasks.filter((t) => t.id !== id);
|
||||||
|
|||||||
Reference in New Issue
Block a user