Powered by AppSignal & Oban Pro

Gantt

gantt.livemd

Gantt

Mix.install([
  {:youtrack, path: "./youtrack"},
  {:kino, "~> 0.14"},
  {:kino_vega_lite, "~> 0.1"},
  {:vega_lite, "~> 0.1"},
  {:tz, "~> 0.26"}
])

alias VegaLite, as: Vl
alias Youtrack.{Client, Fields, Status, StartAt, WorkItems, Workstreams, WorkstreamsLoader}

Abstract

This notebook fetches issues from YouTrack and generates Gantt-style charts showing work distribution across team members and workstreams.

Inputs / envs

base_url_in =
  Kino.Input.text(
    "YouTrack base URL (e.g. https://YOUR.youtrack.cloud)",
    default: System.get_env("YOUTRACK_BASE_URL")
      || "https://your-instance.youtrack.cloud")

token_in =
  Kino.Input.password(
    "Permanent token (env YOUTRACK_TOKEN is recommended)",
    default: System.get_env("YOUTRACK_TOKEN")
      || "")

base_query_in =
  Kino.Input.text(
    "YouTrack base query (e.g. project: MYPROJECT)",
    default: System.get_env("YOUTRACK_BASE_QUERY") || "project: MYPROJECT")

days_back_in =
  Kino.Input.number(
    "Days back to fetch (e.g. 90)",
    default: String.to_integer(System.get_env("YOUTRACK_DAYS_BACK") || "90"))

# Names of custom fields can vary between instances.
state_field_in =
  Kino.Input.text("State field name (custom field)",
    default: System.get_env("YOUTRACK_STATE_FIELD")
      || "State")

assignees_field_in =
  Kino.Input.text("Assignees field name (custom field)",
    default: System.get_env("YOUTRACK_ASSIGNEES_FIELD") || "Assignee")

# Your “start at” definition:
in_progress_names_in =
  Kino.Input.text(
    "Comma-separated In Progress state names",
    default: System.get_env("YOUTRACK_IN_PROGRESS") || "In Progress")

# Toggle: fetch activities to compute 'start_at' precisely (slower, more API calls)
use_activities_in =
  Kino.Input.checkbox(
    "Compute start_at via activities (State -> In Progress)",
    default: true)

# Optional filter by project prefix (leave empty to include all)
project_prefix_in =
  Kino.Input.text(
    "Filter by issue ID prefix (e.g. MYPROJ, leave empty for all)",
    default: System.get_env("YOUTRACK_PROJECT_PREFIX") || "")

# Exclude specific assignees by login (comma-separated)
excluded_logins_in =
  Kino.Input.text(
    "Excluded assignee logins (comma-separated)",
    default: System.get_env("YOUTRACK_EXCLUDED_LOGINS") || "")

# Include substreams: when on, issues in a substream also count toward parent workstreams
include_substreams_in =
  Kino.Input.checkbox(
    "Include substreams (expand to parent workstreams)",
    default: true)

# Tag used to mark unplanned/interrupt work (e.g., "on the ankles")
unplanned_tag_in =
  Kino.Input.text(
    "Unplanned work tag (e.g., 'on the ankles')",
    default: System.get_env("YOUTRACK_UNPLANNED_TAG") || "on the ankles")

inputs =
    Kino.Layout.grid([
      base_url_in, token_in,
      base_query_in, days_back_in,
      state_field_in, assignees_field_in,
      in_progress_names_in, use_activities_in,
      project_prefix_in, excluded_logins_in,
      include_substreams_in, unplanned_tag_in
    ], columns: 2)

Stream Mapping

# Try to load from workstreams.yaml, fall back to example file
{default_stream_rules, workstreams_path} = WorkstreamsLoader.load_from_default_paths()

if workstreams_path do
  Kino.render(Kino.Markdown.new("Loading workstreams from `#{workstreams_path}`"))
else
  Kino.render(Kino.Markdown.new("⚠️ No workstreams.yaml found, using empty config"))
end

stream_rules_in =
  Kino.Input.textarea(
    "Stream rules (edit or paste Elixir map literal to override)",
    default: inspect(default_stream_rules, pretty: true, limit: :infinity)
  )

YouTrack Client

