diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index f3047ec..98abc6d 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -114,6 +114,7 @@ const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks const tasks = ref([]); const notes = ref([]); const tasksLoading = ref(false); +const tasksError = ref(null); const notesLoading = ref(false); const milestones = ref([]); @@ -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>(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>(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() {
+

{{ tasksError }}

-
+
@@ -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 {