25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

47KB

Spec Driven Development — Chapter-21: 9.5 - Deleting a Task in Practice: the Whole Loop, File by File

  • Source: /library/Spec Driven Development/source-file.pdf
  • PDF pages: 340–401
  • Pages without text: none

9.5 - Deleting a Task in Practice: the Whole Loop, File by File How to Read This Chapter Chapter 9 followed the fourth lap of the cycle with the spotlight on tasks and analyze . Here you see the whole lap, exactly as it was recorded in the example app's repository, in the 004-excluir-tarefa feature. This is the conflict lap. analyze cross-checked the deletion spec against the spec for the completion feature (002, already merged) and found a contradiction between artifacts: deletion said the task “vanishes for good,” while completion promised that “a completed task stays in the list.” Read together, they seemed to contradict each other. analyze fixed nothing on its own; it produced a report, pointed to the right place for the fix (the deletion spec, not the tasks) and demanded a second clean run before releasing implement . That report, with both runs, is the central artifact of this chapter, and you read it in full below. As in the earlier laps, the artifacts appear as markdown (with headings demoted) and the code in blocks with a sentence of context before each one. Several files are modifications of files you have already seen; when that is the case, the text points out what changed.

The Input: the /speckit-specify Prompt “Delete a task from the personal to-do list. The person removes a task they no longer want to see. Out of scope for this feature: bulk deletion, a trash bin, and multi-undo.” What specify and clarify Returned: spec.md Notice the fourth clarification (Q4): it is born already answering the conflict that analyze would later formalize. The spec below is the corrected version, after the second run of analyze ; FR-002 and Scenario 2 carry the caveat that tells deleting apart from completing. Feature Specification: Delete a task Feature Branch: 004-excluir-tarefa Created: 2026-06-27 Status: Draft Input: User description: “Delete a task from the personal to-do list. The person removes a task they no longer want to see. Out of scope for this feature: bulk deletion, a trash bin, and multi- undo.” Clarifications Session 2026-06-27 Q: Is deletion reversible (trash/undo) or permanent? → A:

Permanent. The simplest option that respects the constitution (YAGNI): the task disappears from the list and from storage, with no trash bin and no undo. If reversibility is ever wanted, it becomes its own feature. Q: Does the person confirm before deleting, or is deletion immediate? → A: Confirms. Since it is a destructive action with no undo, the interface asks for a simple confirmation before removing; it is the only safeguard, decided here and without turning into an elaborate mechanism. Q: Can a task be deleted in any state (open or completed)? → A: Yes. Deletion does not look at state: it removes the indicated task, whether it is open or completed. Q: Doesn't “permanent” conflict with 002 (FR-006: “a completed task stays in the list”)? → A: No. They are distinct actions. Completing only marks the task and keeps it visible, in place; that permanence is what 002's FR-006 speaks of (completing does not auto-remove). Deleting is a separate, deliberate, confirmed action that takes the task away for good. The permanence promised by 002 holds for the act of completing; it does not prevent the act of deleting. User Scenarios & Testing (mandatory) User Story 1 - Delete a task (Priority: P1) The person has a task in the list they no longer want to see: it was created by mistake, it lost its point, or it is already done and now just takes up space. They trigger the deletion of that task, confirm, and the task disappears from the list for good. Why this priority: It is the entire reason for the feature. Without it, the list only grows; the person needs a way to take out for good what no longer belongs there.

Independent Test: Can be tested by creating a task, deleting it, and checking that it no longer appears in the list and does not come back when the app is reopened. Acceptance Scenarios:

  1. Given a list with a few tasks, When the person deletes one of them and confirms, Then the task disappears from the list and does not return when the app is reopened.
  2. Given a completed task that no longer matters, When the person deletes it, Then it is permanently removed from the list, like any other. Completing would keep it visible (002 FR- 006); deleting is the deliberate act that takes it out. Edge Cases Deleting a task that no longer exists (already removed in another tab, for example) is handled as a predictable error (error as value), not as an exception that crashes the application. Deletion is permanent: there is no trash bin and no undo. Once confirmed, the task disappears from the list and from storage. Canceling the confirmation does not delete: the task stays intact in the list. Requirements (mandatory) Functional Requirements FR-001: The system MUST allow deleting an existing task from the list. FR-002: Deletion MUST be permanent: the task disappears from the list and from storage, with no trash bin and no undo. “Permanent” qualifies the action of deleting, distinct from

completing (002): completing marks and keeps the task in the list (002 FR-006); deleting takes it out for good. The two do not contradict each other. FR-003: The system MUST ask for a simple confirmation before deleting; canceling the confirmation preserves the task. FR-004: The system MUST handle the deletion of a nonexistent task as a predictable error (error as value), without throwing an exception across layers. FR-005: Deleting a task MUST NOT change the position of the others in the list (creation order is preserved). Non-Goals Explicitly out of scope: bulk deletion, a trash bin, undo (multi or single), and archiving (YAGNI). Key Entities Task: reused from 001/002 with no field changes. Deletion removes the whole task; it adds no new field (no “deleted” as a state, since there is no trash bin). Success Criteria (mandatory) SC-001: The person can delete a task and see it disappear from the list right away. SC-002: After closing and reopening the app, the deleted task remains absent (the deletion persisted). SC-003: Canceling the confirmation keeps the task in the list, without removing it. Assumptions The feature extends those for creating/listing (001),