The Youtrack.Client module from the package handles API communication.

Notebook-Specific Helpers

Status, StartAt, and WorkItems are now in the youtrack library. Only chart-building helpers remain notebook-local.

defmodule TimeFmt do
  @moduledoc false

  def iso8601_ms(ms) when is_integer(ms) do
    DateTime.from_unix!(div(ms, 1000))
    |> DateTime.to_iso8601()
  end
end

defmodule GanttChart do
  @moduledoc "VegaLite Gantt chart spec builder"

  def build(work_items) do
    vl_data =
      work_items
      |> Enum.map(fn wi ->
        wi
        |> Map.put(:start, TimeFmt.iso8601_ms(wi.start_at))
        |> Map.put(:end, TimeFmt.iso8601_ms(wi.end_at))
        |> Map.put(:work_type, if(wi.is_unplanned, do: "unplanned", else: "planned"))
      end)

    base =
      Vl.new(width: 900, height: 70)
      |> Vl.data_from_values(vl_data)
      |> Vl.transform(filter: "isValid(datum.start) && isValid(datum.end)")

    inner_spec =
      Vl.new()
      |> Vl.mark(:bar, tooltip: true)
      |> Vl.encode_field(:x, "start", type: :temporal, title: "Time")
      |> Vl.encode_field(:x2, "end")
      |> Vl.encode_field(:y, "stream", type: :nominal, title: "Stream")
      |> Vl.encode_field(:color, "work_type",
        type: :nominal,
        title: "Work Type",
        scale: [domain: ["planned", "unplanned"], range: ["steelblue", "orangered"]]
      )
      |> Vl.encode_field(:opacity, "status",
        type: :nominal,
        scale: [domain: ["finished", "ongoing", "unfinished"], range: [0.4, 1.0, 0.7]],
        title: "Status"
      )
      |> Vl.encode(:tooltip, [
        [field: "issue_id", type: :nominal, title: "Issue"],
        [field: "title", type: :nominal, title: "Title"],
        [field: "work_type", type: :nominal, title: "Work Type"],
        [field: "status", type: :nominal, title: "Status"],
        [field: "state", type: :nominal, title: "State"],
        [field: "stream", type: :nominal, title: "Stream"],
        [field: "start", type: :temporal, title: "Start"],
        [field: "end", type: :temporal, title: "End"]
      ])

    facet_spec = [field: "person_name", type: :nominal, header: %{title: nil}]

    Vl.facet(base, facet_spec, inner_spec)
  end
end

Fetch Issues (API call)

Re-run this cell only when you need to fetch fresh data from YouTrack.

base_url = Kino.Input.read(base_url_in) |> String.trim()
token = Kino.Input.read(token_in) |> String.trim()
base_query = Kino.Input.read(base_query_in) |> String.trim()
days_back = Kino.Input.read(days_back_in)

state_field = Kino.Input.read(state_field_in) |> String.trim()
assignees_field = Kino.Input.read(assignees_field_in) |> String.trim()

# Build full query with time interval
today = Date.utc_today() |> Date.to_iso8601()
start_date = Date.utc_today() |> Date.add(-days_back) |> Date.to_iso8601()
query = "#{base_query} updated: #{start_date} .. #{today}"

Kino.render(Kino.Markdown.new("**Query:** `#{query}`"))

req = Client.new!(base_url, token)
raw_issues = Client.fetch_issues!(req, query)

Kino.Markdown.new("Fetched **#{length(raw_issues)}** issues from API.")

Filter & Process

Iterate on filtering without hitting the API again.

in_progress_names =
  Kino.Input.read(in_progress_names_in)
  |> String.split(",", trim: true)
  |> Enum.map(&String.trim/1)

use_activities? = Kino.Input.read(use_activities_in)
project_prefix = Kino.Input.read(project_prefix_in) |> String.trim()

excluded_logins =
  Kino.Input.read(excluded_logins_in)
  |> String.split(",", trim: true)
  |> Enum.map(&String.trim/1)

include_substreams? = Kino.Input.read(include_substreams_in)
unplanned_tag = Kino.Input.read(unplanned_tag_in) |> String.trim()

rules = Workstreams.parse_rules!(Kino.Input.read(stream_rules_in))

