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
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:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user