2026-08-17 00:03:20 +02:00
|
|
|
<script>
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
2026-09-07 14:46:49 +02:00
|
|
|
* Kommende: a run of days as a list, one week as a grid, or the month.
|
2026-08-17 19:25:10 +02:00
|
|
|
*
|
2026-08-18 09:04:30 +02:00
|
|
|
* The views ask for different things and none is a subset of the others — seven
|
|
|
|
|
* days from today, against a chosen week, against six whole weeks around a
|
|
|
|
|
* month that can be any month — so each loads its own, and switching reloads.
|
|
|
|
|
* Loading them all up front would mean the list waiting on a month it is not
|
|
|
|
|
* showing.
|
|
|
|
|
*
|
|
|
|
|
* The week is the answer to dragging across a month boundary. A month grid is
|
|
|
|
|
* anchored to a month, so the two days on either side of its edge sit in
|
|
|
|
|
* different grids and moving a task between them means paging mid-drag — which
|
|
|
|
|
* cannot be done, because a drag in flight swallows the click that would page.
|
|
|
|
|
* A week that straddles the 31st and the 1st has both days in the same row.
|
2026-08-17 19:25:10 +02:00
|
|
|
*/
|
2026-08-17 00:03:20 +02:00
|
|
|
import { api } from '$lib/api.js';
|
2026-09-07 14:46:49 +02:00
|
|
|
import { app, upcomingView, UPCOMING_DAYS } from '$lib/stores.svelte.js';
|
2026-08-17 19:25:10 +02:00
|
|
|
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-20 07:23:42 +02:00
|
|
|
import { t, tag } from '$lib/i18n.svelte.js';
|
2026-08-17 00:03:20 +02:00
|
|
|
|
|
|
|
|
let days = $state([]);
|
|
|
|
|
|
2026-09-07 14:46:49 +02:00
|
|
|
// Reads `upcomingView.days` inside the effect on purpose: that is what makes
|
|
|
|
|
// choosing a longer horizon reload, rather than extending the strip with days
|
|
|
|
|
// nobody asked the server about.
|
2026-08-17 00:03:20 +02:00
|
|
|
$effect(() => {
|
2026-08-17 19:25:10 +02:00
|
|
|
if (upcomingView.mode !== 'list') return;
|
2026-09-07 14:46:49 +02:00
|
|
|
api.upcoming(upcomingView.days).then((data) => {
|
2026-08-17 00:03:20 +02:00
|
|
|
days = data.days;
|
|
|
|
|
app.tasks = data.days.flatMap((d) => d.tasks);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
2026-08-18 09:04:30 +02:00
|
|
|
* Loads the dates a grid is showing, whichever grid it is.
|
2026-08-17 19:25:10 +02:00
|
|
|
*
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-08-18 09:04:30 +02:00
|
|
|
function loadRange({ from, to }) {
|
2026-08-17 19:25:10 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-08-20 07:23:42 +02:00
|
|
|
if (diff === 0) return t('task.today');
|
|
|
|
|
if (diff === 1) return t('task.tomorrow');
|
|
|
|
|
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
|
2026-08-17 00:03:20 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-07 14:46:49 +02:00
|
|
|
// A month of headings repeats every weekday four times, and "mandag 6. okt"
|
|
|
|
|
// alone does not say which mandag. The week number is the label that does, and
|
|
|
|
|
// it only earns its place once the strip is longer than a week.
|
|
|
|
|
function weekOf(date) {
|
|
|
|
|
if (upcomingView.days <= 7) return null;
|
|
|
|
|
const day = new Date(date + 'T00:00:00');
|
|
|
|
|
// ISO weeks: Thursday decides which year and which week a row belongs to.
|
|
|
|
|
const thursday = new Date(day);
|
|
|
|
|
thursday.setDate(day.getDate() - ((day.getDay() + 6) % 7) + 3);
|
|
|
|
|
const firstThursday = new Date(thursday.getFullYear(), 0, 4);
|
|
|
|
|
firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3);
|
|
|
|
|
const n = 1 + Math.round((thursday - firstThursday) / (7 * 86400000));
|
|
|
|
|
return t('view.weekNumber', { n });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
// 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-18 09:04:30 +02:00
|
|
|
<div class="view" class:wide={upcomingView.mode !== 'list'}>
|
2026-08-17 19:25:10 +02:00
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
<h1>{t('view.upcoming')}</h1>
|
2026-09-07 14:46:49 +02:00
|
|
|
{#if upcomingView.mode === 'list'}
|
|
|
|
|
<!-- Only the list takes a horizon. A week grid is a week and a month grid
|
|
|
|
|
is a month; there is no fortnight for them to show. -->
|
|
|
|
|
<div class="views" role="group" aria-label={t('view.horizon')}>
|
|
|
|
|
{#each UPCOMING_DAYS as n}
|
|
|
|
|
<button
|
|
|
|
|
class:active={upcomingView.days === n}
|
|
|
|
|
onclick={() => upcomingView.setDays(n)}
|
|
|
|
|
aria-pressed={upcomingView.days === n}>{t('view.days', { n })}</button
|
|
|
|
|
>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2026-08-18 09:46:08 +02:00
|
|
|
<div class="views" role="group" aria-label={t('view.mode')}>
|
|
|
|
|
{#each [['list', t('view.list')], ['week', t('view.week')], ['calendar', t('view.month')]] as [value, label]}
|
2026-08-17 19:25:10 +02:00
|
|
|
<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'}
|
2026-08-18 09:04:30 +02:00
|
|
|
<CalendarView onrange={loadRange} />
|
|
|
|
|
{:else if upcomingView.mode === 'week'}
|
|
|
|
|
<CalendarView span="week" onrange={loadRange} />
|
2026-08-17 19:25:10 +02:00
|
|
|
{:else}
|
|
|
|
|
{#each days as day (day.date)}
|
|
|
|
|
{@const tasks = live(day.date)}
|
2026-09-07 14:46:49 +02:00
|
|
|
{@const week = weekOf(day.date)}
|
2026-08-17 19:25:10 +02:00
|
|
|
<!-- 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)}
|
|
|
|
|
>
|
2026-09-07 14:46:49 +02:00
|
|
|
<h2>
|
|
|
|
|
{heading(day.date)}
|
|
|
|
|
{#if week}<span class="week">{week}</span>{/if}
|
|
|
|
|
</h2>
|
2026-08-17 19:25:10 +02:00
|
|
|
{#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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 23:09:08 +02:00
|
|
|
/* Wraps, for the same reason the project header does: a title and a switcher
|
|
|
|
|
are more than a phone's width, and the switcher belongs underneath rather
|
|
|
|
|
than squeezing the heading. */
|
2026-08-17 19:25:10 +02:00
|
|
|
header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
2026-08-17 23:09:08 +02:00
|
|
|
gap: var(--s3);
|
|
|
|
|
flex-wrap: wrap;
|
2026-08-17 19:25:10 +02:00
|
|
|
margin-bottom: var(--s5);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
h1 {
|
|
|
|
|
font-size: var(--text-2xl);
|
2026-08-17 23:09:08 +02:00
|
|
|
flex: 1 1 8ch;
|
|
|
|
|
min-width: 8ch;
|
2026-08-17 19:25:10 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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 {
|
2026-09-07 14:46:49 +02:00
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: var(--s2);
|
2026-08-17 00:03:20 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 14:46:49 +02:00
|
|
|
/* Lighter than the day it sits beside: it is there to be found when you are
|
|
|
|
|
lost in the fourth week, not read on every row. */
|
|
|
|
|
.week {
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:03:20 +02:00
|
|
|
/* 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>
|