From 62194d0a32f73a4db3030f38e16fea9dc8c0803e Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Mon, 15 Jun 2026 14:27:11 -0400 Subject: [PATCH] Tool tip for column and swim lane age --- app/Controllers/BoardsController.php | 29 ++++++++ app/Controllers/CardsController.php | 1 + app/Models/Board.php | 4 ++ app/Models/Card.php | 3 + app/Repositories/BoardRepository.php | 25 ++++++- app/Repositories/CardRepository.php | 20 ++++-- app/Views/boards/show.php | 4 +- app/Views/partials/settings-panel.php | 23 +++++++ ...000002_add_card_age_settings_to_boards.php | 24 +++++++ ...15_000003_add_cell_entered_at_to_cards.php | 26 +++++++ public/css/kanban.css | 16 +++++ public/js/kanban-board.js | 67 +++++++++++++++++++ public/js/kanban-settings.js | 28 ++++++++ routes/web.php | 1 + 14 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 database/migrations/20260615_000002_add_card_age_settings_to_boards.php create mode 100644 database/migrations/20260615_000003_add_cell_entered_at_to_cards.php diff --git a/app/Controllers/BoardsController.php b/app/Controllers/BoardsController.php index f21535d..411fa68 100644 --- a/app/Controllers/BoardsController.php +++ b/app/Controllers/BoardsController.php @@ -173,6 +173,35 @@ class BoardsController extends Controller return $this->redirect('/board/' . $board->slug); } + public function updateCardAgeSettings(Request $request, int $id): mixed + { + if (!AuthService::isLoggedIn()) { + return $this->json(['ok' => false, 'error' => 'Unauthorized'], 401); + } + + $row = $this->boards()->find($id); + if ($row === null) { + return $this->json(['ok' => false, 'error' => 'Not found'], 404); + } + + $showCardAge = (string) $request->input('show_card_age', '0') === '1'; + $cardAgeWarningDays = max(0, (int) $request->input('card_age_warning_days', 0)); + + $this->boards()->updateCardAgeSettings( + $id, + $showCardAge, + $cardAgeWarningDays, + date('Y-m-d H:i:s'), + AuthService::getCurrentUsername() + ); + + return $this->json([ + 'ok' => true, + 'show_card_age' => $showCardAge, + 'card_age_warning_days' => $cardAgeWarningDays, + ]); + } + public function destroy(string $slug): mixed { if ($guard = AuthService::requireLogin()) { diff --git a/app/Controllers/CardsController.php b/app/Controllers/CardsController.php index 5816866..8c97b71 100644 --- a/app/Controllers/CardsController.php +++ b/app/Controllers/CardsController.php @@ -47,6 +47,7 @@ class CardsController extends Controller $card->notes = trim((string) $request->input('notes', '')); $card->fullNote = (string) $request->input('full_note', ''); $card->position = $nextPos; + $card->cellEnteredAt = $now; $card->createdAt = $now; $card->createdBy = $username; $card->updatedAt = $now; diff --git a/app/Models/Board.php b/app/Models/Board.php index 16b8125..5c63830 100644 --- a/app/Models/Board.php +++ b/app/Models/Board.php @@ -11,6 +11,8 @@ class Board public string $slug = ''; public bool $importFromPrintstream = false; public string $printstreamJobName = ''; + public bool $showCardAge = false; + public int $cardAgeWarningDays = 0; public ?string $createdAt = null; public string $createdBy = ''; public ?string $updatedAt = null; @@ -24,6 +26,8 @@ class Board $model->slug = (string) ($row['slug'] ?? ''); $model->importFromPrintstream = (bool) ($row['import_from_printstream'] ?? false); $model->printstreamJobName = (string) ($row['printstream_job_name'] ?? ''); + $model->showCardAge = (bool) ($row['show_card_age'] ?? false); + $model->cardAgeWarningDays = (int) ($row['card_age_warning_days'] ?? 0); $model->createdAt = $row['created_at'] ?? null; $model->createdBy = (string) ($row['created_by'] ?? ''); $model->updatedAt = $row['updated_at'] ?? null; diff --git a/app/Models/Card.php b/app/Models/Card.php index e067676..4a99318 100644 --- a/app/Models/Card.php +++ b/app/Models/Card.php @@ -18,6 +18,7 @@ class Card public string $notes = ''; public string $fullNote = ''; public int $position = 0; + public ?string $cellEnteredAt = null; public ?string $createdAt = null; public string $createdBy = ''; public ?string $updatedAt = null; @@ -38,6 +39,7 @@ class Card $model->notes = (string) ($row['notes'] ?? ''); $model->fullNote = (string) ($row['full_note'] ?? ''); $model->position = (int) ($row['position'] ?? 0); + $model->cellEnteredAt = $row['cell_entered_at'] ?? null; $model->createdAt = $row['created_at'] ?? null; $model->createdBy = (string) ($row['created_by'] ?? ''); $model->updatedAt = $row['updated_at'] ?? null; @@ -60,6 +62,7 @@ class Card 'notes' => $this->notes, 'full_note' => $this->fullNote, 'position' => $this->position, + 'cell_entered_at' => $this->cellEnteredAt, ]; } } diff --git a/app/Repositories/BoardRepository.php b/app/Repositories/BoardRepository.php index f565341..2e69d94 100644 --- a/app/Repositories/BoardRepository.php +++ b/app/Repositories/BoardRepository.php @@ -63,14 +63,18 @@ class BoardRepository extends Repository { $this->database->execute( 'INSERT INTO boards - (name, slug, import_from_printstream, printstream_job_name, created_at, created_by, updated_at, updated_by) + (name, slug, import_from_printstream, printstream_job_name, show_card_age, card_age_warning_days, + created_at, created_by, updated_at, updated_by) VALUES - (:name, :slug, :import_from_printstream, :printstream_job_name, :created_at, :created_by, :updated_at, :updated_by)', + (:name, :slug, :import_from_printstream, :printstream_job_name, :show_card_age, :card_age_warning_days, + :created_at, :created_by, :updated_at, :updated_by)', [ 'name' => $board->name, 'slug' => $board->slug, 'import_from_printstream' => $board->importFromPrintstream ? 1 : 0, 'printstream_job_name' => $board->printstreamJobName, + 'show_card_age' => $board->showCardAge ? 1 : 0, + 'card_age_warning_days' => $board->cardAgeWarningDays, 'created_at' => $board->createdAt, 'created_by' => $board->createdBy, 'updated_at' => $board->updatedAt, @@ -102,4 +106,21 @@ class BoardRepository extends Repository ] ); } + + public function updateCardAgeSettings(int $id, bool $showCardAge, int $cardAgeWarningDays, string $updatedAt, string $updatedBy): void + { + $this->database->execute( + 'UPDATE boards + SET show_card_age = :show_card_age, card_age_warning_days = :card_age_warning_days, + updated_at = :updated_at, updated_by = :updated_by + WHERE id = :id', + [ + 'show_card_age' => $showCardAge ? 1 : 0, + 'card_age_warning_days' => $cardAgeWarningDays, + 'updated_at' => $updatedAt, + 'updated_by' => $updatedBy, + 'id' => $id, + ] + ); + } } diff --git a/app/Repositories/CardRepository.php b/app/Repositories/CardRepository.php index 625c68e..c475962 100644 --- a/app/Repositories/CardRepository.php +++ b/app/Repositories/CardRepository.php @@ -44,10 +44,10 @@ class CardRepository extends Repository $this->database->execute( 'INSERT INTO cards (board_id, column_id, swim_lane_id, job_number, job_name, customer_name, delivery_date, - quantity, notes, full_note, position, created_at, created_by, updated_at, updated_by) + quantity, notes, full_note, position, cell_entered_at, created_at, created_by, updated_at, updated_by) VALUES (:board_id, :column_id, :swim_lane_id, :job_number, :job_name, :customer_name, :delivery_date, - :quantity, :notes, :full_note, :position, :created_at, :created_by, :updated_at, :updated_by)', + :quantity, :notes, :full_note, :position, :cell_entered_at, :created_at, :created_by, :updated_at, :updated_by)', [ 'board_id' => $card->boardId, 'column_id' => $card->columnId, @@ -60,6 +60,7 @@ class CardRepository extends Repository 'notes' => $card->notes, 'full_note' => $card->fullNote, 'position' => $card->position, + 'cell_entered_at' => $card->cellEnteredAt, 'created_at' => $card->createdAt, 'created_by' => $card->createdBy, 'updated_at' => $card->updatedAt, @@ -100,9 +101,18 @@ class CardRepository extends Repository { $this->database->execute( 'UPDATE cards SET column_id = :column_id, swim_lane_id = :swim_lane_id, position = :position, - updated_at = :updated_at, updated_by = :updated_by WHERE id = :id', - ['column_id' => $columnId, 'swim_lane_id' => $swimLaneId, 'position' => $position, - 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, 'id' => $id] + updated_at = :updated_at, updated_by = :updated_by, + cell_entered_at = CASE + WHEN column_id != :check_column_id OR swim_lane_id != :check_swim_lane_id THEN :cell_entered_at + ELSE cell_entered_at + END + WHERE id = :id', + [ + 'column_id' => $columnId, 'swim_lane_id' => $swimLaneId, 'position' => $position, + 'updated_at' => $updatedAt, 'updated_by' => $updatedBy, + 'check_column_id' => $columnId, 'check_swim_lane_id' => $swimLaneId, + 'cell_entered_at' => $updatedAt, 'id' => $id, + ] ); } diff --git a/app/Views/boards/show.php b/app/Views/boards/show.php index 4db7a29..766c423 100644 --- a/app/Views/boards/show.php +++ b/app/Views/boards/show.php @@ -84,7 +84,9 @@ var KANBAN = { boardId: id ?>, boardSlug: "slug) ?>", - cards: + cards: , + showCardAge: showCardAge ? 'true' : 'false' ?>, + cardAgeWarningDays: cardAgeWarningDays ?> }; var KANBAN_COLS = ['id' => $c->id, 'name' => $c->name, 'position' => $c->position, 'show_card_count' => $c->showCardCount], $columns)) ?>; var KANBAN_LANES = ['id' => $l->id, 'name' => $l->name, 'position' => $l->position], $lanes)) ?>; diff --git a/app/Views/partials/settings-panel.php b/app/Views/partials/settings-panel.php index d5cea0a..4bd5bc0 100644 --- a/app/Views/partials/settings-panel.php +++ b/app/Views/partials/settings-panel.php @@ -79,5 +79,28 @@ + +
+ Card Age + +
+ showCardAge ? 'checked' : '' ?>> + +
+ + +
+ + days +
+
Set to 0 to disable.
+
+ diff --git a/database/migrations/20260615_000002_add_card_age_settings_to_boards.php b/database/migrations/20260615_000002_add_card_age_settings_to_boards.php new file mode 100644 index 0000000..aa2755d --- /dev/null +++ b/database/migrations/20260615_000002_add_card_age_settings_to_boards.php @@ -0,0 +1,24 @@ +execute( + 'ALTER TABLE boards ADD COLUMN show_card_age INTEGER NOT NULL DEFAULT 0' + ); + $database->execute( + 'ALTER TABLE boards ADD COLUMN card_age_warning_days INTEGER NOT NULL DEFAULT 0' + ); + } + + public function down(Database $database): void + { + // SQLite cannot drop columns without recreating the table. + } +}; diff --git a/database/migrations/20260615_000003_add_cell_entered_at_to_cards.php b/database/migrations/20260615_000003_add_cell_entered_at_to_cards.php new file mode 100644 index 0000000..209fbbc --- /dev/null +++ b/database/migrations/20260615_000003_add_cell_entered_at_to_cards.php @@ -0,0 +1,26 @@ +execute( + 'ALTER TABLE cards ADD COLUMN cell_entered_at DATETIME' + ); + + // Backfill existing cards so the "time in column/lane" calculation has a starting point. + $database->execute( + 'UPDATE cards SET cell_entered_at = COALESCE(created_at, updated_at) WHERE cell_entered_at IS NULL' + ); + } + + 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 4abbd21..0ad8507 100644 --- a/public/css/kanban.css +++ b/public/css/kanban.css @@ -292,6 +292,22 @@ body.kanban-page .navbar .btn-outline-secondary:hover { display: none !important; } +.kanban-card-overdue { + border-color: #f1b3ab; + background: linear-gradient(180deg, #fff5f4 0%, #ffeceb 100%); + box-shadow: 0 4px 12px rgba(214, 62, 44, 0.12); +} + +.kanban-card-overdue:hover { + border-color: #e2897e; + box-shadow: 0 10px 22px rgba(214, 62, 44, 0.18); +} + +.kanban-card-overdue .card-job-number { + color: #b3261e; + background: #fde2df; +} + .card-headline { display: flex; align-items: center; diff --git a/public/js/kanban-board.js b/public/js/kanban-board.js index 1026793..8c62ab1 100644 --- a/public/js/kanban-board.js +++ b/public/js/kanban-board.js @@ -37,6 +37,62 @@ .replace(/"/g, '"'); } + function nowDbString() { + var d = new Date(); + function pad(n) { return n < 10 ? '0' + n : n; } + return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); + } + + function ageInDays(cellEnteredAt) { + if (!cellEnteredAt) return 0; + var entered = new Date(String(cellEnteredAt).replace(' ', 'T')); + if (isNaN(entered.getTime())) return 0; + return (Date.now() - entered.getTime()) / (24 * 60 * 60 * 1000); + } + + function formatAge(cellEnteredAt) { + var days = ageInDays(cellEnteredAt); + if (!cellEnteredAt || isNaN(days)) return null; + if (days < 0) days = 0; + + var totalDays = Math.floor(days); + if (totalDays >= 1) { + return totalDays + ' day' + (totalDays === 1 ? '' : 's'); + } + + var totalHours = Math.floor(days * 24); + if (totalHours >= 1) { + return totalHours + ' hour' + (totalHours === 1 ? '' : 's'); + } + + return 'less than an hour'; + } + + function applyCardAge(el, card) { + if (KANBAN.showCardAge) { + var age = formatAge(card.cell_entered_at); + if (age) { + el.title = 'In this column/lane for ' + age; + } else { + el.removeAttribute('title'); + } + } else { + el.removeAttribute('title'); + } + + var isOverdue = KANBAN.cardAgeWarningDays > 0 && + ageInDays(card.cell_entered_at) >= KANBAN.cardAgeWarningDays; + el.classList.toggle('kanban-card-overdue', isOverdue); + } + + function refreshAllCardAges() { + document.querySelectorAll('.kanban-card').forEach(function (el) { + var card = KANBAN.cards.find(function (c) { return String(c.id) === String(el.dataset.id); }); + if (card) applyCardAge(el, card); + }); + } + function applyGridTemplate() { var grid = document.getElementById('kanban-grid'); var colHs = grid.querySelectorAll('.kanban-col-header'); @@ -185,6 +241,7 @@ div.dataset.laneId = card.swim_lane_id; div.dataset.searchText = buildCardSearchText(card); div.innerHTML = cardBodyHtml(card); + applyCardAge(div, card); div.addEventListener('click', function () { var c = KANBAN.cards.find(function (x) { return String(x.id) === String(div.dataset.id); }); if (!c) return; @@ -237,10 +294,15 @@ var card = KANBAN.cards.find(function (c) { return String(c.id) === String(cardId); }); var oldColId = card ? card.column_id : null; + var oldLaneId = card ? card.swim_lane_id : null; if (card) { + if (String(oldColId) !== String(newColId) || String(oldLaneId) !== String(newLaneId)) { + card.cell_entered_at = nowDbString(); + } card.column_id = parseInt(newColId, 10); card.swim_lane_id = parseInt(newLaneId, 10); card.position = newPos; + applyCardAge(evt.item, card); } evt.item.dataset.columnId = newColId; @@ -480,6 +542,11 @@ setColumnShowCount: function (colId, show) { columnShowCount[String(colId)] = show; refreshColumnCount(colId); + }, + setCardAgeSettings: function (showCardAge, cardAgeWarningDays) { + KANBAN.showCardAge = !!showCardAge; + KANBAN.cardAgeWarningDays = parseInt(cardAgeWarningDays, 10) || 0; + refreshAllCardAges(); } }; diff --git a/public/js/kanban-settings.js b/public/js/kanban-settings.js index 40e6924..d3a1344 100644 --- a/public/js/kanban-settings.js +++ b/public/js/kanban-settings.js @@ -212,6 +212,34 @@ document.querySelectorAll('#lane-list li').forEach(bindLaneItem); + /* ═══════════════════════════════════════════════════════════ + CARD AGE + ═══════════════════════════════════════════════════════════ */ + + var cardAgeTooltipToggle = document.getElementById('card-age-tooltip-toggle'); + var cardAgeWarningDays = document.getElementById('card-age-warning-days'); + + function saveCardAgeSettings() { + var showCardAge = cardAgeTooltipToggle.checked; + var warningDays = parseInt(cardAgeWarningDays.value, 10); + if (isNaN(warningDays) || warningDays < 0) warningDays = 0; + cardAgeWarningDays.value = warningDays; + + post('/boards/' + boardId + '/card-age-settings', { + show_card_age: showCardAge ? '1' : '0', + card_age_warning_days: warningDays + }, function (res) { + if (!res.ok) { + alert(res.error || 'Update failed'); + return; + } + window.KanbanBoard.setCardAgeSettings(res.show_card_age, res.card_age_warning_days); + }); + } + + cardAgeTooltipToggle.addEventListener('change', saveCardAgeSettings); + cardAgeWarningDays.addEventListener('change', saveCardAgeSettings); + /* ── Inline rename helper ─────────────────────────────────── */ function startRename(li, labelSel, saveCb) { var span = li.querySelector(labelSel); diff --git a/routes/web.php b/routes/web.php index 5398aed..608ee0d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,7 @@ $router->get('/board/{slug}', [BoardsController::class, 'show']); $router->get('/board/{slug}/edit', [BoardsController::class, 'edit']); $router->post('/board/{slug}/update', [BoardsController::class, 'update']); $router->post('/board/{slug}/delete', [BoardsController::class, 'destroy']); +$router->post('/boards/{id}/card-age-settings', [BoardsController::class, 'updateCardAgeSettings']); // Cards (JSON API) $router->post('/cards', [CardsController::class, 'store']);