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.VizJSto render DOT diagrams inline. You can also copy DOT output into PlantText or rundot -Tpng diagram.dot -o diagram.pnglocally. 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:
- 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. - 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
- Mermaid Kanban Syntax
- Mermaid Gantt Syntax
- Critical Path Method (Wikipedia)
- Elixir
Datemodule — forstart_dateanddue_datevalues