feat(groups): a group becomes somewhere you can go

It was a heading over some rows: a name, a colour, and whether it was folded.
Enough to tidy a list, and not enough to be what people mean by it — "Arbejde" is
a body of work with context of its own, and documents that belong to all of it
rather than to any one project inside it.

`/gruppe/{id}` carries three things a heading cannot. The projects in it, each
with what is still open — open tasks, not all of them, because "12" beside a
project somebody finished last year is a number that means nothing. A description,
written in place and saved on blur, the same shape a task's description has. And
the files that govern the whole group: the contract, the drawing, the agreement
everything else rests on.

The name in the sidebar is a link to it and the chevron beside it still folds.
Two intentions, two targets — the single one they used to share could only ever
serve the smaller.

Migration 0012 rebuilds `attachments`, because its CHECK spelled out exactly which
parent columns may be set and SQLite cannot alter a CHECK. The rule is still
"exactly one parent", counted rather than written out as pairwise comparisons:
with three columns that is three clauses, with four it is six, and the version
that is wrong looks exactly like the one that is right.

A group's file is reachable by its owner and nobody else. A group belongs to one
person and is never shared, so `reachAttachment` asks about ownership rather than
a project role — both old callers resolved straight to a task, so a group's
attachment answered 404 for the person who uploaded it.

