Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions backlog/tasks/back-468 - Bug-6-Custom-Blocked-Status-Styling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
id: BACK-468
title: 'Bug #6: Custom Blocked-Status Styling'
status: In Progress
assignee:
- '@claude'
created_date: '2026-05-06 19:59'
updated_date: '2026-05-11 21:28'
labels: []
dependencies:
- BACK-465
ordinal: 26000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
Fix hardcoded 'Blocked' styling in status-icon.ts and TaskColumn.tsx.

Add optional config key blockedStatuses: string[] (analogous to terminalStatuses).

Files to fix:
- src/ui/status-icon.ts: hardcoded exact-match 'Blocked' key in statusMap
- src/web/components/TaskColumn.tsx:88: includes('blocked') || includes('stuck') substring check

Fix strategy:
- Add blockedStatuses?: string[] to config type
- Pass config into getStatusStyle() and badge-class helper
- Check if status matches any configured blockedStatuses
- Fallback: keep includes('blocked')/includes('stuck') heuristic for English boards

Tests: extend status-icon.test.ts with custom blocked-status scenarios.

Depends on: Task A (BACK-465)
<!-- SECTION:DESCRIPTION:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [x] #1 bunx tsc --noEmit passes when TypeScript touched
- [x] #2 bun run check . passes when formatting/linting touched
- [x] #3 bun test (or scoped test) passes
<!-- DOD:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 - [ ] src/types/index.ts: blockedStatuses?: string[] added to BacklogConfig
- [x] #2 src/file-system/operations.ts: blocked_statuses key parsed in parseConfig (same array pattern as statuses)
- [x] #3 src/ui/status-icon.ts: hardcoded 'Blocked' exact-match replaced with config-aware check using blockedStatuses
- [x] #4 src/ui/status-icon.ts: fallback heuristic includes('blocked') preserved for English boards
- [x] #5 src/web/components/TaskColumn.tsx: badge-class logic updated to check blockedStatuses from config
- [x] #6 src/web/components/TaskColumn.tsx: fallback includes('blocked')/includes('stuck') preserved for English boards
- [x] #7 New tests in status-icon.test.ts cover custom blocked-status scenarios
- [x] #8 bun test: no new failures
- [x] #9 Tests written and failing (RED) before any implementation starts — run bun test to confirm
- [x] #10 bun run check . passes on all modified files
<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->
## Implementation Plan

### Context
BACK-465 introduced terminalStatuses config infrastructure (already on main).
This task adds analogous blockedStatuses support for visual blocked-status styling.

Two completely independent concepts:
1. Status name is 'Blocked' → visual styling (red icon/badge) [THIS TASK]
2. Task has unresolved dependencies → statistics blockedTasks count [fixed in BACK-466]

### Tooling Rules (mandatory)
- Code reads/writes: Serena MCP ONLY (mcp__plugin_serena_serena__*)
- No Read/Edit/Write/grep-via-Bash for source code
- Bash only for: git operations, bun test, ~/.bun/bin/backlog CLI

### Branch
git checkout -b fix/back-468-blocked-styling

---

### Phase RED — Write failing tests first (before any implementation)

