feat(calendar): ugen er et døgn, og dagene er lige brede
Release / Build and publish image (push) Failing after 9m58s
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:
co-authored by
Claude Opus 5
parent
4b3abc44fb
commit
9d34b57166
+129
-11
@@ -976,10 +976,19 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async (
|
|||||||
|
|
||||||
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
||||||
await expect(chip).toBeVisible();
|
await expect(chip).toBeVisible();
|
||||||
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
|
||||||
|
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
|
||||||
|
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
|
||||||
|
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
|
||||||
|
// slipper der, hvor man kan se, man slipper.
|
||||||
|
await chip.dragTo(page.locator(`[data-date="${to}"]`), { targetPosition: { x: 30, y: 20 } });
|
||||||
|
|
||||||
await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
// Sluppet på timesøjlen, landet i heldagsbåndet — og det er det rigtige sted.
|
||||||
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
// Et træk hen på en dag flytter datoen og sætter ikke et klokkeslæt, så opgaven
|
||||||
|
// har ingen tid at stå ved. `data-allday` er den anden halvdel af dagen: siden
|
||||||
|
// ugen blev et døgn, er en dag to kasser, og en prøve skal sige hvilken.
|
||||||
|
await expect(page.locator(`[data-allday="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-allday="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -1131,10 +1140,14 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
|||||||
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
|
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
|
||||||
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
|
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
|
||||||
|
|
||||||
// Both kinds are in the same cell, which is the whole point of the view.
|
// Begge slags på den samme dag, som er hele pointen med visningen — men en dag
|
||||||
const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' });
|
// er to kasser nu, siden ugen blev et døgn: timesøjlen bærer det, der har et
|
||||||
const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' });
|
// klokkeslæt, og båndet foroven bærer det, der ikke har. En heldagsbegivenhed
|
||||||
const task = cell.locator('.chip').filter({ hasText: 'vande blomster' });
|
// lagt klokken nul ville påstå, den holdt fra midnat til midnat.
|
||||||
|
const band = page.locator(`[data-allday="${day}"]`);
|
||||||
|
const event = cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' });
|
||||||
|
const allDay = band.locator('.event').filter({ hasText: 'Feriedag' });
|
||||||
|
const task = band.locator('.chip').filter({ hasText: 'vande blomster' });
|
||||||
await expect(event).toBeVisible();
|
await expect(event).toBeVisible();
|
||||||
await expect(allDay).toBeVisible();
|
await expect(allDay).toBeVisible();
|
||||||
await expect(task).toBeVisible();
|
await expect(task).toBeVisible();
|
||||||
@@ -1159,11 +1172,19 @@ test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven ka
|
|||||||
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
|
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
|
||||||
|
|
||||||
// The task still reschedules by drag, in a grid that now has events in it.
|
// The task still reschedules by drag, in a grid that now has events in it.
|
||||||
await task.dragTo(page.locator(`[data-date="${next}"]`));
|
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
|
||||||
await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible();
|
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
|
||||||
await expect(cell.getByText('vande blomster')).toBeHidden();
|
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
|
||||||
|
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
|
||||||
|
// slipper der, hvor man kan se, man slipper.
|
||||||
|
await task.dragTo(page.locator(`[data-date="${next}"]`), { targetPosition: { x: 30, y: 20 } });
|
||||||
|
// Landet i næste dags bånd: et træk hen på en dag flytter datoen og sætter ikke
|
||||||
|
// et klokkeslæt, så opgaven har ingen tid at stå ved.
|
||||||
|
await expect(page.locator(`[data-allday="${next}"]`).getByText('vande blomster')).toBeVisible();
|
||||||
|
await expect(band.getByText('vande blomster')).toBeHidden();
|
||||||
// The events stayed where they were; a rescheduled task moves nothing else.
|
// The events stayed where they were; a rescheduled task moves nothing else.
|
||||||
await expect(cell.locator('.event')).toHaveCount(2);
|
await expect(cell.locator('.tevent')).toHaveCount(1);
|
||||||
|
await expect(band.locator('.event')).toHaveCount(1);
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -3092,3 +3113,100 @@ test('en note uden krop må ikke låse ruden', async ({ page }) => {
|
|||||||
).toHaveText('Beta', { timeout: 5000 });
|
).toHaveText('Beta', { timeout: 5000 });
|
||||||
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
|
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ugen er et døgn, ikke syv punktopstillinger.
|
||||||
|
*
|
||||||
|
* En uge, hvor hver dag er en liste, 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.
|
||||||
|
*
|
||||||
|
* Begivenhederne kommer fra en rute, der svarer i stedet for Google: det, der
|
||||||
|
* prøves her, er gitteret, og en prøve, der først skal have en OAuth-forbindelse,
|
||||||
|
* er en prøve, der aldrig kører.
|
||||||
|
*/
|
||||||
|
test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
// Mandag i den uge, siden åbner på.
|
||||||
|
const monday = (n) => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + n);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
|
d.getDate()
|
||||||
|
).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
const at = (n, from, to, id, summary) => ({
|
||||||
|
id,
|
||||||
|
calendar_id: 'c1',
|
||||||
|
summary,
|
||||||
|
starts_at: `${monday(n)}T${from}:00+02:00`,
|
||||||
|
ends_at: `${monday(n)}T${to}:00+02:00`,
|
||||||
|
start_day: monday(n),
|
||||||
|
end_day: monday(n),
|
||||||
|
all_day: false,
|
||||||
|
calendar_name: 'Privat'
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/calendar/events*', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
events: [
|
||||||
|
at(2, '09:00', '10:00', 'e1', 'Morgenmøde'),
|
||||||
|
at(2, '09:30', '11:00', 'e2', 'Oveni det første'),
|
||||||
|
at(2, '17:00', '18:00', 'e3', 'Sent på dagen'),
|
||||||
|
{
|
||||||
|
id: 'e4',
|
||||||
|
calendar_id: 'c1',
|
||||||
|
summary: 'Aalborg',
|
||||||
|
start_day: monday(1),
|
||||||
|
end_day: monday(1),
|
||||||
|
all_day: true,
|
||||||
|
calendar_name: 'Privat'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/kalender');
|
||||||
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||||
|
await expect(page.locator('.weekgrid')).toBeVisible();
|
||||||
|
|
||||||
|
// Syv lige brede dage. `1fr` er `minmax(auto, 1fr)`, så ét langt ord i én dag
|
||||||
|
// gjorde den bredere end de seks andre — hvilket er det modsatte af et gitter.
|
||||||
|
const widths = await page
|
||||||
|
.locator('.weekgrid .daycol')
|
||||||
|
.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().width)));
|
||||||
|
expect(widths, 'ugen har ikke syv søjler').toHaveLength(7);
|
||||||
|
expect(Math.max(...widths) - Math.min(...widths), 'dagene er ikke lige brede').toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// Klokken bestemmer, hvor noget står. Morgenmødet skal ligge over det sene.
|
||||||
|
const box = async (name) => (await page.locator('.tevent', { hasText: name }).first().boundingBox());
|
||||||
|
const morning = await box('Morgenmøde');
|
||||||
|
const evening = await box('Sent på dagen');
|
||||||
|
expect(morning.y, 'morgenmødet står ikke over det sene møde').toBeLessThan(evening.y);
|
||||||
|
|
||||||
|
// To ting på samme tid står ved siden af hinanden. Oven på hinanden er præcis
|
||||||
|
// den konflikt, visningen findes for at vise.
|
||||||
|
const overlapping = await box('Oveni det første');
|
||||||
|
expect(overlapping.x, 'de to overlappende ligger oven på hinanden').toBeGreaterThan(morning.x);
|
||||||
|
expect(overlapping.x, 'den anden er havnet i næste dag').toBeLessThan(morning.x + morning.width * 2);
|
||||||
|
|
||||||
|
// Heldagsting hører til båndet foroven: en heldagsbegivenhed lagt klokken nul
|
||||||
|
// ville påstå, den holdt fra midnat til midnat.
|
||||||
|
// `.weekgrid > .allday` er selve cellen; brikken indeni bærer også klassen.
|
||||||
|
await expect(page.locator('.weekgrid > .allday', { hasText: 'Aalborg' })).toBeVisible();
|
||||||
|
|
||||||
|
// Og måneden er stadig lige bred hele vejen rundt.
|
||||||
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||||
|
const monthWidths = await page
|
||||||
|
.locator('.grid .day')
|
||||||
|
.evaluateAll((els) => els.slice(0, 7).map((e) => Math.round(e.getBoundingClientRect().width)));
|
||||||
|
expect(
|
||||||
|
Math.max(...monthWidths) - Math.min(...monthWidths),
|
||||||
|
'månedens dage er ikke lige brede'
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -279,6 +279,146 @@
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
await app.reschedule(id, iso(date));
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="calendar" class:week={span === 'week'}>
|
<div class="calendar" class:week={span === 'week'}>
|
||||||
@@ -296,95 +436,195 @@
|
|||||||
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
|
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="weekdays" aria-hidden="true">
|
<!--
|
||||||
{#each WEEKDAYS as day}
|
Én brik, tegnet ét sted.
|
||||||
<span>{day}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
Måneden og ugen viser de samme to ting og skal vise dem ens — men de placerer
|
||||||
{#each grid as date (date.toISOString())}
|
dem forskelligt: måneden lader dem stå i rækkefølge i en rude, ugen sætter
|
||||||
{@const dayEvents = eventsOn(date)}
|
dem på et klokkeslæt. Det er placeringen, der er forskellig, ikke brikken, så
|
||||||
<!-- Events first, then whatever room is left goes to tasks. An event is
|
det er kun placeringen, der står to steder.
|
||||||
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)}
|
`extra` er den absolutte placering i ugegitteret og tom i måneden. `at` er
|
||||||
{@const link = linkOf(event)}
|
klokkeslættet, når det ikke allerede står i brikken selv.
|
||||||
{@const label = t('cal.eventCannotMove', {
|
-->
|
||||||
name: event.summary,
|
{#snippet eventChip(event, date, kind, extra = '', at = '')}
|
||||||
calendar: event.calendar_name || t('cal.title')
|
{@const link = linkOf(event)}
|
||||||
})}
|
{@const label = t('cal.eventCannotMove', {
|
||||||
<!-- Not draggable, and it says so twice: the attribute stops the
|
name: event.summary,
|
||||||
browser dragging an anchor of its own accord, and the title says
|
calendar: event.calendar_name || t('cal.title')
|
||||||
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
|
<!-- Not draggable, and it says so twice: the attribute stops the browser
|
||||||
the page and then will not land reads as a bug rather than as a
|
dragging an anchor of its own accord, and the title says why to anybody
|
||||||
rule. It is also not a button: there is nothing here to complete,
|
who tries. The drop targets would refuse it anyway — they only accept the
|
||||||
and a checkbox next to a meeting is an offer verdande cannot keep. -->
|
task MIME type — but a chip that lifts off the page and then will not land
|
||||||
<svelte:element
|
reads as a bug rather than as a rule. It is also not a button: there is
|
||||||
this={link ? 'a' : 'span'}
|
nothing here to complete, and a checkbox next to a meeting is an offer
|
||||||
class="event"
|
verdande cannot keep. -->
|
||||||
class:allday={event.all_day}
|
<svelte:element
|
||||||
draggable="false"
|
this={link ? 'a' : 'span'}
|
||||||
href={link || undefined}
|
class={kind}
|
||||||
target={link ? '_blank' : undefined}
|
class:allday={event.all_day}
|
||||||
rel={link ? 'noreferrer noopener' : undefined}
|
draggable="false"
|
||||||
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
href={link || undefined}
|
||||||
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
|
target={link ? '_blank' : undefined}
|
||||||
>
|
rel={link ? 'noreferrer noopener' : undefined}
|
||||||
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
|
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
||||||
{event.summary}
|
style={[swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : '', extra]
|
||||||
</svelte:element>
|
.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}
|
||||||
|
|
||||||
{#each tasks as task (task.id)}
|
<!-- Båndet foroven: det, der ikke har et klokkeslæt og derfor ikke kan
|
||||||
<!-- Draggable as well as clickable: within a month, moving something
|
stå ét sted på døgnet. En heldagsbegivenhed lagt klokken nul ville
|
||||||
to another day is the whole reason to be looking at a month. The
|
påstå, den holdt fra midnat til midnat. -->
|
||||||
browser needs a few pixels of movement before it calls a press a
|
<div class="gutter">{t('cal.allDay')}</div>
|
||||||
drag, so the click survives. -->
|
{#each grid as date (date.toISOString())}
|
||||||
<button
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
class="chip"
|
<div
|
||||||
data-priority={task.priority}
|
class="allday"
|
||||||
draggable="true"
|
data-allday={iso(date)}
|
||||||
ondragstart={(e) => startDrag(e, TASK, task.id)}
|
class:over={over === iso(date)}
|
||||||
ondragend={() => (over = null)}
|
ondragover={(e) => onDragOver(e, date)}
|
||||||
onclick={() => onselect?.(task)}
|
ondragleave={() => (over = null)}
|
||||||
|
ondrop={(e) => onDrop(e, date)}
|
||||||
>
|
>
|
||||||
{task.content}
|
{#each untimedOn(date) as item (item.kind + item.ref.id)}
|
||||||
</button>
|
{#if item.kind === 'event'}
|
||||||
|
{@render eventChip(item.ref, date, 'event')}
|
||||||
|
{:else}
|
||||||
|
{@render taskChip(item.ref, 'chip')}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if hidden > 0}
|
<div class="gutter times">
|
||||||
<span class="more">{t('view.more', { n: hidden })}</span>
|
{#each Array(hours.count) as _, i}
|
||||||
{/if}
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -448,9 +688,19 @@
|
|||||||
border: 1px solid var(--line);
|
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 {
|
.weekdays {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
margin-bottom: var(--s2);
|
margin-bottom: var(--s2);
|
||||||
}
|
}
|
||||||
@@ -465,7 +715,7 @@
|
|||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -473,6 +723,168 @@
|
|||||||
overflow: hidden;
|
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 {
|
.day {
|
||||||
background: var(--ground);
|
background: var(--ground);
|
||||||
min-height: 92px;
|
min-height: 92px;
|
||||||
|
|||||||
Reference in New Issue
Block a user