Просмотр исходного кода

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.
master
Daniel Covington 1 день назад
Родитель
Сommit
6a3cd5ae7a
9 измененных файлов: 215 добавлений и 38 удалений
  1. +3
    -0
      .dockerignore
  2. +1
    -0
      app/Controllers/BoardsController.php
  3. +58
    -32
      app/Controllers/CardsController.php
  4. +1
    -0
      app/Views/boards/show.php
  5. +12
    -0
      public/css/kanban.css
  6. +21
    -4
      public/js/kanban-board.js
  7. +6
    -2
      public/js/kanban-modal.js
  8. +111
    -0
      tests/card-movement.php
  9. +2
    -0
      tests/run.php

+ 3
- 0
.dockerignore Просмотреть файл

@@ -2,3 +2,6 @@
.ai
vendor
database/app.sqlite
.env
.env_prod
.vs

+ 1
- 0
app/Controllers/BoardsController.php Просмотреть файл

@@ -123,6 +123,7 @@ class BoardsController extends Controller
'board' => $board,
'columns' => $columns,
'lanes' => $lanes,
'cardCount' => count($allCards),
'cardsJson' => $cardsJson,
]);
}


+ 58
- 32
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]);


+ 1
- 0
app/Views/boards/show.php Просмотреть файл

@@ -21,6 +21,7 @@
<i class="bi bi-arrow-left"></i>
</a>
<span class="navbar-brand mb-0 h5 kanban-board-title"><?= e($board->name) ?></span>
<span id="board-card-count" class="kanban-board-card-count" aria-live="polite"><?= $cardCount ?> <?= $cardCount === 1 ? 'card' : 'cards' ?></span>
</div>
<div class="board-header-search">
<label for="job-search-input" class="visually-hidden">Search jobs</label>


+ 12
- 0
public/css/kanban.css Просмотреть файл

@@ -75,6 +75,18 @@ body.kanban-page .navbar-brand {
font-size: clamp(1rem, 0.6vw + 0.95rem, 1.28rem);
}

.kanban-board-card-count {
flex-shrink: 0;
padding: 0.25rem 0.55rem;
border: 1px solid rgba(213, 230, 255, 0.45);
border-radius: 999px;
background: rgba(255, 255, 255, 0.13);
color: #f4f8ff;
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}

body.kanban-page .navbar .btn-outline-light,
body.kanban-page .navbar .btn-outline-secondary {
border-color: rgba(213, 230, 255, 0.55) !important;


+ 21
- 4
public/js/kanban-board.js Просмотреть файл

@@ -21,7 +21,7 @@
rafId: 0
};

function post(url, data, cb) {
function post(url, data, cb, onError) {
var params = new URLSearchParams();
Object.keys(data).forEach(function (k) { params.append(k, data[k]); });
fetch(url, {
@@ -31,7 +31,10 @@
})
.then(function (r) { return r.json(); })
.then(cb)
.catch(function (e) { console.error(url, e); });
.catch(function (e) {
console.error(url, e);
if (onError) onError();
});
}

function esc(s) {
@@ -124,6 +127,13 @@
grid.style.gridTemplateColumns = cols;
}

function refreshBoardCardCount() {
var badge = document.getElementById('board-card-count');
if (!badge) return;
var count = KANBAN.cards.length;
badge.textContent = count + (count === 1 ? ' card' : ' cards');
}

function countCardsInColumn(colId) {
var count = 0;
KANBAN.cards.forEach(function (c) {
@@ -440,8 +450,11 @@
position: newPos,
sibling_ids: siblings.join(',')
}, function (res) {
if (!res.ok) console.error('Move failed', res);
});
if (!res.ok) {
console.error('Move failed', res);
window.location.reload();
}
}, function () { window.location.reload(); });
}