Three tests also stopped racing their own navigation. Creating a project navigates
and `goto` is a promise nobody was holding, so a quick add fired straight after
could land in the Inbox instead — intermittent, and it read as the quick add
having failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:20:03 +02:00
co-authored by Claude Opus 5
parent 0b467beb3e
commit 804963b222
12 changed files with 1047 additions and 82 deletions
+86 -11
View File
@@ -264,10 +264,15 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
await sidebar.getByLabel('Gruppens navn').press('Enter');
// `exact`, because a group's name is a user-chosen word that can turn up inside
// another button's name — Playwright matches an accessible name by substring
// unless told otherwise.
const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true });
// The name is a link to the group's own page; the chevron beside it is a
// separate button that folds. Two intentions, two targets — the single one they
// used to share could only ever serve the smaller.
//
// `exact` on the link, because a group's name is a user-chosen word that can
// turn up inside another control's name, and Playwright matches an accessible
// name by substring unless told otherwise.
const heading = sidebar.getByRole('link', { name: 'Arbejde', exact: true });
const fold = sidebar.getByRole('button', { name: /^Fold Arbejde/ });
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
@@ -278,7 +283,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
// Both buttons still worked, so every assertion in this file passed.
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
[...el.querySelectorAll('button')].map((b) => {
// Links as well as buttons: the group's name is a link to its page now, and
// a measurement that skipped it would miss the widest thing in the row.
[...el.querySelectorAll('button, a')].map((b) => {
const r = b.getBoundingClientRect();
return { text: b.textContent.trim(), left: r.left, right: r.right };
})
@@ -303,14 +310,14 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
// Folded, reloaded, still folded. localStorage would have passed the first
// half of that and failed the second on another machine.
await heading.click();
await expect(heading).toHaveAttribute('aria-expanded', 'false');
await fold.click();
await expect(fold).toHaveAttribute('aria-expanded', 'false');
await page.reload();
await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute(
await expect(sidebar.getByRole('button', { name: /^Fold Arbejde/ })).toHaveAttribute(
'aria-expanded',
'false'
);
await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click();
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
// Colour, which is chosen while renaming rather than from the row. Both are
// edits to the same thing, and a heading carrying four controls crowded the
@@ -363,14 +370,14 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeVisible();
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeVisible();
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
expect(trouble).toEqual([]);
@@ -399,6 +406,11 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
// Waited for. Creating a project navigates, and `goto` is a promise nobody here
// is holding — quick add fired before it landed puts the task in the Inbox with
// no date, where the Today view is right not to show it. Intermittent, and it
// reads as the quick add having failed.
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
await page.getByLabel('Ny opgave').fill('brænde kaffe');
await page.getByLabel('Ny opgave').press('Enter');
@@ -451,6 +463,8 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
await sidebar.getByLabel('Projektnavn').press('Enter');
// Waited for before navigating away, or the create can lose its own redirect.
await expect(sidebar.getByRole('link', { name: 'Ærinder' })).toBeVisible();
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Liste', exact: true }).click();
@@ -761,6 +775,9 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Fælles');
await sidebar.getByLabel('Projektnavn').press('Enter');
// Waited for, like everywhere else here: creating a project navigates, and the
// controls below only exist once it has.
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
await page.getByRole('button', { name: 'Del' }).click();
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
@@ -1006,6 +1023,64 @@ test('projektsiden er brugbar på en telefon', async ({ page }) => {
expect(trouble).toEqual([]);
});
/**
* A group is somewhere you can go.
*
* It was a heading over some rows: a name, a colour, and whether it was folded.
* Enough to tidy a list and not enough to be what people mean by it — "Arbejde" is
* a body of work with context of its own, and the documents that belong to all of
* it rather than to any one project inside it.
*/
test('en gruppe er en side med sine projekter, en beskrivelse og filer', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
await sidebar.getByLabel('Gruppens navn').fill('Værksted');
await sidebar.getByLabel('Gruppens navn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Værksted' })).toBeVisible();
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Drejebænk');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Drejebænk' })).toBeVisible();
await page.getByLabel('Ny opgave').fill('bestil stål');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('bestil stål', { exact: true })).toBeVisible();
// Into the group, then onto its page — which is what the heading is a link to
// now. The chevron beside it still folds; they are two intentions and the old
// single target could only serve the smaller one.
await sidebar
.getByRole('link', { name: 'Drejebænk' })
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await sidebar.getByRole('link', { name: 'Værksted' }).click();
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
const row = page.locator('.projects a').filter({ hasText: 'Drejebænk' });
await expect(row).toBeVisible();
await expect(row.getByText('1', { exact: true }), 'antallet af åbne opgaver').toBeVisible();
// The description, which is the part a heading cannot carry. Click to write,
// blur to save — the same shape a task's description has.
await page.getByRole('button', { name: /Skriv hvad det her er/ }).click();
const about = page.getByLabel('Om gruppen');
await expect(about).toBeVisible();
await about.fill('Alt der larmer og støver.');
await about.blur();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
// And it survives a reload, which is the difference between the page keeping up
// and the server taking it.
await page.reload();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
await expect(page.locator('.projects a').filter({ hasText: 'Drejebænk' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The sidebar never scrolls sideways.
*
+2
View File
@@ -225,7 +225,9 @@ export const api = {
listProjectGroups: () => get('/project-groups'),
createProjectGroup: (name) => post('/project-groups', { name }),
getProjectGroup: (id) => get(`/project-groups/${id}`),
updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data),
uploadGroupAttachment: (id, file) => upload(`/project-groups/${id}/attachments`, file),
deleteProjectGroup: (id) => del(`/project-groups/${id}`),
reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }),
+44 -14
View File
@@ -590,27 +590,33 @@
the heading away from a screen reader. The whole heading folds —
at this size a lone chevron is a target you miss, and the name is
where the eye already is. -->
<!-- The chevron folds; the name opens the group's own page. Two
targets in one heading, because they are two different intentions
and the old single one could only ever serve the smaller. -->
<button
class="chevron-button"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed ? `Fold ${group.name} ud` : `Fold ${group.name} sammen`}
>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<h2 class="fold">
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
<svg
class="chevron"
class:collapsed={group.collapsed}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M6 9l6 6 6-6" />
</svg>
<!-- Inside the button rather than before it, so the heading starts
on the same edge as everything else in the sidebar. As a
sibling it pushed the whole row in, and a group heading that
is indented says the heading is inside something. -->
<a href="/gruppe/{group.id}" onclick={onnavigate}>
<span
class="dot group-dot"
style="background: {colorVar(group.color)}"
aria-hidden="true"
></span>
{group.name}
</button>
</a>
</h2>
<span class="count">{inside.length}</span>
<button
@@ -921,6 +927,25 @@
min-width: 0;
}
/* The chevron on its own, so the name beside it can be a link to the group's
page. Sized as a target rather than as a glyph: 11px of arrow is something
you miss. */
.chevron-button {
flex: none;
width: 18px;
height: 18px;
display: grid;
place-items: center;
border-radius: var(--radius-sm);
color: inherit;
}
.chevron-button:hover {
color: var(--ink);
background: var(--surface);
}
.fold a,
.fold button {
display: flex;
align-items: center;
@@ -936,10 +961,15 @@
white-space: nowrap;
}
.fold a:hover,
.fold button:hover {
color: var(--ink);
}
.fold a {
text-decoration: none;
}
.chevron {
width: 11px;
height: 11px;
+426
View File
@@ -0,0 +1,426 @@
<script>
/**
* A group, as a place rather than a heading.
*
* A heading can carry a name. This carries what the group *is*: the sentence
* that says why these projects are one body of work, the documents that belong
* to all of them rather than to any one, and the projects themselves with what
* is still open in each.
*
* The count beside a project is open tasks, not all tasks. "12" on a project
* you finished last year is a number that means nothing; the question a list of
* projects answers is what is left.
*/
import { page } from '$app/stores';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
let group = $state(null);
let projects = $state([]);
let attachments = $state([]);
let status = $state('loading');
let id = $derived($page.params.id);
$effect(() => {
load(id);
});
async function load(groupId) {
if (!groupId) return;
status = 'loading';
try {
const r = await api.getProjectGroup(groupId);
group = r.group;
projects = r.projects;
attachments = r.attachments;
description = r.group.description ?? '';
status = 'ready';
} catch (e) {
group = null;
// 404 is the answer for both "no such group" and "not yours", so probing
// ids teaches nothing. Anything else is the request itself having failed,
// which is worth saying differently and worth offering to retry.
status = e?.status === 404 ? 'denied' : 'failed';
}
}
// --- the description ---------------------------------------------------------
let description = $state('');
let editing = $state(false);
let saving = $state(false);
async function saveDescription() {
const trimmed = description.trim();
editing = false;
if (!group || trimmed === (group.description ?? '')) return;
saving = true;
const previous = group;
group = { ...group, description: trimmed };
try {
group = await api.updateProjectGroup(group.id, { description: trimmed });
} catch (e) {
group = previous;
description = previous.description ?? '';
app.toast(humanMessage(e));
} finally {
saving = false;
}
}
// --- the documents -----------------------------------------------------------
let uploading = $state(false);
async function addFile(event) {
const file = event.target.files?.[0];
if (!file) return;
// Cleared straight away, so choosing the same file twice in a row is two
// uploads rather than one and a change event that never fires.
event.target.value = '';
uploading = true;
try {
attachments = [...attachments, await api.uploadGroupAttachment(group.id, file)];
} catch (e) {
app.toast(humanMessage(e));
} finally {
uploading = false;
}
}
async function removeFile(attachment) {
if (!confirm(`Fjern "${attachment.filename}"?`)) return;
const previous = attachments;
attachments = attachments.filter((a) => a.id !== attachment.id);
try {
await api.deleteAttachment(attachment.id);
} catch (e) {
attachments = previous;
app.toast(humanMessage(e));
}
}
function size(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
</script>
<svelte:head><title>{group?.name ?? 'Gruppe'} — verdande</title></svelte:head>
<div class="view">
{#if status === 'denied'}
<p class="empty">Gruppen findes ikke, eller den er ikke din.</p>
{:else if status === 'failed'}
<p class="empty">
Gruppen kunne ikke hentes.
<button class="link" onclick={() => load(id)}>Prøv igen</button>
</p>
{:else if group}
<header>
<span class="dot" style="background: {colorVar(group.color)}" aria-hidden="true"></span>
<h1>{group.name}</h1>
<span class="count">{projects.length === 1 ? '1 projekt' : `${projects.length} projekter`}</span>
</header>
<!-- Click to write, blur to save. The same shape as a task's description,
because it is the same act — and a Gem-knap for one field is a button
that exists to be pressed once and then be in the way. -->
<section class="about">
{#if editing}
<textarea
bind:value={description}
use:focusOnMount
rows="4"
aria-label="Om gruppen"
placeholder="Hvad er det her for et stykke arbejde?"
onblur={saveDescription}
onkeydown={(e) => e.key === 'Escape' && (editing = false)}
></textarea>
{:else}
<button class="description" onclick={() => (editing = true)}>
{#if group.description}
{group.description}
{:else}
<span class="placeholder">Skriv hvad det her er for et stykke arbejde.</span>
{/if}
</button>
{/if}
{#if saving}<span class="saved">Gemmer…</span>{/if}
</section>
<section class="projects">
<h2>Projekter</h2>
{#if projects.length}
<ul>
{#each projects as project (project.id)}
<li>
<a href="/projekt/{project.id}">
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
></span>
{project.name}
<!-- Counted by the server in the same request. Open tasks, not all
of them: "12" beside a project finished last year is a number
that means nothing. -->
<span class="count">{project.open_tasks}</span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="empty">
Ingen projekter i gruppen endnu. Træk et herop i sidebjælken, eller vælg gruppen,
når du opretter et.
</p>
{/if}
</section>
<section class="files">
<h2>Dokumenter</h2>
<p class="hint">
Filer, der hører til hele gruppen frem for til én opgave i den — kontrakten,
tegningen, den aftale alt det andet hviler på.
</p>
{#if attachments.length}
<ul class="list">
{#each attachments as attachment (attachment.id)}
<li>
<a class="file" href={api.attachmentURL(attachment.id)} download>
{attachment.filename}
</a>
<span class="secondary">{size(attachment.size)}</span>
<button class="remove" onclick={() => removeFile(attachment)} aria-label="Fjern {attachment.filename}">
Fjern
</button>
</li>
{/each}
</ul>
{/if}
<label class="add">
<input type="file" onchange={addFile} disabled={uploading} />
<span>{uploading ? 'Lægger op…' : '+ Læg en fil op'}</span>
</label>
</section>
{/if}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
display: flex;
flex-direction: column;
gap: var(--s5);
}
/* Wraps, for the same reason the project header does: a title and a count are
more than a phone's width once the title is a real name. */
header {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--s3);
}
h1 {
font-size: var(--text-2xl);
min-width: 0;
overflow-wrap: anywhere;
}
h2 {
font-size: var(--text-xs);
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
margin-bottom: var(--s2);
}
.dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex: none;
align-self: center;
}
.count {
font-size: var(--text-sm);
color: var(--ink-faint);
flex: none;
}
.about textarea {
width: 100%;
padding: var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font: inherit;
font-size: var(--text-sm);
line-height: 1.6;
resize: vertical;
outline: none;
}
.about textarea:focus {
border-color: var(--accent);
}
.description {
display: block;
width: 100%;
text-align: left;
padding: var(--s3);
border-radius: var(--radius);
font-size: var(--text-sm);
line-height: 1.6;
color: var(--ink);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.description:hover {
background: var(--surface);
}
.placeholder {
color: var(--ink-faint);
}
.projects ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.projects a {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3);
border-radius: var(--radius);
color: var(--ink);
text-decoration: none;
font-size: var(--text-sm);
transition: background var(--fast) var(--ease);
}
.projects a:hover {
background: var(--surface);
}
.projects .count {
margin-left: auto;
}
.list {
list-style: none;
margin: 0 0 var(--s3);
padding: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.list li {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3);
border-bottom: 1px solid var(--line);
}
.list li:last-child {
border-bottom: 0;
}
.file {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
color: var(--ink);
}
.secondary {
font-size: var(--text-xs);
color: var(--ink-faint);
flex: none;
}
.remove {
font-size: var(--text-xs);
color: var(--ink-faint);
flex: none;
}
.remove:hover {
color: var(--danger);
}
.hint {
margin: 0 0 var(--s3);
font-size: var(--text-sm);
color: var(--ink-muted);
line-height: 1.5;
}
/* The input itself is hidden and the label is the control: a browser's own file
button cannot be styled, and it says "Vælg fil" in whatever language the
browser is in rather than the one the app is in. */
.add input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.add span {
display: inline-block;
padding: var(--s2) var(--s4);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink-muted);
cursor: pointer;
}
.add span:hover {
color: var(--ink);
border-color: var(--ink-faint);
}
.empty {
font-size: var(--text-sm);
color: var(--ink-faint);
}
.link {
color: var(--accent);
text-decoration: underline;
font-size: inherit;
}
.saved {
font-size: var(--text-sm);
color: var(--accent);
}
</style>