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:
parent
b810854880
commit
f5a138178d
11
README.md
11
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue