From 6a3cd5ae7ab63299b6dc621b01da63d97817b3d7 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Thu, 12 Nov 2026 10:59:18 -0500 Subject: [PATCH] Track card moves and show board card totals Save move history atomically with source and destination cells, display local movement times, and keep the board total current. Exclude local env files from Docker builds and remove obsolete employee route checks from the test runner. --- .dockerignore | 3 + app/Controllers/BoardsController.php | 1 + app/Controllers/CardsController.php | 90 ++++++++++++++-------- app/Views/boards/show.php | 1 + public/css/kanban.css | 12 +++ public/js/kanban-board.js | 25 +++++- public/js/kanban-modal.js | 8 +- tests/card-movement.php | 111 +++++++++++++++++++++++++++ tests/run.php | 2 + 9 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 tests/card-movement.php diff --git a/.dockerignore b/.dockerignore index f4a0755..e40ff2e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,6 @@ .ai vendor database/app.sqlite +.env +.env_prod +.vs diff --git a/app/Controllers/BoardsController.php b/app/Controllers/BoardsController.php index c897dad..3796df6 100644 --- a/app/Controllers/BoardsController.php +++ b/app/Controllers/BoardsController.php @@ -123,6 +123,7 @@ class BoardsController extends Controller 'board' => $board, 'columns' => $columns, 'lanes' => $lanes, + 'cardCount' => count($allCards), 'cardsJson' => $cardsJson, ]); } diff --git a/app/Controllers/CardsController.php b/app/Controllers/CardsController.php index e062aa2..4090713 100644 --- a/app/Controllers/CardsController.php +++ b/app/Controllers/CardsController.php @@ -13,6 +13,7 @@ use App\Repositories\SwimLaneRepository; use App\Services\AuthService; use Core\Controller; use Core\Request; +use Core\Response; class CardsController extends Controller { @@ -121,47 +122,72 @@ class CardsController extends Controller $card = $this->cards()->findById($id); if ($card === null) { - return $this->json(['ok' => false, 'error' => 'Not found'], 404); + return Response::json(['ok' => false, 'error' => 'Not found'], 404); } $columnId = (int) $request->input('column_id', 0); $swimLaneId = (int) $request->input('swim_lane_id', 0); $position = (int) $request->input('position', 0); - $now = date('Y-m-d H:i:s'); - $username = AuthService::getCurrentUsername(); + $toColumn = $columnId > 0 ? $this->columns()->find($columnId) : null; + $toLane = $swimLaneId > 0 ? $this->lanes()->find($swimLaneId) : null; - $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); - - if ($columnId !== $card->columnId || $swimLaneId !== $card->swimLaneId) { - $fromColumn = $this->columns()->find($card->columnId); - $toColumn = $this->columns()->find($columnId); - $fromLane = $this->lanes()->find($card->swimLaneId); - $toLane = $this->lanes()->find($swimLaneId); - - $movement = new CardMovement(); - $movement->cardId = $id; - $movement->fromColumnId = $card->columnId; - $movement->fromColumnName = $fromColumn['name'] ?? null; - $movement->toColumnId = $columnId; - $movement->toColumnName = $toColumn['name'] ?? null; - $movement->fromSwimLaneId = $card->swimLaneId; - $movement->fromSwimLaneName = $fromLane['name'] ?? null; - $movement->toSwimLaneId = $swimLaneId; - $movement->toSwimLaneName = $toLane['name'] ?? null; - $movement->movedAt = $now; - $movement->movedBy = $username; - - $this->cardMovements()->insert($movement); + if ($toColumn === null || $toLane === null || + (int) $toColumn['board_id'] !== $card->boardId || + (int) $toLane['board_id'] !== $card->boardId || $position < 0) { + return Response::json(['ok' => false, 'error' => 'Invalid destination'], 422); } - $siblings = trim((string) $request->input('sibling_ids', '')); - if ($siblings !== '') { - foreach (explode(',', $siblings) as $idx => $sibId) { - $sibId = (int) trim($sibId); - if ($sibId > 0) { - $this->cards()->updatePosition($sibId, $idx, $now, $username); + $now = gmdate('Y-m-d H:i:s'); + $username = AuthService::getCurrentUsername(); + $siblings = trim((string) $request->input('sibling_ids', '')); + + try { + database()->transaction(function () use ( + $card, + $id, + $columnId, + $swimLaneId, + $position, + $toColumn, + $toLane, + $now, + $username, + $siblings + ): void { + $this->cards()->move($id, $columnId, $swimLaneId, $position, $now, $username); + + if ($columnId !== $card->columnId || $swimLaneId !== $card->swimLaneId) { + $fromColumn = $this->columns()->find($card->columnId); + $fromLane = $this->lanes()->find($card->swimLaneId); + + $movement = new CardMovement(); + $movement->cardId = $id; + $movement->fromColumnId = $card->columnId; + $movement->fromColumnName = $fromColumn['name'] ?? null; + $movement->toColumnId = $columnId; + $movement->toColumnName = $toColumn['name']; + $movement->fromSwimLaneId = $card->swimLaneId; + $movement->fromSwimLaneName = $fromLane['name'] ?? null; + $movement->toSwimLaneId = $swimLaneId; + $movement->toSwimLaneName = $toLane['name']; + $movement->movedAt = $now; + $movement->movedBy = $username; + + $this->cardMovements()->insert($movement); + } + + if ($siblings !== '') { + foreach (explode(',', $siblings) as $idx => $sibId) { + $sibId = (int) trim($sibId); + if ($sibId > 0) { + $this->cards()->updatePosition($sibId, $idx, $now, $username); + } + } } - } + }); + } catch (\Throwable $e) { + error_log('Card move failed: ' . $e->getMessage()); + return Response::json(['ok' => false, 'error' => 'Could not move card'], 500); } return $this->json(['ok' => true]); diff --git a/app/Views/boards/show.php b/app/Views/boards/show.php index 10f2dd7..c15f492 100644 --- a/app/Views/boards/show.php +++ b/app/Views/boards/show.php @@ -21,6 +21,7 @@ name) ?> +