From f5a138178de1ccf7c61c5683c78dc0cb81e997bb Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 5 Oct 2026 09:49:25 -0600 Subject: [PATCH] Extend carry-forward to KPIs with hollow-point chart rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 11 +- src/app/Support/BowlerGrid.php | 69 +++++++++--- src/app/Support/KpiChart.php | 100 +++++++++++------- src/resources/views/charts/_grid.blade.php | 4 +- .../views/charts/_kpi-chart.blade.php | 35 ++++-- 5 files changed, 152 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 07c7d87..21f3103 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app/Support/BowlerGrid.php b/src/app/Support/BowlerGrid.php index 201f5e2..15b454e 100644 --- a/src/app/Support/BowlerGrid.php +++ b/src/app/Support/BowlerGrid.php @@ -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 + * 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 $months + * @param string $start first day of the 12-month window (Y-m-d) + * @return array */ - 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 $monthEntries + * @return array + */ + 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, ]; } diff --git a/src/app/Support/KpiChart.php b/src/app/Support/KpiChart.php index dbf1ff5..f804e89 100644 --- a/src/app/Support/KpiChart.php +++ b/src/app/Support/KpiChart.php @@ -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 $cells + * @param array $monthEntries per-month entry, with earlier entries carried forward into empty months * @param array $months * @return array */ - 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 */ - 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 */ - 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'], ]; } diff --git a/src/resources/views/charts/_grid.blade.php b/src/resources/views/charts/_grid.blade.php index 8c4a55d..c1ba0a1 100644 --- a/src/resources/views/charts/_grid.blade.php +++ b/src/resources/views/charts/_grid.blade.php @@ -88,8 +88,8 @@

- 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.

diff --git a/src/resources/views/charts/_kpi-chart.blade.php b/src/resources/views/charts/_kpi-chart.blade.php index 38fdc46..2763bf5 100644 --- a/src/resources/views/charts/_kpi-chart.blade.php +++ b/src/resources/views/charts/_kpi-chart.blade.php @@ -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 . --}} + {{-- 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'] }} @endif @@ -78,22 +90,29 @@ @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'])) + fill="{{ $markerFill }}" stroke="{{ $markerStroke }}" stroke-width="2" /> {{ $marker['glyph'] }} + font-size="10" font-weight="700" fill="{{ $glyphFill }}">{{ $marker['glyph'] }} @else {{ $marker['title'] }} + fill="{{ $markerFill }}" stroke="{{ $markerStroke }}" stroke-width="2" /> {{ $marker['glyph'] }} + font-size="10" font-weight="700" fill="{{ $glyphFill }}">{{ $marker['glyph'] }} @endif @endforeach