defmodule Mix.Tasks.PhoenixLiveGantt.Dump do
@shortdoc "Dump structured geometry for a PhoenixLiveGantt fixture (debug aid)"
@moduledoc """
Render a named PhoenixLiveGantt fixture and pretty-print its geometry to
stdout. Use this to debug "what does this chart actually look like?"
without running the dev server.
mix phoenix_live_gantt.dump # list fixtures
mix phoenix_live_gantt.dump simple
mix phoenix_live_gantt.dump fanout
mix phoenix_live_gantt.dump fanout --stagger 4
mix phoenix_live_gantt.dump fanout --zoom day
Options:
--zoom day|week|month (default: week)
--stagger N (default: 0) outgoing+incoming bus stagger
--expanded a,b,c comma-separated sub-project ids to render in
the EXPANDED state (default: all collapsed)
--expanded * expand every sub-project
--raw also print the raw HTML below the structured dump
"""
use Mix.Task
use Phoenix.Component
import Phoenix.LiveViewTest, only: [rendered_to_string: 1]
alias PhoenixLiveGantt.Inspector
alias PhoenixLiveGantt.TestHelpers
@impl true
def run(args) do
Mix.Task.run("app.start")
{opts, positional, _} =
OptionParser.parse(args,
strict: [
zoom: :string,
stagger: :integer,
raw: :boolean,
expanded: :string
],
aliases: [z: :zoom, s: :stagger, e: :expanded]
)
case positional do
[] ->
list_fixtures()
[name | _] ->
case fetch_fixture(name) do
{:ok, fixture} ->
dump(name, fixture, opts)
:error ->
Mix.shell().error("Unknown fixture: #{name}\n")
list_fixtures()
end
end
end
# -- Fixtures --
defp list_fixtures do
Mix.shell().info("""
Available fixtures:
simple — 3 tasks, single FS chain
fanout — 1 source, 5 outgoing FS targets (component-library style)
fanin — 5 sources, 1 target with FF fan-in (frontend-qa style)
mixed — Hub with both incoming and outgoing on the same side
conflict — Backward FS arrow (target scheduled before source-end)
all_types — 4 connectors covering :fs, :ss, :ff, :sf
demo — Subset of the phoenix_kit demo: 7-way fan-out hub +
7-way :ff fan-in, with cross-group critical chain.
Best for stress-testing real production scenarios.
Usage: mix phoenix_live_gantt.dump <fixture_name> [--zoom week] [--stagger N] [--raw]
""")
end
defp fetch_fixture(name) do
today = ~D[2026-05-01]
d = &Date.add(today, &1)
case name do
"simple" ->
events = [
ev("a", d.(0), d.(5)),
ev("b", d.(6), d.(10)),
ev("c", d.(11), d.(15))
]
connectors = [
%{from: "a", to: "b", critical: true},
%{from: "b", to: "c"}
]
{:ok, {events, connectors, today}}
"fanout" ->
events = [
ev("hub", d.(0), d.(10)),
ev("t1", d.(11), d.(15)),
ev("t2", d.(11), d.(15)),
ev("t3", d.(11), d.(15)),
ev("t4", d.(11), d.(15)),
ev("t5", d.(11), d.(15))
]
connectors = [
%{from: "hub", to: "t1", critical: true},
%{from: "hub", to: "t2"},
%{from: "hub", to: "t3"},
%{from: "hub", to: "t4", critical: true},
%{from: "hub", to: "t5"}
]
{:ok, {events, connectors, today}}
"fanin" ->
events = [
ev("s1", d.(0), d.(5)),
ev("s2", d.(0), d.(5)),
ev("s3", d.(0), d.(5)),
ev("s4", d.(0), d.(5)),
ev("s5", d.(0), d.(5)),
ev("hub", d.(6), d.(10))
]
connectors = [
%{from: "s1", to: "hub", type: :ff},
%{from: "s2", to: "hub", type: :ff},
%{from: "s3", to: "hub", type: :ff, critical: true},
%{from: "s4", to: "hub", type: :ff},
%{from: "s5", to: "hub", type: :ff}
]
{:ok, {events, connectors, today}}
"mixed" ->
events = [
ev("upstream", d.(0), d.(5)),
ev("hub", d.(6), d.(12)),
ev("downstream", d.(13), d.(18))
]
connectors = [
# upstream → hub on hub's WEST side (incoming)
%{from: "upstream", to: "hub"},
# hub → downstream on hub's EAST side (outgoing)
%{from: "hub", to: "downstream"}
]
{:ok, {events, connectors, today}}
"conflict" ->
events = [
ev("source", d.(5), d.(15)),
# target ends BEFORE source ends — backward arrow
ev("target", d.(0), d.(8))
]
connectors = [%{from: "source", to: "target"}]
{:ok, {events, connectors, today}}
"all_types" ->
events = [
ev("a", d.(0), d.(5)),
ev("b", d.(8), d.(12)),
ev("c", d.(15), d.(20)),
ev("d", d.(22), d.(28))
]
connectors = [
%{from: "a", to: "b", type: :fs, label: "FS"},
%{from: "a", to: "c", type: :ss, label: "SS"},
%{from: "b", to: "d", type: :ff, label: "FF"},
%{from: "c", to: "d", type: :sf, label: "SF"}
]
{:ok, {events, connectors, today}}
"demo" ->
{:ok, {full_demo_events(d), full_demo_connectors(), today}}
_ ->
:error
end
end
defp ev(id, start_d, end_d, opts \\ []) do
%PhoenixLiveGantt.Task{
id: id,
start: start_d,
end: end_d,
color: Keyword.get(opts, :color, "bg-primary"),
category: Keyword.get(opts, :category, ""),
icon: Keyword.get(opts, :icon),
status: Keyword.get(opts, :status, :active),
extra: Keyword.get(opts, :extra, %{})
}
end
# ---- Full phoenix_kit demo mirror ----
# Kept in sync with `phoenix_kit/lib/phoenix_kit_web/live/calendar_demo.ex`'s
# `generate_waterfall_events/1` and `generate_waterfall_connectors/0`.
# When the actual demo changes, update this fixture so audit results
# reflect the production layout.
defp full_demo_events(d) do
[
# Phase 1: Discovery
ev("wf-stakeholders", d.(0), d.(5), color: "bg-info", category: "Phase 1: Discovery"),
ev("wf-market-research", d.(2), d.(7), color: "bg-info", category: "Phase 1: Discovery"),
ev("wf-competitive", d.(1), d.(5), color: "bg-info", category: "Phase 1: Discovery"),
ev("wf-personas", d.(5), d.(9), color: "bg-info", category: "Phase 1: Discovery"),
ev("wf-discovery-readout", d.(9), d.(11), color: "bg-info", category: "Phase 1: Discovery"),
ev("wf-discovery-signoff", d.(12), d.(12),
color: "bg-info",
icon: "◆",
category: "Phase 1: Discovery"
),
# Phase 2: Design
ev("wf-ia", d.(13), d.(19), color: "bg-accent", category: "Phase 2: Design"),
# Sub-project (no explicit end date — auto-rolled from children)
ev("wf-design-system", d.(13), nil, color: "bg-accent", category: "Phase 2: Design"),
ev("wf-ds-foundation", d.(13), d.(17),
color: "bg-accent",
category: "Phase 2: Design",
extra: %{parent_id: "wf-design-system"}
),
# Nested sub-project (depth 2): rolls up over the 3 ds-comp-* events
ev("wf-ds-components", d.(17), nil,
color: "bg-accent",
category: "Phase 2: Design",
extra: %{parent_id: "wf-design-system"}
),
ev("wf-ds-comp-primitives", d.(17), d.(19),
color: "bg-accent",
category: "Phase 2: Design",
extra: %{parent_id: "wf-ds-components"}
),
ev("wf-ds-comp-overlays", d.(18), d.(20),
color: "bg-accent",
category: "Phase 2: Design",
extra: %{parent_id: "wf-ds-components"}
),
ev("wf-ds-comp-data", d.(19), d.(21),
color: "bg-accent",
category: "Phase 2: Design",
extra: %{parent_id: "wf-ds-components"}
),
ev("wf-ds-docs", d.(21), d.(23),
color: "bg-accent",
category: "Phase 2: Design",
extra: %{parent_id: "wf-design-system"}
),
ev("wf-wf-landing", d.(20), d.(25), color: "bg-accent", category: "Phase 2: Design"),
ev("wf-wf-dashboard", d.(20), d.(27), color: "bg-accent", category: "Phase 2: Design"),
ev("wf-wf-settings", d.(22), d.(26), color: "bg-accent", category: "Phase 2: Design"),
ev("wf-mobile-mockups", d.(26), d.(34), color: "bg-accent", category: "Phase 2: Design"),
ev("wf-desktop-mockups", d.(27), d.(35), color: "bg-accent", category: "Phase 2: Design"),
ev("wf-design-qa", d.(35), d.(37), color: "bg-accent", category: "Phase 2: Design"),
ev("wf-design-signoff", d.(37), d.(37),
color: "bg-accent",
icon: "◆",
category: "Phase 2: Design"
),
# Phase 3: Backend
ev("wf-db-schema", d.(23), d.(27), category: "Phase 3: Backend"),
ev("wf-db-migrations", d.(27), d.(30), category: "Phase 3: Backend"),
ev("wf-auth-service", d.(30), d.(38), category: "Phase 3: Backend"),
ev("wf-search-api", d.(28), d.(40), category: "Phase 3: Backend"),
ev("wf-notification-service", d.(30), d.(37), category: "Phase 3: Backend"),
ev("wf-user-api", d.(38), d.(48), category: "Phase 3: Backend"),
ev("wf-settings-api", d.(38), d.(44), category: "Phase 3: Backend"),
ev("wf-payment-integration", d.(48), d.(58),
category: "Phase 3: Backend",
extra: %{
badges: [
%{content: "3", color: "bg-error", flash: true},
%{content: "!", corner: :bottom_left, color: "bg-warning"}
],
actions: [
%{
id: "comments",
icon: "hero-chat-bubble-left-mini",
tooltip: "Comments (3)",
phx_click: "wf_action_comments",
badge: %{content: "3", color: "bg-error", flash: true}
},
%{
id: "assign",
icon: "hero-user-plus-mini",
tooltip: "Assign someone",
phx_click: "wf_action_assign"
},
%{
id: "details",
icon: "hero-arrow-top-right-on-square-mini",
tooltip: "Open details",
phx_click: "wf_action_details"
}
]
}
),
ev("wf-reporting-api", d.(48), d.(56), category: "Phase 3: Backend"),
ev("wf-backend-integration", d.(56), d.(61), category: "Phase 3: Backend"),
ev("wf-backend-freeze", d.(61), d.(61), icon: "◆", category: "Phase 3: Backend"),
# Phase 4: Frontend
ev("wf-fe-scaffold", d.(28), d.(33), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-component-library", d.(38), d.(50),
color: "bg-secondary",
category: "Phase 4: Frontend",
# Mirrors the demo's per-task stagger override on this hub
extra: %{
bus_stagger_outgoing_px: 4,
actions: [
%{
id: "comments",
icon: "hero-chat-bubble-left-mini",
tooltip: "Comments (12)",
phx_click: "wf_action_comments"
},
%{
id: "approve",
icon: "hero-check-circle-mini",
tooltip: "Locked: pending design sign-off",
phx_click: "wf_action_approve",
class: "text-success",
disabled: true
}
]
}
),
ev("wf-auth-flows", d.(50), d.(56), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-landing-page", d.(50), d.(55), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-dashboard", d.(55), d.(65), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-settings-page", d.(55), d.(61),
color: "bg-secondary",
category: "Phase 4: Frontend"
),
ev("wf-search-ui", d.(50), d.(58), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-payment-ui", d.(60), d.(67), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-reports-ui", d.(56), d.(62), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-frontend-qa", d.(65), d.(69), color: "bg-secondary", category: "Phase 4: Frontend"),
ev("wf-frontend-freeze", d.(69), d.(69),
color: "bg-secondary",
icon: "◆",
category: "Phase 4: Frontend"
),
# Phase 5: Integration & QA
ev("wf-cross-team", d.(65), d.(70),
color: "bg-warning",
category: "Phase 5: Integration & QA"
),
ev("wf-security-audit", d.(65), d.(71),
color: "bg-warning",
category: "Phase 5: Integration & QA"
),
# Top-level sub-project: rolls up over baseline/tuning/monitoring
ev("wf-performance", d.(70), nil,
color: "bg-warning",
category: "Phase 5: Integration & QA"
),
ev("wf-perf-baseline", d.(70), d.(72),
color: "bg-warning",
category: "Phase 5: Integration & QA",
extra: %{parent_id: "wf-performance"}
),
ev("wf-perf-tuning", d.(71), d.(73),
color: "bg-warning",
category: "Phase 5: Integration & QA",
extra: %{parent_id: "wf-performance"}
),
ev("wf-perf-monitoring", d.(72), d.(74),
color: "bg-warning",
category: "Phase 5: Integration & QA",
extra: %{parent_id: "wf-performance"}
),
ev("wf-load-testing", d.(74), d.(77),
color: "bg-warning",
category: "Phase 5: Integration & QA"
),
ev("wf-bug-bash", d.(75), d.(78),
color: "bg-warning",
status: :pending_approval,
category: "Phase 5: Integration & QA"
),
ev("wf-preprod", d.(78), d.(78),
color: "bg-warning",
icon: "◆",
category: "Phase 5: Integration & QA"
),
# Phase 6: Launch
ev("wf-documentation", d.(62), d.(72),
color: "bg-success",
status: :tentative,
category: "Phase 6: Launch"
),
ev("wf-legal-review", d.(70), d.(76), color: "bg-success", category: "Phase 6: Launch"),
ev("wf-marketing", d.(75), d.(85), color: "bg-success", category: "Phase 6: Launch"),
ev("wf-old-feature-removal", d.(73), d.(80),
color: "bg-base-content/30",
status: :cancelled,
category: "Phase 6: Launch"
),
ev("wf-beta-rollout", d.(78), d.(83), color: "bg-success", category: "Phase 6: Launch"),
ev("wf-prod-deploy", d.(84), d.(84),
color: "bg-success",
icon: "🚀",
category: "Phase 6: Launch"
),
ev("wf-post-launch", d.(84), d.(95), color: "bg-success", category: "Phase 6: Launch"),
ev("wf-launch", d.(95), d.(95),
color: "bg-success",
icon: "🎉",
category: "Phase 6: Launch"
),
# Phase 7: full-sized CRM connector sub-project (depth 2 nested)
ev("wf-crm-connector", d.(38), nil,
color: "bg-info",
category: "Phase 7: Custom CRM connector"
),
ev("wf-crm-discovery", d.(38), d.(42),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-connector"}
),
ev("wf-crm-api-spec", d.(42), d.(46),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-connector"}
),
ev("wf-crm-backend", d.(46), nil,
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-connector"}
),
ev("wf-crm-be-auth", d.(46), d.(50),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-backend"}
),
ev("wf-crm-be-sync", d.(49), d.(56),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-backend"}
),
ev("wf-crm-be-mapping", d.(52), d.(59),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-backend"}
),
ev("wf-crm-be-error", d.(56), d.(62),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-backend"}
),
ev("wf-crm-frontend", d.(58), nil,
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-connector"}
),
ev("wf-crm-fe-config", d.(58), d.(62),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-frontend"}
),
ev("wf-crm-fe-dashboard", d.(60), d.(67),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-frontend"}
),
ev("wf-crm-fe-history", d.(63), d.(70),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-frontend"}
),
ev("wf-crm-qa", d.(70), d.(75),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-connector"}
),
ev("wf-crm-rollout", d.(74), d.(78),
color: "bg-info",
category: "Phase 7: Custom CRM connector",
extra: %{parent_id: "wf-crm-connector"}
)
]
end
defp full_demo_connectors do
[
# Phase 1
%{from: "wf-stakeholders", to: "wf-discovery-readout"},
%{from: "wf-market-research", to: "wf-discovery-readout", critical: true},
%{from: "wf-competitive", to: "wf-discovery-readout"},
%{from: "wf-personas", to: "wf-discovery-readout"},
%{from: "wf-discovery-readout", to: "wf-discovery-signoff", critical: true},
# Phase 1 → Phase 2
%{from: "wf-discovery-signoff", to: "wf-ia", critical: true},
%{from: "wf-discovery-signoff", to: "wf-design-system"},
%{from: "wf-ds-foundation", to: "wf-ds-components", critical: true},
%{from: "wf-ds-components", to: "wf-ds-docs"},
%{from: "wf-ds-comp-primitives", to: "wf-ds-comp-overlays"},
%{from: "wf-ds-comp-overlays", to: "wf-ds-comp-data"},
%{from: "wf-perf-baseline", to: "wf-perf-tuning", critical: true},
%{from: "wf-perf-tuning", to: "wf-perf-monitoring"},
# Phase 2
%{from: "wf-ia", to: "wf-design-system", type: :ss, label: "parallel"},
%{from: "wf-ia", to: "wf-wf-landing"},
%{from: "wf-ia", to: "wf-wf-dashboard", critical: true},
%{from: "wf-ia", to: "wf-wf-settings"},
%{from: "wf-wf-landing", to: "wf-desktop-mockups"},
%{from: "wf-wf-dashboard", to: "wf-mobile-mockups", critical: true},
%{from: "wf-wf-dashboard", to: "wf-desktop-mockups"},
%{from: "wf-wf-settings", to: "wf-desktop-mockups"},
%{from: "wf-design-system", to: "wf-design-qa"},
%{from: "wf-mobile-mockups", to: "wf-design-qa", critical: true},
%{from: "wf-desktop-mockups", to: "wf-design-qa"},
%{from: "wf-design-qa", to: "wf-design-signoff", critical: true},
# Phase 2 → Phase 3
%{from: "wf-design-signoff", to: "wf-db-schema", critical: true},
# Phase 2 → Phase 4
%{from: "wf-design-signoff", to: "wf-component-library", critical: true},
# Phase 3
%{from: "wf-db-schema", to: "wf-db-migrations", critical: true},
%{from: "wf-db-schema", to: "wf-auth-service"},
%{from: "wf-db-schema", to: "wf-search-api"},
%{from: "wf-db-migrations", to: "wf-user-api", critical: true},
%{from: "wf-db-migrations", to: "wf-settings-api"},
%{from: "wf-auth-service", to: "wf-user-api", critical: true},
%{from: "wf-auth-service", to: "wf-notification-service", type: :ss, label: "parallel"},
%{from: "wf-user-api", to: "wf-payment-integration", critical: true},
%{from: "wf-user-api", to: "wf-reporting-api"},
%{from: "wf-user-api", to: "wf-backend-integration", type: :ff},
%{from: "wf-settings-api", to: "wf-backend-integration", type: :ff},
%{from: "wf-search-api", to: "wf-backend-integration", type: :ff},
%{from: "wf-backend-integration", to: "wf-backend-freeze", critical: true},
%{from: "wf-payment-integration", to: "wf-backend-freeze", label: "must complete"},
%{from: "wf-notification-service", to: "wf-backend-freeze"},
%{from: "wf-reporting-api", to: "wf-backend-freeze"},
# Phase 3 → Phase 4 (cross-group API → UI)
%{from: "wf-design-signoff", to: "wf-fe-scaffold"},
%{from: "wf-auth-service", to: "wf-auth-flows"},
%{from: "wf-user-api", to: "wf-dashboard"},
%{from: "wf-settings-api", to: "wf-settings-page"},
%{from: "wf-search-api", to: "wf-search-ui"},
%{from: "wf-payment-integration", to: "wf-payment-ui", critical: true},
%{from: "wf-reporting-api", to: "wf-reports-ui"},
# Phase 4 (component-library 7-way fan-out + frontend-qa 7-way :ff fan-in)
%{from: "wf-component-library", to: "wf-auth-flows", critical: true},
%{from: "wf-component-library", to: "wf-landing-page"},
%{from: "wf-component-library", to: "wf-dashboard"},
%{from: "wf-component-library", to: "wf-settings-page"},
%{from: "wf-component-library", to: "wf-search-ui"},
%{from: "wf-component-library", to: "wf-payment-ui", critical: true},
%{from: "wf-component-library", to: "wf-reports-ui"},
%{from: "wf-landing-page", to: "wf-frontend-qa", type: :ff},
%{from: "wf-dashboard", to: "wf-frontend-qa", type: :ff, critical: true},
%{from: "wf-settings-page", to: "wf-frontend-qa", type: :ff},
%{from: "wf-search-ui", to: "wf-frontend-qa", type: :ff},
%{from: "wf-payment-ui", to: "wf-frontend-qa", type: :ff, critical: true},
%{from: "wf-reports-ui", to: "wf-frontend-qa", type: :ff},
%{from: "wf-auth-flows", to: "wf-frontend-qa", type: :ff},
%{from: "wf-frontend-qa", to: "wf-frontend-freeze", critical: true},
# Phase 4 + 3 → Phase 5
%{from: "wf-frontend-freeze", to: "wf-cross-team", critical: true},
%{from: "wf-backend-freeze", to: "wf-cross-team"},
# Phase 5
%{from: "wf-cross-team", to: "wf-performance", critical: true},
%{from: "wf-cross-team", to: "wf-security-audit", type: :ss, label: "parallel"},
%{from: "wf-performance", to: "wf-load-testing", label: "must finish first"},
%{from: "wf-load-testing", to: "wf-bug-bash"},
%{from: "wf-security-audit", to: "wf-bug-bash"},
%{from: "wf-bug-bash", to: "wf-preprod", critical: true},
# Phase 6
%{from: "wf-backend-freeze", to: "wf-documentation", type: :ff},
%{from: "wf-frontend-freeze", to: "wf-legal-review"},
%{from: "wf-preprod", to: "wf-beta-rollout", critical: true},
%{from: "wf-beta-rollout", to: "wf-prod-deploy", critical: true, label: "go/no-go"},
%{from: "wf-prod-deploy", to: "wf-post-launch", type: :ss, label: "parallel"},
%{from: "wf-prod-deploy", to: "wf-launch", critical: true},
%{from: "wf-documentation", to: "wf-launch", label: "publish"},
%{from: "wf-marketing", to: "wf-launch"},
# Phase 7 internal chain
%{from: "wf-crm-discovery", to: "wf-crm-api-spec", critical: true},
%{from: "wf-crm-api-spec", to: "wf-crm-backend", critical: true},
%{from: "wf-crm-api-spec", to: "wf-crm-frontend"},
%{from: "wf-crm-be-auth", to: "wf-crm-be-sync", critical: true},
%{from: "wf-crm-be-auth", to: "wf-crm-be-mapping"},
%{from: "wf-crm-be-sync", to: "wf-crm-be-error", critical: true},
%{from: "wf-crm-be-mapping", to: "wf-crm-be-error"},
%{from: "wf-crm-fe-config", to: "wf-crm-fe-dashboard"},
%{from: "wf-crm-fe-config", to: "wf-crm-fe-history"},
%{from: "wf-crm-fe-dashboard", to: "wf-crm-fe-history", type: :ff},
%{from: "wf-crm-backend", to: "wf-crm-qa", critical: true},
%{from: "wf-crm-frontend", to: "wf-crm-qa", critical: true},
%{from: "wf-crm-qa", to: "wf-crm-rollout", critical: true},
%{from: "wf-discovery-signoff", to: "wf-crm-discovery"},
%{from: "wf-auth-service", to: "wf-crm-be-auth", type: :ff, label: "auth ready"},
%{from: "wf-crm-rollout", to: "wf-launch", label: "must complete"},
# Intentional conflicts (broken schedules — render as red dashed)
%{from: "wf-prod-deploy", to: "wf-marketing", label: "conflict"},
%{from: "wf-documentation", to: "wf-legal-review", label: "blocked"}
]
end
# -- Render + dump --
defp dump(name, {events, connectors, today}, opts) do
zoom = opts |> Keyword.get(:zoom, "week") |> String.to_atom()
stagger = Keyword.get(opts, :stagger, 0)
expanded = parse_expanded_opt(opts) |> resolve_expanded(events)
range = derive_range(events)
render_opts = %{
events: events,
date_range: range,
connectors: connectors,
zoom: zoom,
today: today,
bus_stagger_outgoing_px: stagger,
bus_stagger_incoming_px: stagger,
expanded: expanded
}
html = render(render_opts)
geom = Inspector.inspect_html(html)
Mix.shell().info("""
╔══════════════════════════════════════════════════
║ PhoenixLiveGantt fixture: #{name}
║ zoom=#{zoom} stagger=#{stagger} range=#{range.first}..#{range.last}
║ expanded=#{format_expanded(expanded)}
╚══════════════════════════════════════════════════
""")
print_rows(geom)
print_subproject_tree(geom)
print_connectors(geom)
print_edges(geom)
print_audit(html)
if Keyword.get(opts, :raw, false) do
Mix.shell().info("\n=== Raw HTML ===\n#{html}")
end
end
# `--expanded a,b,c` → MapSet.new(["a", "b", "c"])
# `--expanded *` → expand-all sentinel; expand every sub-project
# visible in the rendered output.
defp parse_expanded_opt(opts) do
case Keyword.get(opts, :expanded) do
nil -> MapSet.new()
"*" -> :all
str -> str |> String.split(",", trim: true) |> MapSet.new()
end
end
defp format_expanded(set) do
case MapSet.size(set) do
0 -> "(none — all sub-projects collapsed)"
n when n > 8 -> "#{n} sub-projects"
_ -> set |> Enum.sort() |> Enum.join(",")
end
end
# `--expanded *` → all sub-projects (any event whose id is referenced
# as another event's `extra.parent_id`).
defp resolve_expanded(:all, events) do
events
|> Enum.flat_map(fn ev ->
case ev do
%{extra: %{parent_id: pid}} when is_binary(pid) -> [pid]
_ -> []
end
end)
|> MapSet.new()
end
defp resolve_expanded(set, _events), do: set
# Run the same geometry assertions used in tests against the rendered
# html. The user has burned us before by missing piercings here that
# only showed up in the live demo — running the full audit on every
# dump catches that without us having to remember.
defp print_audit(html) do
issues = TestHelpers.find_geometry_issues(html)
Mix.shell().info("\n=== Geometry audit ===")
case issues do
[] ->
Mix.shell().info(" ✓ no issues found")
_ ->
Mix.shell().info(" ✗ #{length(issues)} issue group(s):")
Enum.each(issues, fn {name, msg} ->
Mix.shell().info(" [#{name}]")
msg
|> String.split("\n")
|> Enum.each(fn line -> Mix.shell().info(" #{line}") end)
end)
end
end
# Render via the component-call syntax so attr defaults are injected
# by Phoenix.Component's macro. Without this we'd have to maintain a
# full default-assigns map matching every PhoenixLiveGantt attr.
defp render(attrs) do
assigns = %{attrs: attrs}
rendered_to_string(~H"<PhoenixLiveGantt.gantt {@attrs} />")
end
defp derive_range(events) do
dates =
events
|> Enum.flat_map(fn e ->
[
to_date(e.start),
to_date(PhoenixLiveGantt.Task.effective_end(e))
]
end)
first = Enum.min(dates, Date) |> Date.add(-1)
last = Enum.max(dates, Date) |> Date.add(1)
Date.range(first, last)
end
defp to_date(%Date{} = d), do: d
defp to_date(%DateTime{} = dt), do: DateTime.to_date(dt)
defp to_date(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_date(ndt)
defp print_rows(geom) do
Mix.shell().info("=== Rows (top → bottom) ===")
Enum.each(Enum.with_index(geom.rows), fn {id, i} ->
bar = Map.get(geom.bars, id, %{})
Mix.shell().info(" #{String.pad_leading("#{i}", 2)}: #{id}#{format_bar(bar)}")
end)
end
# Shows the sub-project tree as derived from the rendered HTML's
# `data-parent-id` attributes — useful for confirming that the
# parent/child wiring made it through the renderer correctly, and
# to surface where the sub-project frames landed (only present
# when sub-projects are expanded).
defp print_subproject_tree(geom) do
if geom.parent_map == %{} and geom.subproject_frames == [] do
:ok
else
Mix.shell().info("\n=== Sub-projects ===")
roots =
geom.rows
|> Enum.filter(fn id ->
# A "root" here = an event in the tree (parent or child of
# something) whose own parent isn't visible in this render.
(Inspector.subproject?(geom, id) or Map.has_key?(geom.parent_map, id)) and
is_nil(Map.get(geom.parent_map, id))
end)
Enum.each(roots, &print_subproject_node(geom, &1, 0))
if geom.subproject_frames != [] do
Mix.shell().info(" -- Frames (only present for EXPANDED sub-projects) --")
Enum.each(geom.subproject_frames, fn f ->
Mix.shell().info(
" rect x=#{f.left_px}..#{f.left_px + f.width} y=#{f.top_y}..#{f.top_y + f.height} bg=#{f.background_color}"
)
end)
end
end
end
defp print_subproject_node(geom, id, depth) do
children = Inspector.children_of(geom, id)
marker = if children == [], do: "•", else: "▾"
pad = String.duplicate(" ", depth + 1)
Mix.shell().info("#{pad}#{marker} #{id}")
Enum.each(children, &print_subproject_node(geom, &1, depth + 1))
end
defp print_connectors(geom) do
Mix.shell().info("\n=== Connectors (#{length(geom.connectors)}) ===")
Enum.each(geom.connectors, fn c ->
flags =
[{c.critical, "critical"}, {c.invalid, "INVALID"}]
|> Enum.filter(&elem(&1, 0))
|> Enum.map(&elem(&1, 1))
flag_str = if flags == [], do: "", else: " [#{Enum.join(flags, ", ")}]"
Mix.shell().info(" #{c.from} → #{c.to} (#{c.type})#{flag_str}")
Mix.shell().info(" #{format_segments(c.segments)}")
end)
end
defp print_edges(%{edges: %{earlier: 0, later: 0}}), do: :ok
defp print_edges(geom) do
Mix.shell().info("\n=== Edge indicators ===")
Mix.shell().info(" ← #{geom.edges.earlier} earlier #{geom.edges.later} later →")
end
defp format_bar(%{kind: :bar, left: l, width: w}),
do: " bar @ x=#{l}..#{l + w} (#{w}px wide)"
defp format_bar(%{kind: :milestone, left: l}), do: " ◆ milestone @ x=#{l}"
defp format_bar(_), do: ""
defp format_segments(%{kind: :forward, x1: x1, y1: y1, mid: mid, y2: y2, arrow_stop: stop}),
do: "forward: src=(#{x1},#{y1}) → mid=#{mid} → tgt=(#{stop},#{y2})"
defp format_segments(%{
kind: :detour,
x1: x1,
y1: y1,
stem_out: so,
detour_y: dy,
stem_in: si,
y2: y2,
arrow_stop: stop
}),
do:
"detour: src=(#{x1},#{y1}) → stem_out=#{so} → detour_y=#{dy} → stem_in=#{si} → tgt=(#{stop},#{y2})"
defp format_segments(%{kind: :unknown, raw: r}), do: "unknown: #{r}"
end