merge: engelske skærmbilleder, og otte steder hvor fladen blev ved med at være dansk
Release / Build and publish image (push) Failing after 9m54s
Skærmbillederne er engelske, fjorten i alt, og der er to mennesker i dem. Det sidste er ikke pynt: halvdelen af det, programmet er til, findes ikke med én konto — en ansvarlig, et navn i historikken, en medlemsliste, og "Venter på andre" som andet end en tom side. Sproget kommer fra kontoens locale og ikke fra strenge skrevet i hånden, hvilket er dét, der fandt fejlene: otte steder, hvor grænsefladen blev ved med at være dansk, uanset hvad kontoen sagde. "Forsinket" og "I dag" skrevet ind i markup, "Uden sektion" som boardets første kolonne, gentagelsesreglerne halvt oversat, alle nitten navne på hændelser i historikken, og et `da-DK` hårdkodet i Kommende. Gentagelsesreglerne er flyttet til `when.js`, så menuen og mærket ikke kan blive uenige igen — det er præcis, hvad den fil er til. README's billeder havde været brudte siden `37605ba`, som lavede PNG'erne om til WebP og slettede dem, uden at teksten fulgte med: forsidebilledet og alle seks i tabellen var 404 på GitHub. Og noterne — hele funktionen — stod ikke nævnt hverken i README, på forsiden eller i dokumentationen. Nu skrives PNG til docs og WebP til site, altså præcis dét, de to steder læser. Før efterlod hver kørsel tolv usporede PNG'er i site, som intet pegede på. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -6,7 +6,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="site/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
<img src="docs/screenshots/today.png" alt="The Today view: projects, filters and labels in the sidebar, beside the tasks due today." width="900">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -35,6 +35,11 @@ mail-to-task address, and an MCP endpoint so Claude can work your task list dire
|
|||||||
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
in Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||||
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
- **Projects, sections, sub-tasks, labels, saved filters.** List, board and calendar
|
||||||
views, and foldable groups over the projects in the sidebar.
|
views, and foldable groups over the projects in the sidebar.
|
||||||
|
- **Notes, beside the work.** Rich text that is Markdown underneath — headings,
|
||||||
|
lists, quotes, fenced code. A note is shared by filing it in a project;
|
||||||
|
`#project` and `[[another note]]` are links, and a task shows the notes that
|
||||||
|
mention it. A zip of Markdown goes in — Obsidian, Bear, or Apple Notes through
|
||||||
|
the exporter in [`tools/`](tools/) — and comes back out the same shape.
|
||||||
- **Drag where it means something.** Reorder a list or a board, file a project under
|
- **Drag where it means something.** Reorder a list or a board, file a project under
|
||||||
a group, drop a task on another project, or drop it on another day.
|
a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
- **Sharing.** Owner, editor and viewer roles. There is no open registration:
|
||||||
@@ -90,6 +95,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
|||||||
| Project templates | Done, tested |
|
| Project templates | Done, tested |
|
||||||
| Nightly backups with rotation | Done, tested |
|
| Nightly backups with rotation | Done, tested |
|
||||||
| Comments, attachments, notifications, Web Push | Built |
|
| Comments, attachments, notifications, Web Push | Built |
|
||||||
|
| Notes: rich text, Markdown on disk, links to projects, notes and tasks | Done, tested |
|
||||||
|
| Notes in and out: a zip of Markdown, both directions | Done, tested |
|
||||||
| Todoist CSV import and export, account export | Done, tested |
|
| Todoist CSV import and export, account export | Done, tested |
|
||||||
| MCP server for Claude | Done, tested |
|
| MCP server for Claude | Done, tested |
|
||||||
| CalDAV server (two-way VTODO) | Done, tested |
|
| CalDAV server (two-way VTODO) | Done, tested |
|
||||||
@@ -116,17 +123,20 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
| Board view — one column per section. | The light theme. |
|
| Board view — one column per section. | The light theme. |
|
||||||
|  |  |
|
|  |  |
|
||||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
|
| A week at a time, so a task can be dragged across a month boundary. | A task, opened: description, priority, labels, sub-tasks and its files. |
|
||||||
|  |  |
|
|  |  |
|
||||||
| Five themes, all measured for contrast. | What has been done on the server. |
|
| Notes, filed in a project to share them. | The same note as its source — which is what the export writes to disk. |
|
||||||
|
|  |  |
|
||||||
|
| Five themes and four looks: how bright, and how it reads. | What has been done on the server. |
|
||||||
|
|
||||||
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
|
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
|
||||||
the real binary, creates its own account, fills it with demo data and photographs
|
the real binary, creates its own account and a second one to share with, fills them
|
||||||
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
|
with demo data and photographs fourteen surfaces — nothing to set up and nothing to
|
||||||
still works, and is the way to photograph an instance that is already running.)
|
remember. (`go run ./tools/shots` still works, and is the way to photograph an
|
||||||
|
instance that is already running.)
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
Firma, labelled regnskab. Danish and English, mixed freely in one line.
|
||||||
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
- **Projects, sections, sub-tasks, labels, [saved filters](filters.md).** List,
|
||||||
board and calendar views, and foldable groups over the projects in the sidebar.
|
board and calendar views, and foldable groups over the projects in the sidebar.
|
||||||
|
- **Notes, beside the work.** Rich text to write in, Markdown on disk. A note is
|
||||||
|
shared by filing it in a project; `#project` and `[[another note]]` are links,
|
||||||
|
and a task shows what has been written about it.
|
||||||
- **Drag where it means something** — reorder a list or a board, file a project
|
- **Drag where it means something** — reorder a list or a board, file a project
|
||||||
under a group, drop a task on another project, or drop it on another day.
|
under a group, drop a task on another project, or drop it on another day.
|
||||||
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
- **Sharing** with owner, editor and viewer roles. Anyone outside your instance
|
||||||
@@ -31,6 +34,33 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
|
|||||||
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
- **[Calendar](caldav.md), [mail](mail.md) and [Claude](mcp.md)** connected over
|
||||||
open standards rather than private formats.
|
open standards rather than private formats.
|
||||||
|
|
||||||
|
## What it looks like
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Upcoming, as a month. A week and a plain list are the other two, and a task can be
|
||||||
|
dragged from one day onto another.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The same project as a list, where sections read as bands. As a board they are
|
||||||
|
columns.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*What you have handed to somebody else, grouped by whoever has it.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Where [Gmail](mailboxes.md), the [calendar feed](caldav.md) and the
|
||||||
|
[mail-to-task address](mail.md) are connected.*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Inviting somebody to the instance. There is no open registration — everybody
|
||||||
|
arrives through a link, and picks their own password. See
|
||||||
|
[signing in](signing-in.md).*
|
||||||
|
|
||||||
## Getting it running
|
## Getting it running
|
||||||
|
|
||||||
verdande is built to be deployed as a **Rune** in
|
verdande is built to be deployed as a **Rune** in
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 180 KiB |
@@ -621,13 +621,13 @@
|
|||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/today-light.webp"
|
src="screenshots/notes.webp"
|
||||||
alt="The same Today view in the light theme."
|
alt="Notes: a list of notes on the left, one open on the right with headings, bullet lists, a quotation and a shell command in a code block."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>The light theme.</figcaption>
|
<figcaption>Notes. Rich text on top, Markdown underneath.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -642,7 +642,7 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/detail.webp"
|
src="screenshots/detail.webp"
|
||||||
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
|
alt="A task open in the detail drawer, with its description, priority, due date, labels, sub-tasks and an attached file."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
@@ -652,12 +652,12 @@
|
|||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
src="screenshots/themes.webp"
|
src="screenshots/themes.webp"
|
||||||
alt="The settings page showing the five themes as swatches."
|
alt="The settings page showing five themes as swatches and, below them, four looks written in the typeface each one selects."
|
||||||
width="1400"
|
width="1400"
|
||||||
height="900"
|
height="900"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<figcaption>Five themes, all of them measured for contrast.</figcaption>
|
<figcaption>Five themes and four looks: how bright, and how it reads.</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<figure class="shot">
|
<figure class="shot">
|
||||||
<img
|
<img
|
||||||
@@ -689,6 +689,16 @@
|
|||||||
invite link. Changes appear live.
|
invite link. Changes appear live.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cell">
|
||||||
|
<h3>Notes, beside the work</h3>
|
||||||
|
<p>
|
||||||
|
Rich text to write in, Markdown on disk. File a note in a project and
|
||||||
|
everybody in it can read it; <code>#project</code> and
|
||||||
|
<code>[[another note]]</code> are links, and a task shows what has been
|
||||||
|
written about it. A zip of Markdown goes in and comes back out the same
|
||||||
|
shape, so nothing is trapped here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="cell">
|
<div class="cell">
|
||||||
<h3>A real CalDAV server</h3>
|
<h3>A real CalDAV server</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -5,7 +5,7 @@
|
|||||||
// cookie. Kept in the repository so the images can be regenerated when the
|
// cookie. Kept in the repository so the images can be regenerated when the
|
||||||
// interface changes, rather than being a thing somebody did once by hand.
|
// interface changes, rather than being a thing somebody did once by hand.
|
||||||
//
|
//
|
||||||
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out site/screenshots
|
// go run ./tools/shots -url http://localhost:8096 -cookie <session> -out docs/screenshots
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -28,7 +28,9 @@ var chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|||||||
func main() {
|
func main() {
|
||||||
base := flag.String("url", "http://localhost:8096", "the running instance")
|
base := flag.String("url", "http://localhost:8096", "the running instance")
|
||||||
cookie := flag.String("cookie", "", "session cookie value")
|
cookie := flag.String("cookie", "", "session cookie value")
|
||||||
out := flag.String("out", "site/screenshots", "where to write the PNGs")
|
// docs/, not site/: site/screenshots holds the WebP the landing page reads, and
|
||||||
|
// PNGs dropped in beside them are files nothing points at.
|
||||||
|
out := flag.String("out", "docs/screenshots", "where to write the PNGs")
|
||||||
width := flag.Int("width", 1400, "viewport width")
|
width := flag.Int("width", 1400, "viewport width")
|
||||||
height := flag.Int("height", 900, "viewport height")
|
height := flag.Int("height", 900, "viewport height")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|||||||
@@ -9,17 +9,22 @@
|
|||||||
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a
|
* 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
|
* 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.
|
* 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
|
* This one starts the real binary, creates its own two accounts, fills them with
|
||||||
* demo data, and takes the pictures. Nothing to set up and nothing to remember.
|
* 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
|
* 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,
|
* 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
|
* exactly as the smoke tests do — a capture of a stale embedded build would be a
|
||||||
* picture of an app nobody is running.
|
* 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 { chromium } from '@playwright/test';
|
||||||
import { spawn, execSync } from 'node:child_process';
|
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 { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
@@ -31,13 +36,21 @@ const dataDir = join(web, '.screenshot-data');
|
|||||||
const PORT = 8098;
|
const PORT = 8098;
|
||||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||||
const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' };
|
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 <img> declares. A capture at another size
|
// 1400×900 is what the landing page's <img> declares. A capture at another size
|
||||||
// would be resampled by the browser and look soft next to the text beside it.
|
// would be resampled by the browser and look soft next to the text beside it.
|
||||||
const WIDTH = 1400;
|
const WIDTH = 1400;
|
||||||
const HEIGHT = 900;
|
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.
|
* 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
|
// 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.
|
// as columns — and the shape most people actually work in.
|
||||||
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
|
{ 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: '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: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
|
||||||
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' },
|
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' }
|
||||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const NOTE_TITLE = 'Launch meeting — agenda';
|
||||||
|
|
||||||
async function showList(page) {
|
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);
|
await page.waitForTimeout(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showMonth(page) {
|
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);
|
await page.waitForTimeout(800);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showWeek(page) {
|
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);
|
await page.waitForTimeout(800);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFirstTask(page) {
|
async function openMeetingNote(page) {
|
||||||
await page.locator('.row').first().click();
|
// 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);
|
await page.waitForTimeout(600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,29 +160,42 @@ let browser;
|
|||||||
try {
|
try {
|
||||||
await waitForHealth();
|
await waitForHealth();
|
||||||
|
|
||||||
browser = await chromium.launch();
|
// The browser's own language, which is not the same setting as the context's.
|
||||||
const page = await browser.newPage({
|
// `<input type="date">` is drawn by Chromium, not by the app, and it takes its
|
||||||
viewport: { width: WIDTH, height: HEIGHT },
|
// order from this one — so without it the drawer shows an American date inside
|
||||||
// The same pinning the smoke tests use, and for the same reason: the sign-up
|
// a day-month app, which is the exact thing i18n.svelte.js warns about.
|
||||||
// form sends navigator.language as the account's locale, and every due date
|
browser = await chromium.launch({ args: ['--lang=en-GB'] });
|
||||||
// is resolved in the account's zone.
|
const context = await browser.newContext(viewing());
|
||||||
locale: 'da-DK',
|
const page = await context.newPage();
|
||||||
timezoneId: 'Europe/Copenhagen',
|
|
||||||
deviceScaleFactor: 2
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto(baseURL);
|
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(/Navn/).fill(USER.name);
|
||||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||||
await page.getByRole('button', { name: 'Opret konto' }).click();
|
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…');
|
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 });
|
console.log('adding the second person…');
|
||||||
mkdirSync(OUT[1], { recursive: true });
|
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) {
|
for (const shot of SHOTS) {
|
||||||
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
|
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
|
||||||
@@ -151,9 +210,9 @@ try {
|
|||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
}
|
}
|
||||||
const png = await page.screenshot();
|
const png = await page.screenshot();
|
||||||
writeFileSync(join(OUT[0], `${shot.name}.png`), png);
|
writeFileSync(join(PNG_DIR, `${shot.name}.png`), png);
|
||||||
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
|
writeFileSync(join(WEBP_DIR, `${shot.name}.webp`), await toWebP(encoder, png));
|
||||||
console.log(` ${shot.name}.png`);
|
console.log(` ${shot.name}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await browser?.close();
|
await browser?.close();
|
||||||
@@ -166,6 +225,25 @@ try {
|
|||||||
rmSync(dataDir, { recursive: true, force: true });
|
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() {
|
async function waitForHealth() {
|
||||||
for (let i = 0; i < 240; i++) {
|
for (let i = 0; i < 240; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -179,6 +257,92 @@ async function waitForHealth() {
|
|||||||
throw new Error('the server never came up');
|
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.
|
* 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
|
* header the API insists on come along without being reconstructed here. Written
|
||||||
* as one function because it is passed to the browser as source.
|
* as one function because it is passed to the browser as source.
|
||||||
*
|
*
|
||||||
* The content is ordinary Danish household and work tasks. Screenshots of "Task
|
* Ordinary English household and work tasks, the same shape the Danish set had.
|
||||||
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
|
* Screenshots of "Task 1, Task 2, Task 3" show the layout and hide whether the app
|
||||||
* thing it is for.
|
* 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 api = async (path, body, method = 'POST') => {
|
||||||
const r = await fetch(`/api/v1${path}`, {
|
const r = await fetch(`/api/v1${path}`, {
|
||||||
method,
|
method,
|
||||||
@@ -210,63 +374,199 @@ async function seed() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Two headings over the projects, which is what the sidebar grew for.
|
// Two headings over the projects, which is what the sidebar grew for.
|
||||||
const arbejde = await api('/project-groups', { name: 'Arbejde' });
|
const work = await api('/project-groups', { name: 'Work' });
|
||||||
const privat = await api('/project-groups', { name: 'Privat' });
|
const home = await api('/project-groups', { name: 'Home' });
|
||||||
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
|
await api(`/project-groups/${work.id}`, { color: 'indigo' }, 'PATCH');
|
||||||
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
|
await api(`/project-groups/${home.id}`, { color: 'green' }, 'PATCH');
|
||||||
|
|
||||||
const work = await api('/projects', {
|
const launch = await api('/projects', {
|
||||||
name: 'Sæsonstart',
|
name: 'Autumn launch',
|
||||||
color: 'indigo',
|
color: 'indigo',
|
||||||
view_mode: 'board',
|
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 house = await api('/projects', { name: 'House', color: 'amber', group_id: home.id });
|
||||||
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.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 planning = await api(`/projects/${launch.id}/sections`, { name: 'Planning' });
|
||||||
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
|
const inFlight = await api(`/projects/${launch.id}/sections`, { name: 'In progress' });
|
||||||
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
|
const review = await api(`/projects/${launch.id}/sections`, { name: 'Up for review' });
|
||||||
|
|
||||||
const tasks = [
|
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
|
// Today, in the Inbox and across the projects: the Today view is the first
|
||||||
// picture and it has to look like a real day.
|
// 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: 'Call the roofer about the ridge tiles', due_date: day(0), priority: 1, project_id: house.id },
|
||||||
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
|
|
||||||
{
|
{
|
||||||
content: 'Skriv oplæg til sæsonmødet',
|
content: 'Write the opening notes for the launch meeting',
|
||||||
due_date: day(0),
|
due_date: day(0),
|
||||||
priority: 2,
|
priority: 2,
|
||||||
project_id: work.id,
|
project_id: launch.id,
|
||||||
section_id: planning.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: 'Drop the bike off for its service', due_date: day(0), priority: 3, labels: ['errands'] },
|
||||||
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
|
{ 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.
|
// 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: 'Book a plumber for the bathroom', 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: 'Cut the beech hedge back', 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: 'Go through the budget', due_date: day(3), priority: 1, project_id: launch.id, section_id: inFlight.id },
|
||||||
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
|
{ content: 'Order bulbs for the autumn planting', due_date: day(5), project_id: garden.id },
|
||||||
{ content: 'Skift dæk', due_date: day(8), priority: 3 },
|
{ content: 'Swap the winter tyres on', due_date: day(8), priority: 3 },
|
||||||
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
|
{ content: 'Send the minutes round', due_date: day(9), project_id: launch.id, section_id: review.id },
|
||||||
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
|
{ content: 'Paint the window sills', due_date: day(12), project_id: house.id, priority: 2 },
|
||||||
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
|
{ content: 'Sow winter herbs in the cold frame', 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: 'Quarterly review', due_date: day(21), priority: 1, project_id: launch.id, section_id: planning.id },
|
||||||
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.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.
|
// 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: 'Find a new packaging supplier', project_id: launch.id, section_id: planning.id, priority: 3 },
|
||||||
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
|
{ content: 'Write the job advert', project_id: launch.id, section_id: inFlight.id },
|
||||||
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
|
{ 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 saved filter, because the sidebar shows them and an empty heading reads as
|
||||||
// a feature that does not work.
|
// 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
|
||||||
|
]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
// The unsectioned column comes first, as it does in the list view: it is where
|
// The unsectioned column comes first, as it does in the list view: it is where
|
||||||
// quick add puts things, so it is where new work appears.
|
// quick add puts things, so it is where new work appears.
|
||||||
let columns = $derived([
|
let columns = $derived([
|
||||||
{ id: '', name: 'Uden sektion' },
|
{ id: '', name: t('project.noSection') },
|
||||||
...sections.map((s) => ({ id: s.id, name: s.name }))
|
...sections.map((s) => ({ id: s.id, name: s.name }))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { app, projectName } from '$lib/stores.svelte.js';
|
import { app, projectName } from '$lib/stores.svelte.js';
|
||||||
import { focusOnMount } from '$lib/focus.js';
|
import { focusOnMount } from '$lib/focus.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { REPEATS } from '$lib/when.js';
|
||||||
|
|
||||||
let { task, onclose } = $props();
|
let { task, onclose } = $props();
|
||||||
|
|
||||||
@@ -29,17 +30,6 @@
|
|||||||
/** Who the task can be given to. Only ever more than one on a shared project. */
|
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||||
let members = $state([]);
|
let members = $state([]);
|
||||||
|
|
||||||
// The rules worth a menu entry. Anything more specific is typed into the
|
|
||||||
// title, where the parser understands far more than a list ever could.
|
|
||||||
const REPEATS = [
|
|
||||||
{ rule: '', label: 'Aldrig' },
|
|
||||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
|
||||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
|
||||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
|
||||||
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
|
|
||||||
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
|
|
||||||
];
|
|
||||||
|
|
||||||
let subtasks = $state([]);
|
let subtasks = $state([]);
|
||||||
let comments = $state([]);
|
let comments = $state([]);
|
||||||
let attachments = $state([]);
|
let attachments = $state([]);
|
||||||
@@ -452,13 +442,13 @@
|
|||||||
<label for="repeat">{t('detail.repeats')}</label>
|
<label for="repeat">{t('detail.repeats')}</label>
|
||||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||||
{#each REPEATS as option (option.rule)}
|
{#each REPEATS as option (option.rule)}
|
||||||
<option value={option.rule}>{option.label}</option>
|
<option value={option.rule}>{t(option.label)}</option>
|
||||||
{/each}
|
{/each}
|
||||||
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||||
showing it rather than snapping to the nearest preset is the
|
showing it rather than snapping to the nearest preset is the
|
||||||
difference between a menu and a menu that quietly edits. -->
|
difference between a menu and a menu that quietly edits. -->
|
||||||
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
||||||
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
|
<option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
*/
|
*/
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t, tag } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
|
import { repeatLabel } from '$lib/when.js';
|
||||||
|
|
||||||
let { task, onedit } = $props();
|
let { task, onedit } = $props();
|
||||||
|
|
||||||
|
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
|
||||||
|
|
||||||
let leaving = $state(false);
|
let leaving = $state(false);
|
||||||
|
|
||||||
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
|
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
<span class="description">{task.description}</span>
|
<span class="description">{task.description}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
|
{#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
{#if assignee}
|
{#if assignee}
|
||||||
<!-- First in the row, because "who" changes what the rest of the line
|
<!-- First in the row, because "who" changes what the rest of the line
|
||||||
@@ -102,15 +105,15 @@
|
|||||||
{due.text}{#if time} {time}{/if}
|
{due.text}{#if time} {time}{/if}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if task.recurrence_text}
|
{#if repeats}
|
||||||
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
|
<span class="repeat" title={t('task.repeats', { rule: repeats })}>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M17 2l4 4-4 4" />
|
<path d="M17 2l4 4-4 4" />
|
||||||
<path d="M3 11V9a4 4 0 014-4h14" />
|
<path d="M3 11V9a4 4 0 014-4h14" />
|
||||||
<path d="M7 22l-4-4 4-4" />
|
<path d="M7 22l-4-4 4-4" />
|
||||||
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
||||||
</svg>
|
</svg>
|
||||||
{task.recurrence_text}
|
{repeats}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- What is underneath, said as a fraction rather than a total: "3" tells
|
<!-- What is underneath, said as a fraction rather than a total: "3" tells
|
||||||
|
|||||||
@@ -1,40 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* What each recorded activity event is called.
|
* What each recorded activity event is called.
|
||||||
*
|
*
|
||||||
* The keys are the server's and they are stable; this is the only place they
|
* The keys are the server's and they are stable; this is the only place they get a
|
||||||
* become Danish. It lives in its own module because two surfaces read the same log
|
* name. It lives in its own module because two surfaces read the same log now — a
|
||||||
* now — a project's own history panel, and the administrator's instance-wide audit
|
* project's own history panel, and the administrator's instance-wide audit page —
|
||||||
* page — and two copies of nineteen strings is two places for them to drift apart.
|
* and two copies of twenty strings is two places for them to drift apart.
|
||||||
|
*
|
||||||
|
* The names themselves are in the dictionaries rather than here. They were written
|
||||||
|
* out in Danish, which made the whole of both history pages Danish for somebody
|
||||||
|
* reading the rest of the app in English.
|
||||||
*
|
*
|
||||||
* An unknown key falls through to the key itself rather than being hidden: a build
|
* An unknown key falls through to the key itself rather than being hidden: a build
|
||||||
* that records something this table has not learned yet should still show that it
|
* that records something this table has not learned yet should still show that it
|
||||||
* happened.
|
* happened.
|
||||||
*/
|
*/
|
||||||
|
import { t } from './i18n.svelte.js';
|
||||||
|
|
||||||
export const EVENTS = {
|
export const EVENTS = {
|
||||||
'project.created': 'oprettede projektet',
|
'project.created': 'event.projectCreated',
|
||||||
'project.updated': 'ændrede projektet',
|
'project.updated': 'event.projectUpdated',
|
||||||
'project.deleted': 'slettede projektet',
|
'project.deleted': 'event.projectDeleted',
|
||||||
'project.imported': 'importerede projektet',
|
'project.imported': 'event.projectImported',
|
||||||
'section.created': 'oprettede en sektion',
|
'section.created': 'event.sectionCreated',
|
||||||
'section.updated': 'ændrede en sektion',
|
'section.updated': 'event.sectionUpdated',
|
||||||
'section.deleted': 'slettede en sektion',
|
'section.deleted': 'event.sectionDeleted',
|
||||||
'member.invited': 'inviterede',
|
'member.invited': 'event.memberInvited',
|
||||||
'member.added': 'tilføjede',
|
'member.added': 'event.memberAdded',
|
||||||
'member.removed': 'fjernede',
|
'member.removed': 'event.memberRemoved',
|
||||||
'member.role_changed': 'ændrede rollen for',
|
'member.role_changed': 'event.memberRoleChanged',
|
||||||
'task.created': 'oprettede en opgave',
|
'task.created': 'event.taskCreated',
|
||||||
'task.updated': 'ændrede en opgave',
|
'task.updated': 'event.taskUpdated',
|
||||||
'task.completed': 'lukkede en opgave',
|
'task.completed': 'event.taskCompleted',
|
||||||
'task.reopened': 'genåbnede en opgave',
|
'task.reopened': 'event.taskReopened',
|
||||||
'task.moved': 'flyttede en opgave',
|
'task.moved': 'event.taskMoved',
|
||||||
'task.deleted': 'slettede en opgave',
|
'task.deleted': 'event.taskDeleted',
|
||||||
'task.split': 'delte en opgave op',
|
'task.split': 'event.taskSplit',
|
||||||
'comment.created': 'skrev en kommentar'
|
'comment.created': 'event.commentCreated',
|
||||||
|
'attachment.added': 'event.attachmentAdded'
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The name of an event, or the raw key when it is one this build does not know. */
|
/** The name of an event, or the raw key when it is one this build does not know. */
|
||||||
export function eventName(event) {
|
export function eventName(event) {
|
||||||
return EVENTS[event] ?? event;
|
const key = EVENTS[event];
|
||||||
|
return key ? t(key) : event;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The bit of context an entry carries, when it has one worth reading. */
|
/** The bit of context an entry carries, when it has one worth reading. */
|
||||||
|
|||||||
@@ -318,6 +318,9 @@ export const da = {
|
|||||||
'users.taskOne': 'opgave',
|
'users.taskOne': 'opgave',
|
||||||
'users.taskMany': 'opgaver',
|
'users.taskMany': 'opgaver',
|
||||||
'users.never': 'aldrig set',
|
'users.never': 'aldrig set',
|
||||||
|
'users.toProject': 'til {name}',
|
||||||
|
'users.toInstance': 'til instansen',
|
||||||
|
'users.invitedBy': 'inviteret af {name}',
|
||||||
|
|
||||||
// --- settings: history and errors -----------------------------------------------
|
// --- settings: history and errors -----------------------------------------------
|
||||||
'history.title': 'Historik',
|
'history.title': 'Historik',
|
||||||
@@ -333,6 +336,30 @@ export const da = {
|
|||||||
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
|
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
|
||||||
'history.nothing': 'Der er ikke registreret noget endnu.',
|
'history.nothing': 'Der er ikke registreret noget endnu.',
|
||||||
'history.deletedAccount': 'En slettet konto',
|
'history.deletedAccount': 'En slettet konto',
|
||||||
|
|
||||||
|
// Hvad hver hændelse i loggen hedder. Nøglerne er serverens og ligger i
|
||||||
|
// events.js; her står de ord, de bliver til.
|
||||||
|
'event.projectCreated': 'oprettede projektet',
|
||||||
|
'event.projectUpdated': 'ændrede projektet',
|
||||||
|
'event.projectDeleted': 'slettede projektet',
|
||||||
|
'event.projectImported': 'importerede projektet',
|
||||||
|
'event.sectionCreated': 'oprettede en sektion',
|
||||||
|
'event.sectionUpdated': 'ændrede en sektion',
|
||||||
|
'event.sectionDeleted': 'slettede en sektion',
|
||||||
|
'event.memberInvited': 'inviterede',
|
||||||
|
'event.memberAdded': 'tilføjede',
|
||||||
|
'event.memberRemoved': 'fjernede',
|
||||||
|
'event.memberRoleChanged': 'ændrede rollen for',
|
||||||
|
'event.taskCreated': 'oprettede en opgave',
|
||||||
|
'event.taskUpdated': 'ændrede en opgave',
|
||||||
|
'event.taskCompleted': 'lukkede en opgave',
|
||||||
|
'event.taskReopened': 'genåbnede en opgave',
|
||||||
|
'event.taskMoved': 'flyttede en opgave',
|
||||||
|
'event.taskDeleted': 'slettede en opgave',
|
||||||
|
'event.taskSplit': 'delte en opgave op',
|
||||||
|
'event.commentCreated': 'skrev en kommentar',
|
||||||
|
'event.attachmentAdded': 'vedhæftede en fil',
|
||||||
|
|
||||||
'errors.title': 'Serverfejl',
|
'errors.title': 'Serverfejl',
|
||||||
'errors.hint':
|
'errors.hint':
|
||||||
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
|
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
|
||||||
@@ -512,8 +539,13 @@ export const da = {
|
|||||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||||
'detail.project': 'Projekt',
|
'detail.project': 'Projekt',
|
||||||
'detail.repeats': 'Gentages',
|
'detail.repeats': 'Gentages',
|
||||||
|
'detail.never': 'Aldrig',
|
||||||
|
'detail.daily': 'Hver dag',
|
||||||
|
'detail.weekly': 'Hver uge',
|
||||||
|
'detail.fortnightly': 'Hver anden uge',
|
||||||
'detail.monthly': 'Hver måned',
|
'detail.monthly': 'Hver måned',
|
||||||
'detail.yearly': 'Hvert år',
|
'detail.yearly': 'Hvert år',
|
||||||
|
'detail.asWritten': 'Som skrevet',
|
||||||
'detail.assignee': 'Ansvarlig',
|
'detail.assignee': 'Ansvarlig',
|
||||||
'detail.subtasks': 'Undertasks',
|
'detail.subtasks': 'Undertasks',
|
||||||
'detail.addSubtask': 'Tilføj en undertask',
|
'detail.addSubtask': 'Tilføj en undertask',
|
||||||
@@ -703,6 +735,7 @@ export const da = {
|
|||||||
'push.upToDate': 'Ingen nyere version fundet.',
|
'push.upToDate': 'Ingen nyere version fundet.',
|
||||||
|
|
||||||
'view.nothingMoreToday': 'Ikke mere i dag.',
|
'view.nothingMoreToday': 'Ikke mere i dag.',
|
||||||
|
'view.overdue': 'Forsinket',
|
||||||
'project.roleFor': 'Rolle for {name}',
|
'project.roleFor': 'Rolle for {name}',
|
||||||
|
|
||||||
'project.more': 'Flere handlinger',
|
'project.more': 'Flere handlinger',
|
||||||
|
|||||||
@@ -313,6 +313,9 @@ export const en = {
|
|||||||
'users.taskOne': 'task',
|
'users.taskOne': 'task',
|
||||||
'users.taskMany': 'tasks',
|
'users.taskMany': 'tasks',
|
||||||
'users.never': 'never seen',
|
'users.never': 'never seen',
|
||||||
|
'users.toProject': 'to {name}',
|
||||||
|
'users.toInstance': 'to the instance',
|
||||||
|
'users.invitedBy': 'invited by {name}',
|
||||||
|
|
||||||
// --- settings: history and errors -----------------------------------------------
|
// --- settings: history and errors -----------------------------------------------
|
||||||
'history.title': 'History',
|
'history.title': 'History',
|
||||||
@@ -328,6 +331,30 @@ export const en = {
|
|||||||
'history.noMatch': 'Nothing matches the filters.',
|
'history.noMatch': 'Nothing matches the filters.',
|
||||||
'history.nothing': 'Nothing has been recorded yet.',
|
'history.nothing': 'Nothing has been recorded yet.',
|
||||||
'history.deletedAccount': 'A deleted account',
|
'history.deletedAccount': 'A deleted account',
|
||||||
|
|
||||||
|
// What each recorded event is called. The keys are the server's and live in
|
||||||
|
// events.js; these are the words they become.
|
||||||
|
'event.projectCreated': 'created the project',
|
||||||
|
'event.projectUpdated': 'changed the project',
|
||||||
|
'event.projectDeleted': 'deleted the project',
|
||||||
|
'event.projectImported': 'imported the project',
|
||||||
|
'event.sectionCreated': 'created a section',
|
||||||
|
'event.sectionUpdated': 'changed a section',
|
||||||
|
'event.sectionDeleted': 'deleted a section',
|
||||||
|
'event.memberInvited': 'invited',
|
||||||
|
'event.memberAdded': 'added',
|
||||||
|
'event.memberRemoved': 'removed',
|
||||||
|
'event.memberRoleChanged': 'changed the role of',
|
||||||
|
'event.taskCreated': 'created a task',
|
||||||
|
'event.taskUpdated': 'changed a task',
|
||||||
|
'event.taskCompleted': 'closed a task',
|
||||||
|
'event.taskReopened': 'reopened a task',
|
||||||
|
'event.taskMoved': 'moved a task',
|
||||||
|
'event.taskDeleted': 'deleted a task',
|
||||||
|
'event.taskSplit': 'split a task up',
|
||||||
|
'event.commentCreated': 'wrote a comment',
|
||||||
|
'event.attachmentAdded': 'attached a file',
|
||||||
|
|
||||||
'errors.title': 'Server errors',
|
'errors.title': 'Server errors',
|
||||||
'errors.hint':
|
'errors.hint':
|
||||||
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
|
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
|
||||||
@@ -508,8 +535,13 @@ export const en = {
|
|||||||
'detail.labelsPlaceholder': 'separated by commas',
|
'detail.labelsPlaceholder': 'separated by commas',
|
||||||
'detail.project': 'Project',
|
'detail.project': 'Project',
|
||||||
'detail.repeats': 'Repeats',
|
'detail.repeats': 'Repeats',
|
||||||
|
'detail.never': 'Never',
|
||||||
|
'detail.daily': 'Every day',
|
||||||
|
'detail.weekly': 'Every week',
|
||||||
|
'detail.fortnightly': 'Every other week',
|
||||||
'detail.monthly': 'Every month',
|
'detail.monthly': 'Every month',
|
||||||
'detail.yearly': 'Every year',
|
'detail.yearly': 'Every year',
|
||||||
|
'detail.asWritten': 'As written',
|
||||||
'detail.assignee': 'Assigned to',
|
'detail.assignee': 'Assigned to',
|
||||||
'detail.subtasks': 'Sub-tasks',
|
'detail.subtasks': 'Sub-tasks',
|
||||||
'detail.addSubtask': 'Add a sub-task',
|
'detail.addSubtask': 'Add a sub-task',
|
||||||
@@ -698,6 +730,7 @@ export const en = {
|
|||||||
'push.upToDate': 'No newer version found.',
|
'push.upToDate': 'No newer version found.',
|
||||||
|
|
||||||
'view.nothingMoreToday': 'Nothing more today.',
|
'view.nothingMoreToday': 'Nothing more today.',
|
||||||
|
'view.overdue': 'Overdue',
|
||||||
'project.roleFor': 'Role for {name}',
|
'project.roleFor': 'Role for {name}',
|
||||||
|
|
||||||
'project.more': 'More actions',
|
'project.more': 'More actions',
|
||||||
|
|||||||
@@ -49,3 +49,32 @@ export function fullDate(iso) {
|
|||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repeat rules worth a menu entry, and what to call them.
|
||||||
|
*
|
||||||
|
* Here rather than in the drawer that draws the menu, because the row draws the
|
||||||
|
* same rule as a badge and the two had already disagreed: the menu said "Hver
|
||||||
|
* dag" in both languages, and the badge said whatever the server said.
|
||||||
|
*
|
||||||
|
* The server sends `recurrence_text` with every task and sends it in Danish. It
|
||||||
|
* has to: one payload goes over the WebSocket to everybody in the project at
|
||||||
|
* once, and they do not all read the same language. So the rules that have a name
|
||||||
|
* are named here, on the side that knows whose screen this is, and anything more
|
||||||
|
* specific — "hver anden tirsdag", typed into the title — falls back to what the
|
||||||
|
* server wrote.
|
||||||
|
*/
|
||||||
|
export const REPEATS = [
|
||||||
|
{ rule: '', label: 'detail.never' },
|
||||||
|
{ rule: 'FREQ=DAILY', label: 'detail.daily' },
|
||||||
|
{ rule: 'FREQ=WEEKLY', label: 'detail.weekly' },
|
||||||
|
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'detail.fortnightly' },
|
||||||
|
{ rule: 'FREQ=MONTHLY', label: 'detail.monthly' },
|
||||||
|
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** What a repeating task says on its row. */
|
||||||
|
export function repeatLabel(rule, fallback = '') {
|
||||||
|
const known = REPEATS.find((option) => option.rule && option.rule === rule);
|
||||||
|
return known ? t(known.label) : fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
{#if liveOverdue.length}
|
{#if liveOverdue.length}
|
||||||
<section>
|
<section>
|
||||||
<h2 class="overdue">Forsinket</h2>
|
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||||
{#each liveOverdue as task (task.id)}
|
{#each liveOverdue as task (task.id)}
|
||||||
<TaskRow {task} />
|
<TaskRow {task} />
|
||||||
{/each}
|
{/each}
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if liveOverdue.length}
|
{#if liveOverdue.length}
|
||||||
<h2>I dag</h2>
|
<h2>{t('nav.today')}</h2>
|
||||||
{/if}
|
{/if}
|
||||||
{#each liveToday as task (task.id)}
|
{#each liveToday as task (task.id)}
|
||||||
<TaskRow {task} />
|
<TaskRow {task} />
|
||||||
|
|||||||
@@ -177,9 +177,11 @@
|
|||||||
<div class="what">
|
<div class="what">
|
||||||
<span class="primary-line">{pending.email}</span>
|
<span class="primary-line">{pending.email}</span>
|
||||||
<span class="secondary">
|
<span class="secondary">
|
||||||
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
|
{pending.project_name
|
||||||
|
? t('users.toProject', { name: pending.project_name })
|
||||||
|
: t('users.toInstance')}
|
||||||
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
|
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
|
||||||
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
|
{#if pending.invited_by}· {t('users.invitedBy', { name: pending.invited_by })}{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
|
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
|
||||||
@@ -209,7 +211,8 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="secondary">
|
<span class="secondary">
|
||||||
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
|
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
|
||||||
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
|
{#if user.project_count}·
|
||||||
|
{count(user.project_count, 'users.projectOne', 'users.projectMany')}{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -159,9 +159,12 @@
|
|||||||
<span class="who" class:gone={!entry.user_name}
|
<span class="who" class:gone={!entry.user_name}
|
||||||
>{entry.user_name || t('history.deletedAccount')}</span
|
>{entry.user_name || t('history.deletedAccount')}</span
|
||||||
>
|
>
|
||||||
|
<!-- The space is written out. Svelte drops the whitespace that
|
||||||
|
opens a block, so "invited" and the address ran together. -->
|
||||||
<span class="event"
|
<span class="event"
|
||||||
>{eventName(entry.event)}{#if eventDetail(entry)}
|
>{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
|
||||||
<span class="detail">{eventDetail(entry)}</span>{/if}</span
|
>{eventDetail(entry)}</span
|
||||||
|
>{/if}</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span class="secondary">{entry.project_name}</span>
|
<span class="secondary">{entry.project_name}</span>
|
||||||
|
|||||||
@@ -581,8 +581,9 @@
|
|||||||
>{entry.user_name || t('history.deletedAccount')}</span
|
>{entry.user_name || t('history.deletedAccount')}</span
|
||||||
>
|
>
|
||||||
<span class="what">
|
<span class="what">
|
||||||
{eventName(entry.event)}{#if eventDetail(entry)}
|
{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
|
||||||
<span class="detail">{eventDetail(entry)}</span>{/if}
|
>{eventDetail(entry)}</span
|
||||||
|
>{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="when">{when(entry.created_at)}</span>
|
<span class="when">{when(entry.created_at)}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import TaskRow from '$lib/components/TaskRow.svelte';
|
import TaskRow from '$lib/components/TaskRow.svelte';
|
||||||
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
import QuickAdd from '$lib/components/QuickAdd.svelte';
|
||||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t, tag } from '$lib/i18n.svelte.js';
|
||||||
|
|
||||||
let days = $state([]);
|
let days = $state([]);
|
||||||
|
|
||||||
@@ -48,9 +48,9 @@
|
|||||||
const today = new Date(new Date().toDateString());
|
const today = new Date(new Date().toDateString());
|
||||||
const diff = Math.round((day - today) / 86400000);
|
const diff = Math.round((day - today) / 86400000);
|
||||||
|
|
||||||
if (diff === 0) return 'I dag';
|
if (diff === 0) return t('task.today');
|
||||||
if (diff === 1) return 'I morgen';
|
if (diff === 1) return t('task.tomorrow');
|
||||||
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' });
|
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reading tasks back out of the store keeps a completed row disappearing
|
// Reading tasks back out of the store keeps a completed row disappearing
|
||||||
|
|||||||