# Apply project prefix filter
issues =
  if project_prefix == "" do
    raw_issues
  else
    Enum.filter(raw_issues, fn %{"idReadable" => id_readable} ->
      String.starts_with?(id_readable, project_prefix)
    end)
  end

Kino.render(Kino.Markdown.new("After filtering: **#{length(issues)}** issues."))

# Optional: activities (N+1 calls). Use concurrency but be nice.
issue_start_at =
  if use_activities? do
    issues
    |> Task.async_stream(
      fn issue ->
        id = issue["id"]
        acts = Client.fetch_activities!(req, id)
        start_at = StartAt.from_activities(acts, state_field, in_progress_names)
        {id, start_at}
      end,
      max_concurrency: 8,
      timeout: 60_000
    )
    |> Enum.reduce(%{}, fn
      {:ok, {id, start_at}}, acc -> Map.put(acc, id, start_at)
      _, acc -> acc
    end)
  else
    %{}
  end

# Normalize into "work items" (explode assignees, attach streams)
work_items =
  WorkItems.build(issues,
    state_field: state_field,
    assignees_field: assignees_field,
    rules: rules,
    in_progress_names: in_progress_names,
    issue_start_at: issue_start_at,
    excluded_logins: excluded_logins,
    include_substreams: include_substreams?,
    unplanned_tag: unplanned_tag
  )

Kino.DataTable.new(Enum.take(work_items, 30))

Charts

chart = GanttChart.build(work_items)
Kino.VegaLite.new(chart)

Unplanned Work Analysis

# Separate planned vs unplanned work items
unplanned_items = Enum.filter(work_items, & &1.is_unplanned)
planned_items = Enum.reject(work_items, & &1.is_unplanned)

total_items = length(work_items)
unplanned_count = length(unplanned_items)
planned_count = length(planned_items)

unplanned_pct = if total_items > 0, do: Float.round(unplanned_count / total_items * 100, 1), else: 0.0

Kino.render(Kino.Markdown.new("""
### Summary
- **Total work items:** #{total_items}
- **Planned:** #{planned_count} (#{Float.round(100 - unplanned_pct, 1)}%)
- **Unplanned (#{unplanned_tag}):** #{unplanned_count} (#{unplanned_pct}%)
"""))

# Pie chart: planned vs unplanned
pie_data = [
  %{type: "Planned", count: planned_count},
  %{type: "Unplanned", count: unplanned_count}
]

pie_chart =
  Vl.new(width: 300, height: 300, title: "Planned vs Unplanned Work")
  |> Vl.data_from_values(pie_data)
  |> Vl.mark(:arc, tooltip: true)
  |> Vl.encode_field(:theta, "count", type: :quantitative)
  |> Vl.encode_field(:color, "type",
    type: :nominal,
    scale: [domain: ["Planned", "Unplanned"], range: ["steelblue", "orangered"]]
  )
  |> Vl.encode(:tooltip, [
    [field: "type", type: :nominal, title: "Type"],
    [field: "count", type: :quantitative, title: "Count"]
  ])

Kino.VegaLite.new(pie_chart)

Unplanned % by Person

person_stats =
  work_items
  |> Enum.group_by(& &1.person_name)
  |> Enum.map(fn {person, items} ->
    total = length(items)
    unplanned = Enum.count(items, & &1.is_unplanned)
    pct = if total > 0, do: Float.round(unplanned / total * 100, 1), else: 0.0

    %{person: person, total: total, unplanned: unplanned, unplanned_pct: pct}
  end)
  |> Enum.sort_by(& &1.unplanned_pct, :desc)

person_chart =
  Vl.new(width: 600, height: 300, title: "Unplanned Work % by Person")
  |> Vl.data_from_values(person_stats)
  |> Vl.mark(:bar, tooltip: true)
  |> Vl.encode_field(:x, "person", type: :nominal, title: "Person", sort: "-y")
  |> Vl.encode_field(:y, "unplanned_pct", type: :quantitative, title: "Unplanned %")
  |> Vl.encode_field(:color, "unplanned_pct",
    type: :quantitative,
    scale: [scheme: "oranges"]
  )
  |> Vl.encode(:tooltip, [
    [field: "person", type: :nominal, title: "Person"],
    [field: "total", type: :quantitative, title: "Total Items"],
    [field: "unplanned", type: :quantitative, title: "Unplanned"],
    [field: "unplanned_pct", type: :quantitative, title: "Unplanned %"]
  ])

