Merge pull request 'Project Bring Forward on Bowler' (#1) from Project-KPIs into main

Reviewed-on: #1
This commit is contained in:
brianfertig 2026-09-02 22:15:57 +00:00
commit 80120ecc84
3 changed files with 78 additions and 23 deletions

View File

@ -92,7 +92,10 @@ items first. (Items grandfathered from before teams existed appear under "No Tea
"Uncategorized".) "Uncategorized".)
The grid shows the trailing 12 months; each cell is the **most recent entry in that month** The grid shows the trailing 12 months; each cell is the **most recent entry in that month**
(color + value for KPIs, color for projects, tooltip with date and notes). Rows expand in place: (color + value for KPIs, color for projects, tooltip with date and notes). Project months without
their own entry **carry the most recent earlier entry forward** (marked "Carried forward from …" in the
tooltip and detail row), so a project's latest status stays visible through the current month —
months before its first entry remain blank. Rows expand in place:
**click a KPI row** for an inline SVG trend chart with its threshold zones shaded (line + status-colored **click a KPI row** for an inline SVG trend chart with its threshold zones shaded (line + status-colored
points for numeric KPIs, a met/missed timeline for milestones); **click a project's month cell** for that points for numeric KPIs, a met/missed timeline for milestones); **click a project's month cell** for that
month's notes/headwinds/tailwinds/highlights/blockers. Clicking anywhere else collapses the row. month's notes/headwinds/tailwinds/highlights/blockers. Clicking anywhere else collapses the row.

View File

@ -5,14 +5,17 @@ namespace App\Support;
use App\Enums\KpiEvaluation; use App\Enums\KpiEvaluation;
use App\Models\Kpi; use App\Models\Kpi;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectEntry;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
* Builds the rolling 12-month bowler grid. Rows are grouped * Builds the rolling 12-month bowler grid. Rows are grouped
* Team -> Category (by name, case-insensitively, so same-named teams * Team -> Category (by name, case-insensitively, so same-named teams
* from different owners merge on shared charts); within each month the * from different owners merge on shared charts); within each month the
* most recent entry wins (entry_date desc, id desc). Items without a * most recent entry wins (entry_date desc, id desc). Project months
* team/category fall into "No Team" / "Uncategorized", sorted last. * without their own entry carry the most recent earlier entry forward,
* so the latest status stays visible through the current month. Items
* without a team/category fall into "No Team" / "Uncategorized", sorted last.
*/ */
class BowlerGrid class BowlerGrid
{ {
@ -65,7 +68,7 @@ class BowlerGrid
'chart' => KpiChart::build($kpi, $cells, $months), 'chart' => KpiChart::build($kpi, $cells, $months),
]; ];
}) })
->concat($projects->map(fn (Project $p) => ['type' => 'project', 'item' => $p, 'cells' => self::projectCells($p)])); ->concat($projects->map(fn (Project $p) => ['type' => 'project', 'item' => $p, 'cells' => self::projectCells($p, $months, $start)]));
// Group by team then category, merging names case-insensitively. // Group by team then category, merging names case-insensitively.
$grouped = []; $grouped = [];
@ -123,17 +126,60 @@ class BowlerGrid
} }
/** /**
* @return array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}> * @param array<int, array{key: string, label: string}> $months
* @param string $start first day of the 12-month window (Y-m-d)
* @return array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string, carriedForward: bool, details: array}>
*/ */
private static function projectCells(Project $project): array private static function projectCells(Project $project, array $months, string $start): array
{ {
$cells = []; $cells = [];
$byMonth = [];
// Entries are ordered ascending, so the last write per month wins.
foreach ($project->entries as $entry) { foreach ($project->entries as $entry) {
$cells[$entry->entry_date->format('Y-m')] = [ $key = $entry->entry_date->format('Y-m');
$cells[$key] = self::entryCell($entry);
$byMonth[$key] = $entry;
}
// Carry the most recent entry forward into any month without its
// own entry (including the current month). Months before the first
// entry ever stay blank. The fill is seeded with the latest entry
// that predates the window, so an old "dormant" status also carries
// across the gap up to the first recent entry.
$filler = $project->entries()
->where('entry_date', '<', $start)
->orderByDesc('entry_date')
->orderByDesc('id')
->first();
foreach ($months as $month) {
$key = $month['key'];
if (isset($byMonth[$key])) {
$filler = $byMonth[$key];
continue;
}
if ($filler !== null) {
$cells[$key] = self::entryCell($filler, carriedForward: true);
}
}
return $cells;
}
/**
* @return array{status: \App\Enums\RagStatus, display: string, tooltip: string, carriedForward: bool, details: array}
*/
private static function entryCell(ProjectEntry $entry, bool $carriedForward = false): array
{
$tooltip = trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? "{$entry->notes}" : ''));
return [
'status' => $entry->status, 'status' => $entry->status,
'display' => strtoupper(substr($entry->status->value, 0, 1)), 'display' => strtoupper(substr($entry->status->value, 0, 1)),
'tooltip' => trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? "{$entry->notes}" : '')), 'tooltip' => $carriedForward ? 'Carried forward from ' . $tooltip : $tooltip,
'carriedForward' => $carriedForward,
// Payload for the click-to-expand detail row on charts // Payload for the click-to-expand detail row on charts
'details' => [ 'details' => [
'date' => $entry->entry_date->format('Y-m-d'), 'date' => $entry->entry_date->format('Y-m-d'),
@ -144,10 +190,8 @@ class BowlerGrid
'tailwinds' => $entry->tailwinds, 'tailwinds' => $entry->tailwinds,
'highlights' => $entry->highlights, 'highlights' => $entry->highlights,
'blockers' => $entry->blockers, 'blockers' => $entry->blockers,
'carriedForward' => $carriedForward,
], ],
]; ];
} }
return $cells;
}
} }

View File

@ -88,7 +88,8 @@
</table> </table>
</div> </div>
<p class="mt-2 text-xs text-gray-400"> <p class="mt-2 text-xs text-gray-400">
Each cell shows the most recent entry in that month. The current month is highlighted. Each cell shows the most recent entry in that month; project months without an entry carry the most recent
status forward (the tooltip marks it). The current month is highlighted.
Click a KPI row to expand its trend chart; click a project row for its latest details, or a specific cell for that month. Click a KPI row to expand its trend chart; click a project row for its latest details, or a specific cell for that month.
</p> </p>
@ -146,6 +147,13 @@
function populate(td, d) { function populate(td, d) {
td.innerHTML = ''; td.innerHTML = '';
if (d.carriedForward) {
var cf = document.createElement('p');
cf.className = 'mb-2 text-xs text-gray-500';
cf.textContent = 'No entry recorded for this month — showing the most recent entry.';
td.appendChild(cf);
}
var head = document.createElement('div'); var head = document.createElement('div');
head.className = 'mb-2 flex items-center gap-3'; head.className = 'mb-2 flex items-center gap-3';
var date = document.createElement('span'); var date = document.createElement('span');