ソースを参照

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";

読み込み中…
キャンセル
保存

Powered by TurnKey Linux.