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)'}
>