Kino.render(Kino.VegaLite.new(person_chart))
Kino.DataTable.new(person_stats, name: "Unplanned by Person")

Unplanned % by Stream

stream_stats =
  work_items
  |> Enum.group_by(& &1.stream)
  |> Enum.map(fn {stream, items} ->
    total = length(items)
    unplanned = Enum.count(items, & &1.is_unplanned)
    pct = if total > 0, do: Float.round(unplanned / total * 100, 1), else: 0.0

    %{stream: stream, total: total, unplanned: unplanned, unplanned_pct: pct}
  end)
  |> Enum.sort_by(& &1.unplanned_pct, :desc)

stream_chart =
  Vl.new(width: 600, height: 300, title: "Unplanned Work % by Workstream")
  |> Vl.data_from_values(stream_stats)
  |> Vl.mark(:bar, tooltip: true)
  |> Vl.encode_field(:x, "stream", type: :nominal, title: "Stream", sort: "-y")
  |> Vl.encode_field(:y, "unplanned_pct", type: :quantitative, title: "Unplanned %")
  |> Vl.encode_field(:color, "unplanned_pct",
    type: :quantitative,
    scale: [scheme: "oranges"]
  )
  |> Vl.encode(:tooltip, [
    [field: "stream", type: :nominal, title: "Stream"],
    [field: "total", type: :quantitative, title: "Total Items"],
    [field: "unplanned", type: :quantitative, title: "Unplanned"],
    [field: "unplanned_pct", type: :quantitative, title: "Unplanned %"]
  ])

Kino.render(Kino.VegaLite.new(stream_chart))
Kino.DataTable.new(stream_stats, name: "Unplanned by Stream")

Interrupt Timeline Heatmaps

# Parse dates from unplanned items
unplanned_dates =
  unplanned_items
  |> Enum.filter(& &1.created)
  |> Enum.map(fn item ->
    dt = DateTime.from_unix!(div(item.created, 1000))
    date = DateTime.to_date(dt)
    weekday = Date.day_of_week(date)
    monthday = date.day

    weekday_name = case weekday do
      1 -> "Mon"
      2 -> "Tue"
      3 -> "Wed"
      4 -> "Thu"
      5 -> "Fri"
      6 -> "Sat"
      7 -> "Sun"
    end

    %{
      date: Date.to_iso8601(date),
      weekday: weekday,
      weekday_name: weekday_name,
      monthday: monthday,
      person: item.person_name
    }
  end)

# Weekday heatmap
weekday_counts =
  unplanned_dates
  |> Enum.frequencies_by(& &1.weekday_name)
  |> Enum.map(fn {day, count} -> %{weekday: day, count: count} end)
  |> Enum.sort_by(fn %{weekday: d} ->
    case d do
      "Mon" -> 1
      "Tue" -> 2
      "Wed" -> 3
      "Thu" -> 4
      "Fri" -> 5
      "Sat" -> 6
      "Sun" -> 7
    end
  end)

weekday_chart =
  Vl.new(width: 400, height: 200, title: "Interrupts by Day of Week")
  |> Vl.data_from_values(weekday_counts)
  |> Vl.mark(:bar, tooltip: true, color: "orangered")
  |> Vl.encode_field(:x, "weekday",
    type: :ordinal,
    title: "Day",
    sort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
  )
  |> Vl.encode_field(:y, "count", type: :quantitative, title: "Interrupt Count")

# Monthday heatmap
monthday_counts =
  unplanned_dates
  |> Enum.frequencies_by(& &1.monthday)
  |> Enum.map(fn {day, count} -> %{monthday: day, count: count} end)
  |> Enum.sort_by(& &1.monthday)

monthday_chart =
  Vl.new(width: 600, height: 200, title: "Interrupts by Day of Month")
  |> Vl.data_from_values(monthday_counts)
  |> Vl.mark(:bar, tooltip: true, color: "orangered")
  |> Vl.encode_field(:x, "monthday", type: :ordinal, title: "Day of Month")
  |> Vl.encode_field(:y, "count", type: :quantitative, title: "Interrupt Count")