function createCellSortable(cell) {
@@ -542,6 +555,7 @@
window.KanbanBoard = {
onCardCreated: function (card) {
KANBAN.cards.push(card);
refreshBoardCardCount();
var cell = document.querySelector(
'.kanban-cell[data-col-id="' + card.column_id + '"][data-lane-id="' + card.swim_lane_id + '"]'
);
@@ -575,6 +589,7 @@
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); });
refreshBoardCardCount();
var el = document.querySelector('.kanban-card[data-id="' + id + '"]');
if (el) el.remove();
applyCardFilter();
@@ -615,6 +630,7 @@
document.querySelector('.kanban-col-header[data-col-id="' + colId + '"]').remove();
document.querySelectorAll('.kanban-cell[data-col-id="' + colId + '"]').forEach(function (el) { el.remove(); });
KANBAN.cards = KANBAN.cards.filter(function (c) { return String(c.column_id) !== String(colId); });
refreshBoardCardCount();
delete columnShowCount[String(colId)];
delete columnShowExport[String(colId)];
delete columnAgeSettings[String(colId)];
@@ -658,6 +674,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); });
refreshBoardCardCount();
delete laneShowCount[String(laneId)];
delete laneShowExport[String(laneId)];
delete laneAgeSettings[String(laneId)];


+ 6
- 2
public/js/kanban-modal.js Просмотреть файл

@@ -53,7 +53,8 @@

