Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6116ed2e21 | ||
|
|
1ba26ee723 | ||
|
|
fcedab0b19 | ||
|
|
72b6ea3803 | ||
|
|
4f25905d81 | ||
|
|
e3eeb3037c |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 344 KiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 216 KiB |
@@ -926,6 +926,26 @@
|
||||
</figure>
|
||||
|
||||
<div class="shots">
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/calendar-light.webp"
|
||||
alt="A week in the calendar: timed tasks drawn to their length, a fifteen-minute stand-up short and a ninety-minute planning block tall, each on the day and at the time it is due."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>The calendar draws a task to its length — drag its foot to change it.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/notes-shared-light.webp"
|
||||
alt="Notes with a Shared with me group at the top of the list, holding a note somebody else shared, marked with whose it is, open on the right."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>A note shared with you, gathered under Shared with me.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/board.webp"
|
||||
@@ -1063,6 +1083,23 @@
|
||||
written about it. Archive what is done with it; the bin is for mistakes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Share a note, or a page</h3>
|
||||
<p>
|
||||
Hand a single note to a person — to read or to edit — without standing up
|
||||
a project for it. Notes other people share with you gather under
|
||||
<em>Shared with me</em>, each marked with whose it is. File it in a project
|
||||
instead and everyone who can read the project reads the note.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>How long it takes</h3>
|
||||
<p>
|
||||
A task can carry a length, and the calendar draws it to scale. Drag the
|
||||
foot of a task to make it longer or shorter, or drag its body to another
|
||||
day or time — the length comes along.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<h3>Waiting on others</h3>
|
||||
<p>
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 38 KiB |
@@ -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');
|
||||
@@ -2561,6 +2624,36 @@ test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsnin
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
// One note to point at, then another to write the link in.
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await ed.click();
|
||||
await page.keyboard.type('Rejseplan til Berlin');
|
||||
await ed.blur();
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await ed.click();
|
||||
await page.keyboard.type('Se ');
|
||||
await page.keyboard.type('[[Rejse');
|
||||
|
||||
// The picker offers the note by title, and choosing it closes the brackets.
|
||||
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
await expect(ed).toContainText('[[Rejseplan til Berlin]]');
|
||||
|
||||
// And the link is real: it turns up in the note's links, not just as text.
|
||||
await ed.blur();
|
||||
await expect(page.locator('.links')).toContainText('Rejseplan til Berlin');
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
|
||||
@@ -73,6 +73,11 @@ const SHOTS = [
|
||||
{ name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' },
|
||||
{ name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote },
|
||||
{ name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource },
|
||||
// The new work, and in the light theme, which is what most people want to look
|
||||
// at a landing page in. The calendar drawing tasks to their length, and a note
|
||||
// somebody shared into the "Shared with me" group.
|
||||
{ name: 'calendar-light', path: '/kalender', theme: 'light', prepare: showWeek },
|
||||
{ name: 'notes-shared-light', path: '/noter', theme: 'light', prepare: openSharedNote },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
// Scrolled, because the themes are the second panel on the page and a picture
|
||||
@@ -114,6 +119,13 @@ async function showNoteSource(page) {
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function openSharedNote(page) {
|
||||
// The note Mette shared in, so the "Shared with me" group and its owner chip are
|
||||
// what the picture is of rather than something the group happens to sit above.
|
||||
await page.locator('button.row', { hasText: 'Packaging quotes' }).first().click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showThemes(page) {
|
||||
// scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is
|
||||
// already a few pixels inside the viewport when the page loads, so "if needed"
|
||||
@@ -340,6 +352,23 @@ async function handOver(ids) {
|
||||
await api(`/tasks/${ids.detail}/comments`, {
|
||||
body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.'
|
||||
});
|
||||
|
||||
// A note of Mette's own, handed to the owner — so their Notes page has a
|
||||
// "Shared with me" group with a real note in it, marked as hers. This is the
|
||||
// other half of sharing: not the project path, but a page passed to a person.
|
||||
const note = await api('/notes', {
|
||||
body: `# Packaging quotes
|
||||
|
||||
Three back, one still to come:
|
||||
|
||||
- **Nordfelt** — 79 øre a bag at 2,000, plates included
|
||||
- **Kolding Karton** — 84 øre, faster, kinder about small runs
|
||||
- Bruun still owes me a number
|
||||
|
||||
I'd lean Kolding for the first run and revisit at volume. #Autumn launch`
|
||||
});
|
||||
await api(`/notes/${note.id}/shares`, { user_id: ids.owner, role: 'editor' });
|
||||
|
||||
return { colleague: me.id };
|
||||
}
|
||||
|
||||
@@ -373,6 +402,8 @@ async function seed({ colleague, noteTitle }) {
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const me = await api('/auth/me', null, 'GET');
|
||||
|
||||
// Two headings over the projects, which is what the sidebar grew for.
|
||||
const work = await api('/project-groups', { name: 'Work' });
|
||||
const home = await api('/project-groups', { name: 'Home' });
|
||||
@@ -420,6 +451,17 @@ async function seed({ colleague, noteTitle }) {
|
||||
{ content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] },
|
||||
|
||||
// A handful given a time and a length, so the calendar's week draws them to
|
||||
// scale rather than as a row of identical blocks — a stand-up, a long
|
||||
// planning block, a call, a review. Spread across the day and the days, so
|
||||
// the picture is a week that has a shape.
|
||||
{ content: 'Stand-up', due_date: day(0), due_time: '09:00', duration_min: 15, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Planning block', due_date: day(0), due_time: '10:00', duration_min: 90, priority: 2, project_id: launch.id, section_id: planning.id, labels: ['meeting'] },
|
||||
{ content: 'Call with the printer', due_date: day(0), due_time: '13:30', duration_min: 30, project_id: launch.id, labels: ['waiting'] },
|
||||
{ content: 'Interview: designer', due_date: day(1), due_time: '11:00', duration_min: 60, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'Budget review', due_date: day(2), due_time: '14:00', duration_min: 45, priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||
{ content: 'One-to-one with Mette', due_date: day(3), due_time: '15:30', duration_min: 30, project_id: launch.id },
|
||||
|
||||
// The next couple of weeks, so Upcoming and the month grid have something in
|
||||
// them rather than a row of empty cells.
|
||||
{ content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id },
|
||||
@@ -560,6 +602,7 @@ The opening notes are a task, so they can be ticked off: task:${detail.id}`
|
||||
await api('/users', { email: '[email protected]' });
|
||||
|
||||
return {
|
||||
owner: me.id,
|
||||
work: launch.id,
|
||||
detail: detail.id,
|
||||
invite: invite.link,
|
||||
|
||||
@@ -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 @@
|
||||
</svelte:element>
|
||||
{/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
|
||||
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. -->
|
||||
<button
|
||||
class={kind}
|
||||
class:resizing={resizing?.id === task.id}
|
||||
data-priority={task.priority}
|
||||
draggable="true"
|
||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
||||
@@ -562,6 +614,16 @@
|
||||
>
|
||||
{#if at}<span class="at">{at}</span>{/if}
|
||||
{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>
|
||||
{/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}
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
|
||||
let { note, onchange, onsave } = $props();
|
||||
let { note, notes = [], onchange, onsave } = $props();
|
||||
|
||||
let editor;
|
||||
// Forslagslisten, så tastaturvalget kan rulle den frem.
|
||||
@@ -390,9 +390,26 @@
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||
|
||||
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (!match) return null;
|
||||
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||
|
||||
// A note link, [[, is the same idea as a project tag and gets the same
|
||||
// completion — checked first because it is the longer opener. The term runs
|
||||
// to the caret and is anything up to the closing bracket, which has not been
|
||||
// typed yet.
|
||||
const note = /\[\[([^\]\n]*)$/u.exec(before);
|
||||
if (note) {
|
||||
return { kind: 'note', node, start: selection.anchorOffset - note[1].length, term: note[1] };
|
||||
}
|
||||
|
||||
const project = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||
if (project) {
|
||||
return {
|
||||
kind: 'project',
|
||||
node,
|
||||
start: selection.anchorOffset - project[1].length,
|
||||
term: project[1]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,33 +453,41 @@
|
||||
// pixels for hvert bogstav, mens listen snævres ind.
|
||||
if (!suggestions.length) menuAt = tagPosition(partial);
|
||||
|
||||
// Alle der passer, ikke de seks første.
|
||||
//
|
||||
// Loftet på seks var sat, dengang listen ikke kunne rulle, og det gjorde
|
||||
// noget værre end at skjule: med tyve projekter og et blot tastet `#` viste
|
||||
// den seks vilkårlige og så færdig ud. Man kunne ikke se, at der manglede
|
||||
// noget — kun at ens eget projekt ikke var der.
|
||||
//
|
||||
// Rækkefølgen bærer nu det, loftet gjorde forkert: det, der begynder med
|
||||
// det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
const matches = app.projects.filter(
|
||||
(p) => !p.is_inbox && p.name.toLowerCase().includes(term)
|
||||
);
|
||||
matches.sort((a, b) => {
|
||||
const an = a.name.toLowerCase();
|
||||
const bn = b.name.toLowerCase();
|
||||
const ap = an.startsWith(term);
|
||||
const bp = bn.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return an.localeCompare(bn, 'da');
|
||||
});
|
||||
suggestions = matches;
|
||||
suggestions = partial.kind === 'note' ? noteMatches(term) : projectMatches(term);
|
||||
chosen = 0;
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name. */
|
||||
function accept(project) {
|
||||
// Alle der passer, ikke de seks første. Rækkefølgen bærer det: det, der begynder
|
||||
// med det tastede, står øverst, resten alfabetisk. Skriver man "ga", er
|
||||
// GarageRisteriet den første, ikke den, der tilfældigvis lå først.
|
||||
function projectMatches(term) {
|
||||
return app.projects
|
||||
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||
.map((p) => ({ id: p.id, kind: 'project', label: p.name, color: p.color }))
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
// The same completion for [[ as for #, over the notes the page has loaded. The
|
||||
// current note is left out — a note that links to itself is a loop nobody meant
|
||||
// — and a note with no title yet cannot be pointed at, so it is skipped too.
|
||||
function noteMatches(term) {
|
||||
return notes
|
||||
.filter((n) => n.id !== note?.id && n.title && n.title.toLowerCase().includes(term))
|
||||
.map((n) => ({ id: n.id, kind: 'note', label: n.title }))
|
||||
.sort((a, b) => rank(a.label, b.label, term));
|
||||
}
|
||||
|
||||
function rank(a, b, term) {
|
||||
const al = a.toLowerCase();
|
||||
const bl = b.toLowerCase();
|
||||
const ap = al.startsWith(term);
|
||||
const bp = bl.startsWith(term);
|
||||
if (ap !== bp) return ap ? -1 : 1;
|
||||
return al.localeCompare(bl, 'da');
|
||||
}
|
||||
|
||||
/** Replaces the half-typed tag with the whole name, and closes a note link. */
|
||||
function accept(item) {
|
||||
const partial = partialTag();
|
||||
if (!partial) return;
|
||||
|
||||
@@ -473,9 +498,11 @@
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// A trailing space, because the next thing typed is a word and not more of
|
||||
// the tag — and without it the suggestions come straight back.
|
||||
document.execCommand('insertText', false, project.name + ' ');
|
||||
// A note link closes its own brackets — the opener is already in the text —
|
||||
// and both end in a space, because the next thing typed is a word and not
|
||||
// more of the tag, and without it the suggestions come straight back.
|
||||
const inserted = item.kind === 'note' ? `${item.label}]] ` : `${item.label} `;
|
||||
document.execCommand('insertText', false, inserted);
|
||||
suggestions = [];
|
||||
onchange?.(htmlToMarkdown(editor));
|
||||
}
|
||||
@@ -796,7 +823,7 @@
|
||||
style:top={menuAt ? `${menuAt.top}px` : null}
|
||||
style:left={menuAt ? `${menuAt.left}px` : null}
|
||||
>
|
||||
{#each suggestions as project, i (project.id)}
|
||||
{#each suggestions as item, i (item.kind + item.id)}
|
||||
<li>
|
||||
<button
|
||||
class:on={i === chosen}
|
||||
@@ -804,12 +831,17 @@
|
||||
aria-selected={i === chosen}
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
accept(project);
|
||||
accept(item);
|
||||
}}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{project.name}
|
||||
{#if item.kind === 'note'}
|
||||
<span class="dot note" aria-hidden="true">[[</span>
|
||||
{item.label}
|
||||
{:else}
|
||||
<span class="dot" style="background: {colorVar(item.color)}" aria-hidden="true"
|
||||
></span>
|
||||
#{item.label}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -962,6 +994,18 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* A note suggestion wears the brackets that open it where a project wears its
|
||||
colour, so the two kinds are told apart at a glance in the same list. */
|
||||
.suggestions .dot.note {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: none;
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.suggestions button.on {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
<!-- 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>
|
||||
@@ -731,6 +750,11 @@
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.due input.dur {
|
||||
flex: none;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.due input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -876,6 +876,7 @@
|
||||
{#if selected}
|
||||
<NoteEditor
|
||||
note={selected}
|
||||
notes={notes}
|
||||
onchange={(body) => {
|
||||
draft = body;
|
||||
typed();
|
||||
|
||||