Powered by AppSignal & Oban Pro

Choreo Planner: Comprehensive Walkthrough

livebooks/planner_walkthrough.livemd

Choreo Planner: Comprehensive Walkthrough

Mix.install([
  # {:choreo, "~> 0.8"},
  {:choreo, path: Path.expand("~/repos/elixir/choreo")},
  {:kino_vizjs, "~> 0.8.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, and bottlenecks, 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
alias Choreo.Planner
alias Choreo.Planner.Analysis

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.VizJS.render(Planner.to_dot(legend))
Kino.Mermaid.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.new("Sprint 42")
  |> Planner.add_milestone(:sprint_42, title: "Sprint 42")
  |> Planner.add_task(:design, title: "Design API", status: :done, estimate_hours: 16)
  |> Planner.add_task(:impl, title: "Implement API", status: :in_progress, estimate_hours: 24)
  |> Planner.add_task(:test, title: "Write Tests", status: :backlog, estimate_hours: 8)
  |> Planner.add_task(:docs, title: "Update Docs", status: :backlog, estimate_hours: 4)
  |> Planner.add_user(:alice, name: "Alice")
  |> Planner.add_user(:bob, name: "Bob")
  |> Planner.contains(:sprint_42, :design)
  |> Planner.contains(:sprint_42, :impl)
  |> Planner.contains(:sprint_42, :test)
  |> Planner.contains(:sprint_42, :docs)
  |> Planner.depends_on(:impl, :design)
  |> Planner.depends_on(:test, :impl)
  |> Planner.assign(:design, :alice)
  |> Planner.assign(:impl, :alice)
  |> Planner.assign(:test, :bob)

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.

> Note: Livebook’s bundled Mermaid renderer may not support the native kanban syntax (requires Mermaid ≥11.4). Use :kanban_compat for a flowchart-based Kanban that works everywhere, or :kanban for native syntax in newer environments like GitHub.

Kino.Mermaid.new(Planner.to_mermaid(sprint, syntax: :kanban_compat))

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.Mermaid.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.Mermaid.new(Planner.to_mermaid(sprint, syntax: :flowchart))
Kino.VizJS.render(Planner.to_dot(sprint))

Example 2: Cross-Team Release

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

release =
  Planner.new("Q3 Release")
  |> Planner.add_milestone(:backend, title: "Backend Work")
  |> Planner.add_milestone(:frontend, title: "Frontend Work")
  |> Planner.add_milestone(:launch, title: "Launch Day")
  |> Planner.add_task(:auth_api, title: "Auth API", status: :done, estimate_hours: 16, priority: :high)
  |> Planner.add_task(:user_api, title: "User API", status: :in_progress, estimate_hours: 24, priority: :high)
  |> Planner.add_task(:payment_api, title: "Payment API", status: :backlog, estimate_hours: 32, priority: :critical)
  |> Planner.add_task(:login_ui, title: "Login UI", status: :backlog, estimate_hours: 12)
  |> Planner.add_task(:dashboard_ui, title: "Dashboard UI", status: :backlog, estimate_hours: 20)
  |> Planner.add_task(:marketing_site, title: "Marketing Site", status: :backlog, estimate_hours: 16)
  |> Planner.add_task(:load_test, title: "Load Testing", status: :backlog, estimate_hours: 8)
  |> Planner.add_task(:deploy, title: "Deploy to Prod", status: :backlog, estimate_hours: 4)
  |> Planner.add_user(:alice, name: "Alice")
  |> Planner.add_user(:bob, name: "Bob")
  |> Planner.add_user(:carol, name: "Carol")
  |> Planner.add_label(:backend_label, title: "backend")
  |> Planner.add_label(:frontend_label, title: "frontend")
  |> Planner.contains(:backend, :auth_api)
  |> Planner.contains(:backend, :user_api)
  |> Planner.contains(:backend, :payment_api)
  |> Planner.contains(:frontend, :login_ui)
  |> Planner.contains(:frontend, :dashboard_ui)
  |> Planner.contains(:launch, :marketing_site)
  |> Planner.contains(:launch, :load_test)
  |> Planner.contains(:launch, :deploy)
  |> Planner.depends_on(:user_api, :auth_api)
  |> Planner.depends_on(:payment_api, :user_api)
  |> Planner.depends_on(:login_ui, :auth_api)
  |> Planner.depends_on(:dashboard_ui, :user_api)
  |> Planner.depends_on(:load_test, :payment_api)
  |> Planner.depends_on(:deploy, :load_test)
  |> Planner.depends_on(:deploy, :marketing_site)
  |> Planner.assign(:auth_api, :alice)
  |> Planner.assign(:user_api, :alice)
  |> Planner.assign(:payment_api, :bob)
  |> Planner.assign(:login_ui, :carol)
  |> Planner.assign(:dashboard_ui, :carol)
  |> Planner.assign(:marketing_site, :bob)
  |> Planner.assign(:load_test, :alice)
  |> Planner.assign(:deploy, :alice)
  |> Planner.tag(:auth_api, :backend_label)
  |> Planner.tag(:user_api, :backend_label)
  |> Planner.tag(:payment_api, :backend_label)
  |> Planner.tag(:login_ui, :frontend_label)
  |> Planner.tag(:dashboard_ui, :frontend_label)

Full Dependency Graph

Kino.VizJS.render(Planner.to_dot(release), height: "700px")
Kino.Mermaid.new(Planner.to_mermaid(release, syntax: :flowchart, direction: :lr))

Per-Milestone Kanban

View only the launch milestone tasks:

Kino.Mermaid.new(Planner.to_mermaid(release, syntax: :kanban_compat, milestone: :launch))

Per-Assignee Kanban

See what Carol is working on:

Kino.Mermaid.new(Planner.to_mermaid(release, syntax: :kanban_compat, assignee: :carol))

Gantt by Assignee

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

Kino.Mermaid.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)

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.new("INC-2026-0042")
  |> Planner.add_task(:detect, title: "Detect Issue", status: :done, estimate_hours: 1)
  |> Planner.add_task(:triage, title: "Triage Severity", status: :done, estimate_hours: 1)
  |> Planner.add_task(:isolate, title: "Isolate Affected Service", status: :in_progress, estimate_hours: 2)
  |> Planner.add_task(:fix, title: "Deploy Fix", status: :backlog, estimate_hours: 4)
  |> Planner.add_task(:verify, title: "Verify in Prod", status: :backlog, estimate_hours: 1)
  |> Planner.add_task(:postmortem, title: "Write Postmortem", status: :backlog, estimate_hours: 4)
  |> Planner.add_task(:notify_customers, title: "Notify Customers", status: :backlog, estimate_hours: 1)
  |> Planner.add_user(:oncall, name: "On-Call Engineer")
  |> Planner.depends_on(:triage, :detect)
  |> Planner.depends_on(:isolate, :triage)
  |> Planner.depends_on(:fix, :isolate)
  |> Planner.depends_on(:verify, :fix)
  |> Planner.depends_on(:postmortem, :verify)
  |> Planner.depends_on(:notify_customers, :verify)
  |> Planner.blocks(:isolate, :fix)
  |> Planner.assign(:detect, :oncall)
  |> Planner.assign(:triage, :oncall)
  |> Planner.assign(:isolate, :oncall)

Incident Kanban

Kino.Mermaid.new(Planner.to_mermaid(incident, syntax: :kanban_compat))

Incident Gantt

Kino.Mermaid.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
  dot = Planner.to_dot(sprint, theme: theme)
  IO.puts("Theme: #{theme}")
  # In a real notebook you'd render each one
end

:ok
Kino.Mermaid.new(Planner.to_mermaid(sprint, syntax: :flowchart, theme: :dark))

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))

Further Reading