Project Bring Forward on Bowler
This commit is contained in:
parent
7eb2751cd4
commit
8db7f91c55
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue