Extend carry-forward to KPIs with hollow-point chart rendering

- KPI months without an entry now carry the most recent earlier value
  forward, matching the existing project carry-forward behavior
- Chart renders carried-forward points as hollow (surface fill,
  status-colored ring) with dashed connecting legs to visually
  distinguish them from recorded values
- Milestone KPIs show hollow met/missed markers for carried-forward
  months
- Tooltips and detail rows annotate carried-forward values with
  "Carried forward from …"
- README and grid footnote updated to reflect that both KPIs and
  projects now carry forward
This commit is contained in:
Brian Fertig 2026-10-05 09:49:25 -06:00
parent b810854880
commit f5a138178d
5 changed files with 152 additions and 67 deletions

View File

@ -92,12 +92,13 @@ items first. (Items grandfathered from before teams existed appear under "No Tea
"Uncategorized".)
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). 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:
(color + value for KPIs, color for projects, tooltip with date and notes). KPI and project months
without their own entry **carry the most recent earlier entry forward** (marked "Carried forward from …"
in the tooltip, and in a project's detail row), so the latest value or status stays visible through the
current month — months before the 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
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; carried-forward months show as hollow
points joined by dashed legs); **click a project's month cell** for that
month's notes/headwinds/tailwinds/highlights/blockers. Clicking anywhere else collapses the row.
Group members can click through to any charted item's entries read-only; only the owner (or an
admin) can edit. Everyone also has a personal chart at `/my-bowler` covering all of their own items.

View File

@ -4,6 +4,7 @@ namespace App\Support;
use App\Enums\KpiEvaluation;
use App\Models\Kpi;
use App\Models\KpiEntry;
use App\Models\Project;
use App\Models\ProjectEntry;
use Illuminate\Support\Collection;
@ -12,9 +13,9 @@ use Illuminate\Support\Collection;
* Builds the rolling 12-month bowler grid. Rows are grouped
* Team -> Category (by name, case-insensitively, so same-named teams
* from different owners merge on shared charts); within each month the
* most recent entry wins (entry_date desc, id desc). Project months
* without their own entry carry the most recent earlier entry forward,
* so the latest status stays visible through the current month. Items
* most recent entry wins (entry_date desc, id desc). Project and KPI
* months 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
@ -58,14 +59,14 @@ class BowlerGrid
$months = self::months();
$rows = $kpis->map(function (Kpi $kpi) use ($months) {
$cells = self::kpiCells($kpi);
$rows = $kpis->map(function (Kpi $kpi) use ($months, $start) {
$monthEntries = self::kpiMonthEntries($kpi, $months, $start);
return [
'type' => 'kpi',
'item' => $kpi,
'cells' => $cells,
'chart' => KpiChart::build($kpi, $cells, $months),
'cells' => self::kpiCells($kpi, $monthEntries),
'chart' => KpiChart::build($kpi, $monthEntries, $months),
];
})
->concat($projects->map(fn (Project $p) => ['type' => 'project', 'item' => $p, 'cells' => self::projectCells($p, $months, $start)]));
@ -103,22 +104,66 @@ class BowlerGrid
}
/**
* @return array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}>
* The entry that represents each month for a KPI: its own latest entry,
* or (for months without one) the most recent earlier entry carried
* forward, including the current month. Months before the first entry
* ever stay absent. The fill is seeded with the latest entry that
* predates the window, so an old value carries across the gap up to the
* first recent entry. Shared by the grid cells and the expanded chart.
*
* @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{entry: KpiEntry, carriedForward: bool}>
*/
private static function kpiCells(Kpi $kpi): array
private static function kpiMonthEntries(Kpi $kpi, array $months, string $start): array
{
$cells = [];
$byMonth = [];
// Entries are ordered ascending, so the last write per month wins.
foreach ($kpi->entries as $entry) {
$byMonth[$entry->entry_date->format('Y-m')] = $entry;
}
$filler = $kpi->entries()
->where('entry_date', '<', $start)
->orderByDesc('entry_date')
->orderByDesc('id')
->first();
$result = [];
foreach ($months as $month) {
$key = $month['key'];
if (isset($byMonth[$key])) {
$filler = $byMonth[$key];
$result[$key] = ['entry' => $filler, 'carriedForward' => false];
} elseif ($filler !== null) {
$result[$key] = ['entry' => $filler, 'carriedForward' => true];
}
}
return $result;
}
/**
* @param array<string, array{entry: KpiEntry, carriedForward: bool}> $monthEntries
* @return array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string, carriedForward: bool}>
*/
private static function kpiCells(Kpi $kpi, array $monthEntries): array
{
$cells = [];
foreach ($monthEntries as $key => ['entry' => $entry, 'carriedForward' => $carriedForward]) {
$display = $kpi->evaluation === KpiEvaluation::Milestone
? ($entry->value >= 1 ? 'Met' : 'Miss')
: $kpi->value_type->format($entry->value, $kpi->unit_label);
$cells[$entry->entry_date->format('Y-m')] = [
$tooltip = trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? " — {$entry->notes}" : ''));
$cells[$key] = [
'status' => $kpi->evaluate($entry->value),
'display' => $display,
'tooltip' => trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? " — {$entry->notes}" : '')),
'tooltip' => $carriedForward ? 'Carried forward from ' . $tooltip : $tooltip,
'carriedForward' => $carriedForward,
];
}

