Pairing Patterns
Purpose
This notebook analyzes pairing patterns from YouTrack issues. It identifies which team members frequently work together, how pairing trends evolve over time, and how pairing is distributed across workstreams and projects.
Setup
Mix.install([
{:youtrack, path: "./youtrack"},
{:kino, "~> 0.14"},
{:kino_vega_lite, "~> 0.1"}
])
alias VegaLite, as: Vl
alias Youtrack.{Client, Fields, PairingAnalysis, Workstreams, WorkstreamsLoader}
Inputs
base_url_in =
Kino.Input.text(
"YouTrack base URL",
default: System.get_env("YOUTRACK_BASE_URL") || "https://your-instance.youtrack.cloud"
)
token_in =
Kino.Input.password(
"Permanent token",
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",
default: String.to_integer(System.get_env("YOUTRACK_DAYS_BACK") || "90")
)
assignees_field_in =
Kino.Input.text(
"Assignees field name",
default: System.get_env("YOUTRACK_ASSIGNEES_FIELD") || "Assignee"
)
project_prefix_in =
Kino.Input.text(
"Filter by issue ID prefix (leave empty for all)",
default: System.get_env("YOUTRACK_PROJECT_PREFIX") || ""
)
excluded_logins_in =
Kino.Input.text(
"Excluded logins (comma-separated)",
default: System.get_env("YOUTRACK_EXCLUDED_LOGINS") || ""
)
include_substreams_in =
Kino.Input.checkbox(
"Include substreams (expand to parent workstreams)",
default: true)
unplanned_tag_in =
Kino.Input.text(
"Unplanned work tag (e.g., 'on the ankles')",
default: System.get_env("YOUTRACK_UNPLANNED_TAG") || "on the ankles")
Kino.Layout.grid(
[
base_url_in,
token_in,
base_query_in,
days_back_in,
assignees_field_in,
project_prefix_in,
excluded_logins_in,
include_substreams_in,
unplanned_tag_in
],
columns: 2
)
Workstreams Config
# Load workstreams from file
{workstream_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
Kino.nothing()
Pairing Analysis
PairingAnalysis is now in the youtrack library.
Fetch Issues
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)
assignees_field = Kino.Input.read(assignees_field_in) |> String.trim()
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)
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)
issues =
if project_prefix == "" do
raw_issues
else
Enum.filter(raw_issues, fn %{"idReadable" => id} ->
String.starts_with?(id, project_prefix)
end)
end
Kino.Markdown.new("Fetched **#{length(issues)}** issues.")
Extract Pairing Data
include_substreams? = Kino.Input.read(include_substreams_in)
unplanned_tag = Kino.Input.read(unplanned_tag_in) |> String.trim()
pair_records =
PairingAnalysis.extract_pairs(issues,
assignees_field: assignees_field,
excluded_logins: excluded_logins,
workstream_rules: workstream_rules,
include_substreams: include_substreams?,
unplanned_tag: unplanned_tag
)
paired_issues = pair_records |> Enum.map(& &1.issue_id) |> Enum.uniq() |> length()
total_issues = length(issues)
unplanned_pairs = Enum.count(pair_records, & &1.is_unplanned)
Kino.Markdown.new("""
**Pairing summary:**
- Total issues: #{total_issues}
- Issues with 2+ assignees: #{paired_issues} (#{Float.round(paired_issues / max(total_issues, 1) * 100, 1)}%)
- Total pair occurrences: #{length(pair_records)}
- Unplanned pair occurrences: #{unplanned_pairs} (#{Float.round(unplanned_pairs / max(length(pair_records), 1) * 100, 1)}%)
""")
Pair Matrix Heatmap
matrix_data = PairingAnalysis.pair_matrix(pair_records)
Vl.new(width: 500, height: 500, title: "Pair Matrix")
|> Vl.data_from_values(matrix_data)
|> Vl.mark(:rect, tooltip: true)
|> Vl.encode_field(:x, "person_a", type: :nominal, title: "Person", sort: :ascending)
|> Vl.encode_field(:y, "person_b", type: :nominal, title: "Person", sort: :ascending)
|> Vl.encode_field(:color, "count",
type: :quantitative,
title: "Times paired",
scale: [scheme: "blues"]
)
|> Vl.encode(:tooltip, [
[field: "person_a", type: :nominal, title: "Person A"],
[field: "person_b", type: :nominal, title: "Person B"],
[field: "count", type: :quantitative, title: "Times paired"]
])
Pairing Trend Over Time
trend_data = PairingAnalysis.trend_by_week(pair_records)
Vl.new(width: 700, height: 300, title: "Pairing Trend by Week")
|> Vl.data_from_values(trend_data)
|> Vl.layers([
Vl.new()
|> Vl.mark(:bar, opacity: 0.6)
|> Vl.encode_field(:x, "week", type: :temporal, title: "Week")
|> Vl.encode_field(:y, "pair_count", type: :quantitative, title: "Pair occurrences"),
Vl.new()
|> Vl.mark(:line, color: "red", point: true)
|> Vl.encode_field(:x, "week", type: :temporal)
|> Vl.encode_field(:y, "unique_pairs", type: :quantitative, title: "Unique pairs")
])
|> Vl.encode(:tooltip, [
[field: "week", type: :temporal, title: "Week"],
[field: "pair_count", type: :quantitative, title: "Pair occurrences"],
[field: "unique_pairs", type: :quantitative, title: "Unique pairs"]
])
Pairing by Workstream
workstream_data = PairingAnalysis.by_workstream(pair_records)
Vl.new(width: 500, height: 300, title: "Pairing by Workstream")
|> Vl.data_from_values(workstream_data)
|> Vl.mark(:bar, tooltip: true)
|> Vl.encode_field(:x, "workstream", type: :nominal, title: "Workstream", sort: "-y")
|> Vl.encode_field(:y, "pair_count", type: :quantitative, title: "Pair occurrences")
|> Vl.encode_field(:color, "unique_pairs",
type: :quantitative,
title: "Unique pairs",
scale: [scheme: "oranges"]
)
|> Vl.encode(:tooltip, [
[field: "workstream", type: :nominal, title: "Workstream"],
[field: "pair_count", type: :quantitative, title: "Pair occurrences"],
[field: "unique_pairs", type: :quantitative, title: "Unique pairs"]
])
Top Pairs
top_pairs =
pair_records
|> Enum.frequencies_by(&{&1.person_a, &1.person_b})
|> Enum.sort_by(fn {_, count} -> count end, :desc)
|> Enum.take(15)
|> Enum.map(fn {{a, b}, count} -> %{pair: "#{a} + #{b}", count: count} end)
Kino.DataTable.new(top_pairs, name: "Top 15 Pairs")
Firefighter Detection
Identifies team members who handle a disproportionate amount of unplanned/interrupt work.
By Person
firefighter_persons = PairingAnalysis.firefighters_by_person(pair_records)
firefighter_chart =
Vl.new(width: 600, height: 300, title: "Firefighters: Unplanned Work by Person")
|> Vl.data_from_values(firefighter_persons)
|> Vl.mark(:bar, tooltip: true)
|> Vl.encode_field(:x, "person", type: :nominal, title: "Person", sort: "-y")
|> Vl.encode_field(:y, "unplanned", type: :quantitative, title: "Unplanned Pair Occurrences")
|> Vl.encode_field(:color, "unplanned_pct",
type: :quantitative,
title: "Unplanned %",
scale: [scheme: "oranges"]
)
|> Vl.encode(:tooltip, [
[field: "person", type: :nominal, title: "Person"],
[field: "total", type: :quantitative, title: "Total"],
[field: "unplanned", type: :quantitative, title: "Unplanned"],
[field: "unplanned_pct", type: :quantitative, title: "Unplanned %"]
])
Kino.render(Kino.VegaLite.new(firefighter_chart))
Kino.DataTable.new(firefighter_persons, name: "Firefighters by Person")
By Pair
firefighter_pairs = PairingAnalysis.firefighters_by_pair(pair_records)
# Top 15 pairs by unplanned count
top_firefighter_pairs = Enum.take(firefighter_pairs, 15)
firefighter_pair_chart =
Vl.new(width: 600, height: 300, title: "Firefighter Pairs: Top 15 by Unplanned Work")
|> Vl.data_from_values(top_firefighter_pairs)
|> Vl.mark(:bar, tooltip: true, color: "orangered")
|> Vl.encode_field(:x, "pair", type: :nominal, title: "Pair", sort: "-y")
|> Vl.encode_field(:y, "unplanned", type: :quantitative, title: "Unplanned Occurrences")
|> Vl.encode(:tooltip, [
[field: "pair", type: :nominal, title: "Pair"],
[field: "total", type: :quantitative, title: "Total"],
[field: "unplanned", type: :quantitative, title: "Unplanned"],
[field: "unplanned_pct", type: :quantitative, title: "Unplanned %"]
])
Kino.render(Kino.VegaLite.new(firefighter_pair_chart))
Kino.DataTable.new(top_firefighter_pairs, name: "Firefighter Pairs")
Interrupt Frequency Over Time
Aggregate Trend
interrupt_trend = PairingAnalysis.interrupt_trend_by_week(pair_records)
interrupt_trend_chart =
Vl.new(width: 700, height: 300, title: "Interrupt Frequency Over Time (Aggregate)")
|> Vl.data_from_values(interrupt_trend)
|> Vl.mark(:line, point: true, color: "orangered")
|> Vl.encode_field(:x, "week", type: :temporal, title: "Week")
|> Vl.encode_field(:y, "interrupt_count", type: :quantitative, title: "Interrupt Count")
|> Vl.encode(:tooltip, [
[field: "week", type: :temporal, title: "Week"],
[field: "interrupt_count", type: :quantitative, title: "Interrupts"]
])
Kino.VegaLite.new(interrupt_trend_chart)
Per Person Trend
interrupt_by_person = PairingAnalysis.interrupt_trend_by_person(pair_records)
if length(interrupt_by_person) > 0 do
person_interrupt_chart =
Vl.new(width: 700, height: 400, title: "Interrupt Frequency by Person Over Time")
|> Vl.data_from_values(interrupt_by_person)
|> Vl.mark(:line, point: true)
|> Vl.encode_field(:x, "week", type: :temporal, title: "Week")
|> Vl.encode_field(:y, "interrupt_count", type: :quantitative, title: "Interrupt Count")
|> Vl.encode_field(:color, "person", type: :nominal, title: "Person")
|> Vl.encode(:tooltip, [
[field: "person", type: :nominal, title: "Person"],
[field: "week", type: :temporal, title: "Week"],
[field: "interrupt_count", type: :quantitative, title: "Interrupts"]
])
Kino.VegaLite.new(person_interrupt_chart)
else
Kino.Markdown.new("*No unplanned pair occurrences found.*")
end