feat(tasks): write a task straight into a section

The box at the top puts things in the project with no section, which is right for
capturing. But a project has sections because the work belongs somewhere, and
dragging every new task down into the right one is a second gesture for something
you already knew when you typed it.

Two ways in, because they answer different moments. A field at the foot of each
section and each board column — standing there is enough, no syntax to know. And
`/section` in quick add, for the box at the top and for Today and Upcoming, where
you are not standing in a section at all.

The field goes through quick add rather than createTask, so a line written there
still parses its dates, priorities and labels.

The sigil needed care: '#' and '@' never turn up in a Danish task line by accident
and '/' turns up constantly. It has to follow whitespace and be followed
immediately by the name, which "km/t", "3/4" and "20 kr / stk" all fail. The bare
form is greedy, because "Kunder/Ordrer" is a real section name and the sigil is
inside it.

A section id from the client is checked against the project the task actually
landed in. A `#project` in the same line can move the task elsewhere, and the id
would otherwise follow it into a project that section does not belong to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 11:13:10 +02:00
co-authored by Claude Opus 5
parent 9cc7df69f0
commit 0dbf7a40bf
11 changed files with 463 additions and 9 deletions
+2 -1
View File
@@ -267,7 +267,8 @@ export const api = {
reopenTask: (id) => post(`/tasks/${id}/reopen`),
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
+76
View File
@@ -28,6 +28,23 @@
let adding = $state(false);
let name = $state('');
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
let addingIn = $state(null);
let newInSection = $state('');
async function addToSection(event, sectionId) {
event.preventDefault();
const text = newInSection.trim();
if (!text) return;
newInSection = '';
addingIn = null;
try {
app.upsert(await api.quickAdd(text, project.id, sectionId));
} catch (e) {
app.toast(humanMessage(e));
}
}
async function addSection(event) {
event.preventDefault();
const trimmed = name.trim();
@@ -147,6 +164,35 @@
<p class="empty">{t('view.emptySection')}</p>
{/if}
</div>
<!-- At the foot of the column, where the next card would go. A board's
columns *are* the sections, so this is the one place where "add a
task here" needs no explanation at all. -->
{#if canEdit}
<!-- `column`, which is what the each binds. The unsectioned column has
an empty id, and that is exactly what quick add wants for "in this
project, no section". -->
{#if addingIn === column.id}
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
<input
bind:value={newInSection}
use:focusOnMount
placeholder={t('view.addTaskHere')}
aria-label={t('view.addTaskIn', { name: column.name })}
onblur={() => !newInSection.trim() && (addingIn = null)}
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
/>
</form>
{:else}
<button
class="add-task-open"
onclick={() => {
addingIn = column.id;
newInSection = '';
}}>{t('view.addTask')}</button
>
{/if}
{/if}
</section>
{/each}
@@ -305,6 +351,36 @@
background: transparent;
}
.add-task-open {
align-self: flex-start;
padding: var(--s2);
font-size: var(--text-sm);
color: var(--ink-faint);
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.column:hover .add-task-open,
.add-task-open:focus-visible {
opacity: 1;
}
.add-task-open:hover {
color: var(--ink-muted);
}
.add-task input {
width: 100%;
padding: var(--s2);
background: var(--ground);
border: 1px solid var(--accent);
border-radius: var(--radius);
font: inherit;
font-size: var(--text-sm);
color: var(--ink);
outline: none;
}
.empty {
margin: 0;
padding: var(--s3) var(--s2);
+5 -1
View File
@@ -601,5 +601,9 @@ export const da = {
'project.more': 'Flere handlinger',
'project.removeMember': 'Fjern {name}',
'nav.closeMenu': 'Luk menu'
'nav.closeMenu': 'Luk menu',
'view.addTask': '+ Tilføj opgave',
'view.addTaskHere': 'Skriv en opgave — dato, p1 og @etiket virker her også',
'view.addTaskIn': 'Ny opgave i {name}'
};
+5 -1
View File
@@ -596,5 +596,9 @@ export const en = {
'project.more': 'More actions',
'project.removeMember': 'Remove {name}',
'nav.closeMenu': 'Close menu'
'nav.closeMenu': 'Close menu',
'view.addTask': '+ Add a task',
'view.addTaskHere': 'Write a task — a date, p1 and @label work here too',
'view.addTaskIn': 'New task in {name}'
};