Project board — show all tasks, clamp plan bodies, stop re-collapsing (#100)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 18s

This commit was merged in pull request #100.
This commit is contained in:
2026-08-07 08:26:59 -04:00
+118 -7
View File
@@ -114,6 +114,7 @@ const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks
const tasks = ref<NoteItem[]>([]); const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]); const notes = ref<NoteItem[]>([]);
const tasksLoading = ref(false); const tasksLoading = ref(false);
const tasksError = ref<string | null>(null);
const notesLoading = ref(false); const notesLoading = ref(false);
const milestones = ref<Milestone[]>([]); 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[]) { function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) { 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) { if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id); 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() { async function loadTasks() {
tasksLoading.value = true; tasksLoading.value = true;
tasksError.value = null;
try { try {
const data = await apiGet<{ notes: NoteItem[]; total: number }>( // PAGE UNTIL COMPLETE. This board groups tasks under their milestone and
`/api/projects/${projectId.value}/notes?type=task&limit=100` // 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
tasks.value = data.notes; // 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 { } 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 { } finally {
tasksLoading.value = false; tasksLoading.value = false;
} }
@@ -587,6 +659,7 @@ async function confirmDelete() {
<div class="skel-row skel-row--short"></div> <div class="skel-row skel-row--short"></div>
<div class="skel-row"></div> <div class="skel-row"></div>
</div> </div>
<p v-else-if="tasksError" class="tasks-error">{{ tasksError }}</p>
<template v-else> <template v-else>
<div class="milestone-actions"> <div class="milestone-actions">
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true"> <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> <button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
</div> </div>
</template> </template>
<template v-else>
<div <div
v-else :class="['ms-plan-rendered', 'markdown-body',
class="ms-plan-rendered markdown-body" { 'ms-plan-clamped': isPlanClamped(group.milestone) }]"
@click="startEditPlan(group.milestone.id, group.milestone.body)" @click="startEditPlan(group.milestone.id, group.milestone.body)"
v-html="renderMarkdown(group.milestone.body || '')" v-html="renderMarkdown(group.milestone.body || '')"
></div> ></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>
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban"> <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 { font-size: 0.85rem; color: var(--color-text); cursor: text; }
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); } .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 { .ms-plan-editor {
width: 100%; width: 100%;
font-family: var(--font-mono); 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; } .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; } .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 ───────────────────────────────────────────────────── */
.modal-overlay { .modal-overlay {