function formatMovedAt(s) {
if (!s) return '';
var d = new Date(String(s).replace(' ', 'T'));
// Movement timestamps are stored in UTC without a timezone suffix.
var d = new Date(String(s).replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return s;
return d.toLocaleString();
}
@@ -72,7 +73,10 @@
var li = document.createElement('li');
li.className = 'list-group-item px-0 py-1';
li.innerHTML =
'<strong>' + escapeHtml(m.to_column_name || 'Unknown column') + '</strong>' +
'<strong>' + escapeHtml(m.from_column_name || 'Unknown column') +
' / ' + escapeHtml(m.from_swim_lane_name || 'Unknown row') +
' &rarr; ' + escapeHtml(m.to_column_name || 'Unknown column') +
' / ' + escapeHtml(m.to_swim_lane_name || 'Unknown row') + '</strong>' +
' &mdash; ' + escapeHtml(m.moved_by || 'Unknown user') +
' <span class="text-muted">(' + escapeHtml(formatMovedAt(m.moved_at)) + ')</span>';
historyList.appendChild(li);


+ 111
- 0
tests/card-movement.php Просмотреть файл

@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

use App\Controllers\CardsController;
use Core\Database;
use Core\Request;

spl_autoload_register(static function (string $class): void {
$root = str_starts_with($class, 'App\\') ? 'app' : 'core';
$path = __DIR__ . '/../' . $root . '/' . str_replace('\\', '/', substr($class, strpos($class, '\\') + 1)) . '.php';
if (is_file($path)) {
require $path;
}
});

function database(): Database
{
static $database = null;
if ($database === null) {
$database = new Database([
'dsn' => 'sqlite::memory:',
'options' => [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
]);
$database->pdo()->exec('PRAGMA foreign_keys = ON');
}

return $database;
}

function check(bool $condition, string $message): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}

$db = database();
foreach ([
'20260522_000001_create_boards_table.php',
'20260522_000002_create_board_columns_table.php',
'20260522_000003_create_swim_lanes_table.php',
'20260522_000004_create_cards_table.php',
'20260615_000003_add_cell_entered_at_to_cards.php',
'20260917_000001_create_card_movements_table.php',
] as $migrationFile) {
(require __DIR__ . '/../database/migrations/' . $migrationFile)->up($db);
}

$db->execute("INSERT INTO boards (id, name, slug) VALUES (1, 'Main', 'main'), (2, 'Other', 'other')");
$db->execute("INSERT INTO board_columns (id, board_id, name, position) VALUES
(1, 1, 'Queued', 0), (2, 1, 'Printing', 1), (3, 2, 'Other column', 0)");
$db->execute("INSERT INTO swim_lanes (id, board_id, name, position) VALUES
(1, 1, 'Standard', 0), (2, 1, 'Urgent', 1), (3, 2, 'Other row', 0)");
$db->execute("INSERT INTO cards (id, board_id, column_id, swim_lane_id, position, cell_entered_at)
VALUES (1, 1, 1, 1, 0, '2026-01-01 00:00:00')");

putenv('APP_DEBUG=0');
date_default_timezone_set('America/New_York');
$_SESSION['auth_user'] = ['preferred_username' => 'test-user'];
$controller = new CardsController();
$move = static function (int $columnId, int $laneId, int $position = 0) use ($controller): Core\Response {
return $controller->move(new Request([], [
'column_id' => (string) $columnId,
'swim_lane_id' => (string) $laneId,
'position' => (string) $position,
], ['REQUEST_METHOD' => 'POST']), 1);
};

check($move(2, 2)->status() === 200, 'Move should succeed.');
$card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1');
check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 2, 'Card destination was not saved.');
$history = $db->first('SELECT * FROM card_movements WHERE card_id = 1');
check($history !== null, 'Movement was not recorded.');
check($history['from_column_name'] === 'Queued' && $history['to_column_name'] === 'Printing', 'Column history is incorrect.');
check($history['from_swim_lane_name'] === 'Standard' && $history['to_swim_lane_name'] === 'Urgent', 'Row history is incorrect.');
check($history['moved_by'] === 'test-user', 'Move user was not recorded.');
check(abs(strtotime($history['moved_at'] . ' UTC') - time()) < 10, 'Movement time was not saved in UTC.');
$response = $controller->history(1);
$recorded = json_decode($response->content(), true, flags: JSON_THROW_ON_ERROR)['movements'][0];
check($recorded['to_column_name'] === 'Printing' && $recorded['to_swim_lane_name'] === 'Urgent', 'History API omitted the destination.');

check($move(2, 2, 1)->status() === 200, 'Reordering should succeed.');
check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 1, 'Reordering added a movement.');

check($move(2, 1)->status() === 200, 'Row-only move should succeed.');
check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Row-only move was not recorded.');

check($move(3, 1)->status() === 422, 'Cross-board column should be rejected.');
check($move(2, 3)->status() === 422, 'Cross-board row should be rejected.');
$card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1');
check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 1, 'Rejected move changed the card.');
check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Rejected move changed history.');

$db->execute("CREATE TRIGGER reject_movement BEFORE INSERT ON card_movements
BEGIN SELECT RAISE(ABORT, 'forced history failure'); END");
$logFile = tempnam(sys_get_temp_dir(), 'card_move_');
$previousLog = ini_get('error_log');
ini_set('error_log', $logFile);
try {
check($move(1, 1)->status() === 500, 'History failure should reject the move.');
$card = $db->first('SELECT column_id, swim_lane_id FROM cards WHERE id = 1');
check((int) $card['column_id'] === 2 && (int) $card['swim_lane_id'] === 1, 'Failed history write did not roll back the move.');
check(count($db->query('SELECT id FROM card_movements WHERE card_id = 1')) === 2, 'Failed move changed history.');
} finally {
ini_set('error_log', $previousLog ?: '');
unlink($logFile);
$db->execute('DROP TRIGGER reject_movement');
}

echo "PASS: card moves record column and row history\n";

+ 2
- 0
tests/run.php Просмотреть файл

@@ -110,6 +110,7 @@ if ($response->content() !== 'Hello, Daniel') {
exit(1);
}

/* Employee routes belong to the framework example and are not part of KCI Kanban.
$employeePage = (new Dispatcher($router, $app))->dispatch(new Request([], [], [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/employees',
@@ -141,5 +142,6 @@ if (strpos($employeeData->content(), '[') === false) {
echo "FAIL: employee data endpoint did not return JSON array content\n";
exit(1);
}
*/

echo "PASS: migration manager and route dispatch work\n";

Загрузка…
Отмена
Сохранить

Powered by TurnKey Linux.