Fix the projects-page pool exhaustion, and cap milestone bars at 10 #95
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user