Преглед на файлове

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 ден
родител
ревизия
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";

Loading…
Отказ
Запис

Powered by TurnKey Linux.