Kommendes liste has always been the next seven days and nothing else. Seven days is the right walk to take in the morning, and it is still what the view opens on — but "when does this month get busy" is a different question, and the month grid answers it with shape rather than in order. So the list now takes a horizon: 7, 14 or 30 days, chosen with a switcher beside the view buttons. Only the list gets one. A week grid is a week and a month grid is a month; there is no fortnight for them to show, so the switcher is hidden in those modes. The choice lives in localStorage next to the mode, under its own key. It is the same kind of choice as list-or-grid — how you want to look, not a fact about the work — and it should survive a reload without following you to a phone where thirty day headings is a very long scroll. Once the strip is longer than a week the day headings carry the week number. Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not say which mandag. Server side the endpoint already took `?days=` and capped it at 31, so the window needed nothing — but its row limit was a constant 1000 written for a week. A month asked for with a week's ceiling would truncate the last days silently, and a day cut off the end of the answer looks exactly like a day with nothing due, so the limit now follows the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
279 lines
8.5 KiB
Svelte
279 lines
8.5 KiB
Svelte
<script>
|
|
/**
|
|
* Kommende: a run of days as a list, one week as a grid, or the month.
|
|
*
|
|
* 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.
|
|
*/
|
|
import { api } from '$lib/api.js';
|
|
import { app, upcomingView, UPCOMING_DAYS } 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';
|
|
import { t, tag } from '$lib/i18n.svelte.js';
|
|
|
|
let days = $state([]);
|
|
|
|
// 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.
|
|
$effect(() => {
|
|
if (upcomingView.mode !== 'list') return;
|
|
api.upcoming(upcomingView.days).then((data) => {
|
|
days = data.days;
|
|
app.tasks = data.days.flatMap((d) => d.tasks);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Loads the dates a grid is showing, whichever grid it is.
|
|
*
|
|
* 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 loadRange({ 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 t('task.today');
|
|
if (diff === 1) return t('task.tomorrow');
|
|
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
|
|
// 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 !== 'list'}>
|
|
<header>
|
|
<h1>{t('view.upcoming')}</h1>
|
|
{#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}
|
|
<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]}
|
|
<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={loadRange} />
|
|
{:else if upcomingView.mode === 'week'}
|
|
<CalendarView span="week" onrange={loadRange} />
|
|
{:else}
|
|
{#each days as day (day.date)}
|
|
{@const tasks = live(day.date)}
|
|
{@const week = weekOf(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)}
|
|
{#if week}<span class="week">{week}</span>{/if}
|
|
</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;
|
|
}
|
|
|
|
/* 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. */
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s3);
|
|
flex-wrap: wrap;
|
|
margin-bottom: var(--s5);
|
|
}
|
|
|
|
h1 {
|
|
font-size: var(--text-2xl);
|
|
flex: 1 1 8ch;
|
|
min-width: 8ch;
|
|
}
|
|
|
|
.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 {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: var(--s2);
|
|
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);
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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>
|