From 630afd25176a6e96dd72b3abdd1d7b5175fb6710 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 22 Jun 2026 11:42:20 -0400 Subject: [PATCH] Card Count In Swim Lanes --- app/Controllers/SwimLanesController.php | 18 +++++++ app/Models/SwimLane.php | 2 + app/Repositories/SwimLaneRepository.php | 13 ++++- app/Views/boards/show.php | 2 +- app/Views/partials/settings-panel.php | 4 ++ ...0001_add_show_card_count_to_swim_lanes.php | 21 +++++++++ public/css/kanban.css | 15 ++++++ public/js/kanban-board.js | 47 +++++++++++++++++++ public/js/kanban-settings.js | 20 ++++++-- routes/web.php | 1 + 10 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 database/migrations/20260622_000001_add_show_card_count_to_swim_lanes.php 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: }; var KANBAN_COLS = ['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 = ['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 = ['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 @@
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 ? - '
' + - '' + + '
' + + '' + '
' : '') + '
' + '' + @@ -245,7 +245,7 @@ if (!res.ok) { alert(res.error || 'Failed'); return; } document.getElementById('lane-add-form').classList.add('d-none'); document.getElementById('lane-add-input').value = ''; - var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text', false, 'lane'); + var li = buildListItem(res.id, res.name, 'btn-edit-lane', 'btn-delete-lane', 'lane-label-text', true, 'lane'); document.getElementById('lane-list').appendChild(li); bindLaneItem(li); window.KanbanBoard.addLane(res); @@ -278,6 +278,20 @@ } }); }); + var laneCountToggle = li.querySelector('.lane-count-toggle'); + if (laneCountToggle) { + laneCountToggle.addEventListener('change', function () { + var show = laneCountToggle.checked; + post('/swimlanes/' + li.dataset.id + '/toggle-count', { show_card_count: show ? '1' : '0' }, function (res) { + if (res.ok) { + window.KanbanBoard.setLaneShowCount(li.dataset.id, show); + } else { + laneCountToggle.checked = !show; + alert(res.error || 'Update failed'); + } + }); + }); + } var laneExportToggle = li.querySelector('.lane-export-toggle'); if (laneExportToggle) { laneExportToggle.addEventListener('change', function () { diff --git a/routes/web.php b/routes/web.php index 1d4d2a5..0de209b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -41,6 +41,7 @@ $router->post('/columns', [ColumnsController::class, 'store']); // Swim lanes (JSON API) — /swimlanes/reorder MUST be before /swimlanes/{id} $router->get('/swimlanes/{id}/export', [SwimLanesController::class, 'export']); $router->post('/swimlanes/reorder', [SwimLanesController::class, 'reorder']); +$router->post('/swimlanes/{id}/toggle-count', [SwimLanesController::class, 'toggleCount']); $router->post('/swimlanes/{id}/toggle-export', [SwimLanesController::class, 'toggleExport']); $router->post('/swimlanes/{id}/card-age-settings', [SwimLanesController::class, 'updateCardAgeSettings']); $router->post('/swimlanes/{id}/delete', [SwimLanesController::class, 'destroy']);