completing/reopening (002) and filtering (003), already integrated on main ; it reuses the Task entity, the repository and the existing layered architecture. The To-Do constitution governs the feature: Simplicity/YAGNI, layered architecture, business logic isolated from the UI, error as value and TDD. Deletion is permanent (clarification): the simplest option that respects the constitution. The confirmation before deleting is the only safeguard, without turning into an elaborate undo mechanism. The Completeness Check: checklists/requirements.md Before analyze , checklist checks the spec on its own. Notice what it records in the notes: this time it found no completeness gaps, and it says explicitly that consistency between artifacts is analyze ‘s job, not its own. The two instruments have different targets, and this chapter shows both. Specification Quality Checklist: Delete a task Created: 2026-06-27 Feature: spec.md Content Quality No implementation details (languages, frameworks, APIs) Focused on user value and business needs

Written for non-technical stakeholders All mandatory sections completed Requirement Completeness No [NEEDS CLARIFICATION] markers remain Requirements are testable and unambiguous Success criteria are measurable Success criteria are technology-agnostic All acceptance scenarios are defined Edge cases are identified Scope is clearly bounded Dependencies and assumptions identified Feature Readiness All functional requirements have clear acceptance criteria User scenarios cover primary flows Feature meets measurable outcomes defined in Success Criteria No implementation details leak into specification Notes checklist found no completeness gaps this time: the confirmation and the permanence were already pinned down in clarify . Consistency between artifacts (this spec against the completion spec) is analyze ‘s job, not checklist ‘s.

What plan Decided: plan.md This lap's plan passes the constitution check clean, with no rejected draft. The operation is simple (a remove method on the repository, a DeleteTask use case), and the most interesting design decision is where the confirmation lives: in the view, because it is a UX safeguard, not a business rule. Implementation Plan: Delete a task Branch: 004-excluir-tarefa | Spec: spec.md Input: Feature specification in specs/004-excluir-tarefa/spec.md Summary Remove a task from the list permanently. The spec says what (the task disappears for good, after confirmation); this plan decides how: a new remove(id) method on the repository, a DeleteTask use case in the domain that returns Result (error as value for the nonexistent task), the orchestration in the store and a simple confirmation in the view before triggering the action. No new field enters the task: deleting erases the whole task, it does not mark a state. Technical Context Language/Version: TypeScript 5.x on Node 20+; target: browser (React web). Inherited from 001/002/003. Main dependencies: React 18, Vite, Zustand. No new dependencies (stack inherited from the 001 plan ).

Persistence: the deletion is written to localStorage behind TaskRepository (real removal of the item). It is the counterpart of add : what was persisted ceases to exist. Testing: Vitest for the DeleteTask use case (domain, no DOM), for the in-memory repository's remove and for the store action. Project type: single-page, single-user web application, no backend. How-Decisions (what the spec did not pin down)

  1. Where data removal lives: a new remove(id: string) method on TaskRepository , implemented in LocalStorageTaskRepository (and in the in-memory fake for the tests). It is the counterpart of add ; the domain does not know localStorage .
  2. Who decides and reports the failure: a DeleteTask use case in the domain. It looks the task up by id; if it does not exist, it returns failure({ kind: “task-not-found” }) (error as value, FR-004); if it exists, it calls remove and returns success. The view never talks straight to the repository.
  3. Where the confirmation lives: in the view. The confirmation is an interface gesture (a UX safeguard), not a business rule; the domain receives the delete order already confirmed. The store only orchestrates: it triggers deleteTask and reloads the list. Constitution Check Principle How the plan meets it I. Layered Architecture Data removal sits behind the repository; the rule (does it exist? then remove) sits in the domain use case; the view only confirms and displays. Dependencies

point toward the domain. II. Business Logic Isolated The decision “delete an existing task; nonexistent is a failure” is a pure use case, testable without a UI. The confirmation is UX, not a business decision. III. Error as Value A nonexistent task becomes Result.failure({ kind: “task-not-found” }) , symmetric to CompleteTask ; no exception crosses layers. IV. TDD Tests of the use case (removes an existing one; nonexistent becomes a failure; does not affect the others) and of the repository's remove before implementation. V. Simplicity/YAGNI No trash bin, no undo, no bulk deletion, no new state on the task. One remove method, one use case, one confirmation. VI. Technology Agnosticism The spec names no stack; the stack (React/TS/Vite/Zustand) is inherited. The decisions here are about design (where removal lives, who reports the failure), not new technology. Result: PASS. No violations; no rejected draft in this feature. Traceability clarify/spec → plan Decision (clarify/spec) Effect on the plan Permanent deletion (no remove erases the item from localStorage ; no “deleted” state is stored.

