Files
verdande/web/src/routes/upcoming/+page.svelte
T
Kristian WindandClaude Opus 5 c14559ed0c feat: project groups, a month on Kommende, and dragging things where they go
The three the handoff named as next. They are one commit because they are
one mechanism: all three end in something being dragged, and a drop target
cannot tell what is coming without the module they now share.

Groups are foldable headings over the projects in the sidebar. A table
rather than a `group_name` column on the project, because a group has to
be renamable in one write, has to survive being emptied, and has to
remember whether it is folded — none of which a string repeated on every
project row can do.

Folded state lives on the row, not in localStorage. The sidebar's *width*
is a property of the screen you are sitting at, which is why that one is
per browser; a folded group is a statement about the group — "this is the
part of my work I am not in right now" — and it is true on the laptop and
on the desktop. There is nobody else's answer to disagree with either: a
group belongs to exactly one person.

A shared project cannot be filed. `group_id` is a column on the project
row, so a member grouping somebody else's project would move it in the
owner's sidebar too — the same reason `sort_order` is owner-scoped. The
listing reports the column only to the owner, so a viewer is never told
where somebody else keeps it.

Deleting a group takes the heading and leaves the projects, and the
sidebar decides where a project renders by whether its group still
exists rather than by the id alone. A group deleted in another tab
arrives as one event and not one per project, so for a moment the
projects still name a heading that is no longer drawn — filing by the raw
id would drop them out of the sidebar entirely.

Kommende gains the month grid the project pages already had, behind a
Liste/Kalender toggle. The two views ask for different things — seven days
from today against six whole weeks around an arbitrary month — so each
loads its own, and the grid reports which dates it is showing rather than
having the page guess. Which view is remembered in localStorage: a month
of seven columns needs room, and the answer on a phone is not the answer
on a wide monitor.

And a task can now be dropped on a project in the sidebar, on a day in
Kommende, or on a cell in any month grid. Onto a project it is a *move*,
not a field: `sort_order` belongs to the project, so it has to find a
place among that project's tasks and leave its old section behind.

Each drag now carries its own MIME type. `getData` is deliberately
unreadable while a drag is in the air — the browser hands the payload over
on drop and not before, so a page cannot read what is being dragged across
it — and `dataTransfer.types` is all a target has to decide with. Deciding
is the point: calling preventDefault on a drag you cannot accept is what
turns "no" into a silent, wrong drop. The sidebar takes a project and a
task on the same row and has to tell them apart.

Two things fell out of building it. The month grid has always had one cell
narrower than the other six: `.today` styled the "I dag" button in the
header, and the day cell for today wears the same class, so `margin-left:
auto` was shrinking a grid item to its content. It is scoped to the header
now. And a project page listed whatever was in the store rather than what
belonged to that project, which did not matter until a task could leave a
project while you were looking at it.

Covered by eight new API tests and two new smoke tests, and the smoke tests
drive the drags for real — the type check they exercise exists only while
a drag is in flight, so nothing else can reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:25:10 +02:00

218 lines
5.8 KiB
Svelte

<script>
/**
* Kommende: the next seven days as a list, or the month as a grid.
*
* The two views ask for different things and neither is a subset of the other —
* seven days from today, against six whole weeks around a month that can be any
* month — so each loads its own, and switching reloads. Loading both up front
* would mean the list waiting on a month it is not showing.
*/
import { api } from '$lib/api.js';
import { app, upcomingView } from '$lib/stores.svelte.js';
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
let days = $state([]);
$effect(() => {
if (upcomingView.mode !== 'list') return;
api.upcoming().then((data) => {
days = data.days;
app.tasks = data.days.flatMap((d) => d.tasks);
});
});
/**
* Loads the dates the month grid is showing.
*
* The limit is raised well above the default: a busy month across every project
* is more than two hundred rows, and a silently truncated grid is a calendar
* that quietly lies about a day being clear.
*/
function loadMonth({ from, to }) {
app.loadTasks({ due_from: from, due_before: to, limit: 500 });
}
function heading(date) {
const day = new Date(date + 'T00:00:00');
const today = new Date(new Date().toDateString());
const diff = Math.round((day - today) / 86400000);
if (diff === 0) return 'I dag';
if (diff === 1) return 'I morgen';
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' });
}
// Reading tasks back out of the store keeps a completed row disappearing
// immediately rather than on the next load. It is also what makes a task
// dragged from one day to another land in its new section straight away: the
// day it belongs to is decided here, not by which list the server put it in.
const live = (date) => app.tasks.filter((t) => t.due_date === date && !t.completed);
// --- dragging a task onto another day -------------------------------------------
/** The day section lit up under the pointer. */
let over = $state(null);
function onDragOver(event, date) {
if (!carries(event, TASK)) return;
accept(event);
over = date;
}
async function onDrop(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
await app.reschedule(id, date);
}
</script>
<div class="view" class:wide={upcomingView.mode === 'calendar'}>
<header>
<h1>Kommende</h1>
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['calendar', 'Kalender']] as [value, label]}
<button
class:active={upcomingView.mode === value}
onclick={() => upcomingView.set(value)}
aria-pressed={upcomingView.mode === value}>{label}</button
>
{/each}
</div>
</header>
<QuickAdd />
{#if upcomingView.mode === 'calendar'}
<CalendarView onrange={loadMonth} />
{:else}
{#each days as day (day.date)}
{@const tasks = live(day.date)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class:over={over === day.date}
ondragover={(e) => onDragOver(e, day.date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, day.date)}
>
<h2>{heading(day.date)}</h2>
{#if tasks.length}
{#each tasks as task (task.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="row"
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
ondragend={() => (over = null)}
>
<TaskRow {task} />
</div>
{/each}
{:else}
<!-- The empty day is still a section, and still a target: an em dash
is a small thing to aim at, so the whole section takes the drop. -->
<p class="empty">—</p>
{/if}
</section>
{/each}
{/if}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
/* A month grid needs the width a reading column deliberately withholds — the
same allowance the project page makes for its board and calendar. */
.view.wide {
max-width: 1400px;
}
header {
display: flex;
align-items: center;
gap: var(--s4);
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
flex: 1;
min-width: 0;
}
.views {
display: flex;
gap: 1px;
flex: none;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.views button {
padding: var(--s1) var(--s3);
font-size: var(--text-sm);
background: var(--ground);
color: var(--ink-muted);
transition:
background var(--fast) var(--ease),
color var(--fast) var(--ease);
}
.views button:hover {
color: var(--ink);
}
.views button.active {
background: var(--surface-raised);
color: var(--ink);
font-weight: 500;
}
section {
margin-top: var(--s5);
border-radius: var(--radius);
}
/* The day lights up as a whole. Its heading is the label on a box, and the box
is what you are dropping into — not the gap between two rows, which is what a
line would mean. */
section.over {
box-shadow: 0 0 0 1px var(--accent);
background: var(--surface-sunken);
}
.row[draggable='true'] {
cursor: grab;
}
h2 {
font-size: var(--text-xs);
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
padding: 0 var(--s2) var(--s2);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s2);
}
/* An em dash rather than "ingen opgaver": seven repetitions of a sentence is
noise, and the reader only needs to know the day is clear. */
.empty {
margin: 0;
padding: var(--s2);
color: var(--ink-faint);
font-size: var(--text-sm);
}
</style>