From 9d34b57166b72afa7faf2aca868573ba74ea5f2c Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Thu, 20 Aug 2026 17:01:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20ugen=20er=20et=20d=C3=B8gn,?= =?UTF-8?q?=20og=20dagene=20er=20lige=20brede?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/e2e/smoke.spec.js | 140 ++++- web/src/lib/components/CalendarView.svelte | 578 ++++++++++++++++++--- 2 files changed, 624 insertions(+), 94 deletions(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 3ef953b..01ddc4b 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -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' }); 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(); - await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden(); + // Sluppet på timesøjlen, landet i heldagsbåndet — og det er det rigtige sted. + // 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([]); }); @@ -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 af kontoens kalendere er valgt endnu.')).toBeHidden(); - // Both kinds are in the same cell, which is the whole point of the view. - const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' }); - const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' }); - const task = cell.locator('.chip').filter({ hasText: 'vande blomster' }); + // Begge slags på den samme dag, som er hele pointen med visningen — men en dag + // er to kasser nu, siden ugen blev et døgn: timesøjlen bærer det, der har et + // klokkeslæt, og båndet foroven bærer det, der ikke har. En heldagsbegivenhed + // 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(allDay).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\//); // The task still reschedules by drag, in a grid that now has events in it. - await task.dragTo(page.locator(`[data-date="${next}"]`)); - await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible(); - await expect(cell.getByText('vande blomster')).toBeHidden(); + // 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 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. - await expect(cell.locator('.event')).toHaveCount(2); + await expect(cell.locator('.tevent')).toHaveCount(1); + await expect(band.locator('.event')).toHaveCount(1); expect(trouble).toEqual([]); }); @@ -3092,3 +3113,100 @@ test('en note uden krop må ikke låse ruden', async ({ page }) => { ).toHaveText('Beta', { timeout: 5000 }); 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([]); +}); diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index b00a6f1..bd44d5f 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -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}%` + ); + }
@@ -296,95 +436,195 @@ - + - {@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)} - - {@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()} - -
onDragOver(e, date)} - ondragleave={() => (over = null)} - ondrop={(e) => onDrop(e, date)} - > - - {#if span === 'week'} - {WEEKDAYS[(date.getDay() + 6) % 7]} - {/if} - {date.getDate()} + 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') - })} - - - {#if timeOn(event, date)}{timeOn(event, date)}{/if} - {event.summary} - + `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') + })} + + + {#if at}{at}{:else if timeOn(event, date)}{timeOn(event, date)}{/if} + {event.summary} + + {/snippet} + + {#snippet taskChip(task, kind, extra = '', at = '')} + + + {/snippet} + + {#if span === 'week'} + +
+
+
+ {#each grid as date (date.toISOString())} +
+ {WEEKDAYS[(date.getDay() + 6) % 7]} + {date.getDate()} +
{/each} - {#each tasks as task (task.id)} - - + {#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} +
{/each} - {#if hidden > 0} - {t('view.more', { n: hidden })} - {/if} +
+ {#each Array(hours.count) as _, i} + {clock((hours.from + i) * 60)} + {/each} +
+ {#each grid as date (date.toISOString())} + + +
onDragOver(e, date)} + ondragleave={() => (over = null)} + ondrop={(e) => onDrop(e, date)} + > + {#each Array(hours.count) as _, i} +
+ {/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} +
+ {/each}
- {/each} -
+
+ {:else} + + +
+ {#each grid as date (date.toISOString())} + {@const dayEvents = eventsOn(date)} + + {@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)} + + {@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()} + +
onDragOver(e, date)} + ondragleave={() => (over = null)} + ondrop={(e) => onDrop(e, date)} + > + + {#if span === 'week'} + {WEEKDAYS[(date.getDay() + 6) % 7]} + {/if} + {date.getDate()} + + {#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} + {t('view.more', { n: hidden })} + {/if} +
+ {/each} +
+ {/if}