Powered by AppSignal & Oban Pro

Choreo Planner: Comprehensive Walkthrough

planner_walkthrough.livemd

Choreo Planner: Comprehensive Walkthrough

Mix.install([
  {:choreo, "~> 0.14.1"},
  # {:choreo, path: Path.expand("../..", __DIR__), force: true},
  {:kino_vizjs, "~> 0.9.0"}
])

Section

Rendering diagrams: This livebook uses Kino.VizJS to render DOT diagrams inline. You can also copy DOT output into PlantText or run dot -Tpng diagram.dot -o diagram.png locally. Since version 0.8.0, Choreo also supports Mermaid.js output for GitHub, GitLab, Notion, and any Markdown-based documentation.


What is Choreo.Planner?

Choreo.Planner models projects, tasks, milestones, users, and labels as a typed multigraph. Unlike a static task list, Choreo Planner is a computable project model — you can analyse it for readiness, blocked work, critical paths, bottlenecks, and assignee workload, then render the same data as a Kanban board, Gantt chart, or dependency flowchart.

Common use cases:

  • Sprint planning and backlog grooming
  • Release milestone tracking
  • Cross-team dependency mapping
  • Resource allocation and bottleneck analysis

Node Types

Type Shape Purpose
:task ▭ rounded rect Unit of work with status, priority, estimate
:milestone ◇ diamond Temporal checkpoint or release target
:user ● circle Team member who can be assigned tasks
:label 🏟 stadium Categorical tag (e.g. "frontend", "bug")

Edge Types

Type Builder Direction Meaning
:contains contains/3 milestone → task Task belongs to milestone
:depends_on depends_on/3 dependency → task Must finish before task starts
:blocks blocks/3 blocker → blocked Semantic blocker
:assigned_to assign/3 task → user Ownership
:tagged_with tag/3 task → label Categorisation
:relates_to relates/3 bidirectional Loose association

API Approaches: Programmatic Pipe API vs Lab DSL

Choreo provides two ways to author planners:

  1. Programmatic Pipe API (Choreo.Planner): Explicit, pipe-first builder functions (add_task/3, add_milestone/3, contains/3, depends_on/3, assign/3, tag/3). Best for production code, dynamic graph generation, and data pipelines.
  2. Lab DSL (Choreo.Lab.DSL.Planner): Concise, sketch-oriented syntax (planner do ... end, variable binding, ~>, and pipe modifiers like |> depends_on(), |> assigned_to()). Best for Livebooks, architecture reviews, sprint sketches, and rapid planning.

In this guide, the introductory legend demonstrates the programmatic pipe API, while subsequent examples showcase the expressive Lab DSL.

alias Choreo.Planner
alias Choreo.Planner.Analysis
import Choreo.Lab.DSL.Planner

legend =
  Planner.new("Legend")
  |> Planner.add_milestone(:m1, title: "Milestone")
  |> Planner.add_task(:t1, title: "Task", status: :in_progress)
  |> Planner.add_user(:u1, name: "User")
  |> Planner.add_label(:l1, title: "Label")
  |> Planner.contains(:m1, :t1)
  |> Planner.assign(:t1, :u1)
  |> Planner.tag(:t1, :l1)

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(legend, syntax: :flowchart)),
  Graphviz: Kino.VizJS.render(Planner.to_dot(legend)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(legend, syntax: :flowchart))
)

Example 1: Simple Sprint

Let's model a small two-week sprint with a milestone, a few tasks, and one dependency.

sprint =
  planner "Sprint 42" do
    sprint_42 = milestone("Sprint 42")
    design = task("Design API", status: :done, estimate_hours: 16)
    impl = task("Implement API", status: :in_progress, estimate_hours: 24)
    test = task("Write Tests", status: :backlog, estimate_hours: 8)
    docs = task("Update Docs", status: :backlog, estimate_hours: 4)
    alice = user("Alice")
    bob = user("Bob")

    contains sprint_42 ~> design
    contains sprint_42 ~> impl
    contains sprint_42 ~> test
    contains sprint_42 ~> docs

    design ~> impl |> depends_on()
    impl ~> test |> depends_on()

    design ~> alice |> assigned_to()
    impl ~> alice |> assigned_to()
    test ~> bob |> assigned_to()
  end

Query the Project

IO.puts("Tasks: #{Enum.map_join(Planner.tasks(sprint), ", ", fn {id, _} -> to_string(id) end)}")
IO.puts("Users: #{Enum.map_join(Planner.users(sprint), ", ", fn {_, d} -> d.name end)}")
IO.puts("Alice's tasks: #{Enum.join(Planner.assigned_tasks(sprint, :alice), ", ")}")
IO.puts("Impl depends on: #{Enum.join(Planner.dependencies(sprint, :impl), ", ")}")

