2026-08-17 00:03:20 +02:00
|
|
|
<script>
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-08-17 00:03:20 +02:00
|
|
|
import { api } from '$lib/api.js';
|
2026-08-17 19:25:10 +02:00
|
|
|
import { app, upcomingView } from '$lib/stores.svelte.js';
|
|
|
|
|
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
2026-08-17 00:03:20 +02:00
|
|
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
|
|
|
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
2026-08-17 19:25:10 +02:00
|
|
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
2026-08-17 00:03:20 +02:00
|
|
|
|
|
|
|
|
let days = $state([]);
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-08-17 19:25:10 +02:00
|
|
|
if (upcomingView.mode !== 'list') return;
|
2026-08-17 00:03:20 +02:00
|
|
|
api.upcoming().then((data) => {
|
|
|
|
|
days = data.days;
|
|
|
|
|
app.tasks = data.days.flatMap((d) => d.tasks);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
|
|
|
|
* 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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
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
|
2026-08-17 19:25:10 +02:00
|
|
|
// 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.
|
2026-08-17 00:03:20 +02:00
|
|
|
const live = (date) => app.tasks.filter((t) => t.due_date === date && !t.completed);
|
2026-08-17 19:25:10 +02:00
|
|
|
|
|
|
|
|
// --- 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);
|
|
|
|
|
}
|
2026-08-17 00:03:20 +02:00
|
|
|
</script>
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
<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>
|
2026-08-17 00:03:20 +02:00
|
|
|
|
|
|
|
|
<QuickAdd />
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
{#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}
|
2026-08-17 00:03:20 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.view {
|
|
|
|
|
max-width: var(--content-max);
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: var(--s6) var(--s4) var(--s8);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/* 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
h1 {
|
|
|
|
|
font-size: var(--text-2xl);
|
2026-08-17 19:25:10 +02:00
|
|
|
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;
|
2026-08-17 00:03:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section {
|
|
|
|
|
margin-top: var(--s5);
|
2026-08-17 19:25:10 +02:00
|
|
|
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;
|
2026-08-17 00:03:20 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|