feat(web): Kalender som menupunkt, med Googles begivenheder lagt over egne opgaver
Samme gitter som Kommende, givet en liste mere at tegne. Ikke et gitter nummer to, og grunden er den del, der er nem at få næsten rigtig: et mål skal afgøre ud fra `dataTransfer.types` alene, om det tager imod, og to kopier af det er to steder at være næsten rigtig. En opgave trukket her flyttes præcis som alle andre steder, fordi det er den samme kode, der gør det. En begivenhed er ikke en opgave, og cellen siger det før et eneste ord er læst: en flade af farve, hvor en opgave er et kort med en farvet kant. Den kan ikke trækkes — forbindelsen er læsende, og en chip, der letter fra siden og så ikke vil lande, læses som en fejl frem for som en regel — og den er ikke en knap, for der er ikke noget at lukke ved en andens møde. Farven er Googles egen for den kalender, så to kalendere i ét gitter kendes fra hinanden på den farve, personen allerede kender dem på. Farven tjekkes frem for at blive troet: den ender i en inline `style`, og en værdi, der ikke er en farve, kan lukke erklæringen og åbne en anden. Linket tegnes kun, hvis det begynder med `https://` — et href er dér, hvor en `javascript:`-adresse bliver til et klik, der kører. Prøven bruger uge-visningen med vilje: en månedscelle skæres af ved tre chips, og resten af suiten lægger opgaver på andre dage. Datoen læses ud af siden, ikke ud af testprocessen, som de øvrige har lært at gøre.
This commit is contained in:
@@ -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: '[email protected]',
|
||||
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
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
<div class="grid">
|
||||
{#each grid as date (date.toISOString())}
|
||||
{@const tasks = tasksOn(date)}
|
||||
{@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 -->
|
||||
@@ -254,7 +320,36 @@
|
||||
{/if}
|
||||
<span class="number">{date.getDate()}</span>
|
||||
|
||||
{#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')
|
||||
})}
|
||||
<!-- 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>
|
||||
{/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
|
||||
@@ -271,8 +366,8 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if tasks.length > chipLimit}
|
||||
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
|
||||
{#if hidden > 0}
|
||||
<span class="more">{t('view.more', { n: hidden })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -451,6 +546,58 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* An event reads as a different kind of thing at a glance, before any of the
|
||||
words are read: a solid bar of colour where a task is a card with a coloured
|
||||
edge. That is the whole distinction the eye needs — a task is something you
|
||||
can move and finish, an event is something that is simply happening.
|
||||
|
||||
The colour is Google's own for that calendar, so two calendars in one grid
|
||||
are told apart by the colour the person already knows them by. `--line-strong`
|
||||
is the fallback for a calendar Google gave no colour, which is rare and not
|
||||
worth a second decision. */
|
||||
.event {
|
||||
--event-colour: var(--line-strong);
|
||||
font-size: var(--text-xs);
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* A drag that cannot land is worse than one that cannot start. The grab
|
||||
cursor is the promise; this withholds it. */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.event[href] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* An all-day event has no clock to show, so the colour carries it instead:
|
||||
filled rather than merely edged, which is what an all-day band looks like in
|
||||
every calendar anybody has used. */
|
||||
.event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
||||
}
|
||||
|
||||
.at {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
@@ -482,6 +629,29 @@
|
||||
background: var(--p3);
|
||||
}
|
||||
|
||||
/* Counted rather than read, like the task chips beside it — a 45px column
|
||||
fits no title. A bar rather than a dot, so an event is still tellable from
|
||||
a task at a glance: what the month grid says on a phone is "how much is
|
||||
on this day, and how much of it is somebody else's". */
|
||||
.event {
|
||||
font-size: 0;
|
||||
padding: 0;
|
||||
height: 4px;
|
||||
border-radius: 1px;
|
||||
border-left: none;
|
||||
box-shadow: none;
|
||||
background: var(--event-colour);
|
||||
}
|
||||
|
||||
.event.allday {
|
||||
background: var(--event-colour);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.at {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -536,6 +706,27 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Readable again in the week, for the reason the task chips are: a
|
||||
full-width row has room for a title, and a week somebody opened on
|
||||
purpose is one they want to read rather than count. */
|
||||
.calendar.week .event {
|
||||
font-size: var(--text-xs);
|
||||
height: auto;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.calendar.week .event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
}
|
||||
|
||||
.calendar.week .at {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.calendar.week .more {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,16 @@
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' }
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' },
|
||||
// Kalenderen bærer et gitter, af samme grund som Noter bærer et ark: den
|
||||
// er ikke endnu et filter over de samme opgaver. Den er de samme opgaver
|
||||
// med noget andet lagt over — Googles begivenheder — og en prik ville
|
||||
// sige, at den hørte til blandt I dag og Kommende.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -488,6 +497,26 @@
|
||||
/>
|
||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else if item.key === 'calendar'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect
|
||||
x="2.2"
|
||||
y="3.2"
|
||||
width="11.6"
|
||||
height="10.6"
|
||||
rx="1.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<path
|
||||
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
@@ -835,6 +835,43 @@ function read() {
|
||||
|
||||
export const upcomingView = new UpcomingView();
|
||||
|
||||
/**
|
||||
* Month or week on Kalender.
|
||||
*
|
||||
* Its own key rather than Kommende's, even though the two switchers look alike.
|
||||
* They are not the same choice: Kommende opens on a list of the next seven days
|
||||
* and offers a grid, and Kalender is the grid — sharing the value would mean
|
||||
* choosing a week on one and finding the other had changed, and one of them would
|
||||
* have to answer for the `list` mode the other does not have.
|
||||
*
|
||||
* In localStorage rather than on the account, and for the same reason as the
|
||||
* sidebar's width: a grid of seven columns needs room, and the answer on a phone
|
||||
* is not the answer on a wide monitor.
|
||||
*/
|
||||
const CALENDAR_MODES = ['week', 'month'];
|
||||
|
||||
class CalendarViewMode {
|
||||
mode = $state(readCalendarMode());
|
||||
|
||||
set(next) {
|
||||
if (!CALENDAR_MODES.includes(next)) return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:calendar', next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readCalendarMode() {
|
||||
if (typeof localStorage === 'undefined') return 'month';
|
||||
const stored = localStorage.getItem('verdande:calendar');
|
||||
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
||||
}
|
||||
|
||||
export const calendarView = new CalendarViewMode();
|
||||
|
||||
/**
|
||||
* Whether the lists show what has already been finished.
|
||||
*
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script>
|
||||
/** Gmail, the calendar feed, and the mail-to-task address. */
|
||||
/** Gmail, Google Calendar, the calendar feed, and the mail-to-task address. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { t, plural } from '$lib/i18n.svelte.js';
|
||||
import { t, plural, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- Gmail ------------------------------------------------------------------
|
||||
|
||||
@@ -136,6 +136,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Google Calendar -----------------------------------------------------------
|
||||
|
||||
// Its own panel rather than a second mode inside the Gmail one. They sign in
|
||||
// through the same registration and are otherwise nothing alike: one turns mail
|
||||
// into work you own, the other shows a copy of somebody else's calendar and
|
||||
// never writes to it.
|
||||
let calendar = $state(null);
|
||||
let savingCalendars = $state(false);
|
||||
let calendarsSaved = $state(false);
|
||||
let syncingCalendar = $state(false);
|
||||
/** The ids ticked in the form, which is not what is stored until Save. */
|
||||
let shownCalendars = $state([]);
|
||||
|
||||
const CALENDAR_CALLBACK = {
|
||||
connected: { tone: 'ok', text: t('int.calendarConnected') },
|
||||
state: { tone: 'bad', text: t('int.calendarState') },
|
||||
expired: { tone: 'bad', text: t('int.calendarExpired') },
|
||||
invalid: { tone: 'bad', text: t('int.calendarInvalid') },
|
||||
failed: { tone: 'bad', text: t('int.calendarFailed') },
|
||||
norefresh: { tone: 'bad', text: t('int.calendarNoRefresh') },
|
||||
access_denied: { tone: 'bad', text: t('int.calendarDenied') },
|
||||
// The one worth naming. An OAuth app registered as Internal accepts only
|
||||
// accounts inside the organisation, and Google's own screen does not say
|
||||
// that in words anybody could act on.
|
||||
org_internal: { tone: 'bad', text: t('int.calendarOrgInternal') }
|
||||
};
|
||||
|
||||
let calendarCallback = $derived.by(() => {
|
||||
const value = $page.url.searchParams.get('calendar');
|
||||
if (!value) return null;
|
||||
return CALENDAR_CALLBACK[value] ?? { tone: 'bad', text: t('int.calendarOther', { what: value }) };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
loadCalendar();
|
||||
});
|
||||
|
||||
async function loadCalendar() {
|
||||
try {
|
||||
calendar = await api.getCalendar();
|
||||
shownCalendars = calendar.calendars.filter((c) => c.shown).map((c) => c.id);
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function connectCalendar() {
|
||||
try {
|
||||
const { url } = await api.authorizeCalendar();
|
||||
location.href = url;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendars(event) {
|
||||
event.preventDefault();
|
||||
savingCalendars = true;
|
||||
calendarsSaved = false;
|
||||
try {
|
||||
await api.setCalendars(shownCalendars);
|
||||
await loadCalendar();
|
||||
calendarsSaved = true;
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
savingCalendars = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCalendarNow() {
|
||||
syncingCalendar = true;
|
||||
try {
|
||||
const result = await api.syncCalendar();
|
||||
app.toast(
|
||||
result?.events
|
||||
? plural(result.events, 'int.calendarFetchedOne', 'int.calendarFetchedMany')
|
||||
: t('int.calendarNothingNew')
|
||||
);
|
||||
await loadCalendar();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
} finally {
|
||||
syncingCalendar = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectCalendar() {
|
||||
if (!confirm(t('int.disconnectCalendarQuestion'))) return;
|
||||
try {
|
||||
await api.disconnectCalendar();
|
||||
await loadCalendar();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCalendar(id) {
|
||||
shownCalendars = shownCalendars.includes(id)
|
||||
? shownCalendars.filter((c) => c !== id)
|
||||
: [...shownCalendars, id];
|
||||
}
|
||||
|
||||
// --- calendar feed -------------------------------------------------------------
|
||||
|
||||
let feedURL = $state('');
|
||||
@@ -355,6 +458,86 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('int.calendar')}</h2>
|
||||
<p class="hint">{t('int.calendarHint')}</p>
|
||||
</header>
|
||||
|
||||
{#if calendarCallback}
|
||||
<p class="callback" data-tone={calendarCallback.tone}>{calendarCallback.text}</p>
|
||||
{/if}
|
||||
|
||||
{#if calendar === null}
|
||||
<p class="empty">…</p>
|
||||
{:else if !calendar.connected}
|
||||
<div class="row">
|
||||
<button class="primary" onclick={connectCalendar} disabled={!calendar.has_client}>
|
||||
{t('int.connectCalendar')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The registration is the Gmail panel's — one client, two features — so
|
||||
this only spells out the second redirect URI, which has to be added to
|
||||
it. It is the single most likely thing to be wrong, and the error Google
|
||||
gives for it names neither value. -->
|
||||
{#if app.user?.is_admin}
|
||||
<div class="field">
|
||||
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
||||
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
|
||||
</div>
|
||||
{:else if !calendar.has_client}
|
||||
<p class="hint">{t('int.noClient')}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="hint">
|
||||
{t('int.calendarConnectedAs')}
|
||||
<strong>{calendar.account || t('int.unknownAccount')}</strong>.
|
||||
</p>
|
||||
|
||||
<form onsubmit={saveCalendars}>
|
||||
{#if calendar.calendars.length}
|
||||
<fieldset>
|
||||
<legend>{t('int.whichCalendars')}</legend>
|
||||
{#each calendar.calendars as c (c.id)}
|
||||
<label class="pick">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shownCalendars.includes(c.id)}
|
||||
onchange={() => toggleCalendar(c.id)}
|
||||
/>
|
||||
<!-- Google's own colour for the calendar, which is what the grid
|
||||
draws its events in. Told apart by the colour the person
|
||||
already knows it by, not by one of verdande's ten names. -->
|
||||
<span class="swatch" style:background={c.colour || 'var(--line-strong)'}></span>
|
||||
{c.name}
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{:else}
|
||||
<p class="hint">{t('int.noCalendars')}</p>
|
||||
{/if}
|
||||
|
||||
<p class="hint">
|
||||
{calendar.last_sync_at
|
||||
? t('int.calendarLastSync', {
|
||||
when: new Date(calendar.last_sync_at).toLocaleString(tag())
|
||||
})
|
||||
: t('int.calendarNeverSynced')}
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<button class="primary" type="submit" disabled={savingCalendars}>{t('int.save')}</button>
|
||||
{#if calendarsSaved}<span class="saved">{t('int.saved')}</span>{/if}
|
||||
<button class="secondary" onclick={syncCalendarNow} disabled={syncingCalendar}>
|
||||
{syncingCalendar ? t('int.fetching') : t('int.fetchNow')}
|
||||
</button>
|
||||
<button class="danger" onclick={disconnectCalendar}>{t('int.disconnect')}</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('int.feed')}</h2>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script>
|
||||
/**
|
||||
* Kalender: Google's events and verdande's own dated tasks in one grid.
|
||||
*
|
||||
* The same component Kommende uses, given a second list to draw. Not a second
|
||||
* grid, and the reason is the part that is easy to get subtly wrong: a drop
|
||||
* target has to decide from `dataTransfer.types` alone whether it accepts a
|
||||
* drag, and a second copy of that is a second place for it to be almost right.
|
||||
* A task dragged here reschedules exactly as it does everywhere else, because
|
||||
* it is the same code doing it.
|
||||
*
|
||||
* A month and a week, and no list. The list is Kommende's answer — seven days
|
||||
* of work, in order — and this view is the other question: what does the day
|
||||
* already have in it.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, calendarView } from '$lib/stores.svelte.js';
|
||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let events = $state([]);
|
||||
/** Null until the first answer, so nothing flashes before it is known. */
|
||||
let window_ = $state(null);
|
||||
let connected = $state(null);
|
||||
/** The dates the grid is showing, so the notice below can compare against them. */
|
||||
let showing = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.getCalendar()
|
||||
.then((status) => (connected = status.connected))
|
||||
.catch(() => {
|
||||
// A calendar that could not be asked about is not a reason to withhold
|
||||
// the grid: the tasks in it are verdande's own and do not depend on
|
||||
// Google being reachable.
|
||||
connected = false;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads the dates a grid is showing, whichever grid it is.
|
||||
*
|
||||
* Both halves at once, because they answer the same question about the same
|
||||
* cells. The limit on the tasks is raised well above the default for the reason
|
||||
* Kommende raises it: a busy month across every project is more than two hundred
|
||||
* rows, and a silently truncated grid is a calendar that quietly lies about a
|
||||
* day being clear.
|
||||
*/
|
||||
function loadRange({ from, to }) {
|
||||
showing = { from, to };
|
||||
app.loadTasks({ due_from: from, due_before: to, limit: 500 });
|
||||
api
|
||||
.calendarEvents(from, to)
|
||||
.then((data) => {
|
||||
events = data.events;
|
||||
window_ = { from: data.from, to: data.to };
|
||||
})
|
||||
.catch((e) => {
|
||||
// The grid still draws, with the tasks in it. Saying so once beats a
|
||||
// view that renders half of itself and explains nothing.
|
||||
events = [];
|
||||
app.toast(humanMessage(e));
|
||||
});
|
||||
}
|
||||
|
||||
// Whether the grid has been paged past the window verdande keeps a copy of.
|
||||
//
|
||||
// Said out loud rather than left as an empty month. A calendar showing no events
|
||||
// looks exactly like a calendar with no events in it, and the difference between
|
||||
// "nothing is booked" and "I have not looked that far" is the whole reason
|
||||
// somebody opened this.
|
||||
let beyond = $derived(
|
||||
connected &&
|
||||
window_ &&
|
||||
showing &&
|
||||
(showing.from < window_.from || showing.to > window_.to)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<header>
|
||||
<h1>{t('cal.title')}</h1>
|
||||
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
|
||||
<button
|
||||
class:active={calendarView.mode === value}
|
||||
onclick={() => calendarView.set(value)}
|
||||
aria-pressed={calendarView.mode === value}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if connected === false}
|
||||
<!-- Shown once, and quietly. The grid underneath is already useful without a
|
||||
Google account in it — it is verdande's own calendar, which is what this
|
||||
view was before anything was laid over it. -->
|
||||
<p class="hint">
|
||||
{t('cal.notConnected')}
|
||||
<a href="/indstillinger/integrationer">{t('cal.settings')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if beyond}
|
||||
<p class="hint">{t('cal.beyondWindow', { from: window_.from, to: window_.to })}</p>
|
||||
{/if}
|
||||
|
||||
{#if calendarView.mode === 'week'}
|
||||
<CalendarView span="week" {events} onrange={loadRange} />
|
||||
{:else}
|
||||
<CalendarView {events} onrange={loadRange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* A month grid needs the width a reading column deliberately withholds — the
|
||||
same allowance Kommende and the project page make for theirs. */
|
||||
.view {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
/* Wraps, for the same reason the project header does: a title and a switcher
|
||||
are more than a phone's width, and the switcher belongs underneath rather
|
||||
than squeezing the heading. */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
flex: none;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.views button {
|
||||
padding: var(--s1) var(--s3);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--ground);
|
||||
color: var(--ink-muted);
|
||||
transition:
|
||||
background var(--fast) var(--ease),
|
||||
color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.views button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.views button.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 var(--s4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.hint a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user