Fix the projects-page pool exhaustion, and cap milestone bars at 10 #95

Merged
bvandeusen merged 2 commits from dev into main 2026-08-02 19:43:00 -04:00
Showing only changes of commit 5795fa908a - Show all commits
+62 -3
View File
@@ -113,6 +113,45 @@ function truncate(text: string | null, max = 120): string {
return text.length > max ? text.slice(0, max) + "..." : text; 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<number, { bars: MilestoneBar[]; hidden: number }>();
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 } { function overallPct(project: Project): { total: number; pct: number } {
const counts = project.summary?.task_counts; const counts = project.summary?.task_counts;
if (!counts) return { total: 0, pct: 0 }; if (!counts) return { total: 0, pct: 0 };
@@ -185,11 +224,11 @@ function overallPct(project: Project): { total: number; pct: number } {
<!-- Milestone progress bars --> <!-- Milestone progress bars -->
<div <div
v-if="project.summary?.milestone_summary?.length" v-if="milestoneBars.get(project.id)?.bars.length"
class="milestone-bars" class="milestone-bars"
> >
<div <div
v-for="(ms, i) in project.summary.milestone_summary" v-for="ms in milestoneBars.get(project.id)!.bars"
:key="ms.id" :key="ms.id"
class="milestone-bar-row" class="milestone-bar-row"
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`" :title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
@@ -198,11 +237,23 @@ function overallPct(project: Project): { total: number; pct: number } {
<div class="milestone-bar-track"> <div class="milestone-bar-track">
<div <div
class="milestone-bar-fill" class="milestone-bar-fill"
:style="{ width: ms.pct + '%', background: milestoneColor(i) }" :style="{ width: ms.pct + '%', background: milestoneColor(ms.paletteIndex) }"
></div> ></div>
</div> </div>
<span class="milestone-bar-pct">{{ ms.pct }}%</span> <span class="milestone-bar-pct">{{ ms.pct }}%</span>
</div> </div>
<!-- Say what is withheld. A list that simply stops reads as a
rendering bug; a count reads as a summary. Plain text, not a
link: the whole card already navigates to this project, and a
link nested inside a clickable region is a trap for keyboard
and screen-reader users. -->
<span
v-if="milestoneBars.get(project.id)!.hidden"
class="milestone-more"
>
+{{ milestoneBars.get(project.id)!.hidden }}
{{ milestoneBars.get(project.id)!.hidden === 1 ? 'more milestone' : 'more milestones' }}
</span>
</div> </div>
<div class="card-footer"> <div class="card-footer">
@@ -492,6 +543,14 @@ function overallPct(project: Project): { total: number; pct: number } {
text-align: right; text-align: right;
} }
/* Deliberately quiet — it is a footnote about what is not shown, not another
row competing with the bars above it. */
.milestone-more {
color: var(--color-text-muted);
font-size: var(--fs-size-tiny);
padding-top: 0.15rem;
}
.card-footer { .card-footer {
margin-top: auto; margin-top: auto;
} }