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}%` + ); + }