Skip to main content

max / goingson

Every task surface says whether the task is available The board card, the project dashboard card and the day plan pool drew tasks without the dependency markers the task list grew, so the same task read as blocked in one view and as ordinary work in the next. They now call the row's own renderBlockedBadge/renderUnblocksBadge rather than growing a second copy, which is how the two drifted apart to begin with. The two badges are mutually exclusive by construction, so the denser board card gains at most one of them and the density worry does not arise. The day plan takes only the unblocks half. Its pool already refuses to offer a blocked task until every blocker is in the day, so a bare "Blocked" there would contradict the plan's own answer; the gate names the blocker instead. What the gate cannot say is which task is worth scheduling first.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 21:56 UTC
Signed with PGP, not checked
Commit: 0df34883699f8d24dfb11c03695f8b88778f9ff1
Parent: 241e02a
6 files changed, +119 insertions, -2 deletions
@@ -9858,10 +9858,24 @@
9858 9858 opacity: 0.72;
9859 9859 }
9860 9860
9861 + /* The board card and the dashboard card get the same treatment on the same
9862 + grounds. Their title is the whole line, so it is what dims. */
9863 + .kanban-card.task-blocked .kanban-card-title,
9864 + .card--list-item.task-blocked .dashboard-item-title {
9865 + opacity: 0.72;
9866 + }
9867 +
9861 9868 .task-row.task-in-cycle {
9862 9869 border-left: var(--border-width) solid var(--danger);
9863 9870 }
9864 9871
9872 + /* A card's left border is already spoken for by its priority, so a cycled card
9873 + is marked on the border it has left rather than fighting for that one. */
9874 + .kanban-card.task-in-cycle,
9875 + .card--list-item.task-in-cycle {
9876 + border-top: var(--border-width) solid var(--danger);
9877 + }
9878 +
9865 9879 /* Task detail: the blockers/dependents lists. */
9866 9880
9867 9881 .task-overview-dependency-group {
@@ -269,6 +269,15 @@
269 269 return `<span class="plan-gate${tone}" title="${escAttr(title)}" aria-label="${escAttr(title)}">${esc(label)}</span>`;
270 270 }
271 271
272 + /**
273 + * The pool carries the "frees other work" marker but not the blocked one,
274 + * and that is the plan gate's doing rather than an omission. The pool only
275 + * offers a blocked task once every blocker it has is already in the day, so
276 + * a "Blocked" badge there would contradict the plan's own answer; the gate
277 + * names the blocker instead, which is the more useful half. What the gate
278 + * cannot say is which task is worth scheduling FIRST, so the unblocks
279 + * marker comes from the shared row renderer.
280 + */
272 281 function renderUnscheduledTaskItem(task) {
273 282 return `
274 283 <div class="unscheduled-task priority-${task.priority.toLowerCase()}"
@@ -280,6 +289,7 @@
280 289 <div class="text-sm text-secondary">
281 290 ${task.projectName ? esc(task.projectName) + ' - ' : ''}${task.priority}
282 291 ${renderPlanGate(task)}
292 + ${GoingsOn.tasksRender.renderUnblocksBadge(task)}
283 293 </div>
284 294 <div class="unscheduled-task-actions" data-act="ui.noop">
285 295 <button class="button button--sm button--ghost" data-act="timeTracking.startTimer" data-a1="${escAttr(task.id)}" title="Track Time">Track</button>
@@ -66,10 +66,12 @@
66 66 const progress = t.subtaskProgress ?? 0;
67 67
68 68 return `
69 - <div class="card card--list-item" data-act="tasks.openSubtasks" data-a1="${escAttr(t.id)}"
69 + <div class="card card--list-item task-${t.blockedClass || 'ready'}" data-act="tasks.openSubtasks" data-a1="${escAttr(t.id)}"
70 70 tabindex="0" role="button" aria-label="Open task ${esc(t.description)}">
71 71 <div class="dashboard-item-title">
72 72 ${esc(t.description)}
73 + ${GoingsOn.tasksRender.renderBlockedBadge(t)}
74 + ${GoingsOn.tasksRender.renderUnblocksBadge(t)}
73 75 ${GoingsOn.tasks.renderTaskBadges(t)}
74 76 </div>
75 77 ${t.subtaskCount > 0 ? `
@@ -50,12 +50,22 @@
50 50 }).join('');
51 51 }
52 52
53 + /**
54 + * A board card carries the same dependency markers as a task row, drawn by
55 + * the row's own renderers so the two cannot drift apart.
56 + *
57 + * Density is not the problem it looks like: the two badges are mutually
58 + * exclusive by construction (the unblocks marker bails on a blocked or
59 + * cycled task), so a card gains at most one of them.
60 + */
53 61 function renderCard(task) {
54 62 const displayDesc = task.displayDescription || task.title;
55 63 const progress = task.subtaskProgress ?? 0;
64 + const blocked = GoingsOn.tasksRender.renderBlockedBadge(task);
65 + const unblocks = GoingsOn.tasksRender.renderUnblocksBadge(task);
56 66
57 67 return `
58 - <div class="card kanban-card priority-${task.priority.toLowerCase()}"
68 + <div class="card kanban-card priority-${task.priority.toLowerCase()} task-${task.blockedClass || 'ready'}"
59 69 draggable="true" data-task-id="${escAttr(task.id)}" data-status="${task.status}"
60 70 data-dragstart="tasksKanban.onDragStart" data-a1="@event"
61 71 ondragend="GoingsOn.tasksKanban.onDragEnd(event)"
@@ -65,6 +75,7 @@
65 75 <div class="kanban-card-meta">
66 76 ${task.projectName ? `<span class="kanban-card-project">${esc(task.projectName)}</span>` : ''}
67 77 ${task.dueFormatted ? `<span class="kanban-card-due ${task.isOverdue ? 'overdue' : ''}">${esc(task.dueFormatted)}</span>` : ''}
78 + ${blocked}${unblocks}
68 79 </div>
69 80 ${task.subtaskCount > 0 ? `<div class="progress progress--mini"><div class="progress-fill" data-tone="success" style="width:${progress}%"></div></div>` : ''}
70 81 </div>`;
@@ -356,6 +356,8 @@
356 356
357 357 GoingsOn.tasksRender = {
358 358 renderTaskBadges,
359 + renderBlockedBadge,
360 + renderUnblocksBadge,
359 361 renderTokenDot,
360 362 renderTimeBadge,
361 363 renderRowActions,
@@ -931,6 +931,84 @@
931 931 });
932 932 });
933 933
934 + // Test: the dependency markers, and that every surface draws them the same way
935 +
936 + describe('GoingsOn.tasksRender dependency badges', () => {
937 + const { renderBlockedBadge, renderUnblocksBadge } = GoingsOn.tasksRender;
938 +
939 + test('a ready task is unmarked', () => {
940 + assertEqual(renderBlockedBadge({ isBlocked: false }), '');
941 + });
942 +
943 + test('a blocked task names the depth of the chain it waits on', () => {
944 + const one = renderBlockedBadge({ isBlocked: true, blockDepth: 1 });
945 + assert(one.includes('Waiting on 1 unfinished task'), 'singular at depth 1');
946 + const deep = renderBlockedBadge({ isBlocked: true, blockDepth: 3 });
947 + assert(deep.includes('a chain 3 deep'), 'depth reported past 1');
948 + });
949 +
950 + test('a cycled task is marked Cycle, ahead of being marked blocked', () => {
951 + const html = renderBlockedBadge({ inCycle: true, isBlocked: true, blockDepth: 2 });
952 + assert(html.includes('blocked-badge--cycle'), 'cycle modifier');
953 + assert(!html.includes('Waiting on'), 'the cycle wins over the depth text');
954 + });
955 +
956 + test('the unblocks marker only appears on startable work', () => {
957 + assertEqual(renderUnblocksBadge({ unblocksCount: 3, isBlocked: true }), '');
958 + assertEqual(renderUnblocksBadge({ unblocksCount: 3, inCycle: true }), '');
959 + assertEqual(renderUnblocksBadge({ unblocksCount: 0 }), '');
960 + assert(renderUnblocksBadge({ unblocksCount: 3 }).includes('Unblocks 3'), 'shown when it frees work');
961 + });
962 +
963 + // The two badges are what let the board card carry both without crowding:
964 + // no task can ever earn both, so a surface gains at most one badge.
965 + test('no task earns both badges at once', () => {
966 + for (const t of [
967 + { isBlocked: true, blockDepth: 1, unblocksCount: 4 },
968 + { inCycle: true, unblocksCount: 4 },
969 + { isBlocked: false, unblocksCount: 4 },
970 + ]) {
971 + const both = renderBlockedBadge(t) && renderUnblocksBadge(t);
972 + assertEqual(both, '');
973 + }
974 + });
975 + });
976 +
977 + // A second copy of a badge is how the surfaces drifted apart in the first
978 + // place: the row grew a marker and the board did not. These call sites are the
979 + // contract that they share one renderer.
980 + describe('every task surface draws dependency state from tasks-render', () => {
981 + const srcDir = path.join(__dirname, '..');
982 +
983 + const surfaces = [
984 + { file: 'tasks-render.js', needs: ['renderBlockedBadge(t)', 'renderUnblocksBadge(t)'] },
985 + { file: 'tasks-kanban.js', needs: ['tasksRender.renderBlockedBadge', 'tasksRender.renderUnblocksBadge'] },
986 + { file: 'projects-render.js', needs: ['tasksRender.renderBlockedBadge', 'tasksRender.renderUnblocksBadge'] },
987 + // The day plan deliberately carries only the unblocks half: its pool
988 + // never offers work the day cannot run, and the plan gate names the
989 + // blocker instead of a bare "Blocked".
990 + { file: 'day-planning-render.js', needs: ['tasksRender.renderUnblocksBadge'] },
991 + ];
992 +
993 + for (const { file, needs } of surfaces) {
994 + test(`${file} calls the shared renderers`, () => {
995 + const src = fs.readFileSync(path.join(srcDir, file), 'utf8');
996 + for (const call of needs) {
997 + assert(src.includes(call), `${file} is missing ${call}`);
998 + }
999 + assert(!/function\s+render(Blocked|Unblocks)Badge/.test(src) || file === 'tasks-render.js',
1000 + `${file} re-implements a badge instead of reusing it`);
1001 + });
1002 + }
1003 +
1004 + test('the blocked class reaches the card surfaces, not only the row', () => {
1005 + for (const file of ['tasks-kanban.js', 'projects-render.js']) {
1006 + const src = fs.readFileSync(path.join(srcDir, file), 'utf8');
1007 + assert(src.includes('blockedClass'), `${file} does not set the blocked class`);
1008 + }
1009 + });
1010 + });
1011 +
934 1012 describe('GoingsOn.timeTracking.fmtElapsed', () => {
935 1013 test('formats under an hour as m:ss', () => {
936 1014 assertEqual(GoingsOn.timeTracking.fmtElapsed(new Date(Date.now() - 65 * 1000).toISOString()), '1:05');