diff --git a/internal/httpapi/projects_handlers.go b/internal/httpapi/projects_handlers.go index 22051a8..0ba3dd0 100644 --- a/internal/httpapi/projects_handlers.go +++ b/internal/httpapi/projects_handlers.go @@ -23,6 +23,7 @@ type projectJSON struct { SortOrder float64 `json:"sort_order"` Role string `json:"role"` MemberCount int `json:"member_count"` + OpenCount int `json:"open_count"` Shared bool `json:"shared"` // GroupID is the sidebar group the asking user has filed it under, empty for // none. Always empty on a project shared with them: grouping is a column on @@ -34,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON { return projectJSON{ ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode, OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived, - SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, + SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount, Shared: p.MemberCount > 1, GroupID: p.GroupID, } } diff --git a/internal/store/projects.go b/internal/store/projects.go index 169e60f..bb3ab83 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -30,6 +30,11 @@ type Project struct { Role Role // MemberCount is how many people can see it; 1 means it is not shared. MemberCount int + + // OpenCount is what is left to do: not deleted, not finished. It is the number + // the sidebar shows, because a number beside a project in a task app is read as + // tasks whatever it was meant to be. + OpenCount int } type Section struct { @@ -50,6 +55,13 @@ func (db *DB) ListProjects(ctx context.Context, userID string, includeArchived b p.archived, p.sort_order, p.created_at, p.updated_at, CASE WHEN p.owner_id = ? THEN 'owner' ELSE COALESCE(m.role, '') END AS role, (SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1 AS member_count, + -- What is left to do, which is what the number beside a project in the + -- sidebar means to the person reading it. It used to show member_count, + -- and a "2" on an empty project read as two tasks to everybody who saw + -- it — including the person who wrote the app. + (SELECT count(*) FROM tasks t + WHERE t.project_id = p.id AND t.deleted_at IS NULL AND t.completed_at IS NULL + ) AS open_count, CASE WHEN p.owner_id = ? THEN p.group_id END AS group_id FROM projects p LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = ? @@ -88,6 +100,9 @@ func (db *DB) GetProject(ctx context.Context, projectID, userID string) (*Projec p.archived, p.sort_order, p.created_at, p.updated_at, ? AS role, (SELECT count(*) FROM project_members pm WHERE pm.project_id = p.id) + 1, + (SELECT count(*) FROM tasks t + WHERE t.project_id = p.id AND t.deleted_at IS NULL AND t.completed_at IS NULL + ), CASE WHEN p.owner_id = ? THEN p.group_id END FROM projects p WHERE p.id = ? AND p.deleted_at IS NULL`, string(role), userID, projectID) if err != nil { @@ -113,7 +128,7 @@ func scanProject(rows *sql.Rows) (Project, error) { var role string err := rows.Scan(&p.ID, &p.Name, &p.Color, &icon, &p.ViewMode, &p.OwnerID, &isInbox, - &archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &groupID) + &archived, &p.SortOrder, &created, &updated, &role, &p.MemberCount, &p.OpenCount, &groupID) if err != nil { return p, err } diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 2ba6808..5c1433c 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1846,3 +1846,47 @@ test('en postkasse kan tilføjes, og værtens afvisning når frem til skærmen', // watcher caught still has to be empty. expect(trouble.filter((t) => !t.includes('/mailboxes → 422'))).toEqual([]); }); + +test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastaturet', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('Tallene'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + + const row = sidebar.getByRole('link', { name: /^Tallene/ }); + await expect(row).toBeVisible(); + // Nothing in it yet, so no number at all: a nought beside every empty project + // is a column of noughts. + await expect(row).toHaveText('Tallene'); + + await row.click(); + const box = page.getByLabel('Ny opgave'); + await box.fill('første i dag'); + await box.press('Enter'); + + // One task, one in the sidebar — live, without a reload. + await expect(sidebar.getByRole('link', { name: /^Tallene/ })).toContainText('1', { + timeout: 10000 + }); + + // And it counts what is left, not what has been: finishing it takes the number + // away entirely, which is the whole reason it is not member_count any more — + // that one said 2 on an empty project and meant two people. + await page.getByRole('button', { name: 'Markér som færdig' }).first().click(); + await expect(sidebar.getByRole('link', { name: /^Tallene/ })).toHaveText('Tallene', { + timeout: 10000 + }); + + // The fold has a button, but a rectangle with a line down it is not a word. + const width = () => sidebar.evaluate((el) => el.getBoundingClientRect().width); + expect(await width()).toBeGreaterThan(100); + await page.keyboard.press('ControlOrMeta+b'); + await expect.poll(width).toBeLessThan(5); + await page.keyboard.press('ControlOrMeta+b'); + await expect.poll(width).toBeGreaterThan(100); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index d5f17d6..0b576e3 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -412,8 +412,16 @@ > {projectName(project)} - {#if project.shared} - {project.member_count} + + {#if project.open_count > 0} + {project.open_count} {/if} {/snippet} diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index ae3a5da..47b9cce 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -377,6 +377,7 @@ export const da = { 'data.templatesHint': 'Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er relative, så de bliver regnet ud fra den startdato, du vælger.', + 'keys.foldSidebar': 'fold sidebjælken væk', 'keys.anyLetter': 'et bogstav', 'keys.typeToCapture': 'begynder en opgave, med bogstavet i feltet', 'keys.navigation': 'Navigation', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 0c20b3a..69dc5d2 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -372,6 +372,7 @@ export const en = { 'data.templatesHint': 'A project saved as a shape you can use again. The dates in a template are relative, so they are worked out from the start date you pick.', + 'keys.foldSidebar': 'fold the sidebar away', 'keys.anyLetter': 'any letter', 'keys.typeToCapture': 'starts a task, with the letter already in the field', 'keys.navigation': 'Navigation', diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 7b2729d..f1a7d0e 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -156,6 +156,7 @@ class AppState { break; case 'task.deleted': this.tasks = this.tasks.filter((t) => t.id !== task?.id); + this.refreshCounts(); break; // Projects and labels belong to a person rather than to a project, so @@ -246,6 +247,11 @@ class AppState { */ upsert(task) { if (!task?.id) return; + // Before the branch, not after it. A task that is already in the list can + // still change the counts — moved to another project, finished in another + // tab, a recurring one that closed and opened itself — and the branch below + // returns early for exactly those. + this.refreshCounts(); if (this.tasks.some((t) => t.id === task.id)) { this.replace(task.id, task); return; @@ -301,6 +307,7 @@ class AppState { const what = task?.content ?? ''; await this.#optimistic(id, { completed: true }, () => api.completeTask(id)); + this.refreshCounts(); if (!repeats) { this.toast(t('task.completedUndo', { what: truncate(what) }), { @@ -312,6 +319,7 @@ class AppState { async reopen(id) { await this.#optimistic(id, { completed: false }, () => api.reopenTask(id)); + this.refreshCounts(); } async update(id, patch) { @@ -373,6 +381,7 @@ class AppState { this.tasks = this.tasks.filter((t) => t.id !== id); try { await api.deleteTask(id); + this.refreshCounts(); } catch (e) { this.tasks = previous; this.toast(humanMessage(e)); @@ -417,6 +426,56 @@ class AppState { this.projects = projects; } + /** + * Brings the sidebar's per-project counts back in line after tasks have moved. + * + * The count is open tasks now, which changes on every add, finish and delete — + * where the old one, how many people are in it, changed about twice a year. It + * would be quicker to add and subtract locally, and it would be wrong within a + * day: a task moved between projects, one finished in another tab, a recurring + * one that closed and reopened itself. Asking the server is one request and + * cannot drift. + * + * Coalesced, because a paste of five tasks is five calls in the same tick and + * the answer to all of them is the same. + */ + #countsRunning = false; + #countsAgain = false; + #countsTimer = null; + + refreshCounts() { + // Two windows to coalesce over, not one. Collapsing calls that arrive within + // a tick is the easy half; the half that matters is a change arriving while + // the request is already in flight. Dropping those left the sidebar showing + // the count as it was one task ago, which is the state the test caught and + // which nothing but a reload would have corrected. + if (this.#countsRunning) { + this.#countsAgain = true; + return; + } + clearTimeout(this.#countsTimer); + this.#countsTimer = setTimeout(async () => { + this.#countsRunning = true; + try { + const { projects } = await api.listProjects(); + const counts = new Map(projects.map((p) => [p.id, p.open_count])); + this.projects = this.projects.map((p) => + counts.has(p.id) ? { ...p, open_count: counts.get(p.id) } : p + ); + } catch { + // The number is a convenience; failing to update it must not put an + // error in front of somebody who was only ticking something off. + } finally { + this.#countsRunning = false; + } + if (this.#countsAgain) { + this.#countsAgain = false; + this.refreshCounts(); + } + }, 120); + } + + /** * Puts the projects in the given order. * diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 6b58c9d..4afd66a 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -60,6 +60,15 @@ paletteOpen = true; return; } + // Folding the sidebar had a button and nothing else, and a rectangle with a + // line down it is not a word: somebody who wanted the menu out of the way + // asked for the feature that was already there. ⌘B is where an editor puts + // it. Works while typing as well — it moves nothing in the field. + if ((event.metaKey || event.ctrlKey) && (event.key === 'b' || event.key === 'B')) { + event.preventDefault(); + sidebar.toggle(); + return; + } if (typing || event.metaKey || event.ctrlKey || event.altKey) return; if (app.detailId) return; @@ -123,7 +132,7 @@ onclick={() => sidebar.toggle()} aria-label={sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')} aria-pressed={sidebar.collapsed} - title={sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')} + title={(sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')) + ' (⌘B)'} >