trash/undo) Confirmation before deleting Confirmation in the view; the domain receives the order already confirmed. Deleting holds for any state DeleteTask does not read completed : it removes the indicated task, open or completed. Nonexistent is error as value DeleteTaskFailure = { kind: “task-not-found” } , handled in the store. Project Structure (additions) src/ ├── domain/ │ ├── failures/delete-task-failure.ts # new: { kind: “task-not-found” } │ ├── repositories/task-repository.ts # + remove(id): Promise │ └── usecases/delete-task.ts # new: makeDeleteTask(repository ) ├── data/ │ └── local-storage-task-repository.ts # + remove(id) (counterpart of a dd) └── presentation/ ├── store/task-store.ts # + deleteTask(id) └── components/TaskList.tsx # + delete button with confirmat ion test/ ├── domain/delete-task.test.ts # new: remove; nonexistent become s a failure; preserves the others ├── data/local-storage-task-repository.test.ts # + remove persists the absen ce └── presentation/task-store.test.ts # + deleteTask reloads the list Phase 1 - Design Model of the operation in data-model.md.

Manual validation in quickstart.md. The analyze Report: analyze-report.md This is the central artifact of the chapter. Read it whole: it has two runs, the first catching the HIGH conflict (C1) and ordering a fix at the source, the second passing clean and releasing implement . Consistency report ( analyze ): Delete a task Cross-scan of spec.md × plan.md × tasks.md for the 004-excluir-tarefa feature, read together with the artifacts of the features already integrated on main (especially 002-concluir-tarefa ). analyze rewrites nothing: it produces the report below and recommends where to fix. First run (before the fix) Specification Analysis Report ID Category Severity Location Summary C1 Inconsistency (conflict between artifacts) HIGH 004-excluir- tarefa/spec.md FR-002, Scenario 2, Clarif. Q3 × 002-concluir- tarefa/spec.md FR-006 The deletion spec states that a task (including a completed one) “disappears from the list and from

storage, permanently” the completion spec states that “a completed task stays in the list, in the same position.” Read together the two contradict each other on whether an item can leave the list. A1 Underspecification (minor) LOW 004-excluir- tarefa/spec.md FR-003 “Simple confirmation” does not pin down the means (native dialog, modal) Coverage Summary Total tasks: 12 Requirement coverage (≥1 task): 100% (FR-001..FR-005 mapped to T001-T010) Ambiguity count: 1 (LOW) Duplication count: 0

Conflicts: 1 (HIGH) Diagnosis of conflict C1 The point is not “which spec is right and which is wrong all the way through,” but which wording is ambiguous. Checked against the real artifact 002-concluir-tarefa/spec.md (FR-006, a finished, merged feature), completion's “stays in the list / nothing disappears” referred to a specific thing: completing does not auto-remove the task (it stays struck through, in place), as opposed to the idea that finishing a to-do would erase it. It was not a promise of eternal permanence against any action. So deletion can stay permanent without contradicting completion. The artifact to adjust is the deletion spec: the current wording leaves “permanent” loose, without saying it describes the action of deleting (distinct from completing). That is where, at the source, the clarification goes in. Next Actions HIGH conflict present: resolve before /speckit-implement . Suggested action: edit 004-excluir-tarefa/spec.md (source), realign plan.md / tasks.md if needed and rerun analyze until it passes clean. Do not change a task so the report stops flagging the conflict: that would hide the contradiction instead of resolving it. The fix cascade The fix went in at the source, the deletion spec ( 004-excluir- tarefa/spec.md ): A new clarification Q4 tells completing (marks and keeps) apart from deleting (takes it out for good).

FR-002 now says that “permanent” qualifies the action of deleting, distinct from completing. Scenario 2 gains the same caveat. From there, a cascade check of the downstream artifacts: plan.md : it already modeled deletion as DeleteTask removing in any state; no decision depended on the ambiguous wording. It stays valid, unchanged. tasks.md : the 12 tasks already covered deleting a completed task (T006 tests that case). Unchanged. Since the contradiction was in the spec's wording, not in a technical decision, the cascade was short: with the source fixed, plan and tasks stayed coherent. If the conflict were about a decision (say, the plan having chosen “mark as deleted” instead of removing), the cascade would run down to the tasks and the code. Second run (after the fix) Specification Analysis Report ID Category Severity Location Summary Recommendation — — — — No inconsistency, relevant ambiguity or conflict between artifacts. No action. Coverage Summary

Total tasks: 12 Requirement coverage (≥1 task): 100% Ambiguity count: 0 Duplication count: 0 Conflicts: 0 Next Actions Clean report: cleared for /speckit-implement . The plan ‘s Model of the Operation: data-model.md Deletion adds neither entity nor field; it models an operation on the Task that already exists. The table makes explicit that the error as value lives in the use case, and that storage idempotency (removing an absent id does not break) lives in the repository. Data Model: Delete a task The feature adds neither entity nor field. It models an operation on the existing Task entity: permanent removal. Operation Operation Input Output Predictable failure DeleteTask id: string Result<void, DeleteTaskFailure> task-not-found (no task with that id exists) Counterpart in the repository

