From 5795fa908ac38de577ecb3d5850bb225eacbc0ba Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 2 Aug 2026 19:38:48 -0400 Subject: [PATCH] feat(projects): cap milestone bars at 10, open work first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roundtable's card rendered ~35 milestone bars and ran several viewport-heights tall, so one tile dwarfed the grid and stopped being scannable — which is the whole job of a card (#2391). Now 10 bars, ordered OPEN WORK FIRST and newest first within each group, with "+25 more milestones" beneath. Ordering by recency alone would have been wrong, and the operator's call was to lead with open work: a long-running project's oldest milestones are usually its finished ones, so the ten most recent could easily have been ten completed bars while the three in flight were the ones hidden. A card answers "what is happening", not "what happened". Three details that are the actual work: - The palette index is captured from the FULL list before slicing. Colour keyed to visible position would have recoloured every bar on the card each time a milestone closed or was added. - Computed once per load into a Map rather than called from the template. A helper invoked inside v-for re-runs on every render, and this one sorts. - The overflow notice is plain text, not a link. The whole card already navigates to the project, and a link nested inside a clickable region is a trap for keyboard and screen-reader users. Saying the count matters more than the cap: a list that simply stops reads as a rendering bug, while a count reads as a summary. Payload is unchanged — the API still returns every milestone. Capping server-side would also need the total to travel with it, or the "+N" has nothing to count from; not worth it while the response is two queries (#2384). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/views/ProjectListView.vue | 65 ++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/ProjectListView.vue b/frontend/src/views/ProjectListView.vue index f4573aa..dd6c1c3 100644 --- a/frontend/src/views/ProjectListView.vue +++ b/frontend/src/views/ProjectListView.vue @@ -113,6 +113,45 @@ function truncate(text: string | null, max = 120): string { return text.length > max ? text.slice(0, max) + "..." : text; } +// A card is a glance, not a report. Roundtable had ~35 milestones and its tile +// ran several viewport-heights tall, which made the grid unreadable (#2391). +const MAX_MILESTONE_BARS = 10; + +interface MilestoneBar extends MilestoneSummary { + /** Position in the FULL list, so a bar keeps its colour when another + * milestone is added or finishes. Tying the palette to the visible index + * would recolour the card every time work closed. */ + paletteIndex: number; +} + +/** Bars to draw per project, plus how many were withheld. + * + * Ordered OPEN WORK FIRST, newest first. Recency alone would be wrong here: a + * long-running project's oldest milestones are usually its finished ones, so + * showing 10 completed bars while hiding the 3 in flight is worse than showing + * nothing. What the card is for is "what is happening", not "what happened". + * + * Computed once per load rather than called from the template — a helper in a + * v-for is re-run on every render, and this one sorts. + */ +const milestoneBars = computed(() => { + const byProject = new Map(); + for (const project of projects.value) { + const all = project.summary?.milestone_summary ?? []; + const indexed: MilestoneBar[] = all.map((ms, i) => ({ ...ms, paletteIndex: i })); + const newestFirst = (a: MilestoneBar, b: MilestoneBar) => b.id - a.id; + const ordered = [ + ...indexed.filter((m) => m.pct < 100).sort(newestFirst), + ...indexed.filter((m) => m.pct >= 100).sort(newestFirst), + ]; + byProject.set(project.id, { + bars: ordered.slice(0, MAX_MILESTONE_BARS), + hidden: Math.max(0, ordered.length - MAX_MILESTONE_BARS), + }); + } + return byProject; +}); + function overallPct(project: Project): { total: number; pct: number } { const counts = project.summary?.task_counts; if (!counts) return { total: 0, pct: 0 }; @@ -185,11 +224,11 @@ function overallPct(project: Project): { total: number; pct: number } {
{{ ms.pct }}%
+ + + +{{ milestoneBars.get(project.id)!.hidden }} + {{ milestoneBars.get(project.id)!.hidden === 1 ? 'more milestone' : 'more milestones' }} +