Files
verdande/web/src/lib/components/CalendarView.svelte
T
kristianwindandClaude Opus 4.8 6cf63113df feat(calendar): lighter grid, per-calendar pastels, today marked at top
The calendar was a grey grid where every event was the same sunken block
edged in a colour you had to already know. Now the day itself is the
brightest surface and each event is a soft, dusty wash of its own
calendar's colour — so a screen of them reads as colour laid on white,
and two calendars are told apart at a glance. Tasks stay outlined cards,
the thing you can pick up, against the solid washes you cannot.

Today is marked where the day is named: its number sits in a filled disc
at the top of the column, the same one the month grid gives it, over a
faint wash down the whole column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:09:07 +02:00

1344 lines
44 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/**
* A calendar grid: a whole month, or a single week.
*
* Weeks start on Monday. That is not a preference — it is ISO 8601 and it is
* what every Danish calendar does, and a Sunday-first grid makes a Dane
* misread the whole month at a glance.
*
* One component for both spans rather than two, because the part that is easy
* to get subtly wrong is the same in both: the drag target has to decide from
* `dataTransfer.types` alone whether it accepts a drop, and a second copy of
* that is a second place for it to be almost right.
*/
import { app } from '$lib/stores.svelte.js';
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { t, tag } from '$lib/i18n.svelte.js';
let {
/**
* What a chip does when clicked. Opening the task is what it should do
* everywhere, so it is the default rather than something each caller has to
* remember — the project page did not, and its chips were dead for as long
* as the month view has existed.
*/
onselect = (task) => app.openDetail(task.id),
/**
* Told which dates the grid is showing, whenever that changes.
*
* A project's month view needs nothing: the page has already loaded every
* task in the project. Kommende is the other case — it cannot load every
* task anybody has ever dated — so it asks for the month on screen, and
* this is how it learns which one that is.
*/
onrange,
/**
* Narrows the grid to one project. The store holds whatever the last view
* loaded, so a project's month has to say which project it is — Kommende,
* which shows every project, is the one that leaves this out.
*/
projectId = null,
/**
* 'month' for the whole month as six weeks, 'week' for one week as a single
* row.
*
* The week exists for dragging across a month boundary. A month grid is
* anchored to a month, so the two days on either side of its edge are in
* different grids and moving a task between them means changing month
* mid-drag — which the browser will not let you do, 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.
*/
span = 'month',
/**
* Events read from somewhere else, laid over the same grid.
*
* An event is not a task, and the cell has to say so. It has a span of time
* rather than a day, it cannot be ticked off, and — while the connection is
* read-only — it cannot be moved: verdande holds a copy of somebody else's
* calendar, and a chip that lets itself be dragged is a promise the server
* has no way to keep.
*
* Empty everywhere but Kalender, so no other view pays for it.
*/
events = []
} = $props();
// Sat til i dag og lagt til rette af effekten nedenfor.
//
// Ikke `span === 'week' ? … : …` her: den læser `span`, som den er i det
// øjeblik komponenten bliver til, og fanger den værdi for altid — hvilket
// byggeriet har advaret om lige så længe. Så længe hver visning havde sin egen
// komponent, gjorde det ingen forskel; det gjorde det i samme sekund, et
// projekts kalender fik en uge/måned-vælger, der skifter `span` på den samme.
let cursor = $state(new Date());
/**
* The weekday names, from Intl rather than written out.
*
* Not to save seven strings — so the abbreviations are the ones the language
* actually uses. Every language shortens its weekdays differently, and guessing
* at that from a translation table is how a calendar ends up saying something
* no reader of that language would write.
*
* Monday first, because the grid is. The 1st of January 2024 was a Monday; any
* Monday would do, and a constant one keeps this out of the render path.
*/
const WEEKDAYS = $derived.by(() => {
const format = new Intl.DateTimeFormat(tag(), { weekday: 'short' });
return Array.from({ length: 7 }, (_, i) => format.format(new Date(Date.UTC(2024, 0, 1 + i))));
});
function startOfMonth(d) {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
// getDay() is Sunday-based; this converts to "days since Monday".
function startOfWeek(d) {
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
return start;
}
function startOfSpan(d) {
return span === 'week' ? startOfWeek(d) : startOfMonth(d);
}
// The cursor has to follow the span it is measured in: switching from month to
// week with a cursor on the 1st would otherwise show the week containing the
// 1st rather than the week you were looking at, and switching back would land
// on whichever month that week happened to start in.
//
// Med én undtagelse, og den er hele grunden til at det her er en effekt og ikke
// en udregning: en månedsvisnings markør *er* den første i måneden. Skifter man
// til uge, mens man står i den indeværende måned, er den uge man kigger på ikke
// ugen omkring den 1. — det er den, i dag ligger i. Uden det her landede et
// projekts kalender på "27. jul.–2. aug." for en person, der kiggede på august.
$effect(() => {
const anchor =
span === 'week' && isMonthStart(cursor) && sameMonth(cursor, new Date())
? new Date()
: cursor;
const aligned = startOfSpan(anchor);
// Compared by value, not identity: assigning a fresh Date on every run would
// re-trigger this effect forever, since the effect reads what it writes.
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
});
const isMonthStart = (d) => d.getDate() === 1;
const sameMonth = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
function iso(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
}
// A month always runs whole weeks, so it starts on the Monday on or before the
// first and ends on the Sunday on or after the last. A week is already one.
let grid = $derived.by(() => {
const start = span === 'week' ? startOfWeek(cursor) : startOfWeek(startOfMonth(cursor));
// Six weeks covers every possible month layout, including a 31-day month
// that begins on a Sunday.
const length = span === 'week' ? 7 : 42;
const days = [];
const cell = new Date(start);
for (let i = 0; i < length; i++) {
days.push(new Date(cell));
cell.setDate(cell.getDate() + 1);
}
return days;
});
// Reported after the grid is worked out rather than from the cursor, because
// the grid runs whole weeks: a month view nearly always shows a few days of the
// month before it and a few of the one after, and a caller loading only the
// month itself would leave those corners empty.
$effect(() => {
onrange?.({ from: iso(grid[0]), to: iso(grid[grid.length - 1]) });
});
const todayISO = iso(new Date());
const tasksOn = (date) =>
app.tasks.filter(
(t) =>
!t.completed &&
t.due_date === iso(date) &&
(!projectId || t.project_id === projectId)
);
/**
* Which cells an event covers, rather than where it starts. A trip from Friday
* to Monday is in all four, and asking only about the start day would draw it
* once and leave three days looking clear.
*
* Ordered here rather than trusted from the caller. The server does sort, but a
* cell that shows 14:00 above 09:15 does not look like a list in the wrong
* order — it looks like the clock on the chip is wrong, which is a far worse
* thing to have to rule out. All-day first, because an all-day event is a band
* over the whole day rather than something that happens at a point in it.
*/
const eventsOn = (date) => {
const day = iso(date);
return events
.filter((e) => e.start_day <= day && e.end_day >= day)
.toSorted((a, b) => {
if (a.all_day !== b.all_day) return a.all_day ? -1 : 1;
return (a.starts_at ?? '').localeCompare(b.starts_at ?? '');
});
};
/**
* The time on the chip, and only where it means something.
*
* An all-day event has no time. A timed one shows its start, and only on the
* day it starts: a meeting that runs past midnight would otherwise claim to
* begin at 20:00 on both days.
*
* The clock is read out of the string rather than parsed into a Date. Google
* writes the event's own offset into it, and handing that to the browser asks
* *this device* what time it is — so a phone in another time zone would show a
* Copenhagen meeting an hour out.
*/
function timeOn(event, date) {
if (event.all_day || !event.starts_at) return '';
if (event.start_day !== iso(date)) return '';
return event.starts_at.slice(11, 16);
}
/**
* The calendar's own colour, or nothing.
*
* Checked rather than trusted, even though it comes from Google: this goes into
* an inline `style`, and a value that is not a colour is a value that can close
* the declaration and open another. A hex triple is the whole of what Google
* sends, so anything else is not a colour that failed to render — it is a
* string that has no business being here.
*/
const HEX = /^#[0-9a-f]{3,8}$/i;
const swatch = (colour) => (HEX.test(colour ?? '') ? colour : '');
/**
* Whether the event links back to Google.
*
* `https:` and nothing else. An href is where a `javascript:` URL becomes a
* click that runs, and the address in this field was written by a service, not
* by the person looking at it.
*/
const linkOf = (event) => (String(event.url ?? '').startsWith('https://') ? event.url : '');
const monthName = $derived(
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
);
// "24. aug. – 30. aug. 2026", collapsed to one month name when the week does not
// straddle two. The year is said once, at the end, where it is least in the way.
const weekName = $derived.by(() => {
const from = grid[0];
const to = grid[grid.length - 1];
const day = { day: 'numeric', month: 'short' };
const left =
from.getMonth() === to.getMonth()
? from.toLocaleDateString(tag(), { day: 'numeric' })
: from.toLocaleDateString(tag(), day);
return `${left}–${to.toLocaleDateString(tag(), day)} ${to.getFullYear()}`;
});
/**
* The ISO week number, which is what a Dane means by "uge 35".
*
* Counted from the Thursday of the week, because that is the definition: week 1
* is the one containing the first Thursday of the year, so the Thursday is the
* only day guaranteed to be in the same year as the week it belongs to.
*/
function isoWeek(d) {
const thursday = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
thursday.setUTCDate(thursday.getUTCDate() - ((thursday.getUTCDay() + 6) % 7) + 3);
const first = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));
first.setUTCDate(first.getUTCDate() - ((first.getUTCDay() + 6) % 7) + 3);
return 1 + Math.round((thursday - first) / (7 * 86400000));
}
const heading = $derived(span === 'week' ? weekName : monthName);
const weekNumber = $derived(span === 'week' ? isoWeek(grid[0]) : null);
// A week shows seven cells where a month shows forty-two, so each one has the
// height to be a list rather than a hint. Truncating it to three there would
// hide work in the empty half of a tall cell.
const chipLimit = $derived(span === 'week' ? 10 : 3);
// One step is one span: a month view pages by month, a week view by week. A week
// view that paged by month would be back to the problem it exists to solve.
function step(n) {
if (span === 'week') {
const next = new Date(cursor);
next.setDate(next.getDate() + n * 7);
cursor = next;
return;
}
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + n, 1);
}
// --- dropping a task on a day ---------------------------------------------------
/** The cell lit up under the pointer. */
let over = $state(null);
// A resize in progress: which task, and where its foot is being dragged to, in
// minutes since midnight. Kept apart from the stored length so the task can
// follow the handle live and only be written when the drag ends.
let resizing = $state(null);
/**
* Dragging the foot of a timed task changes how long it is.
*
* Pointer events rather than the HTML5 drag the body uses: the two must not be
* the same gesture, or every attempt to make a task longer would instead pick it
* up and move it. stopPropagation keeps the handle's press from starting the
* move, and the column it lives in is what the minutes are measured against —
* the same box onDropAt reads a drop time from.
*/
function startResize(event, task, date) {
event.stopPropagation();
event.preventDefault();
const col = event.currentTarget.closest('.daycol');
if (!col || !task.due_datetime) return;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
resizing = { id: task.id, from, to: from + (task.duration_min || 30), date: iso(date) };
const onMove = (e) => {
const box = col.getBoundingClientRect();
const share = Math.min(Math.max((e.clientY - box.top) / box.height, 0), 1);
const minutes = hours.from * 60 + share * hours.minutes;
// Quarter-hour steps, at least a quarter long, never past midnight.
const snapped = Math.min(Math.max(Math.round(minutes / 15) * 15, from + 15), 24 * 60);
resizing = { ...resizing, to: snapped };
};
const onUp = async () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
const current = resizing;
resizing = null;
if (current) await app.resize(current.id, current.to - current.from);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
function onDragOver(event, date) {
if (!carries(event, TASK)) return;
accept(event);
over = iso(date);
}
/**
* Sluppet på en dag, uden at sige noget om klokkeslættet — så beholder opgaven
* det, den har. Måneden er den her: en rude er en dag, ikke et døgn, og der er
* ingen tid at læse ud af hvor man slap.
*
* Det var ikke sådan før. Trækket sendte kun datoen, og serveren læser en dato
* uden en tid som "ryd tiden" — så en opgave klokken 14 mistede sit
* klokkeslæt ved at blive flyttet til dagen efter, uden at nogen bad om det.
*/
async function onDrop(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
await app.reschedule(id, iso(date));
}
/**
* Sluppet et sted på døgnet: dér, man slap, er klokkeslættet.
*
* Målt mod søjlens egen kasse frem for mod siden, så det bliver rigtigt, uanset
* hvor gitteret sidder, og uanset hvor langt der er rullet.
*
* Rundet til kvarter. Minuttet under markøren er ikke en oplysning, nogen har —
* pegefingeren er bredere end et minut — og 14:07 er et tal, der ser ud som om
* det blev valgt, hvilket er værre end at være omtrentligt.
*/
async function onDropAt(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
const box = event.currentTarget.getBoundingClientRect();
const share = Math.min(Math.max((event.clientY - box.top) / box.height, 0), 1);
const minutes = hours.from * 60 + share * hours.minutes;
const snapped = Math.min(Math.round(minutes / 15) * 15, 24 * 60 - 15);
await app.reschedule(id, iso(date), clock(snapped));
}
/**
* Sluppet i båndet foroven: opgaven har en dag og ingen tid.
*
* Den tomme streng er ikke det samme som ingenting her — den siger "ryd", hvor
* `undefined` siger "behold". Det er hele grunden til, at de to er skilt ad i
* `reschedule`.
*/
async function onDropAllDay(event, date) {
event.preventDefault();
const id = dragged(event, TASK);
over = null;
if (!id) return;
await app.reschedule(id, iso(date), '');
}
// --- ugen som et døgn ---------------------------------------------------------
//
// En uge, hvor hver dag er en punktopstilling, svarer på "hvad skal der ske
// torsdag" og ikke på "hvornår" — og det andet er det, man åbner en uge for.
// To møder klokken ti er en konflikt, man skal kunne *se*, ikke læse sig til.
//
// Måneden bliver som den er. En dag i en månedsrude er halvanden centimeter høj;
// et døgn tegnet i den er ikke en visning, det er en streg.
/** Minutter siden midnat, læst ud af strengen. */
const minutesAt = (stamp) => Number(stamp.slice(11, 13)) * 60 + Number(stamp.slice(14, 16));
const clock = (minutes) =>
`${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
/**
* Det, der har et klokkeslæt på denne dag, med start og slut i minutter.
*
* Begivenheder læses som tekst, af samme grund som `timeOn` gør det: Google
* skriver begivenhedens egen forskydning ind i stemplet, og at give det til
* browseren spørger *denne maskine*, hvad klokken er. Opgaver læses med `Date`,
* fordi det er sådan TaskRow gør det ét skærmbillede væk — en opgaves tid er
* kontoens egen, og de to steder skal vise det samme tal.
*
* En begivenhed hen over midnat klippes til dagen. Uden det ville et møde fra
* fredag aften til lørdag morgen blive tegnet fra 20:00 til 09:00, altså baglæns.
*/
function timedOn(date) {
const day = iso(date);
const out = [];
for (const event of events) {
if (event.all_day || !event.starts_at) continue;
if (event.start_day > day || event.end_day < day) continue;
const from = event.start_day === day ? minutesAt(event.starts_at) : 0;
let to = 24 * 60;
if (event.ends_at && event.end_day === day) to = minutesAt(event.ends_at);
else if (!event.ends_at) to = from + 60;
// Et kvarters gulv: et nulminuts møde er stadig noget, der skal kunne ses
// og klikkes på.
out.push({ kind: 'event', ref: event, from, to: Math.max(to, from + 15) });
}
for (const task of tasksOn(date)) {
if (!task.due_datetime) continue;
const at = new Date(task.due_datetime);
const from = at.getHours() * 60 + at.getMinutes();
// The task's own length, not a fixed half hour. A quarter-hour floor so a
// zero-minute task is still something to see and grab, and the end of the
// day is the ceiling. While a resize is in flight this task follows the
// handle instead of its stored length.
let to =
resizing && resizing.id === task.id
? resizing.to
: from + (task.duration_min || 30);
to = Math.min(Math.max(to, from + 15), 24 * 60);
out.push({ kind: 'task', ref: task, from, to });
}
return out.toSorted((a, b) => a.from - b.from || a.to - b.to);
}
/** Det uden klokkeslæt: heldagsbegivenheder og opgaver, der kun har en dato. */
function untimedOn(date) {
return [
...eventsOn(date)
.filter((e) => e.all_day || !e.starts_at)
.map((e) => ({ kind: 'event', ref: e })),
...tasksOn(date)
.filter((t) => !t.due_datetime)
.map((t) => ({ kind: 'task', ref: t }))
];
}
/**
* To ting på samme tid står ved siden af hinanden, ikke oven på hinanden.
*
* Sammenhængende klynger frem for parvise sammenligninger: overlapper A med B og
* B med C, skal alle tre dele bredden — også når A og C ikke rører hinanden.
* Ellers ville A og C hver tro, de havde halvdelen, og lægge sig oven på hver sin
* halvdel af B.
*/
function packed(items) {
const out = [];
let cluster = [];
let end = -1;
const flush = () => {
const lanes = [];
for (const item of cluster) {
let lane = lanes.findIndex((free) => free <= item.from);
if (lane === -1) {
lane = lanes.length;
lanes.push(0);
}
lanes[lane] = item.to;
item.lane = lane;
}
for (const item of cluster) item.lanes = lanes.length;
out.push(...cluster);
cluster = [];
end = -1;
};
for (const item of items) {
if (cluster.length && item.from >= end) flush();
cluster.push(item);
end = Math.max(end, item.to);
}
if (cluster.length) flush();
return out;
}
/**
* Hvilke timer der tegnes.
*
* Et helt døgn er fireogtyve rækker, hvoraf de otte altid er tomme, og så er
* gitteret noget man ruller i frem for noget man ser. Arbejdsdagen er gulvet, og
* ugens egne begivenheder skubber kanterne ud, når de ligger uden for den — en
* vagt klokken fem om morgenen kommer med, uden at alle andre uger betaler for
* den med tomme rækker.
*/
let hours = $derived.by(() => {
let from = 8;
let to = 18;
if (span === 'week') {
for (const date of grid) {
for (const item of timedOn(date)) {
from = Math.min(from, Math.floor(item.from / 60));
to = Math.max(to, Math.ceil(item.to / 60));
}
}
}
from = Math.max(0, from);
to = Math.min(24, Math.max(to, from + 1));
return { from, to, count: to - from, minutes: (to - from) * 60 };
});
/** Hvor på døgnet noget står, som procent af de timer, der tegnes. */
function place(item) {
const top = Math.max(0, ((item.from - hours.from * 60) / hours.minutes) * 100);
const height = ((item.to - item.from) / hours.minutes) * 100;
const width = 100 / item.lanes;
return (
`top:${top}%;height:${Math.min(height, 100 - top)}%;` +
`left:${item.lane * width}%;width:${width}%`
);
}
</script>
<div class="calendar" class:week={span === 'week'}>
<header>
<button onclick={() => step(-1)} aria-label={span === 'week' ? t('view.prevWeek') : t('view.prevMonth')}
>‹</button
>
<h2 class:week={span === 'week'}>
{heading}
{#if weekNumber}<span class="weekno">{t('view.weekNumber', { n: weekNumber })}</span>{/if}
</h2>
<button onclick={() => step(1)} aria-label={span === 'week' ? t('view.nextWeek') : t('view.nextMonth')}
>›</button
>
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
</header>
<!--
Én brik, tegnet ét sted.
Måneden og ugen viser de samme to ting og skal vise dem ens — men de placerer
dem forskelligt: måneden lader dem stå i rækkefølge i en rude, ugen sætter
dem på et klokkeslæt. Det er placeringen, der er forskellig, ikke brikken, så
det er kun placeringen, der står to steder.
`extra` er den absolutte placering i ugegitteret og tom i måneden. `at` er
klokkeslættet, når det ikke allerede står i brikken selv.
-->
{#snippet eventChip(event, date, kind, extra = '', at = '')}
{@const link = linkOf(event)}
{@const label = t('cal.eventCannotMove', {
name: event.summary,
calendar: event.calendar_name || t('cal.title')
})}
<!-- Not draggable, and it says so twice: the attribute stops the browser
dragging an anchor of its own accord, and the title says why to anybody
who tries. The drop targets would refuse it anyway — they only accept the
task MIME type — but a chip that lifts off the page and then will not land
reads as a bug rather than as a rule. It is also not a button: there is
nothing here to complete, and a checkbox next to a meeting is an offer
verdande cannot keep. -->
<svelte:element
this={link ? 'a' : 'span'}
class={kind}
class:allday={event.all_day}
draggable="false"
href={link || undefined}
target={link ? '_blank' : undefined}
rel={link ? 'noreferrer noopener' : undefined}
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
style={[swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : '', extra]
.filter(Boolean)
.join(';')}
>
{#if at}<span class="at">{at}</span>{:else if timeOn(event, date)}<span class="at"
>{timeOn(event, date)}</span
>{/if}
{event.summary}
</svelte:element>
{/snippet}
{#snippet taskChip(task, kind, extra = '', at = '', date = null)}
<!-- Draggable as well as clickable: moving something to another day is the
whole reason to be looking at a calendar. The browser needs a few pixels
of movement before it calls a press a drag, so the click survives. -->
<button
class={kind}
class:resizing={resizing?.id === task.id}
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
ondragend={() => (over = null)}
onclick={() => onselect?.(task)}
style={extra}
>
{#if at}<span class="at">{at}</span>{/if}
{task.content}
<!-- Only a task standing at a time can be made longer, and only where the
grid has room to show it — the timed column, which passes the date. -->
{#if date && task.due_datetime}
<span
class="resize"
title={t('cal.resize')}
aria-hidden="true"
onpointerdown={(e) => startResize(e, task, date)}
></span>
{/if}
</button>
{/snippet}
{#if span === 'week'}
<!-- Ugen som et døgn, ikke som syv punktopstillinger.
Rullet vandret på en smal skærm frem for stablet til én søjle: der er
ét sæt afleveringsmål her, og et andet sæt til telefonen ville være
endnu et sted, den samme regel kunne være næsten rigtig. -->
<div class="weekwrap">
<div class="weekgrid" style="--hours: {hours.count}">
<div class="corner"></div>
{#each grid as date (date.toISOString())}
<div class="head" class:today={iso(date) === todayISO}>
<span class="wd">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
<span class="num">{date.getDate()}</span>
</div>
{/each}
<!-- Båndet foroven: det, der ikke har et klokkeslæt og derfor ikke kan
stå ét sted på døgnet. En heldagsbegivenhed lagt klokken nul ville
påstå, den holdt fra midnat til midnat. -->
<div class="gutter">{t('cal.allDay')}</div>
{#each grid as date (date.toISOString())}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="allday"
data-allday={iso(date)}
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDropAllDay(e, date)}
>
{#each untimedOn(date) as item (item.kind + item.ref.id)}
{#if item.kind === 'event'}
{@render eventChip(item.ref, date, 'event')}
{:else}
{@render taskChip(item.ref, 'chip')}
{/if}
{/each}
</div>
{/each}
<div class="gutter times">
{#each Array(hours.count) as _, i}
<span class="hour">{clock((hours.from + i) * 60)}</span>
{/each}
</div>
{#each grid as date (date.toISOString())}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- `data-date` er dagens navn udadtil. Månedens ruder har altid haft
det, og ugens søjler skal have det af samme grund: det er sådan
noget uden for komponenten peger på en dag — og det er det, en
prøve slipper en opgave på. Heldagsbåndet får sit eget navn, så
de to ikke bliver til det samme svar på ét spørgsmål. -->
<div
class="daycol"
data-date={iso(date)}
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDropAt(e, date)}
>
{#each Array(hours.count) as _, i}
<div class="hourline" style="top:{(i / hours.count) * 100}%"></div>
{/each}
{#each packed(timedOn(date)) as item (item.kind + item.ref.id)}
{#if item.kind === 'event'}
{@render eventChip(item.ref, date, 'tevent', place(item))}
{:else}
{@render taskChip(item.ref, 'tevent task', place(item), clock(item.from), date)}
{/if}
{/each}
</div>
{/each}
</div>
</div>
{:else}
<div class="weekdays" aria-hidden="true">
{#each WEEKDAYS as day}
<span>{day}</span>
{/each}
</div>
<div class="grid">
{#each grid as date (date.toISOString())}
{@const dayEvents = eventsOn(date)}
<!-- Events first, then whatever room is left goes to tasks. An event is
fixed to a time somebody else chose; a task is only dated, and if
something in a full cell has to fall behind "+3 mere", it should be
the one that can be moved. -->
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
{@const hidden =
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
<!-- A week has no outside: every day in it is the week you asked for. -->
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="day"
data-date={iso(date)}
class:outside
class:today={iso(date) === todayISO}
class:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
>
<!-- The weekday strip above the grid is enough on a wide screen. A week
stacked into rows on a phone hides it, and a column of bare numbers
is not a week — so the cell carries its own name there. -->
{#if span === 'week'}
<span class="weekday">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
{/if}
<span class="number">{date.getDate()}</span>
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
{@render eventChip(event, date, 'event')}
{/each}
{#each tasks as task (task.id)}
{@render taskChip(task, 'chip')}
{/each}
{#if hidden > 0}
<span class="more">{t('view.more', { n: hidden })}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<style>
header {
display: flex;
align-items: center;
gap: var(--s2);
margin-bottom: var(--s4);
}
h2 {
font-size: var(--text-lg);
text-transform: capitalize;
min-width: 180px;
}
/* A date range is not a month name: "24.–30. aug. 2026" is already capitalised
where it should be, and capitalising it again gives "24.–30. Aug. 2026". */
h2.week {
text-transform: none;
min-width: 220px;
display: flex;
align-items: baseline;
gap: var(--s2);
}
.weekno {
font-size: var(--text-xs);
font-weight: 400;
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
}
header button {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--radius);
color: var(--ink-muted);
font-size: var(--text-lg);
line-height: 1;
}
header button:hover {
background: var(--surface);
color: var(--ink);
}
/* Scoped to the header, because `.today` is not only this button: the day cell
for today carries the same class, and an unscoped `margin-left: auto` made a
grid item shrink to its content and sit against the right of its column. The
month always had one cell narrower than the other six, on every project
calendar, and it read as a grid that had not quite laid out yet. */
header .today {
width: auto;
margin-left: auto;
padding: 0 var(--s3);
font-size: var(--text-sm);
border: 1px solid var(--line);
}
/* `minmax(0, 1fr)` og ikke `1fr`.
*
* `1fr` er `minmax(auto, 1fr)`, og `auto` som minimum betyder, at en søjle ikke
* kan blive smallere end sit længste ord. Én opgave, der hedder "Gårdbutikken på
* Møllerup Gods — opfølgning på mail", gjorde onsdag dobbelt så bred som mandag
* og klemte resten af ugen sammen om den. Syv dage er syv lige store dage; det
* er hele idéen med et gitter.
*
* Nul som minimum lader søjlen blive så smal, den skal — og så er det brikkens
* opgave at klippe sin tekst af, hvilket den allerede gør. */
.weekdays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
margin-bottom: var(--s2);
}
.weekdays span {
font-size: var(--text-xs);
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0 var(--s2);
}
.grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* --- ugen som et døgn ---------------------------------------------------
*
* Et gitter med en tidsrende og syv dage, og tre rækker: dagenes navne, båndet
* med det, der ikke har et klokkeslæt, og selve døgnet.
*
* Rullet vandret på en smal skærm frem for stablet til én søjle. Syv timeglas
* ved siden af hinanden kan ikke blive smalle nok til en telefon, og en uge
* stablet til en liste er præcis det, den her visning findes for at holde op
* med at være. */
.weekwrap {
overflow-x: auto;
}
.weekgrid {
display: grid;
grid-template-columns: 3.25rem repeat(7, minmax(0, 1fr));
gap: 1px;
min-width: 640px;
background: var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* White, not the sidebar's sunken ground: a calendar reads lightest when the
day itself is the brightest thing on the screen and the events are the soft
colour laid on it — the opposite of the grey grid it was, where the events
had to shout to be seen at all. */
.corner,
.head,
.gutter,
.allday,
.daycol {
background: var(--surface);
}
.head {
padding: var(--s2) var(--s1);
text-align: center;
}
/* Today's whole column, faintly, so the eye finds it before it reads a number —
the marker on the date above is the label, this is the glow it sits in. Kept
under the events, so a pastel block still reads as its own calendar's colour
rather than as this wash tinted. */
.head.today,
.allday.today,
.daycol.today {
background: color-mix(in oklab, var(--accent) 6%, var(--surface));
}
.head .wd {
display: block;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
}
.head .num {
font-size: var(--text-sm);
}
/* The marker at the top the day is named by: today's number in a filled disc,
the same one the month grid gives today, so "this is today" is one glance in
either view rather than a colour you have to already know to read. */
.head.today .num {
color: var(--accent-ink);
background: var(--accent);
border-radius: var(--radius-full);
width: 20px;
height: 20px;
display: inline-grid;
place-items: center;
font-weight: 560;
margin-top: 1px;
}
.gutter {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--s1) var(--s2);
font-size: var(--text-xs);
line-height: 1.2;
color: var(--ink-faint);
text-align: right;
}
.allday {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--s1);
min-height: 1.75rem;
}
/* Rækken er lige så høj som de timer, den viser. `--hours` sættes på gitteret,
fordi tallet kommer fra ugens egne begivenheder og ikke fra stilarket. */
.times {
display: grid;
align-items: start;
justify-content: stretch;
grid-template-rows: repeat(var(--hours), 1fr);
height: calc(var(--hours) * 3rem);
padding: 0 var(--s2) 0 0;
}
/* Klokkeslættet står ved sin egen streg og ikke midt i timen under den. */
.times .hour {
transform: translateY(-0.55em);
line-height: 1;
}
/* Undtagen det første, som ellers kravler op i heldagsbåndet: der er ingen streg
over den første time — der er gitterets kant, og et tal, der ligger oven på
den, ser ud til at høre til rækken ovenover. */
.times .hour:first-child {
transform: none;
}
.daycol {
position: relative;
height: calc(var(--hours) * 3rem);
}
.hourline {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--line);
pointer-events: none;
}
/* Den første streg er gitterets egen kant. */
.hourline:first-child {
display: none;
}
.allday.over,
.daycol.over {
background: var(--accent-sunken, var(--surface-raised));
box-shadow: inset 0 0 0 1px var(--accent);
}
/* Placeringen kommer fra `place()` som en inline `style`; alt andet står her.
To ting på samme tid deler bredden, så `margin-right` er den ene hårstrå
luft, der gør to kasser til to kasser. */
/* A soft wash of the calendar's own colour rather than a grey block edged in it:
each calendar keeps a dusty, unmistakable nuance, and a screen of them reads
as colour laid on white instead of a grid of grey bars. The left edge stays,
a shade stronger than the fill, so the block still has a spine. */
.tevent {
position: absolute;
margin-right: 2px;
overflow: hidden;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour, var(--line-strong));
background: color-mix(in oklab, var(--event-colour, var(--line-strong)) 16%, var(--surface));
color: var(--ink);
font-size: var(--text-xs);
line-height: 1.3;
text-align: left;
text-decoration: none;
}
/* A task is a card you can pick up, not an event you cannot — so on the white
column it is an outlined card, where an event is a solid wash. The priority
colour rides the left edge, as it does everywhere else. */
.tevent.task {
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
border-left-color: var(--line-strong);
color: var(--ink);
cursor: grab;
}
/* The grip along the foot of a task, for dragging its length. Out of the way
until the task is hovered or being resized, so it never competes with the
words above it. */
.tevent.task .resize {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 7px;
cursor: ns-resize;
opacity: 0;
touch-action: none;
}
.tevent.task:hover .resize,
.tevent.task.resizing .resize {
opacity: 1;
background: linear-gradient(to bottom, transparent, var(--line-strong));
}
.tevent.task.resizing {
cursor: ns-resize;
z-index: 5;
}
.tevent.task[data-priority='1'] {
border-left-color: var(--p1);
}
.tevent.task[data-priority='2'] {
border-left-color: var(--p2);
}
.tevent.task[data-priority='3'] {
border-left-color: var(--p3);
}
.tevent .at {
color: var(--ink-faint);
margin-right: 0.3em;
}
.day {
background: var(--ground);
min-height: 92px;
padding: var(--s2);
display: flex;
flex-direction: column;
gap: 2px;
}
/* Seven cells instead of forty-two, so the height a month has to spend on six
rows goes into one. It is what makes the week a view rather than only a wider
drop target. */
.calendar.week .day {
min-height: 320px;
}
/* Only shown when the grid has stacked; the strip above says it otherwise. */
.weekday {
display: none;
font-size: var(--text-xs);
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Days from the neighbouring months stay visible but recede — removing them
would leave ragged holes at the corners of the grid. */
.day.outside {
background: var(--surface-sunken);
}
.day.outside .number {
color: var(--ink-faint);
opacity: 0.5;
}
.number {
font-size: var(--text-xs);
color: var(--ink-muted);
}
/* The whole cell, because the whole cell is the target: a day is a box, not a
gap between two boxes. */
.day.over {
background: var(--surface-raised);
box-shadow: inset 0 0 0 1px var(--accent);
}
.day.today .number {
color: var(--accent-ink);
background: var(--accent);
border-radius: var(--radius-full);
width: 18px;
height: 18px;
display: grid;
place-items: center;
font-weight: 560;
}
.chip {
font-size: var(--text-xs);
text-align: left;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
border-left: 2px solid var(--line-strong);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip[data-priority='1'] {
border-left-color: var(--p1);
}
.chip[data-priority='2'] {
border-left-color: var(--p2);
}
.chip[data-priority='3'] {
border-left-color: var(--p3);
}
.chip:hover {
background: var(--surface-sunken);
}
/* An event reads as a different kind of thing at a glance, before any of the
words are read: a solid bar of colour where a task is a card with a coloured
edge. That is the whole distinction the eye needs — a task is something you
can move and finish, an event is something that is simply happening.
The colour is Google's own for that calendar, so two calendars in one grid
are told apart by the colour the person already knows them by. `--line-strong`
is the fallback for a calendar Google gave no colour, which is rare and not
worth a second decision. */
.event {
--event-colour: var(--line-strong);
font-size: var(--text-xs);
text-align: left;
display: block;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour);
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
color: var(--ink);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* A drag that cannot land is worse than one that cannot start. The grab
cursor is the promise; this withholds it. */
cursor: default;
}
.event[href] {
cursor: pointer;
}
.event:hover {
background: color-mix(in oklab, var(--event-colour) 26%, var(--surface));
color: var(--ink);
}
/* An all-day event has no clock to show, so the colour carries it instead:
filled rather than merely edged, which is what an all-day band looks like in
every calendar anybody has used. */
.event.allday {
border-left: none;
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
}
.at {
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
margin-right: 2px;
}
.more {
font-size: var(--text-xs);
color: var(--ink-faint);
padding-left: var(--s1);
}
@media (max-width: 620px) {
.day {
min-height: 56px;
padding: var(--s1);
}
.chip {
font-size: 0;
padding: 0;
height: 4px;
border-radius: var(--radius-full);
border-left: none;
background: var(--line-strong);
}
.chip[data-priority='1'] {
background: var(--p1);
}
.chip[data-priority='2'] {
background: var(--p2);
}
.chip[data-priority='3'] {
background: var(--p3);
}
/* Counted rather than read, like the task chips beside it — a 45px column
fits no title. A bar rather than a dot, so an event is still tellable from
a task at a glance: what the month grid says on a phone is "how much is
on this day, and how much of it is somebody else's". */
.event {
font-size: 0;
padding: 0;
height: 4px;
border-radius: 1px;
border-left: none;
box-shadow: none;
background: var(--event-colour);
}
.event.allday {
background: var(--event-colour);
box-shadow: none;
}
.at {
display: none;
}
.more {
font-size: 10px;
}
/* A week on a phone is not seven columns of 45px. It becomes seven rows —
the same information in the shape the screen has room for, and each row
is still a drop target, which is the whole point of the view.
The chips stay readable here, unlike in the month grid above: a
full-width row has room for a title, and a week somebody opened on
purpose is one they want to read rather than count. */
.calendar.week .grid {
grid-template-columns: 1fr;
}
.calendar.week .day {
min-height: 0;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: var(--s2);
padding: var(--s2);
}
.calendar.week .weekdays {
display: none;
}
.calendar.week .weekday {
display: inline;
}
.calendar.week .chip {
font-size: var(--text-xs);
height: auto;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 2px solid var(--line-strong);
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
}
.calendar.week .chip[data-priority='1'] {
border-left-color: var(--p1);
}
.calendar.week .chip[data-priority='2'] {
border-left-color: var(--p2);
}
.calendar.week .chip[data-priority='3'] {
border-left-color: var(--p3);
}
/* Readable again in the week, for the reason the task chips are: a
full-width row has room for a title, and a week somebody opened on
purpose is one they want to read rather than count. */
.calendar.week .event {
font-size: var(--text-xs);
height: auto;
padding: 1px var(--s1);
border-radius: var(--radius-sm);
border-left: 3px solid var(--event-colour);
background: color-mix(in oklab, var(--event-colour) 16%, var(--surface));
}
.calendar.week .event.allday {
border-left: none;
background: color-mix(in oklab, var(--event-colour) 22%, var(--surface));
}
.calendar.week .at {
display: inline;
}
.calendar.week .more {
font-size: var(--text-xs);
}
}
</style>