From 5dd0a822dd85f15aec8323593286278f5e8fcee9 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Wed, 10 Jun 2026 14:00:51 +0200 Subject: [PATCH 1/6] Drag from any column header, drop onto another column to reorder. Order persists across page reloads and survives background Jira syncs. Signed-off-by: Kai Wagner --- app/assets/stylesheets/korkban.css | 7 ++ .../controllers/board_controller.js | 105 ++++++++++++++++++ app/views/board/_column.html.slim | 2 +- 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/korkban.css b/app/assets/stylesheets/korkban.css index 3566f8d..024f976 100644 --- a/app/assets/stylesheets/korkban.css +++ b/app/assets/stylesheets/korkban.css @@ -364,6 +364,13 @@ html, body { background: var(--kb-bg); font-family: var(--kb-ui); color: var(--k [data-theme="dark"] .kb-card[data-staleness="somewhat"] .kb-avatar { box-shadow: 0 0 0 1.5px #2a2317; } [data-theme="dark"] .kb-card[data-staleness="really"] .kb-avatar { box-shadow: 0 0 0 1.5px #2c1818; } +/* ---------- column drag-and-drop ---------- */ +.kb-col-head { cursor: grab; user-select: none; } +.kb-col-head:active { cursor: grabbing; } +.kb-col[data-dragging="1"] { opacity: 0.4; } +.kb-col[data-drop-side="left"] { box-shadow: -3px 0 0 0 #3b82f6; } +.kb-col[data-drop-side="right"] { box-shadow: 3px 0 0 0 #3b82f6; } + /* animations */ .kb-fade-in { animation: pgfade .16s ease-out; } @keyframes pgfade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } diff --git a/app/javascript/controllers/board_controller.js b/app/javascript/controllers/board_controller.js index 73bfe68..4a94dd2 100644 --- a/app/javascript/controllers/board_controller.js +++ b/app/javascript/controllers/board_controller.js @@ -21,11 +21,24 @@ export default class extends Controller { this._onWinResize = () => this._positionTooltip() window.addEventListener("scroll", this._onWinResize, true) window.addEventListener("resize", this._onWinResize) + this._setupColumnDrag() + this._restoreColOrder() + this._colObserver = new MutationObserver(() => { + clearTimeout(this._morphTimer) + this._morphTimer = setTimeout(() => { + this._colObserver.disconnect() + this._restoreColOrder() + this._colObserver.observe(this.rootTarget, { childList: true }) + }, 0) + }) + this._colObserver.observe(this.rootTarget, { childList: true }) } disconnect() { window.removeEventListener("scroll", this._onWinResize, true) window.removeEventListener("resize", this._onWinResize) + this._teardownColumnDrag() + if (this._colObserver) this._colObserver.disconnect() } // ---------- search ---------- @@ -235,6 +248,98 @@ export default class extends Controller { }[c])) } + // ---------- column drag-and-drop ---------- + _setupColumnDrag() { + const root = this.rootTarget + this._draggedCol = null + this._dropTarget = null + this._dropBefore = false + + this._onColDragStart = (e) => { + const head = e.target.closest(".kb-col-head") + if (!head) return + const col = head.closest(".kb-col") + if (!col) return + this._draggedCol = col + col.dataset.dragging = "1" + e.dataTransfer.effectAllowed = "move" + e.dataTransfer.setData("text/plain", col.dataset.epicKey || "") + } + + this._onColDragOver = (e) => { + if (!this._draggedCol) return + const col = e.target.closest(".kb-col") + if (!col || col === this._draggedCol) return + e.preventDefault() + e.dataTransfer.dropEffect = "move" + const rect = col.getBoundingClientRect() + const before = e.clientX < rect.left + rect.width / 2 + if (this._dropTarget !== col || this._dropBefore !== before) { + root.querySelectorAll(".kb-col[data-drop-side]").forEach(c => delete c.dataset.dropSide) + col.dataset.dropSide = before ? "left" : "right" + this._dropTarget = col + this._dropBefore = before + } + } + + this._onColDrop = (e) => { + e.preventDefault() + root.querySelectorAll(".kb-col[data-drop-side]").forEach(c => delete c.dataset.dropSide) + if (this._dropTarget && this._draggedCol) { + if (this._dropBefore) { + root.insertBefore(this._draggedCol, this._dropTarget) + } else { + this._dropTarget.after(this._draggedCol) + } + this._saveColOrder() + } + this._dropTarget = null + } + + this._onColDragEnd = () => { + if (this._draggedCol) { + delete this._draggedCol.dataset.dragging + this._draggedCol = null + } + root.querySelectorAll(".kb-col[data-drop-side]").forEach(c => delete c.dataset.dropSide) + this._dropTarget = null + } + + root.addEventListener("dragstart", this._onColDragStart) + root.addEventListener("dragover", this._onColDragOver) + root.addEventListener("drop", this._onColDrop) + root.addEventListener("dragend", this._onColDragEnd) + } + + _teardownColumnDrag() { + if (!this.hasRootTarget) return + const root = this.rootTarget + root.removeEventListener("dragstart", this._onColDragStart) + root.removeEventListener("dragover", this._onColDragOver) + root.removeEventListener("drop", this._onColDrop) + root.removeEventListener("dragend", this._onColDragEnd) + } + + _saveColOrder() { + const order = [...this.rootTarget.querySelectorAll(":scope > .kb-col")].map(c => c.dataset.epicKey) + localStorage.setItem("kb-col-order", JSON.stringify(order)) + } + + _restoreColOrder() { + try { + const saved = JSON.parse(localStorage.getItem("kb-col-order") || "null") + if (!saved || !Array.isArray(saved) || saved.length === 0) return + const root = this.rootTarget + const cols = [...root.querySelectorAll(":scope > .kb-col")] + const byKey = Object.fromEntries(cols.map(c => [c.dataset.epicKey, c])) + const seen = new Set() + saved.forEach(key => { + if (byKey[key]) { root.appendChild(byKey[key]); seen.add(key) } + }) + cols.forEach(c => { if (!seen.has(c.dataset.epicKey)) root.appendChild(c) }) + } catch {} + } + // ---------- persistence ---------- persist() { const state = { diff --git a/app/views/board/_column.html.slim b/app/views/board/_column.html.slim index 8f0496a..e554a71 100644 --- a/app/views/board/_column.html.slim +++ b/app/views/board/_column.html.slim @@ -10,7 +10,7 @@ - dist_buckets = ordered_display_states.map { |s| n = all_issues.count { |p| p.display_status == s[:id] }; [s, n] }.select { |_, n| n > 0 } section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-todo-open-value="false" data-stack-done-open-value="false" - .kb-col-head + .kb-col-head draggable="true" .kb-col-accent style="background:#{accent};" .kb-col-title-row span.kb-col-name title=epic.name From 2afd9c1a9ad8e5a3fae32988b44e8e2fdfb62d46 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Wed, 10 Jun 2026 14:07:40 +0200 Subject: [PATCH 2/6] Make the EPIC title a clickable link to Jira Signed-off-by: Kai Wagner --- app/assets/stylesheets/korkban.css | 2 ++ app/views/board/_column.html.slim | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/korkban.css b/app/assets/stylesheets/korkban.css index 024f976..beb9083 100644 --- a/app/assets/stylesheets/korkban.css +++ b/app/assets/stylesheets/korkban.css @@ -228,6 +228,8 @@ html, body { background: var(--kb-bg); font-family: var(--kb-ui); color: var(--k flex: 1; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .kb-col-count { font-size: 11px; font-weight: 700; color: var(--kb-text-mute); flex: 0 0 auto; } +a.kb-col-name { text-decoration: none; color: inherit; } +a.kb-col-name:hover { text-decoration: underline; } .kb-col-meta { display: flex; align-items: center; gap: 6px; margin: 6px 0 7px; } .kb-col-key { font-family: var(--kb-mono); font-size: 9px; color: var(--kb-text-faint); font-weight: 600; } .kb-col-stale { diff --git a/app/views/board/_column.html.slim b/app/views/board/_column.html.slim index e554a71..e563ab9 100644 --- a/app/views/board/_column.html.slim +++ b/app/views/board/_column.html.slim @@ -13,8 +13,12 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to .kb-col-head draggable="true" .kb-col-accent style="background:#{accent};" .kb-col-title-row - span.kb-col-name title=epic.name - = epic.name + - if epic.jira_key == "UNPLANNED" + span.kb-col-name title=epic.name + = epic.name + - else + a.kb-col-name href=jira_url(epic.jira_key) target="_blank" rel="noreferrer" title=epic.name draggable="false" + = epic.name span.kb-col-count= total .kb-col-meta span.kb-col-key= epic.jira_key From d8ad68defc39d73a0818f492c7c963cbc1342c86 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Wed, 10 Jun 2026 14:16:50 +0200 Subject: [PATCH 3/6] adding tests Signed-off-by: Kai Wagner --- test/system/board_test.rb | 23 ++++++++ test/system/column_reorder_test.rb | 84 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 test/system/column_reorder_test.rb diff --git a/test/system/board_test.rb b/test/system/board_test.rb index a9ba4c2..d5dc44f 100644 --- a/test/system/board_test.rb +++ b/test/system/board_test.rb @@ -17,4 +17,27 @@ class BoardSystemTest < ApplicationSystemTestCase assert_selector ".kb-col", minimum: 2 assert_selector ".kb-card", minimum: 4, visible: :all end + + test "epic name in column header links to its jira epic" do + visit "/auth/google_oauth2/callback" + visit "/" + link = find(".kb-col[data-epic-key='PG-1'] a.kb-col-name") + assert_equal "https://example.atlassian.net/browse/PG-1", link["href"] + assert_equal "_blank", link["target"] + end + + test "epic name link is absent for the unplanned column" do + Issue.where(epic_id: nil).delete_all + orphan = Issue.create!( + jira_key: "PG-99", epic: nil, issue_type: "Task", + summary: "Orphan task", jira_status: "In Progress", + status_changed_at_jira: 1.day.ago, created_at_jira: 1.day.ago + ) + visit "/auth/google_oauth2/callback" + visit "/" + assert_selector ".kb-col[data-epic-key='UNPLANNED'] span.kb-col-name" + assert_no_selector ".kb-col[data-epic-key='UNPLANNED'] a.kb-col-name" + ensure + orphan&.destroy + end end diff --git a/test/system/column_reorder_test.rb b/test/system/column_reorder_test.rb new file mode 100644 index 0000000..89f46ec --- /dev/null +++ b/test/system/column_reorder_test.rb @@ -0,0 +1,84 @@ +require "application_system_test_case" + +class ColumnReorderTest < ApplicationSystemTestCase + fixtures :epics, :issues, :sync_runs + + setup do + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new( + provider: "google_oauth2", uid: "u1", + info: { email: "alice@example.com", name: "Alice" } + ) + visit "/auth/google_oauth2/callback" + visit "/" + assert_selector "main.kb-board#board-root" + end + + test "dragging a column header reorders columns" do + assert_equal %w[PG-1 PG-2], column_keys + + drag_col("PG-2", "PG-1", side: :left) + + assert_selector ".kb-col:first-child[data-epic-key='PG-2']" + assert_equal %w[PG-2 PG-1], column_keys + end + + test "column order is restored from localStorage after page reload" do + drag_col("PG-2", "PG-1", side: :left) + assert_selector ".kb-col:first-child[data-epic-key='PG-2']" + + visit "/" + + assert_selector ".kb-col:first-child[data-epic-key='PG-2']" + assert_equal %w[PG-2 PG-1], column_keys + end + + test "column order is preserved after a live broadcast" do + drag_col("PG-2", "PG-1", side: :left) + assert_selector ".kb-col:first-child[data-epic-key='PG-2']" + + broadcast_board + + assert_selector ".kb-col:first-child[data-epic-key='PG-2']" + assert_equal %w[PG-2 PG-1], column_keys + end + + private + + def column_keys + all(".kb-col").map { |c| c["data-epic-key"] } + end + + def drag_col(from_key, to_key, side:) + source = find(".kb-col[data-epic-key='#{from_key}'] .kb-col-head") + target = find(".kb-col[data-epic-key='#{to_key}'] .kb-col-head") + x_offset = side == :left ? -50 : 50 + source.drag_to(target, drop_offset: { x: x_offset, y: 0 }) + end + + def broadcast_board + Turbo::StreamsChannel.broadcast_render_to( + "board", + partial: "board/board_morph", + locals: { presenter: build_presenter, last_sync: SyncRun.ok.most_recent.first } + ) + end + + def build_presenter + BoardPresenter.new( + epics: Epic.active.ordered.includes(:issues), + orphan_issues: Issue.active.orphan, + status_map: KORKBAN_CONFIG.board.status_map, + new_statuses: KORKBAN_CONFIG.board.new_statuses, + done_statuses: KORKBAN_CONFIG.board.done_statuses, + staleness: StalenessCalculator.new( + now: Time.current, + somewhat_days: KORKBAN_CONFIG.board.staleness.somewhat_days, + really_days: KORKBAN_CONFIG.board.staleness.really_days, + ignore_for_new: KORKBAN_CONFIG.board.ignore_staleness_for_new_issues, + new_display_statuses: KORKBAN_CONFIG.board.new_statuses, + done_display_statuses: KORKBAN_CONFIG.board.done_statuses + ) + ) + end +end From d35371fb23b0a813f3c126b652b8a5c80fa46166 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Wed, 10 Jun 2026 14:38:38 +0200 Subject: [PATCH 4/6] adding tests, fixing some UI quirks, making relationsship to subtasks clear and show them in general, show a few more details and highlight the issue ID Signed-off-by: Kai Wagner --- app/assets/stylesheets/korkban.css | 22 ++++++++- app/helpers/board_helper.rb | 4 ++ .../controllers/board_controller.js | 12 +++++ app/models/issue.rb | 24 ++++++++++ app/services/board_presenter.rb | 29 ++++++++++++ app/services/jira_sync.rb | 25 +++++++++-- app/views/board/_column.html.slim | 27 ++++++----- app/views/board/_postit.html.slim | 14 +++++- test/models/issue_test.rb | 15 +++++++ test/services/board_presenter_test.rb | 42 +++++++++++++++++ test/services/jira_sync_test.rb | 45 ++++++++++++++++++- 11 files changed, 240 insertions(+), 19 deletions(-) diff --git a/app/assets/stylesheets/korkban.css b/app/assets/stylesheets/korkban.css index beb9083..1da7664 100644 --- a/app/assets/stylesheets/korkban.css +++ b/app/assets/stylesheets/korkban.css @@ -301,14 +301,32 @@ a.kb-col-name:hover { text-decoration: underline; } .kb-card[data-spotlight="1"] { box-shadow: 0 0 0 2px #1e293b, 0 8px 20px rgba(0,0,0,.18); } .kb-card[data-critical="1"] { box-shadow: 0 0 0 3px rgba(239,68,68,.12); } .kb-card[data-hidden="1"] { display: none; } +.kb-card-subtask { + margin-left: 20px; + padding-top: 7px; + padding-bottom: 7px; + border-left-width: 3px; +} +.kb-card-subtask::before { + content: ""; + position: absolute; + left: -15px; + top: -7px; + width: 11px; + height: 21px; + border-left: 2px solid var(--kb-border-strong); + border-bottom: 2px solid var(--kb-border-strong); + border-bottom-left-radius: 6px; + pointer-events: none; +} +.kb-card-subtask .kb-card-title { font-size: 12px; } .kb-card-row1 { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; } .kb-card-id { font-family: var(--kb-mono); font-size: 10.5px; font-weight: 600; color: #5b6675; letter-spacing: .2px; - opacity: 0; transition: opacity .13s; + opacity: 1; } -.kb-card:hover .kb-card-id { opacity: 1; } .kb-card-meta { margin-left: auto; display: flex; align-items: center; gap: 5px; } .kb-step-chip { font: 800 9.5px var(--kb-ui); diff --git a/app/helpers/board_helper.rb b/app/helpers/board_helper.rb index 1385741..4efb6f2 100644 --- a/app/helpers/board_helper.rb +++ b/app/helpers/board_helper.rb @@ -22,6 +22,8 @@ module BoardHelper TYPE_STYLE = { "story" => { color: "#16a34a", shape: :square }, "task" => { color: "#2563eb", shape: :square }, + "sub-task" => { color: "#0d9488", shape: :square }, + "subtask" => { color: "#0d9488", shape: :square }, "bug" => { color: "#dc2626", shape: :circle }, "spike" => { color: "#7c3aed", shape: :square }, "epic" => { color: "#a855f7", shape: :square } @@ -128,6 +130,8 @@ def type_icon_svg(issue_type, size: 14) '' when "task" '' + when "sub-task", "subtask" + '' when "bug" '' when "spike" diff --git a/app/javascript/controllers/board_controller.js b/app/javascript/controllers/board_controller.js index 4a94dd2..9fada10 100644 --- a/app/javascript/controllers/board_controller.js +++ b/app/javascript/controllers/board_controller.js @@ -207,6 +207,15 @@ export default class extends Controller { const staleClass = stale ? ds.tooltipStale : null const staleColor = ds.tooltipStale === "critical" ? "#fca5a5" : ds.tooltipStale === "stale" ? "#fdba74" : "#e8ecf2" + const parentRow = ds.tooltipParent + ? `Parent${this._esc(ds.tooltipParent)}` + : "" + const labelsRow = ds.tooltipLabels + ? `Labels${this._esc(ds.tooltipLabels)}` + : "" + const componentsRow = ds.tooltipComponents + ? `Components${this._esc(ds.tooltipComponents)}` + : "" this.tooltipTarget.innerHTML = `
${ds.tooltipId || ""} @@ -215,6 +224,9 @@ export default class extends Controller {
${this._esc(ds.tooltipTitle || "")}
Type${this._esc(ds.tooltipType || "—")} + ${parentRow} + ${labelsRow} + ${componentsRow} Assignee${this._esc(ds.tooltipAssignee || "Unassigned")} In state${ds.tooltipDays ? ds.tooltipDays + " days" : "—"}${stale ? " · " + staleClass : ""} Priority${this._esc(ds.tooltipPriority || "Medium")} diff --git a/app/models/issue.rb b/app/models/issue.rb index 92e95e4..e7ad7ff 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -3,4 +3,28 @@ class Issue < ApplicationRecord scope :active, -> { where(removed_at: nil) } scope :orphan, -> { where(epic_id: nil) } + + def parent_jira_key + fields = raw_fields || {} + parent = fields["parent"] || fields[:parent] + return nil unless parent.respond_to?(:[]) + + parent["key"] || parent[:key] + end + + def labels + fields = raw_fields || {} + Array(fields["labels"] || fields[:labels]).filter_map { |label| label.to_s.presence } + end + + def components + fields = raw_fields || {} + Array(fields["components"] || fields[:components]).filter_map do |component| + if component.is_a?(Hash) + (component["name"] || component[:name]).to_s.presence + else + component.to_s.presence + end + end + end end diff --git a/app/services/board_presenter.rb b/app/services/board_presenter.rb index b0f3997..122b910 100644 --- a/app/services/board_presenter.rb +++ b/app/services/board_presenter.rb @@ -1,5 +1,8 @@ +require "set" + class BoardPresenter Warning = Struct.new(:issue_key, :status, :reason) + IssueRow = Struct.new(:postit, :depth) IssuePresenter = Struct.new(:issue, :display_status, :staleness) do def jira_key = issue.jira_key @@ -8,6 +11,9 @@ def assignee = issue.assignee_username def jira_status = issue.jira_status def issue_type = issue.issue_type def priority = issue.priority + def parent_jira_key = issue.parent_jira_key + def labels = issue.labels + def components = issue.components def created_at_jira = issue.created_at_jira def status_changed_at_jira = issue.status_changed_at_jira def transitioned_at = issue.status_changed_at_jira || issue.created_at_jira @@ -17,6 +23,29 @@ def transitioned_at = issue.status_changed_at_jira || issue.created_at_jira def all_issues new_issues + middle_groups.values.flatten + done_issues end + + def issue_rows_for(postits) + issue_keys = Set.new(postits.map(&:jira_key)) + children_by_parent = Hash.new { |h, k| h[k] = [] } + child_keys = Set.new + + postits.each do |postit| + parent_key = postit.parent_jira_key + next if parent_key.blank? || !issue_keys.include?(parent_key) + + children_by_parent[parent_key] << postit + child_keys << postit.jira_key + end + + postits.each_with_object([]) do |postit, rows| + next if child_keys.include?(postit.jira_key) + + rows << IssueRow.new(postit, 0) + children_by_parent[postit.jira_key].each do |child| + rows << IssueRow.new(child, 1) + end + end + end end UNPLANNED_EPIC = Struct.new(:jira_key, :name).new("UNPLANNED", "Unplanned") diff --git a/app/services/jira_sync.rb b/app/services/jira_sync.rb index b3c9ba8..6b4062d 100644 --- a/app/services/jira_sync.rb +++ b/app/services/jira_sync.rb @@ -1,6 +1,6 @@ class JiraSync EPIC_FIELDS = %w[summary status priority].freeze - ISSUE_FIELDS = %w[summary status issuetype assignee priority created parent].freeze + ISSUE_FIELDS = %w[summary status issuetype assignee priority created parent labels components].freeze def initialize(epic_query: KORKBAN_CONFIG.board.epic_query, unplanned_query: KORKBAN_CONFIG.board.unplanned_query, @@ -23,15 +23,26 @@ def run! end if epics_by_key.any? - keys_list = epics_by_key.keys.map { |k| %Q("#{k}") }.join(",") + keys_list = jira_key_list(epics_by_key.keys) child_jql = "parent in (#{keys_list})" children = @client.search_all(child_jql, fields: ISSUE_FIELDS, expand: "changelog") + subtasks = [] + + if children.any? + subtask_jql = "parent in (#{jira_key_list(children.map(&:key))})" + subtasks = @client.search_all(subtask_jql, fields: ISSUE_FIELDS, expand: "changelog") + end children_by_epic = Hash.new { |h, k| h[k] = [] } children.each do |ji| parent_key = ji.fields.dig("parent", "key") children_by_epic[parent_key] << ji end + subtasks_by_parent = Hash.new { |h, k| h[k] = [] } + subtasks.each do |ji| + parent_key = ji.fields.dig("parent", "key") + subtasks_by_parent[parent_key] << ji + end epics_by_key.each do |epic_key, epic| epic_children = children_by_epic[epic_key] @@ -39,10 +50,14 @@ def run! epic_children.each do |ji| upsert_issue(ji, epic, now) seen_issue_keys << ji.key + subtasks_by_parent[ji.key].each do |subtask| + upsert_issue(subtask, epic, now) + seen_issue_keys << subtask.key + end end epic.issues.active.where.not(jira_key: seen_issue_keys).update_all(removed_at: now) end - fetched += children.size + fetched += children.size + subtasks.size end Epic.active.where.not(jira_key: epics_by_key.keys).update_all(removed_at: now) @@ -109,6 +124,10 @@ def upsert_issue(ji, epic, now) issue end + def jira_key_list(keys) + keys.map { |k| %Q("#{k}") }.join(",") + end + def last_status_change_at(ji) histories = ji.attrs.dig("changelog", "histories") || [] times = histories.flat_map do |h| diff --git a/app/views/board/_column.html.slim b/app/views/board/_column.html.slim index e563ab9..7cb2eb0 100644 --- a/app/views/board/_column.html.slim +++ b/app/views/board/_column.html.slim @@ -1,9 +1,13 @@ - epic = column.epic - all_issues = column.all_issues +- todo_rows = column.issue_rows_for(column.new_issues) +- middle_rows = column.middle_groups.transform_values { |postits| column.issue_rows_for(postits) } +- done_rows = column.issue_rows_for(column.done_issues) - total = all_issues.size -- todo_count = column.new_issues.size -- done_count = column.done_issues.size -- middle_count = column.middle_groups.values.sum(&:size) +- todo_count = todo_rows.size +- done_count = done_rows.size +- middle_count = middle_rows.values.sum(&:size) +- visible_middle_group_count = middle_rows.values.count(&:any?) - stale_count = column.middle_groups.values.flatten.count { |p| %i[somewhat really].include?(p.staleness) } - accent_palette = %w[#6366f1 #e11d48 #d97706 #0ea5e9 #8b5cf6 #0d9488 #10b981 #ea580c #c026d3 #64748b] - accent = accent_palette[(epic.jira_key.to_s.bytes.sum % accent_palette.size)] @@ -43,8 +47,8 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to span.kb-stack-trail data-stack-target="todoTrail" | show .kb-stack-body data-stack-target="todoBody" hidden=true - - column.new_issues.each do |pp| - = render "board/postit", p: pp + - todo_rows.each do |row| + = render "board/postit", p: row.postit, nested: row.depth > 0 - if middle_count > 0 && todo_count > 0 .kb-divider data-stack-target="middleDivider" hidden=true @@ -53,15 +57,16 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to span.kb-divider-line - column.middle_groups.each do |display_status, postits| - - next if postits.empty? + - rows = middle_rows[display_status] + - next if rows.empty? - st = state_meta(display_status) - - if column.middle_groups.size > 1 + - if visible_middle_group_count > 1 .kb-divider span.kb-divider-text style="color:#{st[:deep]};" = st[:label].upcase span.kb-divider-line - - postits.each do |pp| - = render "board/postit", p: pp + - rows.each do |row| + = render "board/postit", p: row.postit, nested: row.depth > 0 - if done_count > 0 .kb-stack-wrap data-stack-target="doneWrap" @@ -76,5 +81,5 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to span.kb-stack-trail data-stack-target="doneTrail" | show .kb-stack-body data-stack-target="doneBody" hidden=true - - column.done_issues.each do |pp| - = render "board/postit", p: pp + - done_rows.each do |row| + = render "board/postit", p: row.postit, nested: row.depth > 0 diff --git a/app/views/board/_postit.html.slim b/app/views/board/_postit.html.slim index b0a0efc..8e8df55 100644 --- a/app/views/board/_postit.html.slim +++ b/app/views/board/_postit.html.slim @@ -1,8 +1,12 @@ +- nested = local_assigns.fetch(:nested, false) - state = state_meta(p.display_status) - stale = staleness_meta(p.staleness) - stale_lvl = staleness_label(p.staleness) - avatar = avatar_for(p.assignee) - days = days_in_state(p) +- parent_key = p.parent_jira_key +- labels = p.labels +- components = p.components - step_idx = state_step_index(p.display_status) - step_total = state_total_steps - age_show = p.staleness != :fresh @@ -10,9 +14,15 @@ - card_style << "--kb-card-border:#{stale[:border]};" - card_style << "--kb-paper:#{stale[:paper]};" - card_style << "--kb-avatar-ring:#{stale[:paper]};" -- data_attrs = { assignee: p.assignee.to_s, display_status: p.display_status.to_s, staleness: p.staleness.to_s, days_since_change: days.to_s, search: [p.jira_key, p.summary, p.assignee, state[:label]].compact.join(" ").downcase, board_target: "card", action: "mouseenter->board#showTooltip mouseleave->board#hideTooltip", tooltip_id: p.jira_key, tooltip_title: p.summary, tooltip_type: p.issue_type.to_s, tooltip_state: state[:label], tooltip_state_color: state[:color], tooltip_assignee: avatar[:name], tooltip_days: days.to_s, tooltip_stale: stale_lvl, tooltip_priority: priority_label(p.priority), tooltip_status_raw: p.jira_status.to_s } +- data_attrs = { assignee: p.assignee.to_s, display_status: p.display_status.to_s, staleness: p.staleness.to_s, days_since_change: days.to_s, search: [p.jira_key, p.summary, p.assignee, state[:label], parent_key].compact.join(" ").downcase, board_target: "card", action: "mouseenter->board#showTooltip mouseleave->board#hideTooltip", tooltip_id: p.jira_key, tooltip_title: p.summary, tooltip_type: p.issue_type.to_s, tooltip_state: state[:label], tooltip_state_color: state[:color], tooltip_assignee: avatar[:name], tooltip_days: days.to_s, tooltip_stale: stale_lvl, tooltip_priority: priority_label(p.priority), tooltip_status_raw: p.jira_status.to_s } +- data_attrs[:parent_issue] = parent_key if parent_key.present? +- data_attrs[:tooltip_parent] = parent_key if parent_key.present? +- data_attrs[:tooltip_labels] = labels.join(", ") if labels.any? +- data_attrs[:tooltip_components] = components.join(", ") if components.any? - data_attrs[:critical] = "1" if stale_lvl == "critical" -= link_to jira_url(p.jira_key), data: data_attrs, class: "kb-card", style: card_style, target: "_blank", rel: "noreferrer" do +- card_classes = [ "kb-card" ] +- card_classes << "kb-card-subtask" if nested += link_to jira_url(p.jira_key), data: data_attrs, class: card_classes.join(" "), style: card_style, target: "_blank", rel: "noreferrer" do .kb-card-row1 = type_icon_svg(p.issue_type, size: 14) span.kb-card-id= p.jira_key diff --git a/test/models/issue_test.rb b/test/models/issue_test.rb index 8fe7eb1..03caebe 100644 --- a/test/models/issue_test.rb +++ b/test/models/issue_test.rb @@ -16,4 +16,19 @@ class IssueTest < ActiveSupport::TestCase ) assert_equal "foo", Issue.find(issue.id).raw_fields["labels"].first end + + test "normalizes labels and component names from raw fields" do + issue = Issue.create!( + jira_key: "PG-11", epic: @epic, + issue_type: "Task", summary: "Do it too", + jira_status: "To Do", + raw_fields: { + "labels" => [ "backend", "", nil ], + "components" => [ { "name" => "API" }, { "name" => "" }, "Docs" ] + } + ) + + assert_equal [ "backend" ], issue.labels + assert_equal [ "API", "Docs" ], issue.components + end end diff --git a/test/services/board_presenter_test.rb b/test/services/board_presenter_test.rb index 43cd164..921fb5f 100644 --- a/test/services/board_presenter_test.rb +++ b/test/services/board_presenter_test.rb @@ -37,6 +37,48 @@ def build_presenter(orphan_issues: []) assert_equal [ "PG-11" ], epic1.middle_groups["review"].map(&:jira_key) end + test "issue rows nest same-state subtasks and keep other statuses separate" do + epic = epics(:priority_one) + story = Issue.create!( + jira_key: "PG-20", + epic: epic, + issue_type: "Story", + summary: "Parent story", + jira_status: "In Progress", + status_changed_at_jira: 2.hours.ago, + created_at_jira: 3.hours.ago, + raw_fields: { "parent" => { "key" => epic.jira_key } } + ) + Issue.create!( + jira_key: "PG-21", + epic: epic, + issue_type: "Sub-task", + summary: "Done subtask", + jira_status: "Done", + status_changed_at_jira: 1.hour.ago, + created_at_jira: 2.hours.ago, + raw_fields: { "parent" => { "key" => story.jira_key } } + ) + Issue.create!( + jira_key: "PG-22", + epic: epic, + issue_type: "Sub-task", + summary: "Nested subtask", + jira_status: "In Progress", + status_changed_at_jira: 30.minutes.ago, + created_at_jira: 1.hour.ago, + raw_fields: { "parent" => { "key" => story.jira_key } } + ) + + column = build_presenter.columns.detect { |col| col.epic.jira_key == epic.jira_key } + rows = column.issue_rows_for(column.middle_groups["in_progress"]) + story_index = rows.index { |row| row.postit.jira_key == "PG-20" } + + assert_equal "PG-22", rows[story_index + 1].postit.jira_key + assert_equal 1, rows[story_index + 1].depth + assert_equal 0, column.issue_rows_for(column.done_issues).detect { |row| row.postit.jira_key == "PG-21" }.depth + end + test "warnings include unmapped statuses" do warnings = build_presenter.warnings assert_includes warnings.map(&:issue_key), "PG-14" diff --git a/test/services/jira_sync_test.rb b/test/services/jira_sync_test.rb index 51d8b55..22c99d7 100644 --- a/test/services/jira_sync_test.rb +++ b/test/services/jira_sync_test.rb @@ -25,7 +25,9 @@ class JiraSyncTest < ActiveSupport::TestCase "status" => { "name" => "To Do" }, "issuetype" => { "name" => "Task" }, "assignee" => { "name" => "alice" }, - "parent" => { "key" => "PG-1" } } } + "parent" => { "key" => "PG-1" }, + "labels" => [ "backend", "priority" ], + "components" => [ { "name" => "API" } ] } } ], "total" => 1, "startAt" => 0, "maxResults" => 50 } else { "issues" => [], "total" => 0, "startAt" => 0, "maxResults" => 50 } @@ -40,6 +42,47 @@ class JiraSyncTest < ActiveSupport::TestCase assert_equal "Epic A", Epic.first.name assert_equal 1, Issue.count assert_equal "PG-10", Issue.first.jira_key + assert_equal [ "backend", "priority" ], Issue.first.labels + assert_equal [ "API" ], Issue.first.components + end + + test "fetches subtasks below epic children" do + stub_request(:get, %r{/search}).to_return do |req| + decoded = CGI.unescape(req.uri.to_s) + body = case decoded + when /parent\s+in\s*\([^)]*PG-10[^)]*\)/i + { "issues" => [ + { "key" => "PG-11", "fields" => { "summary" => "Subtask A", + "status" => { "name" => "In Progress" }, + "issuetype" => { "name" => "Sub-task" }, + "parent" => { "key" => "PG-10" } } } + ], "total" => 1, "startAt" => 0, "maxResults" => 50 } + when /labels.*Priority/i + { "issues" => [ + { "key" => "PG-1", "fields" => { "summary" => "Epic A", + "status" => { "name" => "In Progress" }, + "priority" => { "id" => "1" } } } + ], "total" => 1, "startAt" => 0, "maxResults" => 50 } + when /parent\s+in\s*\([^)]*PG-1[^)]*\)/i + { "issues" => [ + { "key" => "PG-10", "fields" => { "summary" => "Story A", + "status" => { "name" => "In Progress" }, + "issuetype" => { "name" => "Story" }, + "parent" => { "key" => "PG-1" } } } + ], "total" => 1, "startAt" => 0, "maxResults" => 50 } + else + { "issues" => [], "total" => 0, "startAt" => 0, "maxResults" => 50 } + end + { status: 200, body: body.to_json, + headers: { "Content-Type" => "application/json" } } + end + + JiraSync.new(epic_query: 'project = PG AND labels = "Priority"').run! + + story = Issue.find_by!(jira_key: "PG-10") + subtask = Issue.find_by!(jira_key: "PG-11") + assert_equal story.epic_id, subtask.epic_id + assert_equal "PG-10", subtask.parent_jira_key end test "uses last status change from changelog for status_changed_at_jira" do From da339e46fdc2c80b8114d95e9765c9ca1fe4aa8f Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Wed, 10 Jun 2026 15:03:42 +0200 Subject: [PATCH 5/6] Fix unplanned work column and add manual sync functionality Signed-off-by: Kai Wagner --- app/assets/stylesheets/korkban.css | 3 ++- app/controllers/syncs_controller.rb | 6 ++++++ app/services/board_presenter.rb | 2 +- app/services/jira_sync.rb | 6 ++++++ app/views/board/_column.html.slim | 3 ++- app/views/board/_stale_banner.html.slim | 6 +++--- app/views/board/show.html.slim | 1 + config/routes.rb | 1 + 8 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 app/controllers/syncs_controller.rb diff --git a/app/assets/stylesheets/korkban.css b/app/assets/stylesheets/korkban.css index 1da7664..9ac8ebe 100644 --- a/app/assets/stylesheets/korkban.css +++ b/app/assets/stylesheets/korkban.css @@ -162,7 +162,8 @@ html, body { background: var(--kb-bg); font-family: var(--kb-ui); color: var(--k font: 600 11.5px var(--kb-ui); color: var(--kb-text-soft); } -.kb-sync { display: inline-flex; align-items: center; gap: 6px; font: 600 11.5px var(--kb-ui); color: var(--kb-text-mute); } +.kb-sync { display: inline-flex; align-items: center; gap: 6px; font: 600 11.5px var(--kb-ui); color: var(--kb-text-mute); text-decoration: none; background: none; border: none; padding: 0; cursor: pointer; } +.kb-sync:hover { opacity: 0.7; } .kb-sync .dot { width: 7px; height: 7px; border-radius: 50%; } .kb-sync .dot-green { background: #22c55e; } .kb-sync .dot-amber { background: #eab308; } diff --git a/app/controllers/syncs_controller.rb b/app/controllers/syncs_controller.rb new file mode 100644 index 0000000..b05b9fa --- /dev/null +++ b/app/controllers/syncs_controller.rb @@ -0,0 +1,6 @@ +class SyncsController < ApplicationController + def create + JiraSyncJob.perform_later + head :no_content + end +end diff --git a/app/services/board_presenter.rb b/app/services/board_presenter.rb index 122b910..3212f78 100644 --- a/app/services/board_presenter.rb +++ b/app/services/board_presenter.rb @@ -48,7 +48,7 @@ def issue_rows_for(postits) end end - UNPLANNED_EPIC = Struct.new(:jira_key, :name).new("UNPLANNED", "Unplanned") + UNPLANNED_EPIC = Struct.new(:jira_key, :name).new("UNPLANNED", "Unplanned Work") def initialize(epics:, status_map:, new_statuses:, done_statuses:, staleness:, orphan_issues: []) @epics = epics diff --git a/app/services/jira_sync.rb b/app/services/jira_sync.rb index 6b4062d..b7dce1c 100644 --- a/app/services/jira_sync.rb +++ b/app/services/jira_sync.rb @@ -80,6 +80,12 @@ def run! partial: "board/board_morph", locals: { presenter: build_presenter, last_sync: run } ) + Turbo::StreamsChannel.broadcast_replace_to( + "sync_status", + target: "kb-sync-status", + partial: "board/stale_banner", + locals: { last_sync: run } + ) run rescue => e run.update!(finished_at: Time.current, ok: false, error_message: e.message) diff --git a/app/views/board/_column.html.slim b/app/views/board/_column.html.slim index 7cb2eb0..e3ffe10 100644 --- a/app/views/board/_column.html.slim +++ b/app/views/board/_column.html.slim @@ -25,7 +25,8 @@ section.kb-col data-epic-key=epic.jira_key data-controller="stack" data-stack-to = epic.name span.kb-col-count= total .kb-col-meta - span.kb-col-key= epic.jira_key + - unless epic.jira_key == "UNPLANNED" + span.kb-col-key= epic.jira_key - if stale_count > 0 span.kb-col-stale | ⚠ #{stale_count} stale diff --git a/app/views/board/_stale_banner.html.slim b/app/views/board/_stale_banner.html.slim index abab286..a56d2d2 100644 --- a/app/views/board/_stale_banner.html.slim +++ b/app/views/board/_stale_banner.html.slim @@ -1,11 +1,11 @@ - if last_sync.nil? - span.kb-sync title="No sync yet" + a.kb-sync#kb-sync-status href="/sync" data-turbo-method="post" title="No sync yet — click to sync" span.dot.dot-amber - | No sync yet + | Sync now - else - age_min = ((Time.current - last_sync.finished_at) / 60).to_i - level = age_min > 30 ? :red : age_min > 5 ? :amber : :green - span.kb-sync title="Last sync: #{last_sync.finished_at.iso8601} (#{age_min} min ago)" + a.kb-sync#kb-sync-status href="/sync" data-turbo-method="post" title="Last sync: #{last_sync.finished_at.iso8601} (#{age_min}m ago) — click to sync" span class="dot dot-#{level}" - if level == :red | JIRA sync stalled (#{age_min}m) diff --git a/app/views/board/show.html.slim b/app/views/board/show.html.slim index 3a2b3c6..3e0e3b5 100644 --- a/app/views/board/show.html.slim +++ b/app/views/board/show.html.slim @@ -1,4 +1,5 @@ = turbo_stream_from "board" += turbo_stream_from "sync_status" - all_issues = @presenter.columns.flat_map(&:all_issues) - total_tickets = all_issues.size diff --git a/config/routes.rb b/config/routes.rb index 954df6a..5aa4750 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ Rails.application.routes.draw do root "board#show" + post "/sync", to: "syncs#create" get "/login", to: "sessions#new" get "/auth/:provider/callback", to: "sessions#create" get "/auth/failure", to: "sessions#failure" From c5722e0e780db4dc8fe722100eecc36e1b275075 Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Wed, 10 Jun 2026 15:39:55 +0200 Subject: [PATCH 6/6] fix orphaned column view Signed-off-by: Kai Wagner --- app/services/jira_sync.rb | 3 +++ test/services/jira_sync_test.rb | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/services/jira_sync.rb b/app/services/jira_sync.rb index b7dce1c..472d355 100644 --- a/app/services/jira_sync.rb +++ b/app/services/jira_sync.rb @@ -66,6 +66,9 @@ def run! orphans = @client.search_all(@unplanned_query, fields: ISSUE_FIELDS, expand: "changelog") seen_orphan_keys = [] orphans.each do |ji| + if (clashing_epic = epics_by_key.delete(ji.key)) + clashing_epic.update!(removed_at: now) + end upsert_issue(ji, nil, now) seen_orphan_keys << ji.key end diff --git a/test/services/jira_sync_test.rb b/test/services/jira_sync_test.rb index 22c99d7..1cc8f3d 100644 --- a/test/services/jira_sync_test.rb +++ b/test/services/jira_sync_test.rb @@ -211,6 +211,38 @@ class JiraSyncTest < ActiveSupport::TestCase assert_not_nil stale.reload.removed_at end + test "does not create orphan Issue when the same key is already an epic" do + stub_request(:get, %r{/search}).to_return do |req| + decoded = CGI.unescape(req.uri.to_s) + body = case decoded + when /labels.*Priority/i + { "issues" => [ + { "key" => "PG-5", "fields" => { "summary" => "Priority issue without parent", + "status" => { "name" => "In Progress" }, + "priority" => { "id" => "2" } } } + ], "total" => 1, "startAt" => 0, "maxResults" => 100 } + when /parent is EMPTY/i + { "issues" => [ + { "key" => "PG-5", "fields" => { "summary" => "Priority issue without parent", + "status" => { "name" => "In Progress" }, + "issuetype" => { "name" => "Story" } } } + ], "total" => 1, "startAt" => 0, "maxResults" => 100 } + else + { "issues" => [], "total" => 0, "startAt" => 0, "maxResults" => 100 } + end + { status: 200, body: body.to_json, headers: { "Content-Type" => "application/json" } } + end + + JiraSync.new( + epic_query: 'project = PG AND labels = "Priority"', + unplanned_query: "project = PG AND parent is EMPTY" + ).run! + + assert_equal 0, Epic.active.count, "PG-5 must not appear as a column" + assert_equal 1, Issue.active.orphan.count + assert_equal "PG-5", Issue.active.orphan.first.jira_key + end + test "skips unplanned fetch when unplanned_query is blank" do stub_request(:get, %r{/search}).to_return( status: 200,