diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index a968022..a4f1b6d 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -979,6 +979,203 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ( * asserting against a project the admin can already see would pass with the old * per-project endpoint behind it. */ +/** + * Kalender: Google's events and verdande's own tasks in one grid, and the rule + * that tells them apart. + * + * The Google half is stubbed at the network layer rather than connected. There are + * no credentials in a test run and there never will be — an OAuth consent screen is + * a human pressing a button on somebody else's website — so what can be proved here + * is what the grid does with events once it has them, which is the part that has a + * rule in it: a task is draggable and reschedules, an event is not and cannot. + * + * The date is read out of the *page*, not out of this process. Playwright pins the + * browser to Europe/Copenhagen and node is pinned to nothing, so for two hours of + * every day the two disagree about what today is — which is how a green suite + * starts failing at half past midnight. Today is also the one day guaranteed to be + * in the month grid whatever the weekday, so nothing here has to page. + */ +test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven kan trækkes', async ({ + page +}) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + // A day at least a week out, whose next day is in the same Monday-to-Sunday + // week — so the drag below crosses one cell and not one grid. + // + // Worked out in the browser, not here. Playwright pins the page to + // Europe/Copenhagen and this process is pinned to nothing, so for two hours of + // every day the two disagree about what today is. A week out also keeps this + // test's day clear of the ones the rest of the suite dates things on. + const { day, next } = await page.evaluate(() => { + const iso = (d) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate() + ).padStart(2, '0')}`; + const monday = (d) => { + const m = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + m.setDate(m.getDate() - ((m.getDay() + 6) % 7)); + return iso(m); + }; + const cursor = new Date(); + cursor.setDate(cursor.getDate() + 7); + for (let i = 0; i < 14; i++) { + cursor.setDate(cursor.getDate() + 1); + const after = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1); + if (monday(cursor) === monday(after)) return { day: iso(cursor), next: iso(after) }; + } + throw new Error('no two consecutive days inside one week in three weeks'); + }); + + // A calendar that is connected and holds two events that day: one with a clock + // and one that lasts the whole day. The window is deliberately wide, so the + // notice about having paged past it stays out of the way. + await page.route('**/api/v1/calendar/events*', (route) => + route.fulfill({ + json: { + from: '2000-01-01', + to: '2099-12-31', + events: [ + { + id: 'ev-1', + calendar_id: 'c1', + summary: 'Bestyrelsesmøde', + starts_at: `${day}T14:00:00+02:00`, + ends_at: `${day}T15:30:00+02:00`, + start_day: day, + end_day: day, + all_day: false, + url: 'https://calendar.google.com/event?eid=abc', + calendar_name: 'Arbejde', + colour: '#0b8043' + }, + { + id: 'ev-2', + calendar_id: 'c1', + summary: 'Feriedag', + start_day: day, + end_day: day, + all_day: true, + calendar_name: 'Familien', + colour: '#3f51b5' + } + ] + } + }) + ); + await page.route('**/api/v1/calendar', (route) => + route.fulfill({ + json: { + connected: true, + account: 'kw@nolimit.dk', + read_only: true, + has_client: true, + redirect_uri: 'http://localhost/oauth/calendar/callback', + calendars: [] + } + }) + ); + + // verdande's own half of the grid: a task on that day, made the way anybody + // would make it. The date is written out rather than typed as "i morgen", so + // the assertion does not depend on the parser as well. + const box = page.getByLabel('Ny opgave'); + await box.fill(`vande blomster ${day}`); + await box.press('Enter'); + // Not asserted here: this is the Today view and the task is dated a week out, + // so it is correctly absent. The grid below is where it has to turn up. + await expect(box).toHaveValue(''); + + // The menu item, which is the thing the sidebar gained. + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.locator('a[href="/kalender"]').click(); + await page.waitForURL('**/kalender'); + await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible(); + + // The week, not the month, and for a reason worth writing down: a month cell + // truncates at three chips and this suite has other tests putting tasks on + // other days. A week cell holds ten, so what is asserted below is what the view + // does with events rather than how full somebody else left the month. + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + + // Forward a week at a time until the row is the one holding that day. Bounded, + // because a loop that pages for ever on a broken button is a timeout with no + // explanation in it. + const cell = page.locator(`[data-date="${day}"]`); + const forward = page.getByRole('button', { name: 'Næste uge' }); + for (let i = 0; i < 6 && (await cell.count()) === 0; i++) { + await forward.click(); + } + await expect(cell, `uge-visningen nåede aldrig frem til ${day}`).toBeVisible(); + + // 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' }); + await expect(event).toBeVisible(); + await expect(allDay).toBeVisible(); + await expect(task).toBeVisible(); + + // A timed event carries its clock; an all-day one has none to carry. + await expect(event).toContainText('14:00'); + await expect(allDay).not.toContainText(':'); + + // The rule. An event is read-only, so it must not offer to be moved — the drop + // targets would refuse it anyway, but a chip that lifts off the page and then + // will not land reads as a bug rather than as a rule. + await expect(event).toHaveAttribute('draggable', 'false'); + await expect(allDay).toHaveAttribute('draggable', 'false'); + await expect(task).toHaveAttribute('draggable', 'true'); + + // And it cannot be ticked off either: there is nothing to complete about + // somebody else's meeting. + expect(await event.evaluate((el) => el.tagName)).not.toBe('BUTTON'); + + // It does link back to Google, which is what makes it a pointer to the event + // rather than a second copy of it. + 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(); + // The events stayed where they were; a rescheduled task moves nothing else. + await expect(cell.locator('.event')).toHaveCount(2); + + expect(trouble).toEqual([]); +}); + +/** + * The view without a calendar connected, which is what every fresh instance is. + * + * It has to draw anyway. The grid is verdande's own tasks with due dates — the + * thing this view was before anything was laid over it — and a Google account that + * is not there must not take it down with it. + */ +test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/kalender'); + + await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible(); + // Seven weekday columns and a grid under them, whatever day of the week it is. + await expect(page.locator('.calendar .grid .day').first()).toBeVisible(); + + // It says so rather than looking like a calendar with nothing in it, and it + // says where to go about it. + await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeVisible(); + await expect( + page.locator('a[href="/indstillinger/integrationer"]').first() + ).toBeVisible(); + + // The week is the same grid with a different span, and switching to it must not + // lose the events prop on the way. + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + await expect(page.locator('.calendar.week')).toBeVisible(); + + expect(trouble).toEqual([]); +}); + test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({ browser, page diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index c880643..56bbe20 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -49,7 +49,19 @@ * flight swallows the click that would page. A week that straddles the * 31st and the 1st has both days in the same row. */ - span = 'month' + span = 'month', + /** + * Events read from somewhere else, laid over the same grid. + * + * An event is not a task, and the cell has to say so. It has a span of time + * rather than a day, it cannot be ticked off, and — while the connection is + * read-only — it cannot be moved: verdande holds a copy of somebody else's + * calendar, and a chip that lets itself be dragged is a promise the server + * has no way to keep. + * + * Empty everywhere but Kalender, so no other view pays for it. + */ + events = [] } = $props(); let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date())); @@ -137,6 +149,53 @@ (!projectId || t.project_id === projectId) ); + // Which cells an event covers, rather than where it starts. A trip from Friday + // to Monday is in all four, and asking only about the start day would draw it + // once and leave three days looking clear. + const eventsOn = (date) => { + const day = iso(date); + return events.filter((e) => e.start_day <= day && e.end_day >= day); + }; + + /** + * The time on the chip, and only where it means something. + * + * An all-day event has no time. A timed one shows its start, and only on the + * day it starts: a meeting that runs past midnight would otherwise claim to + * begin at 20:00 on both days. + * + * The clock is read out of the string rather than parsed into a Date. Google + * writes the event's own offset into it, and handing that to the browser asks + * *this device* what time it is — so a phone in another time zone would show a + * Copenhagen meeting an hour out. + */ + function timeOn(event, date) { + if (event.all_day || !event.starts_at) return ''; + if (event.start_day !== iso(date)) return ''; + return event.starts_at.slice(11, 16); + } + + /** + * The calendar's own colour, or nothing. + * + * Checked rather than trusted, even though it comes from Google: this goes into + * an inline `style`, and a value that is not a colour is a value that can close + * the declaration and open another. A hex triple is the whole of what Google + * sends, so anything else is not a colour that failed to render — it is a + * string that has no business being here. + */ + const HEX = /^#[0-9a-f]{3,8}$/i; + const swatch = (colour) => (HEX.test(colour ?? '') ? colour : ''); + + /** + * Whether the event links back to Google. + * + * `https:` and nothing else. An href is where a `javascript:` URL becomes a + * click that runs, and the address in this field was written by a service, not + * by the person looking at it. + */ + const linkOf = (event) => (String(event.url ?? '').startsWith('https://') ? event.url : ''); + const monthName = $derived( cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' }) ); @@ -232,7 +291,14 @@
{#each grid as date (date.toISOString())} - {@const tasks = tasksOn(date)} + {@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()} @@ -254,7 +320,36 @@ {/if} {date.getDate()} - {#each tasks.slice(0, chipLimit) as task (task.id)} + {#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} + + {/each} + + {#each tasks as task (task.id)} + {#if app.user?.is_admin} +
+ + +
+ {:else if !calendar.has_client} +

{t('int.noClient')}

+ {/if} + {:else} +

+ {t('int.calendarConnectedAs')} + {calendar.account || t('int.unknownAccount')}. +

+ +
+ {#if calendar.calendars.length} +
+ {t('int.whichCalendars')} + {#each calendar.calendars as c (c.id)} + + {/each} +
+ {:else} +

{t('int.noCalendars')}

+ {/if} + +

+ {calendar.last_sync_at + ? t('int.calendarLastSync', { + when: new Date(calendar.last_sync_at).toLocaleString(tag()) + }) + : t('int.calendarNeverSynced')} +

+ +
+ + {#if calendarsSaved}{t('int.saved')}{/if} + + +
+
+ {/if} + +

{t('int.feed')}

@@ -529,6 +712,38 @@ border-color: var(--danger); } + fieldset { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--s3); + display: flex; + flex-direction: column; + gap: var(--s2); + } + + legend { + font-size: var(--text-sm); + color: var(--ink-muted); + padding: 0 var(--s1); + } + + .pick { + display: flex; + align-items: center; + gap: var(--s2); + font-size: var(--text-sm); + } + + /* A solid mark beside a label that carries the meaning, so it only has to be + tellable from its neighbours and visible on both near-black and white — the + same job the project colours' dots do. */ + .swatch { + width: 10px; + height: 10px; + border-radius: var(--radius-full); + flex: none; + } + a { color: var(--accent); } diff --git a/web/src/routes/kalender/+page.svelte b/web/src/routes/kalender/+page.svelte new file mode 100644 index 0000000..81e2c84 --- /dev/null +++ b/web/src/routes/kalender/+page.svelte @@ -0,0 +1,180 @@ + + +
+
+

{t('cal.title')}

+
+ {#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]} + + {/each} +
+
+ + {#if connected === false} + +

+ {t('cal.notConnected')} + {t('cal.settings')} +

+ {/if} + + {#if beyond} +

{t('cal.beyondWindow', { from: window_.from, to: window_.to })}

+ {/if} + + {#if calendarView.mode === 'week'} + + {:else} + + {/if} +
+ +