diff --git a/docs/screenshots/admin.png b/docs/screenshots/admin.png index 3449f0d..244e64f 100644 Binary files a/docs/screenshots/admin.png and b/docs/screenshots/admin.png differ diff --git a/docs/screenshots/board.png b/docs/screenshots/board.png index 5abe8df..1638200 100644 Binary files a/docs/screenshots/board.png and b/docs/screenshots/board.png differ diff --git a/docs/screenshots/delegated.png b/docs/screenshots/delegated.png index a14ee86..0715438 100644 Binary files a/docs/screenshots/delegated.png and b/docs/screenshots/delegated.png differ diff --git a/docs/screenshots/detail.png b/docs/screenshots/detail.png index 894b945..784fa93 100644 Binary files a/docs/screenshots/detail.png and b/docs/screenshots/detail.png differ diff --git a/docs/screenshots/history.png b/docs/screenshots/history.png index b0be2ec..78ef54e 100644 Binary files a/docs/screenshots/history.png and b/docs/screenshots/history.png differ diff --git a/docs/screenshots/notes-source.png b/docs/screenshots/notes-source.png new file mode 100644 index 0000000..4faf0ae Binary files /dev/null and b/docs/screenshots/notes-source.png differ diff --git a/docs/screenshots/notes.png b/docs/screenshots/notes.png new file mode 100644 index 0000000..f36b4b6 Binary files /dev/null and b/docs/screenshots/notes.png differ diff --git a/docs/screenshots/sections.png b/docs/screenshots/sections.png index b8e198a..3214963 100644 Binary files a/docs/screenshots/sections.png and b/docs/screenshots/sections.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index dcbe578..9b1d08d 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/themes.png b/docs/screenshots/themes.png index e56bc18..c2882e8 100644 Binary files a/docs/screenshots/themes.png and b/docs/screenshots/themes.png differ diff --git a/docs/screenshots/today-light.png b/docs/screenshots/today-light.png index c85b213..32d6078 100644 Binary files a/docs/screenshots/today-light.png and b/docs/screenshots/today-light.png differ diff --git a/docs/screenshots/today.png b/docs/screenshots/today.png index 032ab28..e28abea 100644 Binary files a/docs/screenshots/today.png and b/docs/screenshots/today.png differ diff --git a/docs/screenshots/upcoming.png b/docs/screenshots/upcoming.png index ae5617b..eb94dbd 100644 Binary files a/docs/screenshots/upcoming.png and b/docs/screenshots/upcoming.png differ diff --git a/docs/screenshots/week.png b/docs/screenshots/week.png index 6b79242..05fa78a 100644 Binary files a/docs/screenshots/week.png and b/docs/screenshots/week.png differ diff --git a/site/screenshots/admin.webp b/site/screenshots/admin.webp index 87d7cd5..32082b7 100644 Binary files a/site/screenshots/admin.webp and b/site/screenshots/admin.webp differ diff --git a/site/screenshots/board.webp b/site/screenshots/board.webp index c902c66..d24030a 100644 Binary files a/site/screenshots/board.webp and b/site/screenshots/board.webp differ diff --git a/site/screenshots/delegated.webp b/site/screenshots/delegated.webp index ea91c17..e8cb87d 100644 Binary files a/site/screenshots/delegated.webp and b/site/screenshots/delegated.webp differ diff --git a/site/screenshots/detail.webp b/site/screenshots/detail.webp index 8980591..0e138cd 100644 Binary files a/site/screenshots/detail.webp and b/site/screenshots/detail.webp differ diff --git a/site/screenshots/history.webp b/site/screenshots/history.webp index 6842c07..15d92e4 100644 Binary files a/site/screenshots/history.webp and b/site/screenshots/history.webp differ diff --git a/site/screenshots/notes-source.webp b/site/screenshots/notes-source.webp new file mode 100644 index 0000000..b2d29ea Binary files /dev/null and b/site/screenshots/notes-source.webp differ diff --git a/site/screenshots/notes.webp b/site/screenshots/notes.webp new file mode 100644 index 0000000..f1ea12d Binary files /dev/null and b/site/screenshots/notes.webp differ diff --git a/site/screenshots/sections.webp b/site/screenshots/sections.webp index cf295d6..0e07240 100644 Binary files a/site/screenshots/sections.webp and b/site/screenshots/sections.webp differ diff --git a/site/screenshots/settings.webp b/site/screenshots/settings.webp index 48bf56b..5e38b43 100644 Binary files a/site/screenshots/settings.webp and b/site/screenshots/settings.webp differ diff --git a/site/screenshots/themes.webp b/site/screenshots/themes.webp index 4fea5f3..95a339e 100644 Binary files a/site/screenshots/themes.webp and b/site/screenshots/themes.webp differ diff --git a/site/screenshots/today-light.webp b/site/screenshots/today-light.webp index 0b46265..dca1f14 100644 Binary files a/site/screenshots/today-light.webp and b/site/screenshots/today-light.webp differ diff --git a/site/screenshots/today.webp b/site/screenshots/today.webp index 1e48836..0447cd7 100644 Binary files a/site/screenshots/today.webp and b/site/screenshots/today.webp differ diff --git a/site/screenshots/upcoming.webp b/site/screenshots/upcoming.webp index 8df50c3..1eb3399 100644 Binary files a/site/screenshots/upcoming.webp and b/site/screenshots/upcoming.webp differ diff --git a/site/screenshots/week.webp b/site/screenshots/week.webp index 71d38e1..c0d5fad 100644 Binary files a/site/screenshots/week.webp and b/site/screenshots/week.webp differ diff --git a/web/scripts/generate-screenshots.js b/web/scripts/generate-screenshots.js index 57fe251..ab7b8d9 100644 --- a/web/scripts/generate-screenshots.js +++ b/web/scripts/generate-screenshots.js @@ -9,17 +9,22 @@ * It replaces the hand-driven half of `go run ./tools/shots`, which needs a * running instance and a session cookie pasted in by hand — and which therefore * ran rarely enough that the images fell many rounds behind the app they show. - * This one starts the real binary, creates its own account, fills it with the - * demo data, and takes the pictures. Nothing to set up and nothing to remember. + * This one starts the real binary, creates its own two accounts, fills them with + * the demo data, and takes the pictures. Nothing to set up and nothing to remember. * * The binary, not the dev server: the screenshots should show what ships. That * means building the frontend and copying it into cmd/verdande/webbuild first, * exactly as the smoke tests do — a capture of a stale embedded build would be a * picture of an app nobody is running. + * + * English, because the people who read the landing page and the docs read English. + * Not by translating anything here: the account is created with an English locale + * and the app is then in English by itself, which also means a string nobody + * translated shows up in a picture instead of hiding until a user finds it. */ import { chromium } from '@playwright/test'; import { spawn, execSync } from 'node:child_process'; -import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; @@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data'); const PORT = 8098; const baseURL = `http://127.0.0.1:${PORT}`; const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' }; +// Somebody to share with. Half of what the app is for only exists once there are +// two people in it: an assignee on a task, a name in the history, a face in the +// members list, and "Waiting on others" as something other than an empty page. +const COLLEAGUE = { name: 'Mette', email: 'mette@example.dk', password: 'et andet langt kodeord' }; // 1400×900 is what the landing page's declares. A capture at another size // would be resampled by the browser and look soft next to the text beside it. const WIDTH = 1400; const HEIGHT = 900; -const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')]; +// The docs and the README read the PNGs; the landing page reads the WebP, which is +// a fifth of the weight over the wire. Both are written from the same capture, so +// the two sets can never drift apart. +const PNG_DIR = join(repo, 'docs', 'screenshots'); +const WEBP_DIR = join(repo, 'site', 'screenshots'); /** * The pictures, and why each one is here. @@ -54,31 +67,64 @@ const SHOTS = [ // The same project as a list, which is where sections read as bands rather than // as columns — and the shape most people actually work in. { name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList }, - { name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask }, + // By address rather than by clicking the first row: the drawer has to be over + // the one task that was given a description, sub-tasks, a file, a reminder and a + // conversation, or the picture shows an empty form and proves nothing. + { name: 'detail', path: (ids) => `/opgave/${ids.detail}`, theme: 'dark' }, + { name: 'notes', path: '/noter', theme: 'dark', prepare: openMeetingNote }, + { name: 'notes-source', path: '/noter', theme: 'dark', prepare: showNoteSource }, + { name: 'delegated', path: '/uddelegeret', theme: 'dark' }, { name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' }, - { name: 'themes', path: '/indstillinger', theme: 'dark' }, + // Scrolled, because the themes are the second panel on the page and a picture + // captioned "five themes" that shows the profile form is a caption about + // nothing. + { name: 'themes', path: '/indstillinger', theme: 'dark', prepare: showThemes }, { name: 'admin', path: '/indstillinger/brugere', theme: 'dark' }, - { name: 'history', path: '/indstillinger/historik', theme: 'dark' }, - { name: 'delegated', path: '/uddelegeret', theme: 'dark' } + { name: 'history', path: '/indstillinger/historik', theme: 'dark' } ]; +const NOTE_TITLE = 'Launch meeting — agenda'; + async function showList(page) { - await page.getByRole('button', { name: 'Liste', exact: true }).click(); + await page.getByRole('button', { name: 'List', exact: true }).click(); await page.waitForTimeout(600); } async function showMonth(page) { - await page.getByRole('button', { name: 'Måned', exact: true }).click(); + await page.getByRole('button', { name: 'Month', exact: true }).click(); await page.waitForTimeout(800); } async function showWeek(page) { - await page.getByRole('button', { name: 'Uge', exact: true }).click(); + await page.getByRole('button', { name: 'Week', exact: true }).click(); await page.waitForTimeout(800); } -async function openFirstTask(page) { - await page.locator('.row').first().click(); +async function openMeetingNote(page) { + // By text inside the row rather than by its accessible name: a row's name is its + // title, its date, the first line of its body and the project it is filed in, + // all run together. + await page.locator('button.row', { hasText: NOTE_TITLE }).first().click(); + await page.waitForTimeout(800); +} + +async function showNoteSource(page) { + await openMeetingNote(page); + await page.getByRole('button', { name: 'Show as code' }).click(); + await page.waitForTimeout(600); +} + +async function showThemes(page) { + // scrollIntoView rather than Playwright's scrollIntoViewIfNeeded: the heading is + // already a few pixels inside the viewport when the page loads, so "if needed" + // decides nothing is needed and the picture is of the profile form above it. + // + // `.first()`, because the looks below the themes carry the same heading — one + // panel, two questions, and both of them called Appearance. + await page + .getByRole('heading', { name: 'Appearance' }) + .first() + .evaluate((el) => el.closest('section').scrollIntoView({ block: 'start' })); await page.waitForTimeout(600); } @@ -114,29 +160,42 @@ let browser; try { await waitForHealth(); - browser = await chromium.launch(); - const page = await browser.newPage({ - viewport: { width: WIDTH, height: HEIGHT }, - // The same pinning the smoke tests use, and for the same reason: the sign-up - // form sends navigator.language as the account's locale, and every due date - // is resolved in the account's zone. - locale: 'da-DK', - timezoneId: 'Europe/Copenhagen', - deviceScaleFactor: 2 - }); + // The browser's own language, which is not the same setting as the context's. + // `` is drawn by Chromium, not by the app, and it takes its + // order from this one — so without it the drawer shows an American date inside + // a day-month app, which is the exact thing i18n.svelte.js warns about. + browser = await chromium.launch({ args: ['--lang=en-GB'] }); + const context = await browser.newContext(viewing()); + const page = await context.newPage(); await page.goto(baseURL); + // The sign-up form is the one screen that is always Danish: it is drawn before + // there is an account to read a language off, and Danish is the fallback. So + // these labels are Danish on purpose, and the app is English from the moment + // the account exists — which is every screen this script photographs. await page.getByLabel(/Navn/).fill(USER.name); await page.getByLabel(/E-mail/).fill(USER.email); await page.getByLabel(/Adgangskode/).fill(USER.password); await page.getByRole('button', { name: 'Opret konto' }).click(); - await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor(); + await page.getByRole('navigation', { name: 'Main menu' }).waitFor(); console.log('seeding…'); - const ids = await page.evaluate(seed); + const ids = await page.evaluate(seed, { colleague: COLLEAGUE.email, noteTitle: NOTE_TITLE }); - mkdirSync(OUT[0], { recursive: true }); - mkdirSync(OUT[1], { recursive: true }); + console.log('adding the second person…'); + const ids2 = await addColleague(browser, ids); + Object.assign(ids, ids2); + await page.reload(); + await page.getByRole('navigation', { name: 'Main menu' }).waitFor(); + + mkdirSync(PNG_DIR, { recursive: true }); + mkdirSync(WEBP_DIR, { recursive: true }); + + // A page of its own for the WebP encoding, on about:blank: the app's pages send + // a content security policy, and an encoder that borrows whichever page was last + // photographed would be one policy change away from silently producing nothing. + const encoder = await context.newPage(); + await encoder.goto('about:blank'); for (const shot of SHOTS) { const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path; @@ -151,9 +210,9 @@ try { await page.waitForTimeout(400); } const png = await page.screenshot(); - writeFileSync(join(OUT[0], `${shot.name}.png`), png); - copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`)); - console.log(` ${shot.name}.png`); + writeFileSync(join(PNG_DIR, `${shot.name}.png`), png); + writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png)); + console.log(` ${shot.name}`); } } finally { await browser?.close(); @@ -166,6 +225,25 @@ try { rmSync(dataDir, { recursive: true, force: true }); } +/** The browser settings every context here shares. */ +function viewing() { + return { + viewport: { width: WIDTH, height: HEIGHT }, + // The locale is not a formatting preference: the sign-up form sends + // navigator.language as the account's language, so this is what makes the + // interface, the Inbox's name and every date English. The zone is pinned for + // the same reason the smoke tests pin it — every due date is resolved in it. + locale: 'en-GB', + timezoneId: 'Europe/Copenhagen', + // Dark, because that is what the shots are forced to below. app.html reads + // prefers-color-scheme before first paint, and Playwright's default is light + // — which left the settings page rendering dark with the *light* theme + // ringed as the chosen one. + colorScheme: 'dark', + deviceScaleFactor: 2 + }; +} + async function waitForHealth() { for (let i = 0; i < 240; i++) { try { @@ -179,6 +257,92 @@ async function waitForHealth() { throw new Error('the server never came up'); } +/** + * Encodes a PNG as WebP, in the browser that is already open. + * + * Chromium encodes WebP itself, so the alternative — shelling out to `cwebp` — + * would be something to install first, in a script whose whole point is that there + * is nothing to install. Resized on the way through: the landing page shows these + * 1400 CSS pixels wide, and a full-resolution WebP is twice the bytes for detail + * no screen renders. + */ +async function toWebP(encoder, png) { + const base64 = await encoder.evaluate( + async ({ data, width, quality }) => { + const image = new Image(); + image.src = `data:image/png;base64,${data}`; + await image.decode(); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = Math.round((image.naturalHeight / image.naturalWidth) * width); + const ctx = canvas.getContext('2d'); + // The default resampler leaves the interface's hairlines ragged, which on a + // screenshot of a screenshot is the first thing the eye finds. + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/webp', quality).split(',')[1]; + }, + // 0.75 is cwebp's default and where these stop getting smaller for anything + // the eye finds: 0.85 is a fifth more bytes for a difference that survives + // only under magnification, and the landing page's first image is the one + // somebody waits for. + { data: png.toString('base64'), width: 1800, quality: 0.75 } + ); + return Buffer.from(base64, 'base64'); +} + +/** + * Signs the second person up through the invite the seed left behind, has them say + * something, and hands their work back to the first. + * + * A context of its own because a session is a cookie: signing up in the page the + * screenshots are taken from would replace the account they are taken as. + */ +async function addColleague(browser, ids) { + const context = await browser.newContext(viewing()); + const page = await context.newPage(); + try { + await page.goto(ids.invite); + // Danish labels again, and for the same reason: nobody is signed in yet. + await page.getByLabel(/Navn/).fill(COLLEAGUE.name); + await page.getByLabel(/Adgangskode/).fill(COLLEAGUE.password); + await page.getByRole('button', { name: 'Opret konto' }).click(); + await page.getByRole('navigation', { name: 'Main menu' }).waitFor(); + return await page.evaluate(handOver, ids); + } finally { + await context.close(); + } +} + +/** + * What the second person does, from inside their own session: answer the task they + * were opened on, and take the three they are being waited for. + * + * The assignments are made here rather than by the owner because an editor may + * assign, and doing it from this side proves it. Written as one function because it + * is passed to the browser as source. + */ +async function handOver(ids) { + const api = async (path, body, method = 'POST') => { + const r = await fetch(`/api/v1${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined + }); + if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`); + return r.status === 204 ? null : r.json(); + }; + + const me = await api('/auth/me', null, 'GET'); + for (const id of ids.delegate) await api(`/tasks/${id}`, { assignee_id: me.id }, 'PATCH'); + + await api(`/tasks/${ids.detail}/comments`, { + body: 'Room is booked for ten. Summer numbers are in the shared folder — the labels are still with the printer, I chase them Thursday.' + }); + return { colleague: me.id }; +} + /** * The demo data, created through the app's own API from inside the page. * @@ -186,11 +350,11 @@ async function waitForHealth() { * header the API insists on come along without being reconstructed here. Written * as one function because it is passed to the browser as source. * - * The content is ordinary Danish household and work tasks. Screenshots of "Task - * 1, Task 2, Task 3" show the layout and hide whether the app is any good at the - * thing it is for. + * Ordinary English household and work tasks, the same shape the Danish set had. + * Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app + * is any good at the thing it is for. */ -async function seed() { +async function seed({ colleague, noteTitle }) { const api = async (path, body, method = 'POST') => { const r = await fetch(`/api/v1${path}`, { method, @@ -210,63 +374,199 @@ async function seed() { }; // Two headings over the projects, which is what the sidebar grew for. - const arbejde = await api('/project-groups', { name: 'Arbejde' }); - const privat = await api('/project-groups', { name: 'Privat' }); - await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH'); - await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH'); + const work = await api('/project-groups', { name: 'Work' }); + const home = await api('/project-groups', { name: 'Home' }); + await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH'); + await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH'); - const work = await api('/projects', { - name: 'Sæsonstart', + const launch = await api('/projects', { + name: 'Autumn launch', color: 'indigo', view_mode: 'board', - group_id: arbejde.id + group_id: work.id }); - const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id }); - const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id }); + const house = await api('/projects', { name: 'House', color: 'amber', group_id: home.id }); + const garden = await api('/projects', { name: 'Garden', color: 'green', group_id: home.id }); - const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' }); - const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' }); - const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' }); + const planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' }); + const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' }); + const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' }); const tasks = [ + // One thing that slipped. Today with an overdue band on it is what a Thursday + // actually looks like, and it is the only way to photograph the band at all. + { + content: 'Chase the printer about the labels', + due_date: day(-2), + priority: 2, + project_id: launch.id, + section_id: inFlight.id, + labels: ['waiting'] + }, + // Today, in the Inbox and across the projects: the Today view is the first // picture and it has to look like a real day. - { content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id }, - { content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] }, + { content: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id }, { - content: 'Skriv oplæg til sæsonmødet', + content: 'Write the opening notes for the launch meeting', due_date: day(0), priority: 2, - project_id: work.id, + project_id: launch.id, section_id: planning.id, - duration_min: 90 + duration_min: 90, + labels: ['meeting'] }, - { content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id }, - { content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] }, + { content: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] }, + { content: 'Water the greenhouse', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id }, + { content: 'Collect the parcel from the post office', due_date: day(0), labels: ['errands'] }, - // The next couple of weeks, so Kommende and the month grid have something in + // The next couple of weeks, so Upcoming and the month grid have something in // them rather than a row of empty cells. - { content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id }, - { content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id }, - { content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id }, - { content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id }, - { content: 'Skift dæk', due_date: day(8), priority: 3 }, - { content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id }, - { content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 }, - { content: 'Så vinterurter', due_date: day(15), project_id: garden.id }, - { content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id }, - { content: 'Rens tagrenderne', due_date: day(26), project_id: house.id }, + { content: 'Book a plumber for the bathroom', due_date: day(1), priority: 2, project_id: house.id }, + { content: 'Cut the beech hedge back', due_date: day(2), project_id: garden.id }, + { content: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id }, + { content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id }, + { content: 'Swap the winter tyres on', due_date: day(8), priority: 3 }, + { content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id }, + { content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 }, + { content: 'Sow winter herbs in the cold frame', due_date: day(15), project_id: garden.id }, + { content: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.id }, + { content: 'Clear the gutters before the autumn rain', due_date: day(26), project_id: house.id }, // A few without dates, so the board has cards that are not all deadlines. - { content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 }, - { content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id }, - { content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] } + { content: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 }, + { content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id }, + { content: 'Update the price list', project_id: launch.id, section_id: review.id, labels: ['review'] } ]; - for (const task of tasks) await api('/tasks', task); + const made = {}; + for (const task of tasks) made[task.content] = await api('/tasks', task); // A saved filter, because the sidebar shows them and an empty heading reads as // a feature that does not work. - await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' }); + await api('/filters', { name: 'Important this week', query: 'p1 & 7 days' }); - return { work: work.id, house: house.id, garden: garden.id }; + // The one task that is photographed open. Everything the drawer can hold, so the + // picture answers "what is in there" rather than showing an empty form. + const detail = made['Write the opening notes for the launch meeting']; + await api( + `/tasks/${detail.id}`, + { + description: + 'Fifteen minutes, no slides. What shipped over the summer, what is left, and what we are deliberately not doing before the first.' + }, + 'PATCH' + ); + for (const content of [ + 'Pull the numbers from last season', + 'Ask Mette for the packaging quotes', + 'Book the room' + ]) { + await api('/tasks', { content, parent_id: detail.id, project_id: launch.id }); + } + await api(`/tasks/${detail.id}/reminders`, { offset_min: 60 }); + await api(`/tasks/${detail.id}/comments`, { + body: 'Keeping it to three points. Anything else goes on the list for the week after.' + }); + + // A file on the task, because "comments, attachments" is a claim and an empty + // Files heading is the opposite of proof. + const form = new FormData(); + form.append('file', new Blob([new Uint8Array(9400)], { type: 'application/pdf' }), 'packaging-quote.pdf'); + await fetch(`/api/v1/tasks/${detail.id}/attachments`, { method: 'POST', body: form }); + + // The notes, newest last: the list opens on what was touched most recently, so + // the one the screenshots open is the one written last. + const notes = [ + { + body: `# Reading list + +- *Shape Up* — the six-week bit, not the pitch bit +- The SQLite WAL paper, again +- Anything at all about pricing` + }, + { + project_id: garden.id, + body: `# The bulb order + +Two hundred, split three ways, in before the first frost: + +1. Narcissus along the south wall +2. Alliums in the bed by the shed +3. Crocus wherever there is room + +The bed by the shed is heavy — grit in the hole, or they rot. #Garden` + }, + { + body: `# What the roofer said + +Ridge tiles have moved, maybe a metre of them. Not urgent, not something to leave +over a winter either. + +- Quote by the end of the month +- Two days' work, scaffold on the north side +- Ask whether the gutters can be done at the same time + +#House` + }, + { + project_id: launch.id, + body: `# Price list — what changes + +Nothing has been looked at since March. + +- 250 g bag: **79 → 85** +- 1 kg bag: **265 → 279** +- Subscription: **199 → 209** + +Subscribers keep the old price for three months. That is the whole of the goodwill +budget and it is worth it.` + }, + { + pinned: true, + project_id: launch.id, + body: `# ${noteTitle} + +Thursday, fifteen minutes, no slides. + +## What shipped over the summer + +- The new packaging, minus the labels +- A CalDAV feed nobody asked for and everybody uses + +## What is left + +- Labels. Still with the printer, chased on Thursday +- The price list: [[Price list — what changes]] + +> If it is not on the list by Friday it is not in the launch. + +The build we cut the release from: + +\`\`\`bash +cd web && npm run build && cd .. +go build -tags embedweb -o verdande ./cmd/verdande +\`\`\` + +Room is booked. Coffee is **not** my job this week. + +The opening notes are a task, so they can be ticked off: task:${detail.id}` + } + ]; + for (const note of notes) await api('/notes', note); + + // Shared with somebody, and an invite left standing to the instance itself: two + // of the three states the members page has, and the one it never showed before. + const invite = await api(`/projects/${launch.id}/invites`, { email: colleague, role: 'editor' }); + await api('/users', { email: 'anders@example.dk' }); + + return { + work: launch.id, + detail: detail.id, + invite: invite.link, + delegate: [ + made['Send the minutes round'].id, + made['Update the price list'].id, + made['Find a new packaging supplier'].id + ] + }; }