Method ( TaskRepository port) Effect remove(id: string): Promise Erases the task from the storage medium; counterpart of add . Idempotent from the storage point of view (removing an absent id does not break), but the existence check and the error as value live in the use case. Preserved invariants The order of the other tasks (creation, oldest first) does not change when one is removed (FR-005). Deleting does not depend on the task's state: open or completed are removed alike. No “deleted” state is persisted: the task disappears, it is not marked (no trash bin). The Manual Validation: quickstart.md Quickstart / Validation: Delete a task Automated tests npm test # all green, including delete-task, repository.remove and sto re.deleteTask npm run build # clean type-check

Manual validation (Success Criteria)

  1. SC-001: create a task, trigger delete, confirm → the task disappears from the list right away.
  2. SC-002: delete a task, close and reopen the app → the task stays absent.
  3. SC-003: trigger delete and cancel the confirmation → the task stays in the list.
  4. Delete a completed task → it disappears just like an open one (FR-002, clarification).
  5. The other tasks keep their creation order after the removal (FR-005). The tasks Execution List: tasks.md Notice the opening note: purely mechanical steps (creating a failure type, composing a button in the view) do not require a test of their own. It is the granularity Chapter 9 discussed: tasks tells apart what needs a test first from what is just wiring. Tasks: Delete a task Branch: 004-excluir-tarefa | Plan: plan.md TDD order: the test for a behavior comes before the implementation that satisfies it. [P] marks tasks that can run in parallel (distinct files, no dependency). Purely mechanical steps (creating a failure type, composing a button in the view) do not require a test of their own.

Phase 1 - Repository (data layer) T001 test/data/local-storage-task-repository.test.ts : test of remove(id) (removing a task erases only that one from localStorage ; removing an absent id does not break; the order of the others is preserved). Fails first. T002 src/domain/repositories/task-repository.ts : declare remove(id: string): Promise in the repository interface. (mechanical step: contract, no test of its own) T003 src/data/local-storage-task-repository.ts : implement remove(id) (counterpart of add ) until T001 passes. T004 test/helpers/in-memory-task-repository.ts : implement remove(id) in the in-memory fake, for the domain tests. (mechanical step: fake parity with the repository) Phase 2 - Domain (use case) T005 src/domain/failures/delete-task-failure.ts : type DeleteTaskFailure = { kind: “task-not-found” } . (mechanical step: failure type, no test of its own) T006 test/domain/delete-task.test.ts : tests of DeleteTask (removes an existing task and returns success; removes a completed task just like an open one; a nonexistent task returns task-not- found ; removing one does not change the position of the others). Fails first. T007 src/domain/usecases/delete-task.ts : makeDeleteTask(repository) (looks up by id; nonexistent becomes a failure; existing calls remove and returns success); implement until T006 passes. Phase 3 - Presentation T008 test/presentation/task-store.test.ts : test of deleteTask in the store (after deleting, the reloaded list no longer contains the

task). Fails first. T009 src/presentation/store/task-store.ts : deleteTask(id) action (triggers the use case and reloads tasks ); implement until T008 passes. T010 src/presentation/components/TaskList.tsx : a “delete” button on each item, with confirmation before triggering deleteTask (canceling does not delete). (mechanical view step: composition, covered by the manual validation) Phase 4 - Validation T011 npm test (all green) and npm run build (clean type-check). T012 Manual validation of Success Criteria SC-001..SC-003 and of the quickstart. The Code implement Generated Since analyze passed clean on the second run, implement ran without surprises. The design was already coherent; the code is the expected wiring. Where a file already appeared in earlier chapters, the text points out what changed. Domain The deletion's predictable failure, symmetric to completion's. The comment recalls that deletion is permanent, with no intermediate state. // src/domain/failures/delete-task-failure.ts /**

  • Predictable failure when deleting a task .
  • task-not-found: no task with the given id exists (for example, alread y
  • removed in another tab). It is error as value, not an exception (constitut ion,
  • Principle III). Deletion is permanent: there is no intermediate state .

/ export type DeleteTaskFailure = { readonly kind: “task-not-found” }; The repository gains the remove method, the counterpart of add . The contract grows; the real repository and the test fake need to follow. // src/domain/repositories/task-repository.ts import type { Task } from “../entities/task”; /**

  • Domain repository for persisting and reading tasks .
  • The domain declares this interface; the data layer implements it. This wa y
  • the business rule does not know the storage medium .

/ export interface TaskRepository { /** All tasks, in creation order (oldest first). */ getAll(): Promise<Task[]>; /** A task by id, or null if it does not exist. */ getById(id: string): Promise<Task | null>; /** Persists a new task. */ add(task: Task): Promise;

/** Updates an existing task in place, without changing the list order. */ update(task: Task): Promise; /** Removes a task from storage. Counterpart of add. */ remove(id: string): Promise; } The DeleteTask use case: it looks up by id, returns task-not-found if it does not find one, removes it and returns success if it does. It does not read completed , because deleting holds for any state. It is the same shape as the use cases of the earlier laps. // src/domain/usecases/delete-task.ts import type { DeleteTaskFailure } from “../failures/delete-task-failure”; import type { TaskRepository } from “../repositories/task-repository”; import { failure, success, type Result } from “../result”; export type DeleteTask = (id: string) => Promise<Result<void, DeleteTaskFailu re»;