Kanban Board

Render the sprint as a Kanban diagram. Tasks are grouped by status into columns.

With Choreo.Lab.Siren, we can render modern native kanban syntax directly inline!

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(sprint, syntax: :kanban)),
  Graphviz: Kino.VizJS.render(Planner.to_dot(sprint)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(sprint, syntax: :flowchart))
)

Gantt Chart

Render the same sprint as a Gantt chart. Tasks are auto-scheduled based on dependencies and estimate hours (8 hours = 1 day). Done tasks show green, in-progress tasks show blue.

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(sprint, syntax: :gantt, start_date: ~D[2026-06-02])),
  Graphviz: Kino.VizJS.render(Planner.to_dot(sprint)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(sprint, syntax: :gantt, start_date: ~D[2026-06-02]))
)

Dependency Flowchart

See the task network as a flowchart with color-coded status.

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(sprint, syntax: :flowchart)),
  Graphviz: Kino.VizJS.render(Planner.to_dot(sprint), height: "600px"),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(sprint, syntax: :flowchart))
)

Example 2: Cross-Team Release

A larger project with multiple milestones, cross-team dependencies, and labels for categorisation.

release =
  planner "Q3 Release" do
    backend = milestone("Backend Work")
    frontend = milestone("Frontend Work")
    launch = milestone("Launch Day")

    auth_api = task("Auth API", status: :done, estimate_hours: 16, priority: :high)
    user_api = task("User API", status: :in_progress, estimate_hours: 24, priority: :high)
    payment_api = task("Payment API", status: :backlog, estimate_hours: 32, priority: :critical)
    login_ui = task("Login UI", status: :backlog, estimate_hours: 12)
    dashboard_ui = task("Dashboard UI", status: :backlog, estimate_hours: 20)
    marketing_site = task("Marketing Site", status: :backlog, estimate_hours: 16)
    load_test = task("Load Testing", status: :backlog, estimate_hours: 8)
    deploy = task("Deploy to Prod", status: :backlog, estimate_hours: 4)

    alice = user("Alice")
    bob = user("Bob")
    carol = user("Carol")

    backend_label = label("backend")
    frontend_label = label("frontend")

    contains backend ~> auth_api
    contains backend ~> user_api
    contains backend ~> payment_api

    contains frontend ~> login_ui
    contains frontend ~> dashboard_ui

    contains launch ~> marketing_site
    contains launch ~> load_test
    contains launch ~> deploy

    auth_api ~> user_api |> depends_on()
    user_api ~> payment_api |> depends_on()
    auth_api ~> login_ui |> depends_on()
    user_api ~> dashboard_ui |> depends_on()
    payment_api ~> load_test |> depends_on()
    load_test ~> deploy |> depends_on()
    marketing_site ~> deploy |> depends_on()

    auth_api ~> alice |> assigned_to()
    user_api ~> alice |> assigned_to()
    payment_api ~> bob |> assigned_to()
    login_ui ~> carol |> assigned_to()
    dashboard_ui ~> carol |> assigned_to()
    marketing_site ~> bob |> assigned_to()
    load_test ~> alice |> assigned_to()
    deploy ~> alice |> assigned_to()

    auth_api ~> backend_label |> tagged_with()
    user_api ~> backend_label |> tagged_with()
    payment_api ~> backend_label |> tagged_with()
    login_ui ~> frontend_label |> tagged_with()
    dashboard_ui ~> frontend_label |> tagged_with()
  end

Full Dependency Graph

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(release, syntax: :flowchart)),
  Graphviz: Kino.VizJS.render(Planner.to_dot(release), height: "600px"),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(release, syntax: :flowchart))
)

Per-Milestone Kanban

View only the launch milestone tasks:

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(release, syntax: :kanban, milestone: :launch)),
  Graphviz: Kino.VizJS.render(Planner.to_dot(release)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(release, syntax: :flowchart, milestone: :launch))
)

Per-Assignee Kanban

See what Carol is working on:

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(release, syntax: :kanban, assignee: :carol)),
  Graphviz: Kino.VizJS.render(Planner.to_dot(release)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(release, syntax: :flowchart, assignee: :carol))
)

Swimlane Diagrams (Grouping by Assignee, Milestone, or Status)

Swimlanes allow you to visualize task dependencies laid out across logical boundaries.

You can group swimlanes by assignee (swimlane_by: :assignee), milestone (swimlane_by: :milestone), or task status (swimlane_by: :status).

