feat(board): the No Milestone group collapses, and every group's Done column starts folded (#4077)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 42s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 35s

Operator, 2026-09-15: the unmilestoned group should collapse, "especially the
done column as it currently consume a lot of vertical space for [work] that's
already done."

- The "No Milestone" group gets the milestones' chevron and collapse, keyed as
  0 in the same Set (no milestone id is 0), and starts collapsed on first load
  when everything in it is finished — the rule finished milestones already
  follow.
- Each group's Done column header becomes a button that folds its cards, with
  the count still showing and aria-expanded set. Folded by default: done work is
  the part of a board nobody is reading.
- Not persisted, matching the milestone collapse beside it.

Milestone 415 step 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 13:30:11 -04:00
co-authored by Claude Opus 5
parent e886bd2a87
commit 6d0dee48fa
+57 -8
View File
@@ -179,6 +179,25 @@ const milestoneGroups = computed((): MilestoneGroup[] => {
return groups; return groups;
}); });
// The "No Milestone" group has no id, and collapsing it uses the same Set as
// the milestones: 0 stands for it, since no milestone id is ever 0.
const UNASSIGNED_KEY = 0;
function groupKey(group: MilestoneGroup): number {
return group.milestone?.id ?? UNASSIGNED_KEY;
}
// A group's Done column starts COLLAPSED, to its header and count. Done work is
// the part of a board nobody is reading, and listed in full it pushed the open
// columns of every group below it off the screen (operator, 2026-09-15). Not
// persisted, matching the milestone collapse beside it: each load starts from
// the same defaults.
const expandedDone = ref<Set<number>>(new Set());
function toggleDone(group: MilestoneGroup) {
const key = groupKey(group);
if (expandedDone.value.has(key)) expandedDone.value.delete(key);
else expandedDone.value.add(key);
}
function toggleMilestoneCollapse(id: number) { function toggleMilestoneCollapse(id: number) {
if (collapsedMilestones.value.has(id)) { if (collapsedMilestones.value.has(id)) {
collapsedMilestones.value.delete(id); collapsedMilestones.value.delete(id);
@@ -386,6 +405,16 @@ async function loadTasks() {
all.push(...next.notes); all.push(...next.notes);
} }
tasks.value = all; tasks.value = all;
// The No Milestone group follows the milestone rule: once, on first load,
// it starts collapsed when everything in it is finished.
if (!autoCollapsedOnce.value.has(UNASSIGNED_KEY)) {
autoCollapsedOnce.value.add(UNASSIGNED_KEY);
const assigned = new Set(milestones.value.map((m) => m.id));
const loose = all.filter((t) => !t.milestone_id || !assigned.has(t.milestone_id));
if (loose.length && loose.every((t) => t.status === "done" || t.status === "cancelled")) {
collapsedMilestones.value.add(UNASSIGNED_KEY);
}
}
} catch { } catch {
// Say so. This used to swallow the error and leave an empty board, which is // Say so. This used to swallow the error and leave an empty board, which is
// indistinguishable from a project with no tasks — the same "hidden with no // indistinguishable from a project with no tasks — the same "hidden with no
@@ -950,11 +979,12 @@ async function confirmDelete() {
<div v-for="group in milestoneGroups" :key="group.milestone?.id ?? 'unassigned'" class="milestone-group"> <div v-for="group in milestoneGroups" :key="group.milestone?.id ?? 'unassigned'" class="milestone-group">
<div <div
class="milestone-header" class="milestone-header"
:class="{ clickable: !!group.milestone && renamingMilestoneId !== group.milestone?.id }" :class="{ clickable: renamingMilestoneId !== group.milestone?.id }"
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)" :aria-expanded="!collapsedMilestones.has(groupKey(group))"
@click="renamingMilestoneId !== group.milestone?.id && toggleMilestoneCollapse(groupKey(group))"
> >
<span class="ms-chevron" v-if="group.milestone"> <span class="ms-chevron">
<ChevronRight v-if="collapsedMilestones.has(group.milestone.id)" :size="16" /> <ChevronRight v-if="collapsedMilestones.has(groupKey(group))" :size="16" />
<ChevronDown v-else :size="16" /> <ChevronDown v-else :size="16" />
</span> </span>
<template v-if="group.milestone && renamingMilestoneId === group.milestone.id"> <template v-if="group.milestone && renamingMilestoneId === group.milestone.id">
@@ -1023,7 +1053,7 @@ async function confirmDelete() {
</template> </template>
</div> </div>
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban"> <div v-if="!collapsedMilestones.has(groupKey(group))" class="kanban">
<!-- Todo column --> <!-- Todo column -->
<div class="kanban-col col-todo"> <div class="kanban-col col-todo">
<div class="kanban-col-header"> <div class="kanban-col-header">
@@ -1094,12 +1124,19 @@ async function confirmDelete() {
<!-- Done column --> <!-- Done column -->
<div class="kanban-col col-done"> <div class="kanban-col col-done">
<div class="kanban-col-header"> <button
type="button"
class="kanban-col-header col-toggle"
:aria-expanded="expandedDone.has(groupKey(group))"
@click="toggleDone(group)"
>
<span class="col-status-dot dot-done"></span> <span class="col-status-dot dot-done"></span>
<span class="col-label">Done</span> <span class="col-label">Done</span>
<span class="col-count">{{ group.tasks.filter(t => t.status === 'done').length }}</span> <span class="col-count">{{ group.tasks.filter(t => t.status === 'done').length }}</span>
</div> <ChevronDown v-if="expandedDone.has(groupKey(group))" :size="14" />
<div class="kanban-cards"> <ChevronRight v-else :size="14" />
</button>
<div v-if="expandedDone.has(groupKey(group))" class="kanban-cards">
<router-link <router-link
v-for="task in group.tasks.filter(t => t.status === 'done')" v-for="task in group.tasks.filter(t => t.status === 'done')"
:key="task.id" :to="`/tasks/${task.id}`" :key="task.id" :to="`/tasks/${task.id}`"
@@ -1679,6 +1716,18 @@ async function confirmDelete() {
} }
.col-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } .col-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.col-label { flex: 1; } .col-label { flex: 1; }
/* The Done column's header is a button that folds its cards. It keeps the
header's look — the reset is only what a <button> brings with it. */
.col-toggle {
width: 100%;
background: none;
border: none;
padding: 0;
font-family: inherit;
cursor: pointer;
text-align: left;
}
.col-toggle:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); border-radius: var(--fs-radius-sm); }
.col-count { .col-count {
background: var(--fs-surface-raised); background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color); border: 1px solid var(--fs-border-color);