Kino.Layout.grid([
  Kino.VegaLite.new(weekday_chart),
  Kino.VegaLite.new(monthday_chart)
], columns: 2)

Unclassified (debug)

# Build "audit rows" from raw issues (not work_items, because those explode per assignee/stream)
unclassified =
  issues
  |> Enum.map(fn issue ->
    streams = Workstreams.streams_for_issue(issue, rules, include_substreams: include_substreams?)
    slug =
      issue["summary"]
      |> Workstreams.summary_slug()
      |> Workstreams.normalize_slug()
      || "(no slug)"

    %{
      issue_id: issue["idReadable"] || issue["id"],
      title: issue["summary"],
      slug: slug,
      streams: streams
    }
  end)
  |> Enum.filter(fn row -> row.streams == ["(unclassified)"] end)

Kino.render(Kino.Markdown.new("Unclassified issues: **#{length(unclassified)}**"))

# Aggregate counts per slug + keep a few example issue ids/titles
slug_stats =
  unclassified
  |> Enum.group_by(& &1.slug)
  |> Enum.map(fn {slug, rows} ->
    examples =
      rows
      |> Enum.take(5)
      |> Enum.map(fn r -> "#{r.issue_id}: #{r.title}" end)
      |> Enum.join("\n")

    %{
      slug: slug,
      count: length(rows),
      examples: examples
    }
  end)
  |> Enum.sort_by(& &1.count, :desc)

Kino.DataTable.new(slug_stats)

Classifier

defmodule GanttUI do
  @stream_catalog [
    "BACKEND", "FRONTEND", "API", "DATABASE", "INFRA",
    "DOCS", "SECURITY", "BAU"
  ]

  def render_all(state) do
    render_stats(state)
    render_chart(state)
    render_mapping_widget(state)
  end

  def render_stats(state) do
    %{issues: issues, rules_agent: rules_agent, stats_frame: stats_frame} = state
    rules_now = Agent.get(rules_agent, & &1)
    include_substreams = state[:include_substreams] || true

    unclassified_rows = compute_unclassified(issues, rules_now, include_substreams)

    slug_stats =
      unclassified_rows
      |> Enum.group_by(& &1.slug)
      |> Enum.map(fn {slug, rows} ->
        examples =
          rows
          |> Enum.take(3)
          |> Enum.map(fn r -> "#{r.issue_id}: #{r.title}" end)
          |> Enum.join("\n")

        %{slug: slug, count: length(rows), examples: examples}
      end)
      |> Enum.sort_by(& &1.count, :desc)

    stats_content = Kino.Layout.grid([
      Kino.Markdown.new("**Unclassified issues:** #{length(unclassified_rows)}"),
      Kino.DataTable.new(slug_stats)
    ], columns: 1)

    Kino.Frame.render(stats_frame, stats_content)
  end

  def render_chart(state) do
    %{
      issues: issues,
      rules_agent: rules_agent,
      chart_frame: chart_frame,
      state_field: state_field,
      assignees_field: assignees_field,
      in_progress_names: in_progress_names
    } = state

    rules_now = Agent.get(rules_agent, & &1)

    excluded_logins = state[:excluded_logins] || []
    include_substreams = state[:include_substreams] || true
    unplanned_tag = state[:unplanned_tag]

    work_items =
      WorkItems.build(issues,
        state_field: state_field,
        assignees_field: assignees_field,
        rules: rules_now,
        in_progress_names: in_progress_names,
        excluded_logins: excluded_logins,
        include_substreams: include_substreams,
        unplanned_tag: unplanned_tag
      )

    chart = GanttChart.build(work_items)
    Kino.Frame.render(chart_frame, Kino.VegaLite.new(chart))
  end

  def render_mapping_widget(state) do
    %{
      issues: issues,
      rules_agent: rules_agent,
      widget_frame: widget_frame,
      mapping_frame: mapping_frame
    } = state

    rules_now = Agent.get(rules_agent, & &1)
    include_substreams = state[:include_substreams] || true
    unclassified_rows = compute_unclassified(issues, rules_now, include_substreams)

    slug_options =
      unclassified_rows
      |> Enum.group_by(& &1.slug)
      |> Enum.map(fn {slug, rows} -> {"#{slug} (#{length(rows)})", slug} end)
      |> Enum.sort_by(fn {label, _} -> label end)

    slug_pick =
      Kino.Input.select(
        "Pick an unclassified slug",
        if(slug_options == [], do: [{"(none)", nil}], else: slug_options)
      )

    stream_pick =
      Kino.Input.select(
        "Map this slug to stream",
        Enum.map(@stream_catalog, fn s -> {s, s} end)
      )

    # Use Kino.Control.form/2 to bundle inputs and get values on submit
    form = Kino.Control.form(
      [slug: slug_pick, stream: stream_pick],
      submit: "Apply mapping (in-memory)"
    )

    Kino.Frame.render(widget_frame, form)

    # Show current mappings
    current_mappings =
      rules_now.slug_prefix_to_stream
      |> Enum.map(fn {k, v} -> %{slug: k, streams: Enum.join(v, ", ")} end)
      |> Enum.sort_by(& &1.slug)

    Kino.Frame.render(mapping_frame, Kino.DataTable.new(current_mappings))

    # Listen to form submission - data comes with the event
    Kino.listen(form, fn %{data: %{slug: chosen_slug, stream: chosen_stream}} ->
      if chosen_slug not in [nil, "(NO SLUG)"] and chosen_stream != nil do
        Agent.update(rules_agent, fn r ->
          key = Workstreams.normalize_slug(chosen_slug)

          updated =
            Map.update(
              r.slug_prefix_to_stream,
              key,
              [chosen_stream],
              fn existing -> Enum.uniq(existing ++ [chosen_stream]) end
            )

          %{r | slug_prefix_to_stream: updated}
        end)

        # re-render everything with updated rules
        GanttUI.render_all(state)
      end
    end)
  end

  defp compute_unclassified(issues, rules, include_substreams) do
    issues
    |> Enum.map(fn issue ->
      streams = Workstreams.streams_for_issue(issue, rules, include_substreams: include_substreams)

      slug =
        issue["summary"]
        |> Workstreams.summary_slug()
        |> Workstreams.normalize_slug()
        || "(NO SLUG)"

      %{
        issue_id: issue["idReadable"] || issue["id"],
        title: issue["summary"],
        slug: slug,
        streams: streams
      }
    end)
    |> Enum.filter(&(&1.streams == ["(unclassified)"]))
  end