View File

@ -9,6 +9,8 @@ use App\Models\Kpi;
* View-model builder for the click-to-expand KPI chart on bowler charts.
* Produces SVG-ready geometry: threshold zone bands, gridlines, the value
* line (split on missing months), status-colored points, and axis labels.
* Months without their own entry show the most recent earlier entry carried
* forward: hollow points joined by dashed legs (or hollow milestone markers).
*
* Colors follow the dataviz status palette (state, not series identity):
* good/warning/critical washes for the zones, points colored by each
@ -36,31 +38,25 @@ class KpiChart
private const PAD_BOTTOM = 30;
/**
* @param array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}> $cells
* @param array<string, array{entry: \App\Models\KpiEntry, carriedForward: bool}> $monthEntries per-month entry, with earlier entries carried forward into empty months
* @param array<int, array{key: string, label: string}> $months
* @return array<string, mixed>
*/
public static function build(Kpi $kpi, array $cells, array $months): array
public static function build(Kpi $kpi, array $monthEntries, array $months): array
{
if ($kpi->evaluation === KpiEvaluation::Milestone) {
return self::milestone($kpi, $cells, $months);
return self::milestone($kpi, $monthEntries, $months);
}
return self::line($kpi, $cells, $months);
return self::line($kpi, $monthEntries, $months);
}
/**
* @return array<string, mixed>
*/
private static function line(Kpi $kpi, array $cells, array $months): array
private static function line(Kpi $kpi, array $monthEntries, array $months): array
{
// Values come from the loaded entries window (latest per month, ascending order).
$byMonth = [];
foreach ($kpi->entries as $entry) {
$byMonth[$entry->entry_date->format('Y-m')] = $entry;
}
$numbers = array_map(fn ($e) => $e->value, $byMonth);
$numbers = array_map(fn ($m) => $m['entry']->value, $monthEntries);
$thresholds = match ($kpi->evaluation) {
KpiEvaluation::TargetBand => [
$kpi->target - $kpi->yellow_tolerance,
@ -96,41 +92,66 @@ class KpiChart
$gridlines[] = ['y' => $y($tick), 'label' => self::fmtTick($tick, $kpi)];
}
// Points + line segments (gaps split the path).
// Points + line segments. Legs between two real entries are solid (a
// gap splits the path); any leg touching a carried-forward point is
// dashed so brought-forward values read as "not recorded this month".
$points = [];
$segments = [];
$current = [];
$solidRuns = [];
$dashedLegs = [];
$run = [];
$prev = null;
$lastText = null;
foreach ($months as $i => $month) {
$entry = $byMonth[$month['key']] ?? null;
if (! $entry) {
if (count($current) > 1) {
$segments[] = $current;
$item = $monthEntries[$month['key']] ?? null;
if (! $item) {
if (count($run) > 1) {
$solidRuns[] = $run;
}
$current = [];
$run = [];
$prev = null;
continue;
}
$entry = $item['entry'];
$carried = $item['carriedForward'];
$px = round(self::PAD_LEFT + $slot * ($i + 0.5), 1);
$py = $y($entry->value);
$status = $kpi->evaluate($entry->value);
$valueText = $kpi->value_type->format($entry->value, $kpi->unit_label);
$title = $entry->entry_date->format('M Y') . ': ' . $valueText . ' — ' . $status->label();
$points[] = [
'x' => $px,
'y' => $py,
'color' => self::statusColor($status->value),
'title' => $entry->entry_date->format('M Y') . ': '
. $kpi->value_type->format($entry->value, $kpi->unit_label)
. ' — ' . $status->label(),
'title' => $carried ? 'Carried forward from ' . $title : $title,
'note' => $entry->notes,
'carriedForward' => $carried,
];
$current[] = [$px, $py];
$lastText = $valueText;
if ($prev !== null) {
if (! $prev['carried'] && ! $carried) {
if ($run === []) {
$run[] = $prev['xy'];
}
$run[] = [$px, $py];
} else {
if (count($run) > 1) {
$solidRuns[] = $run;
}
$run = [];
$dashedLegs[] = [$prev['xy'], [$px, $py]];
}
}
$prev = ['xy' => [$px, $py], 'carried' => $carried];
}
if (count($current) > 1) {
$segments[] = $current;
if (count($run) > 1) {
$solidRuns[] = $run;
}
$paths = array_map(
fn ($seg) => 'M' . implode(' L', array_map(fn ($p) => "{$p[0]},{$p[1]}", $seg)),
$segments
);
$toPath = fn ($seg) => 'M' . implode(' L', array_map(fn ($p) => "{$p[0]},{$p[1]}", $seg));
$paths = array_map($toPath, $solidRuns);
$carriedPaths = array_map($toPath, $dashedLegs);
// Selective labeling: the value at the line's end only.
$endLabel = null;
@ -139,7 +160,7 @@ class KpiChart
$endLabel = [
'x' => $last['x'] + 9,
'y' => $last['y'] + 4,
'text' => $kpi->value_type->format($byMonth[array_key_last($byMonth)]->value, $kpi->unit_label),
'text' => $lastText,
];
}
@ -152,6 +173,7 @@ class KpiChart
'refLines' => $refLines,
'gridlines' => $gridlines,
'paths' => $paths,
'carriedPaths' => $carriedPaths,
'lineColor' => self::LINE,
'points' => $points,
'endLabel' => $endLabel,
@ -215,13 +237,8 @@ class KpiChart
/**
* @return array<string, mixed>
*/
private static function milestone(Kpi $kpi, array $cells, array $months): array
private static function milestone(Kpi $kpi, array $monthEntries, array $months): array
{
$byMonth = [];
foreach ($kpi->entries as $entry) {
$byMonth[$entry->entry_date->format('Y-m')] = $entry;
}
$height = 110;
$plotW = self::W - self::PAD_LEFT - self::PAD_RIGHT;
$slot = $plotW / count($months);
@ -229,18 +246,21 @@ class KpiChart
$markers = [];
foreach ($months as $i => $month) {
$entry = $byMonth[$month['key']] ?? null;
if (! $entry) {
$item = $monthEntries[$month['key']] ?? null;
if (! $item) {
continue;
}
$entry = $item['entry'];
$met = $entry->value >= 1;
$title = $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met');
$markers[] = [
'x' => round(self::PAD_LEFT + $slot * ($i + 0.5), 1),
'y' => $baseline,
'color' => $met ? self::GOOD : self::CRIT,
'glyph' => $met ? '✓' : '✕',
'title' => $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met'),
'title' => $item['carriedForward'] ? 'Carried forward from ' . $title : $title,
'note' => $entry->notes,
'carriedForward' => $item['carriedForward'],
];
}

View File

@ -88,8 +88,8 @@
</table>
</div>
<p class="mt-2 text-xs text-gray-400">
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.
Each cell shows the most recent entry in that month; months without an entry carry the most recent
KPI value or project 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.
</p>

View File

@ -40,20 +40,32 @@
stroke-linecap="round" stroke-linejoin="round" />
@endforeach
{{-- Points, status-colored with a 2px surface ring. Noted points get a
pulsing halo and a custom hover tooltip instead of the native <title>. --}}
{{-- Carried-forward legs (any leg touching a brought-forward month): dashed, no draw-in --}}
@foreach ($chart['carriedPaths'] as $path)
<path d="{{ $path }}" fill="none" style="stroke: var(--theme-chart-line)" stroke-width="2"
stroke-dasharray="4 3" stroke-linecap="round" stroke-opacity="0.7" />
@endforeach
{{-- Points, status-colored with a 2px surface ring (carried-forward points are hollow:
surface fill, status-colored ring). Noted points get a pulsing halo and a custom
hover tooltip instead of the native <title>. --}}
@foreach ($chart['points'] as $point)
@php
$hollow = !empty($point['carriedForward']);
$pointFill = $hollow ? '#ffffff' : $point['color'];
$pointStroke = $hollow ? $point['color'] : '#ffffff';
@endphp
@if (!empty($point['note']))
<g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}"
data-note="{{ $point['note'] }}" data-title="{{ $point['title'] }}">
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="8" class="kpi-note-ring" />
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5"
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2" />
fill="{{ $pointFill }}" stroke="{{ $pointStroke }}" stroke-width="2" />
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="12" fill="transparent" class="js-note-hit" />
</g>
@else
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5" class="kpi-pop" style="--i: {{ $loop->index }}"
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2">
fill="{{ $pointFill }}" stroke="{{ $pointStroke }}" stroke-width="2">
<title>{{ $point['title'] }}</title>
</circle>
@endif
@ -78,22 +90,29 @@
<line x1="{{ $chart['plot']['x'] }}" y1="{{ $chart['baseline'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $chart['baseline'] }}"
stroke="{{ \App\Support\KpiChart::GRIDLINE }}" stroke-width="1" />
@foreach ($chart['markers'] as $marker)
{{-- Carried-forward markers are hollow: surface fill, status-colored ring and glyph --}}
@php
$hollow = !empty($marker['carriedForward']);
$markerFill = $hollow ? '#ffffff' : $marker['color'];
$markerStroke = $hollow ? $marker['color'] : '#ffffff';
$glyphFill = $hollow ? $marker['color'] : '#ffffff';
@endphp
@if (!empty($marker['note']))
<g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}"
data-note="{{ $marker['note'] }}" data-title="{{ $marker['title'] }}">
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="13" class="kpi-note-ring" />
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9"
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />
fill="{{ $markerFill }}" stroke="{{ $markerStroke }}" stroke-width="2" />
<text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle"
font-size="10" font-weight="700" fill="#ffffff">{{ $marker['glyph'] }}</text>
font-size="10" font-weight="700" fill="{{ $glyphFill }}">{{ $marker['glyph'] }}</text>
</g>
@else
<g class="kpi-pop" style="--i: {{ $loop->index }}">
<title>{{ $marker['title'] }}</title>
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9"
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />
fill="{{ $markerFill }}" stroke="{{ $markerStroke }}" stroke-width="2" />
<text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle"
font-size="10" font-weight="700" fill="#ffffff">{{ $marker['glyph'] }}</text>
font-size="10" font-weight="700" fill="{{ $glyphFill }}">{{ $marker['glyph'] }}</text>
</g>
@endif
@endforeach