diff --git a/app/Controllers/SwimLanesController.php b/app/Controllers/SwimLanesController.php
index 5b5ff3a..8f1d50a 100644
--- a/app/Controllers/SwimLanesController.php
+++ b/app/Controllers/SwimLanesController.php
@@ -79,6 +79,24 @@ class SwimLanesController extends Controller
return $this->json(['ok' => true]);
}
+ public function toggleCount(Request $request, int $id): mixed
+ {
+ if (!AuthService::isLoggedIn()) {
+ return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401);
+ }
+
+ $row = $this->lanes()->find($id);
+ if ($row === null) {
+ return $this->json(['ok' => false, 'error' => 'Not found'], 404);
+ }
+
+ $show = (string) $request->input('show_card_count', '0') === '1';
+
+ $this->lanes()->updateShowCardCount($id, $show, date('Y-m-d H:i:s'), AuthService::getCurrentUsername());
+
+ return $this->json(['ok' => true, 'show_card_count' => $show]);
+ }
+
public function toggleExport(Request $request, int $id): mixed
{
if (!AuthService::isLoggedIn()) {
diff --git a/app/Models/SwimLane.php b/app/Models/SwimLane.php
index 1fa7d3a..dea50fb 100644
--- a/app/Models/SwimLane.php
+++ b/app/Models/SwimLane.php
@@ -10,6 +10,7 @@ class SwimLane
public int $boardId = 0;
public string $name = '';
public int $position = 0;
+ public bool $showCardCount = false;
public bool $showExportButton = false;
public bool $showCardAge = false;
public int $cardAgeWarningDays = 0;
@@ -25,6 +26,7 @@ class SwimLane
$model->boardId = (int) ($row['board_id'] ?? 0);
$model->name = (string) ($row['name'] ?? '');
$model->position = (int) ($row['position'] ?? 0);
+ $model->showCardCount = (bool) ($row['show_card_count'] ?? false);
$model->showExportButton = (bool) ($row['show_export_button'] ?? false);
$model->showCardAge = (bool) ($row['show_card_age'] ?? false);
$model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0);
diff --git a/app/Repositories/SwimLaneRepository.php b/app/Repositories/SwimLaneRepository.php
index d09d431..d84eb82 100644
--- a/app/Repositories/SwimLaneRepository.php
+++ b/app/Repositories/SwimLaneRepository.php
@@ -35,12 +35,13 @@ class SwimLaneRepository extends Repository
public function insert(SwimLane $lane): SwimLane
{
$this->database->execute(
- 'INSERT INTO swim_lanes (board_id, name, position, show_export_button, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by)
- VALUES (:board_id, :name, :position, :show_export_button, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)',
+ 'INSERT INTO swim_lanes (board_id, name, position, show_card_count, show_export_button, show_card_age, card_age_warning_days, created_at, created_by, updated_at, updated_by)
+ VALUES (:board_id, :name, :position, :show_card_count, :show_export_button, :show_card_age, :card_age_warning_days, :created_at, :created_by, :updated_at, :updated_by)',
[
'board_id' => $lane->boardId,
'name' => $lane->name,
'position' => $lane->position,
+ 'show_card_count' => $lane->showCardCount ? 1 : 0,
'show_export_button' => $lane->showExportButton ? 1 : 0,
'show_card_age' => $lane->showCardAge ? 1 : 0,
'card_age_warning_days' => $lane->cardAgeWarningDays,
@@ -65,6 +66,14 @@ class SwimLaneRepository extends Repository
);
}
+ public function updateShowCardCount(int $id, bool $show, string $updatedAt, string $updatedBy): void
+ {
+ $this->database->execute(
+ 'UPDATE swim_lanes SET show_card_count = :show_card_count, updated_at = :updated_at, updated_by = :updated_by WHERE id = :id',
+ ['show_card_count' => $show ? 1 : 0, 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id]
+ );
+ }
+
public function updateShowExportButton(int $id, bool $show, string $updatedAt, string $updatedBy): void
{
$this->database->execute(
diff --git a/app/Views/boards/show.php b/app/Views/boards/show.php
index 35545fe..10f2dd7 100644
--- a/app/Views/boards/show.php
+++ b/app/Views/boards/show.php
@@ -93,7 +93,7 @@ var KANBAN = {
cards: = $cardsJson ?>
};
var KANBAN_COLS = = json_encode(array_map(fn($c) => ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount, 'show_export_button' => $c->showExportButton, 'show_card_age' => $c->showCardAge, 'card_age_warning_days' => $c->cardAgeWarningDays], $columns)) ?>;
-var KANBAN_LANES = = json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_export_button' => $l->showExportButton, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>;
+var KANBAN_LANES = = json_encode(array_map(fn($l) => ['id' => $l->id, 'name' => $l->name, 'position' => $l->position, 'show_card_count' => $l->showCardCount, 'show_export_button' => $l->showExportButton, 'show_card_age' => $l->showCardAge, 'card_age_warning_days' => $l->cardAgeWarningDays], $lanes)) ?>;
diff --git a/app/Views/partials/settings-panel.php b/app/Views/partials/settings-panel.php
index c4f4525..f4a3f7f 100644
--- a/app/Views/partials/settings-panel.php
+++ b/app/Views/partials/settings-panel.php
@@ -94,6 +94,10 @@
= e($lane->name) ?>
+
+ showCardCount ? 'checked' : '' ?>>
+
showExportButton ? 'checked' : '' ?>>
diff --git a/database/migrations/20260622_000001_add_show_card_count_to_swim_lanes.php b/database/migrations/20260622_000001_add_show_card_count_to_swim_lanes.php
new file mode 100644
index 0000000..132b82b
--- /dev/null
+++ b/database/migrations/20260622_000001_add_show_card_count_to_swim_lanes.php
@@ -0,0 +1,21 @@
+execute(
+ 'ALTER TABLE swim_lanes ADD COLUMN show_card_count INTEGER NOT NULL DEFAULT 0'
+ );
+ }
+
+ public function down(Database $database): void
+ {
+ // SQLite cannot drop columns without recreating the table.
+ }
+};
diff --git a/public/css/kanban.css b/public/css/kanban.css
index 0ad8507..be0e12b 100644
--- a/public/css/kanban.css
+++ b/public/css/kanban.css
@@ -173,6 +173,21 @@ body.kanban-page .navbar .btn-outline-secondary:hover {
background: #eff5ff;
}
+.kanban-lane-header .lane-count-badge {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.5rem;
+ height: 1.5rem;
+ padding: 0 0.4rem;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ font-weight: 800;
+ color: #fff;
+ background: #1e4d8f;
+}
+
.kanban-lane-header {
position: sticky;
left: 0;
diff --git a/public/js/kanban-board.js b/public/js/kanban-board.js
index abf1ac0..303bc53 100644
--- a/public/js/kanban-board.js
+++ b/public/js/kanban-board.js
@@ -6,6 +6,7 @@
var laneCollapseStorageKey = 'kanban_lane_collapsed_' + String(boardId);
var collapsedLaneIds = loadCollapsedLaneIds();
var columnShowCount = {};
+ var laneShowCount = {};
var columnShowExport = {};
var laneShowExport = {};
var columnAgeSettings = {};
@@ -154,6 +155,37 @@
});
}
+ function countCardsInLane(laneId) {
+ var count = 0;
+ KANBAN.cards.forEach(function (c) {
+ if (String(c.swim_lane_id) === String(laneId)) count++;
+ });
+ return count;
+ }
+
+ function refreshLaneCount(laneId) {
+ var header = document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]');
+ if (!header) return;
+ var badge = header.querySelector('.lane-count-badge');
+ if (!laneShowCount[String(laneId)]) {
+ if (badge) badge.remove();
+ return;
+ }
+ if (!badge) {
+ badge = document.createElement('span');
+ badge.className = 'lane-count-badge';
+ header.appendChild(badge);
+ }
+ badge.textContent = countCardsInLane(laneId);
+ }
+
+ function initLaneCounts() {
+ KANBAN_LANES.forEach(function (lane) {
+ laneShowCount[String(lane.id)] = !!lane.show_card_count;
+ refreshLaneCount(lane.id);
+ });
+ }
+
function refreshColumnExport(colId) {
var header = document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]');
if (!header) return;
@@ -397,6 +429,11 @@
}
refreshColumnCount(newColId);
+ if (oldLaneId !== null && String(oldLaneId) !== String(newLaneId)) {
+ refreshLaneCount(oldLaneId);
+ }
+ refreshLaneCount(newLaneId);
+
post('/cards/' + cardId + '/move', {
column_id: newColId,
swim_lane_id: newLaneId,
@@ -513,6 +550,7 @@
}
applyCardFilter();
refreshColumnCount(card.column_id);
+ refreshLaneCount(card.swim_lane_id);
},
onCardUpdated: function (id, data) {
var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); });
@@ -535,11 +573,13 @@
onCardDeleted: function (id) {
var card = KANBAN.cards.find(function (c) { return String(c.id) === String(id); });
var colId = card ? card.column_id : null;
+ var laneId = card ? card.swim_lane_id : null;
KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.id) !== String(id); });
var el = document.querySelector('.kanban-card[data-id="' + id + '"]');
if (el) el.remove();
applyCardFilter();
if (colId !== null) refreshColumnCount(colId);
+ if (laneId !== null) refreshLaneCount(laneId);
},
addColumn: function (col) {
var grid = document.getElementById('kanban-grid');
@@ -595,6 +635,7 @@
grid.appendChild(lh);
bindLaneHeaderToggle(lh);
+ laneShowCount[String(lane.id)] = false;
laneShowExport[String(lane.id)] = false;
laneAgeSettings[String(lane.id)] = { showCardAge: false, cardAgeWarningDays: 0 };
@@ -617,6 +658,7 @@
document.querySelector('.kanban-lane-header[data-lane-id="' + laneId + '"]').remove();
document.querySelectorAll('.kanban-cell[data-lane-id="' + laneId + '"]').forEach(function (el) { el.remove(); });
KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.swim_lane_id) !== String(laneId); });
+ delete laneShowCount[String(laneId)];
delete laneShowExport[String(laneId)];
delete laneAgeSettings[String(laneId)];
if (collapsedLaneIds[String(laneId)]) {
@@ -636,6 +678,10 @@
columnShowCount[String(colId)] = show;
refreshColumnCount(colId);
},
+ setLaneShowCount: function (laneId, show) {
+ laneShowCount[String(laneId)] = show;
+ refreshLaneCount(laneId);
+ },
setColumnShowExport: function (colId, show) {
columnShowExport[String(colId)] = !!show;
refreshColumnExport(colId);
@@ -668,4 +714,5 @@
initJobSearch();
initLaneHeaderToggles();
initColumnCounts();
+ initLaneCounts();
})();
diff --git a/public/js/kanban-settings.js b/public/js/kanban-settings.js
index 6183e84..207e3b9 100644
--- a/public/js/kanban-settings.js
+++ b/public/js/kanban-settings.js
@@ -54,8 +54,8 @@
'
' +
'
' + esc(name) + '' +
(countToggle ?
- '