Kino.Layout.tabs(
  "By Assignee": Choreo.Lab.Siren.new(Planner.to_mermaid(release, syntax: :swimlane, swimlane_by: :assignee)),
  "By Milestone": Choreo.Lab.Siren.new(Planner.to_mermaid(release, syntax: :swimlane, swimlane_by: :milestone)),
  "By Status": Choreo.Lab.Siren.new(Planner.to_mermaid(release, syntax: :swimlane, swimlane_by: :status))
)

Gantt by Assignee

Group the Gantt chart sections by team member instead of milestone:

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(release, syntax: :gantt, section_by: :assignee, start_date: ~D[2026-06-02])),
  Graphviz: Kino.VizJS.render(Planner.to_dot(release)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(release, syntax: :gantt, section_by: :assignee, start_date: ~D[2026-06-02]))
)

Analysis

Ready Work

What can be started right now? Tasks whose dependencies are all :done.

Analysis.ready(release)
|> Enum.each(fn {id, data} ->
  IO.puts("  • #{data.title} (#{id})")
end)

Blocked Work

Tasks with unresolved dependencies or blockers.

Analysis.blocked(release)
|> Enum.each(fn {id, data} ->
  deps = Planner.dependencies(release, id) |> Enum.join(", ")
  IO.puts("  • #{data.title} — waiting on: #{deps}")
end)

Orphans

Tasks not in any milestone.

Analysis.orphans(release)

Critical Path

The longest dependency chain by estimated hours. This is the theoretical minimum time to complete the project if every task starts immediately after its prerequisites finish.

{:ok, path, total_estimate: hours} = Analysis.critical_path(release)

IO.puts("Critical path: #{Enum.join(path, " → ")}")
IO.puts("Total estimate: #{hours} hours (~#{Float.ceil(hours / 8)} days)")

Scoped to a single milestone:

{:ok, path, total_estimate: hours} = Analysis.critical_path(release, milestone: :backend)

IO.puts("Backend critical path: #{Enum.join(path, " → ")}")
IO.puts("Backend estimate: #{hours} hours")

Bottlenecks

Tasks ranked by how much downstream work depends on them. High count = delay this task and you delay everything after it.

Analysis.bottlenecks(release)
|> Enum.each(fn {id, count} ->
  title = release.graph.nodes[id][:title] || id
  IO.puts("  • #{title}: #{count} downstream tasks")
end)

Workload by Assignee

Summarise remaining work by owner. Done and cancelled tasks are excluded by default.

Analysis.workload_by_assignee(release)
|> Enum.each(fn {assignee, summary} ->
  name =
    case assignee do
      :unassigned -> "Unassigned"
      id -> release.graph.nodes[id][:name] || inspect(id)
    end

  IO.puts("  • #{name}: #{summary.task_count} open tasks, #{summary.estimate_hours}h estimated")
end)

Validation

Check for structural problems: dependency cycles, unassigned in-progress work, etc.

Analysis.validate(release)

Let's introduce a cycle to see validation in action:

cyclic =
  release
  |> Planner.depends_on(:auth_api, :deploy)

Analysis.validate(cyclic)

Example 3: Incident Response Plan

A different flavour of planner — tracking an active incident with blockers and semantic relationships.

incident =
  planner "INC-2026-0042" do
    detect = task("Detect Issue", status: :done, estimate_hours: 1)
    triage = task("Triage Severity", status: :done, estimate_hours: 1)
    isolate = task("Isolate Affected Service", status: :in_progress, estimate_hours: 2)
    fix = task("Deploy Fix", status: :backlog, estimate_hours: 4)
    verify = task("Verify in Prod", status: :backlog, estimate_hours: 1)
    postmortem = task("Write Postmortem", status: :backlog, estimate_hours: 4)
    notify_customers = task("Notify Customers", status: :backlog, estimate_hours: 1)

    oncall = user("On-Call Engineer")

    detect ~> triage |> depends_on()
    triage ~> isolate |> depends_on()
    isolate ~> fix |> depends_on()
    fix ~> verify |> depends_on()
    verify ~> postmortem |> depends_on()
    verify ~> notify_customers |> depends_on()

    blocks isolate ~> fix

    detect ~> oncall |> assigned_to()
    triage ~> oncall |> assigned_to()
    isolate ~> oncall |> assigned_to()
  end

Incident Kanban

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(incident, syntax: :kanban)),
  Graphviz: Kino.VizJS.render(Planner.to_dot(incident)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(incident, syntax: :flowchart))
)

Incident Gantt