1. Read src/test/status-icon.test.ts via Serena to understand existing structure and imports
2. Add describe block: "blockedStatuses config override"
- Test 1 (custom status): status 'Gesperrt' is styled as blocked when blockedStatuses=['Gesperrt']
- Test 2 (English fallback): 'Blocked' still styled correctly when blockedStatuses not configured
- Test 3 (edge case): empty blockedStatuses array falls back to substring heuristic (includes('blocked'))
3. Run `bun test src/test/status-icon.test.ts` — must FAIL (RED confirmed, check AC #9)

### Phase GREEN — Implement minimal code to make tests pass

#### 1. src/types/index.ts
Add blockedStatuses?: string[] to BacklogConfig (after terminalStatuses?: string[])

#### 2. src/file-system/operations.ts — parseConfig
Add case 'blocked_statuses': (same array pattern as 'terminal_statuses')
Add blockedStatuses: config.blockedStatuses to return object

#### 3. src/ui/status-icon.ts
- Read full file via Serena first to understand current API and all callers
- Find all call sites with mcp__plugin_serena_serena__find_referencing_symbols
- Extend function signature to accept config (or blockedStatuses?: string[])
- Logic: if blockedStatuses provided and non-empty → check if status matches any entry (case-insensitive)
- Fallback: if no blockedStatuses configured → keep existing includes('blocked') substring heuristic

#### 4. src/web/components/TaskColumn.tsx — line ~88
- Read component via Serena to understand how statuses/config are passed (likely props or context)
- Extend badge-class logic: check blockedStatuses from config first, fallback to includes('blocked')/includes('stuck')
- Find how to thread blockedStatuses through the prop chain if needed

#### 5. Run `bun test` — must PASS (GREEN confirmed)

### Phase REFACTOR
- Review all changes for simplification opportunities
- Ensure no duplication with the terminalStatuses implementation pattern
- Run `bun run check .` and `bunx tsc --noEmit` — must be clean

### Investigation Steps (use Serena before editing)
For src/ui/status-icon.ts:
1. mcp__plugin_serena_serena__read_file to see exact current code
2. mcp__plugin_serena_serena__find_referencing_symbols to find all callers
3. Understand how to add config param without breaking existing callers

For src/web/components/TaskColumn.tsx:
1. mcp__plugin_serena_serena__read_file around line 88
2. Check how TaskColumn receives statuses/config currently
3. Determine where blockedStatuses should come from in the component tree
<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Prepared as clean standalone upstream PR on fix/back-468-blocked-styling (PR #637).
All scope pollution removed (terminal-status commits not included).
Full implementation: types, YAML parse/serialize, status-icon TUI wiring, Web board fix, config CLI surface.
Tests: 11 new tests (7 status-icon, 4 config-commands). bun run check + bunx tsc clean.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added blockedStatuses?: string[] config key, analogous to terminalStatuses. Parses/serializes blocked_statuses from config.yml. status-icon.ts now checks blockedStatuses array first, falls back to exact-match 'Blocked' and substring heuristic for English boards. Config threaded from App.tsx down through BoardPage → Board → TaskColumn so the web UI badge-class logic is also config-aware with the same fallback. 3 new TDD tests (RED confirmed before implementation). All checks clean: bun test (3 new passing, 5 pre-existing failures unchanged), bunx tsc --noEmit, bun run check.
<!-- SECTION:FINAL_SUMMARY:END -->
18 changes: 16 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3449,10 +3449,13 @@ configCmd
case "activeBranchDays":
console.log(config.activeBranchDays?.toString() || "30");
break;
case "blockedStatuses":
console.log(config.blockedStatuses?.join(", ") || "");
break;
default:
console.error(`Unknown config key: ${key}`);
console.error(
"Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays",
"Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays, blockedStatuses",
);
process.exit(1);
}
Expand Down Expand Up @@ -3612,6 +3615,14 @@ configCmd
config.activeBranchDays = days;
break;
}
case "blockedStatuses": {
const parsed = value
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
config.blockedStatuses = parsed.length > 0 ? parsed : undefined;
break;
}
case "statuses":
case "labels":
case "milestones":
Expand Down Expand Up @@ -3643,7 +3654,7 @@ configCmd
default:
console.error(`Unknown config key: ${key}`);
console.error(
"Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays",
"Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays, blockedStatuses",
);
process.exit(1);
}
Expand Down Expand Up @@ -3676,6 +3687,9 @@ configCmd
console.log(` defaultStatus: ${config.defaultStatus || "(not set)"}`);
console.log(` statuses: [${config.statuses.join(", ")}]`);
console.log(` labels: [${config.labels.join(", ")}]`);
if (config.blockedStatuses?.length) {
console.log(` blockedStatuses: [${config.blockedStatuses.join(", ")}]`);
}
const milestones = await core.filesystem.listMilestones();
console.log(` milestones: [${milestones.map((milestone) => milestone.id).join(", ")}]`);
console.log(` definitionOfDone: [${(config.definitionOfDone ?? []).join(", ")}]`);
Expand Down
2 changes: 1 addition & 1 deletion src/commands/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function runOverviewCommand(core: Core): Promise<void> {
console.log(`\nPerformance summary: Total time ${totalTime}ms (stats calculation: ${statsTime}ms)`);

const config = await core.fs.loadConfig();
await renderOverviewTui(statistics, config?.projectName || "Project");
await renderOverviewTui(statistics, config?.projectName || "Project", config?.blockedStatuses);
} catch (error) {
loadingScreen?.close();
throw error;
Expand Down
14 changes: 14 additions & 0 deletions src/file-system/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,7 @@ ${description || `Milestone: ${title}`}`,
...config,
...(this.configSource === "root" ? { backlogDirectory: this.resolvedBacklogDirName } : {}),
definitionOfDone: this.normalizeDefinitionOfDone(config.definitionOfDone),
blockedStatuses: config.blockedStatuses?.length ? config.blockedStatuses : undefined,
};
if (this.configSource === "folder") {
delete normalizedConfig.backlogDirectory;
Expand Down Expand Up @@ -1367,6 +1368,15 @@ ${description || `Milestone: ${title}`}`,
.filter(Boolean);
}
break;
case "blocked_statuses":
if (value.startsWith("[") && value.endsWith("]")) {
const arrayContent = value.slice(1, -1);
config.blockedStatuses = arrayContent
.split(",")
.map((item) => item.trim().replace(/['"]/g, ""))
.filter(Boolean);
}
break;
case "definition_of_done":
if (parsedDefinitionOfDone !== undefined) {
config.definitionOfDone = parsedDefinitionOfDone;
Expand Down Expand Up @@ -1429,6 +1439,7 @@ ${description || `Milestone: ${title}`}`,
defaultAssignee: config.defaultAssignee,
defaultReporter: config.defaultReporter,
statuses: config.statuses || [...DEFAULT_STATUSES],
blockedStatuses: config.blockedStatuses?.length ? config.blockedStatuses : undefined,
labels: config.labels || [],
definitionOfDone: config.definitionOfDone,
defaultStatus: config.defaultStatus,
Expand Down Expand Up @@ -1458,6 +1469,9 @@ ${description || `Milestone: ${title}`}`,
...(config.defaultReporter ? [`default_reporter: "${config.defaultReporter}"`] : []),
...(config.defaultStatus ? [`default_status: "${config.defaultStatus}"`] : []),
`statuses: [${config.statuses.map((s) => `"${s}"`).join(", ")}]`,
...(Array.isArray(config.blockedStatuses) && config.blockedStatuses.length > 0
? [`blocked_statuses: [${config.blockedStatuses.map((s) => `"${s}"`).join(", ")}]`]
: []),
`labels: [${config.labels.map((l) => `"${l}"`).join(", ")}]`,
...(Array.isArray(normalizedDefinitionOfDone)
? [`definition_of_done: [${normalizedDefinitionOfDone.map((item) => JSON.stringify(item)).join(", ")}]`]
Expand Down
3 changes: 2 additions & 1 deletion src/formatters/task-plain-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { sortByTaskId } from "../utils/task-sorting.ts";

export type TaskPlainTextOptions = {
filePathOverride?: string;
blockedStatuses?: string[];
};

export function formatDateForDisplay(dateStr: string): string {
Expand Down Expand Up @@ -70,7 +71,7 @@ export function formatTaskPlainText(task: Task, options: TaskPlainTextOptions =
lines.push(`Task ${task.id} - ${task.title}`);
lines.push("=".repeat(50));
lines.push("");
lines.push(`Status: ${formatStatusWithIcon(task.status)}`);
lines.push(`Status: ${formatStatusWithIcon(task.status, options.blockedStatuses)}`);

const priorityLabel = formatPriority(task.priority);
if (priorityLabel) {
Expand Down
47 changes: 47 additions & 0 deletions src/test/config-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,53 @@ describe("Config commands", () => {
expect(listOutput).toContain("milestones: [m-0]");
});

it("config set/get/list blockedStatuses round-trips correctly", async () => {
await $`bun ${CLI_PATH} config set blockedStatuses "Gesperrt,Blockiert"`.cwd(TEST_DIR).quiet();

const getOutput = await $`bun ${CLI_PATH} config get blockedStatuses`.cwd(TEST_DIR).text();
expect(getOutput.trim()).toBe("Gesperrt, Blockiert");

const listOutput = await $`bun ${CLI_PATH} config list`.cwd(TEST_DIR).text();
expect(listOutput).toContain("blockedStatuses");
});

it("blockedStatuses survives a save of another config key (round-trip no data loss)", async () => {
let config = await core.filesystem.loadConfig();
if (config) {
config.blockedStatuses = ["On Hold", "Gesperrt"];
await core.filesystem.saveConfig(config);
}

config = await core.filesystem.loadConfig();
expect(config?.blockedStatuses).toEqual(["On Hold", "Gesperrt"]);

// Save another key and reload
if (config) {
config.defaultEditor = "nano";
await core.filesystem.saveConfig(config);
}

config = await core.filesystem.loadConfig();
expect(config?.blockedStatuses).toEqual(["On Hold", "Gesperrt"]);
expect(config?.defaultEditor).toBe("nano");
});

it("blockedStatuses empty array normalizes to undefined (no empty brackets in YAML)", async () => {
let config = await core.filesystem.loadConfig();
if (config) {
config.blockedStatuses = [];
await core.filesystem.saveConfig(config);
}

config = await core.filesystem.loadConfig();
expect(config?.blockedStatuses).toBeUndefined();
});

it("config list omits blockedStatuses line when not configured", async () => {
const listOutput = await $`bun ${CLI_PATH} config list`.cwd(TEST_DIR).text();
expect(listOutput).not.toContain("blockedStatuses");
});

afterEach(async () => {
try {
await safeCleanup(TEST_DIR);
Expand Down
38 changes: 38 additions & 0 deletions src/test/status-icon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,44 @@ describe("Status Icon Component", () => {
});
});

describe("blockedStatuses config override", () => {
test("custom blocked status is styled as blocked when in blockedStatuses", () => {
const style = getStatusStyle("Gesperrt", ["Gesperrt"]);
expect(style.icon).toBe("●");
expect(style.color).toBe("red");
});

test("'Stuck' still renders as blocked when blockedStatuses config is set (config-PLUS-fallback)", () => {
const style = getStatusStyle("Stuck", ["Gesperrt"]);
expect(style.icon).toBe("●");
expect(style.color).toBe("red");
});

test("English 'Blocked' still styled correctly when no blockedStatuses configured", () => {
const style = getStatusStyle("Blocked");
expect(style.icon).toBe("●");
expect(style.color).toBe("red");
});

test("empty blockedStatuses falls back to substring heuristic for blocked-task", () => {
const style = getStatusStyle("blocked-task", []);
expect(style.icon).toBe("●");
expect(style.color).toBe("red");
});

test("custom blocked status via getStatusColor", () => {
expect(getStatusColor("Gesperrt", ["Gesperrt"])).toBe("red");
});

test("custom blocked status via getStatusIcon", () => {
expect(getStatusIcon("Gesperrt", ["Gesperrt"])).toBe("●");
});

test("custom blocked status via formatStatusWithIcon", () => {
expect(formatStatusWithIcon("Gesperrt", ["Gesperrt"])).toBe("● Gesperrt");
});
});

describe("getStatusColor", () => {
test("returns correct color for each status", () => {
expect(getStatusColor("Done")).toBe("green");
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export interface BacklogConfig {
defaultAssignee?: string;
defaultReporter?: string;
statuses: string[];
blockedStatuses?: string[];
labels: string[];
/** @deprecated Milestones are sourced from milestone files, not config. */
milestones?: string[];
Expand Down
Loading