end

# --- Create frames (persistent across re-renders) ---
widget_frame = Kino.Frame.new()
stats_frame = Kino.Frame.new()
chart_frame = Kino.Frame.new()
mapping_frame = Kino.Frame.new()

# --- Start or reuse the rules agent ---
rules_agent =
  case Process.whereis(:gantt_rules_agent) do
    nil ->
      {:ok, pid} = Agent.start_link(fn -> rules end, name: :gantt_rules_agent)
      pid

    pid ->
      # Update with current rules from input
      Agent.update(pid, fn _ -> rules end)
      pid
  end

# --- Build UI state map with all frames ---
ui_state = %{
  issues: issues,
  rules_agent: rules_agent,
  state_field: state_field,
  assignees_field: assignees_field,
  in_progress_names: in_progress_names,
  excluded_logins: excluded_logins,
  include_substreams: include_substreams?,
  unplanned_tag: unplanned_tag,
  widget_frame: widget_frame,
  stats_frame: stats_frame,
  chart_frame: chart_frame,
  mapping_frame: mapping_frame
}

# --- Render initial layout with all frames ---
Kino.render(
  Kino.Layout.grid(
    [
      Kino.Markdown.new("## Gantt Chart"),
      chart_frame,
      Kino.Markdown.new("## Unclassified Issues"),
      stats_frame,
      Kino.Markdown.new("## Mapping Controls"),
      widget_frame,
      Kino.Markdown.new("## Current Mappings"),
      mapping_frame
    ],
    columns: 1
  )
)

# --- Populate all frames ---
GanttUI.render_all(ui_state)

Cleanup

Run this cell to stop the background agent when you’re done.

case Process.whereis(:gantt_rules_agent) do
  nil -> :already_stopped
  pid -> Agent.stop(pid)
end