Kino.Layout.tabs(
  Siren: Choreo.Lab.Siren.new(Planner.to_mermaid(incident, syntax: :gantt, start_date: Date.utc_today())),
  Graphviz: Kino.VizJS.render(Planner.to_dot(incident)),
  Sketch: Choreo.Lab.Sketch.new(Planner.to_mermaid(incident, syntax: :gantt, start_date: Date.utc_today()))
)

What's Blocking the Fix?

Analysis.blocked(incident)
|> Enum.each(fn {id, data} ->
  blockers = Planner.dependencies(incident, id)
  blocker_names = Enum.map_join(blockers, ", ", fn b -> incident.graph.nodes[b][:title] || b end)
  IO.puts("#{data.title} is blocked by: #{blocker_names}")
end)

Themes

All built-in themes work across every render target.

for theme <- [:default, :dark, :warm, :forest, :ocean] do
  {Atom.to_string(theme) |> String.capitalize(), Kino.VizJS.render(Planner.to_dot(sprint, theme: theme), height: "700px")}
end
|> Kino.Layout.tabs()

Exporting

Planner structs are plain data. Serialize them however you want:

# As an Erlang term (fastest, zero deps)
:erlang.term_to_binary(sprint)

Render to Mermaid and paste into GitHub issues, pull request descriptions, Notion pages, or Obsidian notes:

IO.puts(Planner.to_mermaid(sprint, syntax: :kanban))

Cheat Sheet

Lab DSL Syntax

Syntax Description
planner do ... end Define a project plan model
planner "Name" do ... end Define a named project plan
m = milestone("Title", due_date: ~D[2026-06-01]) Declare a milestone node
t = task("Title", status: :todo, estimate_hours: 8) Declare a task node (:backlog, :todo, :in_progress, :in_review, :done)
u = user("Alice", email: "alice@example.com") Declare a team member / user node
l = label("frontend") Declare a label / tag node
contains milestone ~> task Declare milestone containment
`m ~> t > contains()`
`a ~> b > depends_on()`
blocks blocker ~> blocked Explicit blocker relationship
`t ~> u > assigned_to()`
`t ~> l > tagged_with()`
edge a ~> b, "label", type: :depends_on Explicit edge statement with type and options
milestone "Sprint 1" Standalone milestone declaration
task "Feature A" Standalone task declaration

Programmatic Pipe API & Analysis

Task / Feature Command
Create Plan Planner.new/1 (Accepts name binary or options list)
Add Milestone Planner.add_milestone/3 (Opts: :title, :due_date)
Add Task Planner.add_task/3 (Opts: :title, :status, :priority, :due_date, :estimate_hours, :actual_hours)
Add User Planner.add_user/3 (Opts: :name, :email)
Add Label Planner.add_label/3 (Opts: :title)
Containment Edge Planner.contains/3 (Milestone, task)
Dependency Edge Planner.depends_on/3 (Task, dependency — finish-to-start)
Blocking Edge Planner.blocks/3 (Blocker, blocked)
Assign Task Planner.assign/3 (Task, user)
Tag Task Planner.tag/3 (Task, label)
Relates Edge Planner.relates/3 (Bidirectional association)
Query Tasks Planner.tasks/1, Planner.tasks_by_status/2
Query Milestones & Users Planner.milestones/1, Planner.users/1, Planner.labels/1
Query Relationships Planner.children/2, Planner.parents/2, Planner.dependencies/2, Planner.dependents/2
Query Assignments & Tags Planner.assignees/2, Planner.assigned_tasks/2, Planner.task_labels/2, Planner.tagged_tasks/2
Render Kanban Planner.to_mermaid(plan, syntax: :kanban) (Opts: :milestone, :assignee)
Render Gantt Planner.to_mermaid(plan, syntax: :gantt, start_date: date) (Opts: :section_by)
Render Flowchart Planner.to_mermaid(plan, syntax: :flowchart)
Render Swimlane `Planner.to_mermaid(plan, syntax: :swimlane, swimlane_by: :assignee
Render DOT Graphviz Planner.to_dot(plan, opts) (Opts: :theme, :direction, :highlighted_nodes, :highlighted_edges)
Ready Work Analysis.ready/1 (Tasks in backlog/todo with dependencies resolved)
Blocked Work Analysis.blocked/1 (Tasks waiting on unresolved prerequisites)
Orphan Tasks Analysis.orphans/1 (Tasks not contained in any milestone)
Critical Path Analysis.critical_path/2 (Longest dependency chain by estimate hours)
Bottlenecks Analysis.bottlenecks/1 (Tasks ranked by transitive downstream impact)
Workload Analysis.workload_by_assignee/2 (Open task counts and estimates grouped by owner)
Structural Validation Analysis.validate/1 (Checks for dependency cycles, unassigned work, empty milestones)

Further Reading