fix(project): the board showed 100 of 166 tasks and said nothing
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 42s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 42s
The milestone progress bars and the cards beneath them came from different places. The bar is counted SERVER-SIDE over every task; the kanban rendered whatever a single `limit=100` returned. Project 2 has 166 tasks, so 66 never arrived — and because the route sorts `updated_at desc`, the ones dropped were the least recently touched, which is mostly done tasks in completed milestones. So "v1.0 — 12/12" expanded to two cards, and the auto-collapse rule (100% done starts collapsed) read as arbitrary because the number driving it disagreed with what you saw when you opened it. No benefit was being chased. The limit shipped the day the view was written (012eb1d, March 2), when the project had a couple of dozen tasks. It became wrong as the corpus grew, and nothing was watching: the route returns `total` and the view discarded it. Correct when written, wrong later, silent in between — the same shape as half the coherence survey. Four changes: - **Page until complete.** The board groups by milestone and shows per-milestone progress, so it cannot be right on a partial set. Guards against a page that returns nothing while `total` still claims more, rather than looping forever. - **Stop swallowing the error.** `catch {}` left an empty board, which is indistinguishable from a project with no tasks — the same hidden-with-no- indicator failure one layer up. Styled apart from the empty state deliberately; "no tasks" and "the tasks did not load" must not look alike. - **Clamp long plan bodies** to ~6.5rem with a Show more. A milestone IS the plan, so its body carries the whole design — several hundred words now — and rendered in full one plan pushes every other milestone off screen. max-height rather than line-clamp: the content is rendered markdown with block children, which line-clamp handles unpredictably. Length judged on the source string; a per-milestone scrollHeight measurement is a lot of machinery to decide whether to show one button, and the proxy is only wrong near the threshold. - **Auto-collapse decides ONCE per milestone.** It re-ran on every reload, and `loadMilestones` runs after a task's status changes — so expanding a finished milestone and ticking anything snapped it shut again with no visible cause. That is the other half of why the collapse state looked mixed: it wasn't only deciding at start, it was overriding the reader continuously. Reported by the operator after thefd7097cdeploy. Not caused by it — but restoring `.milestone-header` in #2444 is what made the progress track render again, so the mismatch had been invisible rather than absent.
This commit is contained in:
@@ -114,6 +114,7 @@ const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks
|
||||
const tasks = ref<NoteItem[]>([]);
|
||||
const notes = ref<NoteItem[]>([]);
|
||||
const tasksLoading = ref(false);
|
||||
const tasksError = ref<string | null>(null);
|
||||
const notesLoading = ref(false);
|
||||
|
||||
const milestones = ref<Milestone[]>([]);
|
||||
@@ -170,8 +171,49 @@ function toggleMilestoneCollapse(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// A milestone IS the plan, so its body carries the whole design — Goal,
|
||||
// Approach, Verification, and often several hundred words of reasoning. Rendered
|
||||
// in full, one milestone's plan pushes every other milestone off the screen, and
|
||||
// the board stops being a board.
|
||||
//
|
||||
// Length is judged on the SOURCE, not by measuring the rendered box. Measuring
|
||||
// would be exact, but it means a ref per milestone, a post-render scrollHeight
|
||||
// read, and a re-measure on every markdown change — a lot of machinery to decide
|
||||
// whether to show one button. This proxy is wrong only in the narrow band around
|
||||
// the threshold, where either answer is fine.
|
||||
const PLAN_CLAMP_CHARS = 400;
|
||||
|
||||
const expandedPlans = ref<Set<number>>(new Set());
|
||||
|
||||
function isPlanLong(ms: Milestone): boolean {
|
||||
return (ms.body || "").length > PLAN_CLAMP_CHARS;
|
||||
}
|
||||
|
||||
function isPlanClamped(ms: Milestone): boolean {
|
||||
return isPlanLong(ms) && !expandedPlans.value.has(ms.id);
|
||||
}
|
||||
|
||||
function togglePlanExpanded(id: number) {
|
||||
if (expandedPlans.value.has(id)) {
|
||||
expandedPlans.value.delete(id);
|
||||
} else {
|
||||
expandedPlans.value.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Milestones this has already ruled on. Without it, the rule re-applies on every
|
||||
// reload — and `loadMilestones` runs after a task's status changes. So expanding
|
||||
// a finished milestone and then ticking anything snapped it shut again, with no
|
||||
// visible cause. That is half of why the collapse state read as arbitrary: it
|
||||
// wasn't only deciding at START, it was overriding the reader continuously.
|
||||
const autoCollapsedOnce = ref<Set<number>>(new Set());
|
||||
|
||||
function autoCollapseCompleted(msList: Milestone[]) {
|
||||
for (const ms of msList) {
|
||||
if (autoCollapsedOnce.value.has(ms.id)) continue;
|
||||
autoCollapsedOnce.value.add(ms.id);
|
||||
// Fully done and non-empty: start collapsed. A finished milestone is
|
||||
// history, and the board is for what's live.
|
||||
if (ms.total > 0 && ms.completed === ms.total) {
|
||||
collapsedMilestones.value.add(ms.id);
|
||||
}
|
||||
@@ -294,15 +336,45 @@ async function confirmDeleteMilestone() {
|
||||
}
|
||||
}
|
||||
|
||||
// The route's max_limit. Asking for more is clamped server-side, so this is the
|
||||
// largest page a single request can return.
|
||||
const TASK_PAGE_SIZE = 500;
|
||||
|
||||
async function loadTasks() {
|
||||
tasksLoading.value = true;
|
||||
tasksError.value = null;
|
||||
try {
|
||||
const data = await apiGet<{ notes: NoteItem[]; total: number }>(
|
||||
`/api/projects/${projectId.value}/notes?type=task&limit=100`
|
||||
);
|
||||
tasks.value = data.notes;
|
||||
// PAGE UNTIL COMPLETE. This board groups tasks under their milestone and
|
||||
// shows each milestone's progress beside them, and that progress is counted
|
||||
// SERVER-SIDE over every task. A partial fetch therefore doesn't just hide
|
||||
// rows — it makes the bar disagree with the cards under it, and the
|
||||
// auto-collapse rule (100% done starts collapsed) read as arbitrary.
|
||||
//
|
||||
// The original `limit=100` with no second page shipped the day this view was
|
||||
// written, when the project had a couple of dozen tasks. At 166 it was
|
||||
// dropping 66 — the least-recently-updated, so mostly done tasks in
|
||||
// completed milestones, which is exactly where the mismatch is least
|
||||
// visible and most confusing.
|
||||
const url = (offset: number) =>
|
||||
`/api/projects/${projectId.value}/notes?type=task` +
|
||||
`&limit=${TASK_PAGE_SIZE}&offset=${offset}`;
|
||||
|
||||
const first = await apiGet<{ notes: NoteItem[]; total: number }>(url(0));
|
||||
const all = [...first.notes];
|
||||
while (all.length < first.total) {
|
||||
const next = await apiGet<{ notes: NoteItem[]; total: number }>(url(all.length));
|
||||
// A page that returns nothing while `total` still says there is more means
|
||||
// the two disagree. Stop rather than loop forever; showing what we have
|
||||
// beats hanging the board.
|
||||
if (!next.notes.length) break;
|
||||
all.push(...next.notes);
|
||||
}
|
||||
tasks.value = all;
|
||||
} catch {
|
||||
// Silently fail — tasks just won't show
|
||||
// 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
|
||||
// indicator" failure as the truncation above, one layer up.
|
||||
tasksError.value = "Could not load tasks. Refresh to try again.";
|
||||
} finally {
|
||||
tasksLoading.value = false;
|
||||
}
|
||||
@@ -587,6 +659,7 @@ async function confirmDelete() {
|
||||
<div class="skel-row skel-row--short"></div>
|
||||
<div class="skel-row"></div>
|
||||
</div>
|
||||
<p v-else-if="tasksError" class="tasks-error">{{ tasksError }}</p>
|
||||
<template v-else>
|
||||
<div class="milestone-actions">
|
||||
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
|
||||
@@ -667,12 +740,21 @@ async function confirmDelete() {
|
||||
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="ms-plan-rendered markdown-body"
|
||||
@click="startEditPlan(group.milestone.id, group.milestone.body)"
|
||||
v-html="renderMarkdown(group.milestone.body || '')"
|
||||
></div>
|
||||
<template v-else>
|
||||
<div
|
||||
:class="['ms-plan-rendered', 'markdown-body',
|
||||
{ 'ms-plan-clamped': isPlanClamped(group.milestone) }]"
|
||||
@click="startEditPlan(group.milestone.id, group.milestone.body)"
|
||||
v-html="renderMarkdown(group.milestone.body || '')"
|
||||
></div>
|
||||
<button
|
||||
v-if="isPlanLong(group.milestone)"
|
||||
class="btn-text ms-plan-toggle"
|
||||
@click.stop="togglePlanExpanded(group.milestone.id)"
|
||||
>
|
||||
{{ expandedPlans.has(group.milestone.id) ? "Show less" : "Show more" }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
|
||||
@@ -1114,6 +1196,31 @@ async function confirmDelete() {
|
||||
}
|
||||
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
|
||||
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
|
||||
|
||||
/* max-height rather than -webkit-line-clamp: the body is rendered markdown, so
|
||||
it holds headings, lists and tables. line-clamp counts lines inside ONE inline
|
||||
formatting context and behaves unpredictably once block children are involved,
|
||||
which is most plans. */
|
||||
.ms-plan-clamped {
|
||||
max-height: 6.5rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* Fades into the plan block's own background, which is a tint over the card —
|
||||
restate it here rather than approximating, or the fade shows as a grey band. */
|
||||
.ms-plan-clamped::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
height: 2.25rem;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card))
|
||||
);
|
||||
pointer-events: none; /* the text under the fade stays clickable to edit */
|
||||
}
|
||||
.ms-plan-toggle { padding-left: 0; margin-top: 0.15rem; }
|
||||
.ms-plan-editor {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
@@ -1359,6 +1466,10 @@ async function confirmDelete() {
|
||||
.note-date { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
|
||||
.empty-msg { color: var(--color-text-muted); font-size: 0.875rem; text-align: center; padding: 1rem; }
|
||||
/* Deliberately NOT styled like .empty-msg: "no tasks" and "the tasks did not
|
||||
load" look identical to a user, and conflating them is what let a silent
|
||||
failure read as an empty project. */
|
||||
.tasks-error { color: var(--color-danger); font-size: 0.875rem; padding: 1rem; text-align: center; }
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
|
||||
Reference in New Issue
Block a user