Skip to content

feat: add Blocked task status with blocked reason #307

Closed

Description

@nevridge

Groomed 2026-10-22 — ready for implementation.

Original Description

Problem

Task statuses are Draft, Todo and Completed (InProgress is proposed in #239). There is no way to mark a task as waiting on something external, such as a person, a decision or another task. Blocked work looks the same as actionable work, so it keeps carrying forward in the journal and cluttering the todo list.

Proposal

  • Add a Blocked status (PascalCase, per the enum rule) to Models/Status.cs.
  • Add an optional BlockedReason (short text) and a BlockedAt timestamp (UTC, Z suffix), set on the transition into Blocked and cleared on the transition out.
  • Update validators (TaskItemValidator), the EF migration, DTOs, the regenerated API client (npm run gen:api), and the status-handling code paths in TaskRepository and JournalEntryRepository.
  • UI: see "UI decisions" below. These need to be settled before the frontend work starts.
  • Define the transitions. Proposed: Todo or InProgress -> Blocked -> back to the previous status, and Blocked -> Completed is allowed. Decide whether Draft can be blocked.

UI decisions (to settle before implementation)

Affected surfaces: TaskListRow, TodosSection (journal), TasksPage, ProjectDetailPage, TaskTypeahead, TaskHistoryPanel.

  1. Setting blocked: how does the user mark a task blocked? Options: a status menu on the row, a dedicated "Block" action, or a keyboard shortcut. The current checkbox toggle is binary (Todo/Completed), so it can't express a third state.
  2. Reason entry: prompt for the reason at block time (inline input or popover), or allow blocking first and adding the reason later? Is the reason required or optional?
  3. Visual treatment: badge color, icon and label. It must work in light and dark mode and not rely on color alone. It must stay distinct from InProgress (feat: add InProgress task status with StartedAt #239) and Completed. The .journal-page .classname specificity rule applies to any new buttons.
  4. Display of the reason: inline under the title, tooltip only, or expand on click? Decide truncation.
  5. Journal todos: do blocked tasks stay in the todo list, move to a separate "Blocked" group, or appear dimmed in place? Does a blocked task still show the checkbox?
  6. Tasks page and project page: add a Blocked filter or tab, and decide the sort position and counts. Decide whether project progress counts blocked as incomplete.
  7. Unblocking: one-click unblock that restores the previous status, and what the user sees if the previous status is unknown.
  8. History panel: how Blocked/unblocked events and reasons appear in TaskHistoryPanel.
  9. Accessibility: accessible name for the blocked state, and keyboard reachability of the block and unblock actions.
  10. Mobile/narrow layouts: where the badge and reason go when the row is tight.

Output of this section: a short decision log (or mockups) in this issue's comments before the frontend work begins.

Open questions

  • Should blocked tasks carry forward into the user's today? Proposal: yes, but marked as blocked. Carry-forward must still never move tasks on entry create or switch.
  • Should blocked tasks count as incomplete in project progress and weekly review? Proposal: yes, reported separately.
  • Should a task be able to reference the task blocking it, or is free-text reason enough for v1? Proposal: free text only.
  • Sequencing with feat: add InProgress task status with StartedAt #239: do both in one migration, or land feat: add InProgress task status with StartedAt #239 first? The UI decisions above should account for both statuses together.

Acceptance criteria

  • A task can be set to Blocked with an optional reason and returns to its prior status when unblocked.
  • BlockedAt is set and cleared correctly, and is UTC with a Z suffix.
  • The UI decisions above are recorded in this issue, and the implementation follows them.
  • Blocked tasks are visually distinct in every list and the journal, in light and dark mode.
  • Journal carry-forward behavior for blocked tasks is defined and tested.
  • Backend (75%) and frontend (80%) coverage thresholds still pass. Test fixtures use PascalCase ('Blocked').

Follow-up

The taskflow-assistant MCP needs to expose the new status and reason in list_tasks, update_task and weekly_review once this ships. Tracked in nevridge/taskflow-assistant#12.

Related

#239 (InProgress status)
#308 (journal updates may clear unsent fields; BlockedReason would be wiped the same way unless that is fixed first or the field is merge-on-null)


User Story

As a TaskFlow user, I want to mark a task as Blocked with an optional short reason, and unblock it later so it returns to its previous status, so that work waiting on something external is visibly separate from work I can act on today.

The story is generic over "previous status", so InProgress (#239) follows the same rules as Todo once it exists.

Acceptance Criteria

Setting Blocked (backend)

  1. A Todo task updated to Blocked with reason "Waiting on Sam" returns status "Blocked", blockedReason, a UTC blockedAt with a Z suffix and previousStatus "Todo". IsComplete stays false.
  2. Blocking with no reason succeeds, and blockedReason is empty. The reason is free text.
  3. A reason over 200 characters is rejected with a validation error. A reason of 200 characters or fewer is saved trimmed.
  4. Updating a Draft task to Blocked is rejected with a 422 and a specific error code. The task is unchanged.
  5. Changing only the reason on a Blocked task saves it, leaves previousStatus and blockedAt unchanged, and writes a BlockedReasonChanged event.
  6. An update that omits blockedReason keeps the stored reason (merge-on-null). An empty string clears it.

Leaving Blocked (backend)

  1. Unblocking a task with previousStatus Todo sets Todo and clears blockedReason, blockedAt and previousStatus.
  2. If previousStatus is null, unknown or Draft, unblocking sets Todo.
  3. Setting a Blocked task to Completed completes it (IsComplete true) and clears all three blocked fields. The past-day reopen guard (TASK_REOPEN_PAST_DAY_NOT_ALLOWED) is not applied to unblocking.
  4. A task in any status other than Blocked has empty blockedReason, blockedAt and previousStatus.

History and counts

  1. History shows "Blocked" with the reason, "Unblocked, back to " and "Blocked reason updated", instead of a generic StatusChanged.
  2. Blocked tasks count in OpenTaskCount and are also reported in a new BlockedCount. ProjectRow and ProjectDetailPage show the existing open-count format with the blocked part added, for example "7 open (2 blocked) / 12". The "(M blocked)" part appears only when M > 0.

Journal, carry-forward and counts

  1. A Blocked task on a past day carries into the user's own today and stays Blocked, with its reason and blockedAt intact. Creating or switching an entry still never moves tasks.
  2. In the "open first" and "done last" sort modes, order is open, blocked, completed, kept stable within each group. Child rows follow their parent. "Manual" mode keeps the user's order.
  3. openTodoCount and the X/Y done/total figures count Blocked as not done. A new blockedTodoCount feeds the TODOs card, which reads "X/Y · M blocked", with the suffix shown only when M > 0.
  4. Subtask progress reads "2/5 done, 1 blocked", with the blocked part shown only when N > 0.
  5. TaskTypeahead shows the Blocked badge on blocked tasks.

Journal row behavior

  1. A Blocked journal row's checkbox is enabled, shows a blocked glyph and has aria-label "Mark done (blocked)". Checking it completes the task and clears the blocked fields. It looks different from a normal checkbox because completed tasks on past days can't be reopened.
  2. The Blocked badge is a real button with aria-label "Unblock" and a visible "Unblock" tooltip. Clicking it unblocks the task immediately and restores its previous status (criteria 7 and 8). The change rolls back if the save fails.
  3. A Todo journal row has a real "Block" button, placed after .todo-history and before .todo-x. It appears on row hover, focus-within and :focus-visible. Clicking it opens an inline reason input labeled "Blocked reason, optional". Enter saves, including an empty reason, and Esc cancels. Saving is optimistic with rollback.
  4. After a block or unblock in the journal, focus returns to the row's checkbox.

Tasks page and forms

  1. Draft rows have no Block button. On Todo rows, a Block button (aria-label "Block task: <title>") opens the same inline reason entry, trimmed and limited to 200 characters. Blocked is set only through the Block action. Clicking a Blocked row's badge (aria-label "Unblock task: <title>") unblocks it.
  2. STATUS_OPTIONS and DEFAULT_STATUS_FILTER include Blocked, and a Blocked chip shows a count. Sort order is Draft, Todo, InProgress, Blocked, Completed.
  3. TaskForm offers a Blocked option with a reason field. ProjectDetailPage's StatusBadge supports Blocked, and an unknown status shows its raw value instead of "Draft". Status constants come from one shared module.

Visual treatment and accessibility

  1. In light and dark mode, any Blocked row shows a "Blocked" badge with an SVG icon and a dashed border, plus a 3px left accent. The state is carried by the text and icon, and the color is derived from the danger token via color-mix. The reason is a muted single line with an ellipsis, with the full text in the title attribute and the history panel. Double-clicking the reason edits it.
  2. On narrow layouts the badge shows the icon plus a short label, the Block button is icon-only with its aria-label kept, and the reason input sits on its own full-width line.
  3. These journal classes use the .journal-page .classname form (0-2-0) so they beat the .journal-page button reset: .todo-block, .todo-blocked-badge and .todo.is-blocked .todo-check, plus .todo-block-reason if it sets color or font size.

Cross-cutting

  1. A journal toggle or title edit on a Blocked task doesn't wipe blockedReason. Criterion 6 guarantees this whether or not bug: journal todo toggle and title edit may clear task description and due date #308 is fixed first.
  2. Tests cover at least:
    • the Block button becoming visible on focus-within
    • the checkbox label and glyph
    • the badge unblock
    • sort tiers in each mode
    • the "· M blocked" suffix appearing only when M > 0
    • backend criteria 1-11 and 13
  3. Coverage stays at 75% (backend) and 80% (frontend). Fixtures use 'Blocked', and the regenerated API client includes the new fields.
  4. The UX decisions for this feature are captured in this issue's acceptance criteria and edge cases, and the implementation follows them.

Edge Cases

  • Blocked parent with children: confirm that the child-completion guard and parent auto-complete (TaskItemsController 297-301 and 315-348) treat Blocked children as not done, and add a test.
  • Completed checkbox on past days: make sure the glyph and label make the irreversible completion clear.
  • Concurrent edits: PreviousStatus is recorded server-side on entry, and the last write wins.
  • Existing data: existing rows get null in the three new columns. Confirm the migration is backward compatible and that BlockedAt is in the UTC converter.
  • Repeated carry-forward: a task carried over several days stays Blocked and BlockedAt doesn't reset.
  • Sequencing: feat: add InProgress task status with StartedAt #239 and bug: journal todo toggle and title edit may clear task description and due date #308 touch the same status-handling code, so rebase carefully if they merge first or in parallel.

Out of Scope

Activity

  1. nevridge commented on Oct 22, 2026

    @nevridge
    OwnerAuthor

    Manual UX testing checklist for #307 (Blocked status)

    Branch: feature/blocked-task-status. Test in light and dark themes, and once at a narrow width (about 700px or a touch device).

    Journal

    • Hover a Todo row, then Tab to it: the Block button appears between History and Delete and stays reachable by Tab.
      • Expected result: it is visible on hover, focus-within and focus-visible, and always visible on touch or narrow layouts.
    • Click Block, type "Waiting on Sam", press Enter.
      • Expected result: the row shows a "Blocked" badge (icon, dashed border), a 3px left accent and a muted reason line. Focus lands on the row checkbox.
    • Click Block, then press Esc. Click Block, then Tab away.
      • Expected result: no change in either case. Esc returns focus to the Block button.
    • Click Block and press Enter on an empty input.
      • Expected result: the task is blocked with no reason.
    • Hover the long reason, double-click it, and also focus it and press Enter.
      • Expected result: the full text is in the tooltip and an editor opens. Enter saves and Esc cancels.
    • Click the "Blocked" badge.
      • Expected result: the task returns to Todo immediately and focus lands on the checkbox.
    • Check the checkbox on a Blocked row.
      • Expected result: the task completes and the badge and reason clear. The checkbox is labeled "Mark done (blocked)".
    • Switch the sort mode between open first, done last and manual.
      • Expected result: order is open, blocked, completed, and manual order is unchanged.
    • Look at the TODOs card header and a parent task with blocked subtasks.
      • Expected result: "X/Y · M blocked" and "N/M done, K blocked", each shown only when something is blocked.
    • Block a task while the API is stopped.
      • Expected result: the row rolls back, an inline error appears, and the typed reason is kept in the input.
    • Block a task, open its history, then carry it forward to the next day.
      • Expected result: history shows "Blocked: ." and the carried task stays Blocked with its reason.

    Tasks page and forms

    • Look at Draft, Todo and Completed rows.
      • Expected result: only Todo rows have a Block button.
    • Block a Todo from the Tasks page, then click the badge.
      • Expected result: it blocks with a reason, then unblocks.
    • Look at the status filter.
      • Expected result: Blocked is checked by default and shows a count.
    • Open the task form on a Todo task and choose Blocked.
      • Expected result: a "Blocked reason (optional)" field appears. The option is disabled for Draft and Completed tasks.
    • Open the typeahead in the journal.
      • Expected result: blocked tasks show a styled Blocked badge.

    Projects

    • Open a project with blocked tasks.
      • Expected result: the open count reads "N open (M blocked)" and the status badge shows Blocked.
  2. nevridge commented on Oct 22, 2026

    @nevridge
    OwnerAuthor

    UX Test Results

    PR: #311 | Issue: #307
    Branch: feature/blocked-task-status | SHA: e574da5 | Working tree: clean
    Run: uxtest-20261022134544 | Date: 2026-10-22

    Summary

    Total steps: 8 | Passed: 8 | Failed: 0 | Skipped: 0

    One usability finding (below) did not fail a step.

    Passed Steps

    • [✓] Step 1: Journal, Block a Todo task with reason "Waiting on Sam". Row shows the Blocked badge (Unblock button), the reason text and a "Mark done (blocked)" checkbox. Focus returned to the checkbox. API: status Blocked, blockedReason set, UTC blockedAt with Z, previousStatus Todo. No console errors.
    • [✓] Step 2: Journal, activating the "Mark done (blocked)" checkbox from the keyboard completed the Blocked task. API: status Completed, blockedReason, blockedAt and previousStatus all null. (Incidental: an Enter keypress landed on the focused checkbox. It is still a valid completion of a Blocked task.)
    • [✓] Step 3: Journal, Block with an empty reason (Enter). API: Blocked, blockedReason null. Row shows an "Add reason" prompt; the live region read "Task blocked: <title>".
    • [✓] Step 4: Journal, opened "Add reason" (keyboard Enter), typed "Needs design sign-off", Enter. API: reason saved, status still Blocked.
    • [✓] Step 5: Journal, clicked the Unblock badge. API: status Todo, all three blocked fields null. History contains a Blocked event.
    • [✓] Step 6: Tasks page, Block button on a Todo row, reason entered. API: Blocked with reason. Blocked filter chip reads "Blocked (1)". The Draft row has no Block button. The reason shows with the full text in its title. The Blocked row's badge is Unblock task: <title>.
    • [✓] Step 7: Tasks page, History panel shows "Blocked: Vendor reply pending." Then clicked the Unblock badge. API: status Todo, reason null.
    • [✓] Step 8: Reload of the journal with a Blocked task. The reason persists, and the TODOs header reads "3/9 · 1 blocked".

    Failed Steps

    none

    Skipped Steps

    none

    Finding (not a step failure)

    • "Add reason" ignores a single mouse click. On a Blocked journal row with no reason, clicking the "Add reason" button once does nothing. The editor opens on Enter from the keyboard, and the unit test uses a double-click. A control labelled as a button should respond to one click. Same pattern as double-click-to-edit for existing reasons (AC25), but a prompt with no existing text invites a single click.

    Acceptance Criteria Coverage

    AC Statement Status Evidence
    AC1 Todo to Blocked returns reason, UTC blockedAt with Z, previousStatus Todo Verified Step 1
    AC2 Blocking with no reason succeeds Verified Step 3
    AC3 Reason over 200 chars rejected Verified (API-driven) 201-char PATCH returned 400
    AC4 Draft to Blocked rejected with 422 Verified (API-driven) PATCH returned 422, task stayed Draft
    AC5 Reason-only edit keeps previousStatus and blockedAt Not verified Step 4 saved a reason but did not compare blockedAt or check for a BlockedReasonChanged event
    AC6 Omitted reason keeps it, empty string clears Not verified Not exercised
    AC7 Unblock restores Todo and clears the fields Verified Steps 5, 7
    AC8 Null/Draft previousStatus unblocks to Todo Not verifiable via UI Needs a unit or API test
    AC9 Blocked to Completed clears the fields Verified Step 2
    AC10 Non-Blocked tasks have empty blocked fields Verified Steps 2, 5, 7
    AC11 History shows Blocked, Unblocked and reason-updated events Not verified Step 7 showed the Blocked entry only; Unblocked and reason-updated were not checked in the panel
    AC12 Project BlockedCount and counts Not verified Projects page not exercised
    AC13 Blocked task carries into today, stays Blocked Not verified Past-day setup not exercised
    AC14 Sort tiers (open, blocked, completed) Not verified Journal ran in manual sort mode
    AC15 TODOs card reads "X/Y · M blocked" Verified Step 8
    AC16 Subtask progress "N blocked" Not verified No subtasks seeded
    AC17 TaskTypeahead shows Blocked badge Not verified Not exercised
    AC18 Blocked checkbox label and completes the task Verified Steps 1, 2
    AC19 Blocked badge is an Unblock button Verified Steps 5, 7
    AC20 Journal Block button and inline reason input Verified Steps 1, 3 (hover-reveal and focus-within visibility not checked)
    AC21 Focus returns to the checkbox after block Verified Step 1
    AC22 Tasks page Block and Unblock, Draft has no Block Verified Steps 6, 7
    AC23 Blocked filter chip with count Verified Step 6
    AC24 TaskForm Blocked option, StatusBadge Not verified Not exercised
    AC25 Visual treatment, light and dark Not verified Screenshots taken but no contrast or visual check made in this run
    AC26 Narrow layout Not verified Not exercised
    AC27 CSS specificity Not verifiable via UI Covered by code review
    AC28 Toggle or title edit keeps blockedReason Not verified Not exercised
    AC29-31 Tests, coverage, decisions Not verifiable via UI Covered by CI

    Fixtures and API-driven steps

    • Seeded: 4 Todo tasks on today's journal and 1 Draft task, all prefixed uxtest-20261022134544-.
    • API-driven: seeding, state verification via GET, the Draft to Blocked / 201-char / reason-on-Todo validation calls, and the block in Step 8.
    • Screenshots are local-only and are deleted at cleanup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions