max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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'); |