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".) "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). Project months without (color + value for KPIs, color for projects, tooltip with date and notes). KPI and project months
their own entry **carry the most recent earlier entry forward** (marked "Carried forward from …" in the without their own entry **carry the most recent earlier entry forward** (marked "Carried forward from …"
tooltip and detail row), so a project's latest status stays visible through the current month — in the tooltip, and in a project's detail row), so the latest value or status stays visible through the
months before its first entry remain blank. Rows expand in place: 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 **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. 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 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. 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\Enums\KpiEvaluation;
use App\Models\Kpi; use App\Models\Kpi;
use App\Models\KpiEntry;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectEntry; use App\Models\ProjectEntry;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -12,9 +13,9 @@ 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). Project months * most recent entry wins (entry_date desc, id desc). Project and KPI
* without their own entry carry the most recent earlier entry forward, * months without their own entry carry the most recent earlier entry
* so the latest status stays visible through the current month. Items * forward, so the latest status stays visible through the current month. Items
* without a team/category fall into "No Team" / "Uncategorized", sorted last. * without a team/category fall into "No Team" / "Uncategorized", sorted last.
*/ */
class BowlerGrid class BowlerGrid
@ -58,14 +59,14 @@ class BowlerGrid
$months = self::months(); $months = self::months();
$rows = $kpis->map(function (Kpi $kpi) use ($months) { $rows = $kpis->map(function (Kpi $kpi) use ($months, $start) {
$cells = self::kpiCells($kpi); $monthEntries = self::kpiMonthEntries($kpi, $months, $start);
return [ return [
'type' => 'kpi', 'type' => 'kpi',
'item' => $kpi, 'item' => $kpi,
'cells' => $cells, 'cells' => self::kpiCells($kpi, $monthEntries),
'chart' => KpiChart::build($kpi, $cells, $months), 'chart' => KpiChart::build($kpi, $monthEntries, $months),
]; ];
}) })
->concat($projects->map(fn (Project $p) => ['type' => 'project', 'item' => $p, 'cells' => self::projectCells($p, $months, $start)])); ->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. // Entries are ordered ascending, so the last write per month wins.
foreach ($kpi->entries as $entry) { 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 $display = $kpi->evaluation === KpiEvaluation::Milestone
? ($entry->value >= 1 ? 'Met' : 'Miss') ? ($entry->value >= 1 ? 'Met' : 'Miss')
: $kpi->value_type->format($entry->value, $kpi->unit_label); : $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), 'status' => $kpi->evaluate($entry->value),
'display' => $display, '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. * View-model builder for the click-to-expand KPI chart on bowler charts.
* Produces SVG-ready geometry: threshold zone bands, gridlines, the value * Produces SVG-ready geometry: threshold zone bands, gridlines, the value
* line (split on missing months), status-colored points, and axis labels. * 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): * Colors follow the dataviz status palette (state, not series identity):
* good/warning/critical washes for the zones, points colored by each * good/warning/critical washes for the zones, points colored by each
@ -36,31 +38,25 @@ class KpiChart
private const PAD_BOTTOM = 30; 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 * @param array<int, array{key: string, label: string}> $months
* @return array<string, mixed> * @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) { 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> * @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). $numbers = array_map(fn ($m) => $m['entry']->value, $monthEntries);
$byMonth = [];
foreach ($kpi->entries as $entry) {
$byMonth[$entry->entry_date->format('Y-m')] = $entry;
}
$numbers = array_map(fn ($e) => $e->value, $byMonth);
$thresholds = match ($kpi->evaluation) { $thresholds = match ($kpi->evaluation) {
KpiEvaluation::TargetBand => [ KpiEvaluation::TargetBand => [
$kpi->target - $kpi->yellow_tolerance, $kpi->target - $kpi->yellow_tolerance,
@ -96,41 +92,66 @@ class KpiChart
$gridlines[] = ['y' => $y($tick), 'label' => self::fmtTick($tick, $kpi)]; $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 = []; $points = [];
$segments = []; $solidRuns = [];
$current = []; $dashedLegs = [];
$run = [];
$prev = null;
$lastText = null;
foreach ($months as $i => $month) { foreach ($months as $i => $month) {
$entry = $byMonth[$month['key']] ?? null; $item = $monthEntries[$month['key']] ?? null;
if (! $entry) { if (! $item) {
if (count($current) > 1) { if (count($run) > 1) {
$segments[] = $current; $solidRuns[] = $run;
} }
$current = []; $run = [];
$prev = null;
continue; continue;
} }
$entry = $item['entry'];
$carried = $item['carriedForward'];
$px = round(self::PAD_LEFT + $slot * ($i + 0.5), 1); $px = round(self::PAD_LEFT + $slot * ($i + 0.5), 1);
$py = $y($entry->value); $py = $y($entry->value);
$status = $kpi->evaluate($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[] = [ $points[] = [
'x' => $px, 'x' => $px,
'y' => $py, 'y' => $py,
'color' => self::statusColor($status->value), 'color' => self::statusColor($status->value),
'title' => $entry->entry_date->format('M Y') . ': ' 'title' => $carried ? 'Carried forward from ' . $title : $title,
. $kpi->value_type->format($entry->value, $kpi->unit_label)
. ' — ' . $status->label(),
'note' => $entry->notes, 'note' => $entry->notes,
'carriedForward' => $carried,
]; ];
$current[] = [$px, $py]; $lastText = $valueText;
if ($prev !== null) {
if (! $prev['carried'] && ! $carried) {
if ($run === []) {
$run[] = $prev['xy'];
} }
if (count($current) > 1) { $run[] = [$px, $py];
$segments[] = $current; } else {
if (count($run) > 1) {
$solidRuns[] = $run;
}
$run = [];
$dashedLegs[] = [$prev['xy'], [$px, $py]];
}
}
$prev = ['xy' => [$px, $py], 'carried' => $carried];
}
if (count($run) > 1) {
$solidRuns[] = $run;
} }
$paths = array_map( $toPath = fn ($seg) => 'M' . implode(' L', array_map(fn ($p) => "{$p[0]},{$p[1]}", $seg));
fn ($seg) => 'M' . implode(' L', array_map(fn ($p) => "{$p[0]},{$p[1]}", $seg)), $paths = array_map($toPath, $solidRuns);
$segments $carriedPaths = array_map($toPath, $dashedLegs);
);
// Selective labeling: the value at the line's end only. // Selective labeling: the value at the line's end only.
$endLabel = null; $endLabel = null;
@ -139,7 +160,7 @@ class KpiChart
$endLabel = [ $endLabel = [
'x' => $last['x'] + 9, 'x' => $last['x'] + 9,
'y' => $last['y'] + 4, '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, 'refLines' => $refLines,
'gridlines' => $gridlines, 'gridlines' => $gridlines,
'paths' => $paths, 'paths' => $paths,
'carriedPaths' => $carriedPaths,
'lineColor' => self::LINE, 'lineColor' => self::LINE,
'points' => $points, 'points' => $points,
'endLabel' => $endLabel, 'endLabel' => $endLabel,
@ -215,13 +237,8 @@ class KpiChart
/** /**
* @return array<string, mixed> * @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; $height = 110;
$plotW = self::W - self::PAD_LEFT - self::PAD_RIGHT; $plotW = self::W - self::PAD_LEFT - self::PAD_RIGHT;
$slot = $plotW / count($months); $slot = $plotW / count($months);
@ -229,18 +246,21 @@ class KpiChart
$markers = []; $markers = [];
foreach ($months as $i => $month) { foreach ($months as $i => $month) {
$entry = $byMonth[$month['key']] ?? null; $item = $monthEntries[$month['key']] ?? null;
if (! $entry) { if (! $item) {
continue; continue;
} }
$entry = $item['entry'];
$met = $entry->value >= 1; $met = $entry->value >= 1;
$title = $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met');
$markers[] = [ $markers[] = [
'x' => round(self::PAD_LEFT + $slot * ($i + 0.5), 1), 'x' => round(self::PAD_LEFT + $slot * ($i + 0.5), 1),
'y' => $baseline, 'y' => $baseline,
'color' => $met ? self::GOOD : self::CRIT, 'color' => $met ? self::GOOD : self::CRIT,
'glyph' => $met ? '✓' : '✕', 'glyph' => $met ? '✓' : '✕',
'title' => $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met'), 'title' => $item['carriedForward'] ? 'Carried forward from ' . $title : $title,
'note' => $entry->notes, 'note' => $entry->notes,
'carriedForward' => $item['carriedForward'],
]; ];
} }

View File

@ -88,8 +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; project months without an entry carry the most recent Each cell shows the most recent entry in that month; months without an entry carry the most recent
status forward (the tooltip marks it). The current month is highlighted. 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. 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>

View File

@ -40,20 +40,32 @@
stroke-linecap="round" stroke-linejoin="round" /> stroke-linecap="round" stroke-linejoin="round" />
@endforeach @endforeach
{{-- Points, status-colored with a 2px surface ring. Noted points get a {{-- Carried-forward legs (any leg touching a brought-forward month): dashed, no draw-in --}}
pulsing halo and a custom hover tooltip instead of the native <title>. --}} @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) @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'])) @if (!empty($point['note']))
<g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}" <g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}"
data-note="{{ $point['note'] }}" data-title="{{ $point['title'] }}"> 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="8" class="kpi-note-ring" />
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5" <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" /> <circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="12" fill="transparent" class="js-note-hit" />
</g> </g>
@else @else
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5" class="kpi-pop" style="--i: {{ $loop->index }}" <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> <title>{{ $point['title'] }}</title>
</circle> </circle>
@endif @endif
@ -78,22 +90,29 @@
<line x1="{{ $chart['plot']['x'] }}" y1="{{ $chart['baseline'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $chart['baseline'] }}" <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" /> stroke="{{ \App\Support\KpiChart::GRIDLINE }}" stroke-width="1" />
@foreach ($chart['markers'] as $marker) @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'])) @if (!empty($marker['note']))
<g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}" <g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}"
data-note="{{ $marker['note'] }}" data-title="{{ $marker['title'] }}"> 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="13" class="kpi-note-ring" />
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9" <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" <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> </g>
@else @else
<g class="kpi-pop" style="--i: {{ $loop->index }}"> <g class="kpi-pop" style="--i: {{ $loop->index }}">
<title>{{ $marker['title'] }}</title> <title>{{ $marker['title'] }}</title>
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9" <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" <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> </g>
@endif @endif
@endforeach @endforeach