feat(calendar): ugen er et døgn, og dagene er lige brede
Release / Build and publish image (push) Failing after 9m58s

To ting, meldt ind fra en uge, der ikke svarede på det, man åbnede den for.

**Dagene var ikke lige brede.** Gitteret stod på `repeat(7, 1fr)`, og `1fr` er
`minmax(auto, 1fr)`: `auto` som minimum betyder, at en søjle ikke kan blive
smallere end sit længste ord. Én opgave, der hed "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. `minmax(0, 1fr)` lader søjlen blive så smal, den skal, og så
klipper brikken sin tekst af — hvilket den allerede kunne.

**Ugen var syv punktopstillinger.** Den svarede 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.

Nu et gitter med en tidsrende og syv dage: timerne ned ad siden, begivenheder
placeret efter klokkeslæt, og et bånd foroven til det, der ikke har et — en
heldagsbegivenhed lagt klokken nul ville påstå, den holdt fra midnat til midnat.

Overlappende ting deler bredden i sammenhængende klynger frem for parvist:
overlapper A med B og B med C, skal alle tre dele, 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.

Hvilke timer der tegnes, kommer fra ugens egne begivenheder med arbejdsdagen som
gulv. 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 — men en vagt klokken fem skal
med, uden at alle andre uger betaler for den med tomme rækker.

Måneden er ikke rørt ud over søjlebredden. 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.

Brikkerne er flyttet ud i to snippets, som begge visninger tegner med. De viser
de samme to ting og skal vise dem ens; det er placeringen, der er forskellig, og
så er det kun placeringen, der står to steder.

`data-date` følger med over på ugens timesøjler — det er sådan noget uden for
komponenten peger på en dag. Båndet får sit eget `data-allday`, så de to ikke
bliver til det samme svar på ét spørgsmål.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 17:01:58 +02:00
co-authored by Claude Opus 5
parent 4b3abc44fb
commit 9d34b57166
2 changed files with 624 additions and 94 deletions
+495 -83
View File
@@ -279,6 +279,146 @@
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();
out.push({ kind: 'task', ref: task, from, to: from + 30 });
}
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'}>
@@ -296,95 +436,195 @@
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
</header>
<div class="weekdays" aria-hidden="true">
{#each WEEKDAYS as day}
<span>{day}</span>
{/each}
</div>
<!--
Én brik, tegnet ét sted.
<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>
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.
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
{@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="event"
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)}` : undefined}
>
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
{event.summary}
</svelte:element>
`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 = '')}
<!-- 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}
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}
</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}
{#each tasks as task (task.id)}
<!-- Draggable as well as clickable: within a month, moving something
to another day is the whole reason to be looking at a month. The
browser needs a few pixels of movement before it calls a press a
drag, so the click survives. -->
<button
class="chip"
data-priority={task.priority}
draggable="true"
ondragstart={(e) => startDrag(e, TASK, task.id)}
ondragend={() => (over = null)}
onclick={() => onselect?.(task)}
<!-- 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:over={over === iso(date)}
ondragover={(e) => onDragOver(e, date)}
ondragleave={() => (over = null)}
ondrop={(e) => onDrop(e, date)}
>
{task.content}
</button>
{#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}
{#if hidden > 0}
<span class="more">{t('view.more', { n: hidden })}</span>
{/if}
<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) => onDrop(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))}
{/if}
{/each}
</div>
{/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>
@@ -448,9 +688,19 @@
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, 1fr);
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
margin-bottom: var(--s2);
}
@@ -465,7 +715,7 @@
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
@@ -473,6 +723,168 @@
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;
}
.corner,
.head,
.gutter,
.allday,
.daycol {
background: var(--ground);
}
.head {
padding: var(--s2) var(--s1);
text-align: center;
}
.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);
}
.head.today .num {
color: var(--accent);
font-weight: 560;
}
.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. */
.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: var(--surface-sunken);
color: var(--ink-muted);
font-size: var(--text-xs);
line-height: 1.3;
text-align: left;
text-decoration: none;
}
.tevent.task {
background: var(--surface-raised);
border-left-color: var(--line-strong);
color: var(--ink);
cursor: grab;
}
.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;