/**

  • Deletes a task permanently (FR-001, FR-002) .
  • Deleting does not look at the task's state: it removes the indicated one ,
  • whether open or completed (clarification 2026-06-27). A nonexistent task i s
  • error as value (task-not-found, FR-004), not an exception. Removing a ta sk
  • does not reorder the others (FR-005): the repository only takes the item o ut
  • of the list .

/ export const makeDeleteTask = (repository: TaskRepository): DeleteTask => async (id) => { const task = await repository.getById(id);

if (task === null) { return failure({ kind: “task-not-found” }); } await repository.remove(id); return success(undefined); }; Data The localStorage repository gains remove : it reads everything, filters out the given id and rewrites. The order of the others is preserved because the filter does not reorder. // src/data/local-storage-task-repository.ts import type { Task } from “../domain/entities/task”; import type { TaskRepository } from “../domain/repositories/task-repository”; const STORAGE_KEY = “todo-app.tasks”;

/**

  • Persists tasks as JSON in the browser's localStorage. It is the we b
  • equivalent of “persist on the device” (FR-006): it survives closing an d
  • reopening the app. I/O exceptions stay contained at this boundar y
  • (constitution, Principle III); the domain never sees localStorage .

/ export class LocalStorageTaskRepository implements TaskRepository { constructor(private readonly storage: Storage = localStorage) {} async getAll(): Promise<Task[]> { const raw = this.storage.getItem(STORAGE_KEY); if (raw === null || raw.trim().length === 0) { return [];

} const stored = JSON.parse(raw) as Array<Omit<Task, “completed"> & { compl eted?: boolean }>; // Tasks written by the previous feature have no completed; reading the // missing one as false (open) keeps compatibility without a migration. const tasks: Task[] = stored.map((task) => ({ ...task, completed: task.co mpleted ?? false })); return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } async getById(id: string): Promise<Task | null> { const tasks = await this.getAll(); return tasks.find((task) => task.id === id) ?? null; }

async add(task: Task): Promise { const tasks = await this.getAll(); const updated = [...tasks, task]; this.storage.setItem(STORAGE_KEY, JSON.stringify(updated)); } async update(task: Task): Promise { const tasks = await this.getAll(); const updated = tasks.map((existing) => (existing.id === task.id ? task : existing)); this.storage.setItem(STORAGE_KEY, JSON.stringify(updated)); } async remove(id: string): Promise { const tasks = await this.getAll();

const remaining = tasks.filter((task) => task.id !== id); this.storage.setItem(STORAGE_KEY, JSON.stringify(remaining)); } } Presentation The store gains the deleteTask action, in the same mold as completeTask / reopenTask : it triggers the use case and reloads the list. Nothing new in the structure. // src/presentation/store/task-store.ts import { createStore } from “zustand/vanilla”; import type { Task } from “../../domain/entities/task”; import type { TaskFilter } from “../../domain/entities/task-filter”; import type { CreateTaskFailure } from “../../domain/failures/create-task-fai lure”; import type { CompleteTask } from “../../domain/usecases/complete-task”; import type { CreateTask } from “../../domain/usecases/create-task”;

import type { DeleteTask } from “../../domain/usecases/delete-task”; import type { ListTasks } from “../../domain/usecases/list-tasks”; import type { ReopenTask } from “../../domain/usecases/reopen-task”; export type TaskStoreState = { readonly tasks: Task[]; readonly filter: TaskFilter; readonly lastError: string | null; loadTasks: () => Promise; createTask: (title: string, description: string) => Promise; completeTask: (id: string) => Promise; reopenTask: (id: string) => Promise; deleteTask: (id: string) => Promise; setFilter: (filter: TaskFilter) => void; };

const messageFor = (failure: CreateTaskFailure): string => { switch (failure.kind) { case “title-required”: return “The title is required."; case “duplicate-title”: return “A task with this title already exists."; } }; /**

  • Presentation store (Zustand) that orchestrates the domain use cases an d
  • exposes the state to the UI. It holds no business rule: it only trigger s
  • createTask/listTasks and translates the typed failure into a message f or
  • the screen .

/ export const createTaskStore = ( createTask: CreateTask, listTasks: ListTasks, completeTask: CompleteTask, reopenTask: ReopenTask, deleteTask: DeleteTask ) => createStore((set) => ({ tasks: [], filter: “open”, lastError: null, loadTasks: async () => { const tasks = await listTasks();

set({ tasks, lastError: null }); }, createTask: async (title, description) => { const result = await createTask({ title, description }); if (result.kind === “failure”) { set({ lastError: messageFor(result.error) }); return false; } const tasks = await listTasks(); set({ tasks, lastError: null }); return true; },

completeTask: async (id) => { await completeTask(id); const tasks = await listTasks(); set({ tasks }); }, reopenTask: async (id) => { await reopenTask(id); const tasks = await listTasks(); set({ tasks }); }, deleteTask: async (id) => { await deleteTask(id); const tasks = await listTasks();

set({ tasks }); }, setFilter: (filter) => { set({ filter }); } })); The TaskList gains the “delete” button and the confirmation, the latter via window.confirm in the view itself, exactly where the plan placed it (UX safeguard, not business rule). Canceling does not delete, because deleteTask only fires inside the if . // src/presentation/components/TaskList.tsx import { selectByFilter } from “../../domain/entities/task-filter”; import { useTaskStore } from “../store/task-store-context”; /**

  • Lists the active view's tasks in creation order; an empty list is a vali d
  • state. The choice of which tasks appear comes from the domai n
  • (selectByFilter); here we only display the result. A completed task i s
  • shown struck through, with the action toggling between completing an d
  • reopening .

/ export function TaskList() { const tasks = useTaskStore((state) => state.tasks); const filter = useTaskStore((state) => state.filter); const completeTask = useTaskStore((state) => state.completeTask); const reopenTask = useTaskStore((state) => state.reopenTask); const deleteTask = useTaskStore((state) => state.deleteTask); const visibleTasks = selectByFilter(tasks, filter);

const confirmAndDelete = (id: string, title: string) => {
if (window.confirm(Delete "${title}"? This action cannot be undone.)) {
void deleteTask(id);
}
};
if (visibleTasks.length === 0) {
return

No tasks in this view.

;
}
return (
    {visibleTasks.map((task) => (
    <li
    key={task.id}
    className={task.completed ? “task-list__item task-list__item--done”
    “task-list__item”}
          <label className="task-list__check">
            <input
              type="checkbox"
              checked={task.completed}
              aria-label={task.completed ? `Reopen ${task.title}` : `Complete
    

    ${task.title}`} onChange={() => (task.completed ? reopenTask(task.id) : complet eTask(task.id))} />

    {task.title}

    {task.description.length > 0 &&

    {task.description}

    } <button type="button”

    className="task-list__delete” aria-label={Delete ${task.title}} onClick={() => confirmAndDelete(task.id, task.title)} > Delete ))} ); } The main.tsx composes the new use case at the boundary and injects it into the store, as in the earlier laps, with one more line. // src/main.tsx import { StrictMode } from “react”;

    import { createRoot } from “react-dom/client”; import { LocalStorageTaskRepository } from “./data/local-storage-task-reposit ory”; import { makeCompleteTask } from “./domain/usecases/complete-task”; import { makeCreateTask } from “./domain/usecases/create-task”; import { makeDeleteTask } from “./domain/usecases/delete-task”; import { makeListTasks } from “./domain/usecases/list-tasks”; import { makeReopenTask } from “./domain/usecases/reopen-task”; import { App } from “./presentation/App”; import { createTaskStore } from “./presentation/store/task-store”; import { TaskStoreProvider } from “./presentation/store/task-store-context”; import “./index.css”; // Composition at the boundary: the concrete repository is instantiated here and // injected into the use cases; only at this point do the layers meet. const repository = new LocalStorageTaskRepository();

    const store = createTaskStore( makeCreateTask(repository), makeListTasks(repository), makeCompleteTask(repository), makeReopenTask(repository), makeDeleteTask(repository) ); const rootElement = document.getElementById(“root”); if (rootElement === null) { throw new Error(“Element #root not found in index.html."); } createRoot(rootElement).render(

    ); Tests The DeleteTask tests cover the four cases tasks asked for, including the one that proves deleting a completed task works just like an open one (the case born from the conflict analyze caught). // test/domain/delete-task.test.ts import { describe, expect, it } from "vitest"; import { makeCompleteTask } from "../../src/domain/usecases/complete-task"; import { makeCreateTask } from "../../src/domain/usecases/create-task"; import { makeDeleteTask } from "../../src/domain/usecases/delete-task"; import { InMemoryTaskRepository } from "../helpers/in-memory-task-repository" ;
    const seedTask = async (repository: InMemoryTaskRepository, id: string, title
    string) => { const createTask = makeCreateTask(repository, () => id); const result = await createTask({ title }); if (result.kind === “failure”) { throw new Error(“seed failed”); } return result.value; }; describe(“DeleteTask”, () => { it(“removes an existing task and returns success”, async () => { const repository = new InMemoryTaskRepository(); const task = await seedTask(repository, “id-1”, “Wash the dishes”); const deleteTask = makeDeleteTask(repository);

    const result = await deleteTask(task.id); expect(result.kind).toBe(“success”); expect(await repository.getById(task.id)).toBeNull(); }); it(“removes a completed task just like an open one”, async () => { const repository = new InMemoryTaskRepository(); const task = await seedTask(repository, “id-1”, “Done task”); await makeCompleteTask(repository)(task.id); const deleteTask = makeDeleteTask(repository); const result = await deleteTask(task.id); expect(result.kind).toBe(“success”); expect(await repository.getById(task.id)).toBeNull();

    }); it(“returns task-not-found when the task does not exist”, async () => { const repository = new InMemoryTaskRepository(); const deleteTask = makeDeleteTask(repository); const result = await deleteTask(“nonexistent”); expect(result).toEqual({ kind: “failure”, error: { kind: “task-not-found” } }); }); it(“deleting a task does not change the position of the others”, async () =

    { const repository = new InMemoryTaskRepository(); let tick = 0; const createTask = makeCreateTask(repository, () => id-${tick}, () => n ew Date(2026, 0, 1, 0, 0, tick++)); await createTask({ title: “First” });

    await createTask({ title: “Second” }); await createTask({ title: “Third” }); const deleteTask = makeDeleteTask(repository); await deleteTask(“id-1”); const titles = (await repository.getAll()).map((task) => task.title); expect(titles).toEqual([“First”, “Third”]); }); }); The in-memory fake gains remove , mirroring the real repository for the domain tests. // test/helpers/in-memory-task-repository.ts import type { Task } from “../../src/domain/entities/task”; import type { TaskRepository } from “../../src/domain/repositories/task-repos itory”;

    /** In-memory implementation of TaskRepository for domain and store tests. */ export class InMemoryTaskRepository implements TaskRepository { private readonly tasks: Task[] = []; async getAll(): Promise<Task[]> { return [...this.tasks].sort((a, b) => a.createdAt.localeCompare(b.created At)); } async getById(id: string): Promise<Task | null> { return this.tasks.find((task) => task.id === id) ?? null; } async add(task: Task): Promise { this.tasks.push(task); }

    async update(task: Task): Promise { const index = this.tasks.findIndex((existing) => existing.id === task.id) ; if (index >= 0) { this.tasks[index] = task; } } async remove(id: string): Promise { const index = this.tasks.findIndex((task) => task.id === id); if (index >= 0) { this.tasks.splice(index, 1); } } }

    The production repository tests gain two new cases: remove erases only the indicated task and persists the absence without reordering, and removing an absent id does not break. // test/data/local-storage-task-repository.test.ts import { describe, expect, it } from “vitest”; import { LocalStorageTaskRepository } from “../../src/data/local-storage-task -repository”; import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { MemoryStorage } from “../helpers/memory-storage”; describe(“LocalStorageTaskRepository”, () => { it(“returns an empty list when nothing was saved”, async () => { const repository = new LocalStorageTaskRepository(new MemoryStorage()); expect(await repository.getAll()).toEqual([]); });

    it(“preserves tasks between sessions (closing and reopening the app)", asyn c () => { const storage = new MemoryStorage(); let tick = 0; const createTask = makeCreateTask( new LocalStorageTaskRepository(storage), () => id-${tick}, () => new Date(2026, 0, 1, 0, 0, tick++) ); await createTask({ title: “First” }); await createTask({ title: “Second” }); // A new instance over the same Storage simulates reopening the app. const reopened = new LocalStorageTaskRepository(storage); const titles = (await reopened.getAll()).map((task) => task.title); expect(titles).toEqual([“First”, “Second”]);

    }); it(“update persists the new state without reordering the list”, async () => { const storage = new MemoryStorage(); let tick = 0; const repository = new LocalStorageTaskRepository(storage); const createTask = makeCreateTask(repository, () => id-${tick}, () => n ew Date(2026, 0, 1, 0, 0, tick++)); await createTask({ title: “First” }); await createTask({ title: “Second” }); const second = await repository.getById(“id-1”); await repository.update({ ...second!, completed: true }); const reopened = new LocalStorageTaskRepository(storage); const all = await reopened.getAll(); expect(all.map((task) => task.title)).toEqual([“First”, “Second”]);

    expect(all.find((task) => task.id === “id-1”)?.completed).toBe(true); }); it(“reads old tasks without the completed field as open”, async () => { const storage = new MemoryStorage(); storage.setItem( “todo-app.tasks”, JSON.stringify([{ id: “x”, title: “Old”, description: “", createdAt: “2 026-01-01T00:00:00.000Z” }]) ); const tasks = await new LocalStorageTaskRepository(storage).getAll(); expect(tasks[0].completed).toBe(false); }); it(“remove erases only the indicated task and persists the absence, without reordering the others”, async () => {

    const storage = new MemoryStorage(); let tick = 0; const repository = new LocalStorageTaskRepository(storage); const createTask = makeCreateTask(repository, () => id-${tick}, () => n ew Date(2026, 0, 1, 0, 0, tick++)); await createTask({ title: “First” }); await createTask({ title: “Second” }); await createTask({ title: “Third” }); await repository.remove(“id-1”); const reopened = new LocalStorageTaskRepository(storage); const titles = (await reopened.getAll()).map((task) => task.title); expect(titles).toEqual([“First”, “Third”]); }); it(“removing an absent id does not break or change the list”, async () => {

    const storage = new MemoryStorage(); const repository = new LocalStorageTaskRepository(storage); const createTask = makeCreateTask(repository, () => “id-0”, () => new Dat e(2026, 0, 1)); await createTask({ title: “Only one” }); await repository.remove(“nonexistent”); expect((await repository.getAll()).map((task) => task.title)).toEqual([“O nly one”]); }); }); Finally, the store tests gain the deleteTask case, which proves that the reloaded list no longer contains the removed task, on top of the eight that came from the earlier laps. // test/presentation/task-store.test.ts import { describe, expect, it } from “vitest”; import { makeCompleteTask } from “../../src/domain/usecases/complete-task”;

    import { makeCreateTask } from “../../src/domain/usecases/create-task”; import { makeDeleteTask } from “../../src/domain/usecases/delete-task”; import { makeListTasks } from “../../src/domain/usecases/list-tasks”; import { makeReopenTask } from “../../src/domain/usecases/reopen-task”; import { createTaskStore } from “../../src/presentation/store/task-store”; import { InMemoryTaskRepository } from “../helpers/in-memory-task-repository” ; const buildStore = () => { const repository = new InMemoryTaskRepository(); return createTaskStore( makeCreateTask(repository), makeListTasks(repository), makeCompleteTask(repository), makeReopenTask(repository), makeDeleteTask(repository)

    ); }; describe(“task store”, () => { it(“loads the list of existing tasks”, async () => { const store = buildStore(); await store.getState().createTask(“Task A”, “"); await store.getState().loadTasks(); expect(store.getState().tasks.map((task) => task.title)).toEqual([“Task A “]); }); it(“creating successfully updates the list and clears the error”, async () => { const store = buildStore();

    const created = await store.getState().createTask(“New”, “a description”) ; expect(created).toBe(true); expect(store.getState().tasks).toHaveLength(1); expect(store.getState().lastError).toBeNull(); }); it(“a title-required failure exposes a message without changing the list”, async () => { const store = buildStore(); const created = await store.getState().createTask(” “, “"); expect(created).toBe(false); expect(store.getState().tasks).toHaveLength(0); expect(store.getState().lastError).toBe(“The title is required."); });

    it(“a duplicate-title failure exposes a specific message”, async () => { const store = buildStore(); await store.getState().createTask(“Duplicate”, “"); const created = await store.getState().createTask(“Duplicate”, “"); expect(created).toBe(false); expect(store.getState().lastError).toBe(“A task with this title already e xists."); expect(store.getState().tasks).toHaveLength(1); }); it(“completing a task reflects the completed state in the list”, async () =

    { const store = buildStore(); await store.getState().createTask(“Finish the report”, “"); const taskId = store.getState().tasks[0].id;

    await store.getState().completeTask(taskId); expect(store.getState().tasks[0].completed).toBe(true); }); it(“reopening a completed task returns it to the open state”, async () => { const store = buildStore(); await store.getState().createTask(“Finish the report”, “"); const taskId = store.getState().tasks[0].id; await store.getState().completeTask(taskId); await store.getState().reopenTask(taskId); expect(store.getState().tasks[0].completed).toBe(false); }); it(“the initial view is ‘open’", () => {

    const store = buildStore(); expect(store.getState().filter).toBe(“open”); }); it(“setFilter switches the active view without touching the full list”, asy nc () => { const store = buildStore(); await store.getState().createTask(“Task A”, “"); store.getState().setFilter(“completed”); expect(store.getState().filter).toBe(“completed”); expect(store.getState().tasks).toHaveLength(1); }); it(“deleteTask removes the task and reloads the list without it”, async () => {

    const store = buildStore(); await store.getState().createTask(“Task A”, “"); await store.getState().createTask(“Task B”, “"); const target = store.getState().tasks.find((task) => task.title === “Task A”); await store.getState().deleteTask(target!.id); const titles = store.getState().tasks.map((task) => task.title); expect(titles).toEqual([“Task B”]); }); }); The Portrait of the Lap This lap's code is as routine as the earlier ones: a method on the repository, a use case, an action in the store, a button in the view. Four laps, not one new layer. Why a stable architecture absorbs features without growing belongs to FOCUS Architecture (2026, https://books.kodel.com.br/en/books/focus/), the second volume in this trilogy, which answers where each rule lives and why

    dependencies point inward. You do not need it here: for this lap it is enough to notice that the place for every piece already existed before the feature arrived. What makes it special happened before the code, in analyze . Without it, the contradiction between “the task vanishes for good” and “a completed task stays in the list” would only surface late, maybe as a reported bug, maybe as a hallway argument over which behavior was right. analyze found it by crossing two artifacts from different features, demanded the fix at the source (the spec's wording, not a patch in the tasks) and released the advance only after a second clean pass. It is the central lesson of Chapter 9 in action: tasks breaks the plan into verifiable steps, and analyze ensures the artifacts do not contradict each other before a line is written. In Chapter 10.5, the last lap, the spotlight moves to implement , and with it a refinement that only surfaced while the code was being written: the title- uniqueness rule, on editing, cannot count the task itself. It is the kind of discovery implement brings out, and that goes back to the spec instead of becoming a hidden adjustment.

    Powered by TurnKey Linux.