Compare commits

..

No commits in common. "main" and "Project-KPIs" have entirely different histories.

9 changed files with 68 additions and 232 deletions

View File

@ -92,13 +92,12 @@ 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). 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:
(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
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
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.
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

@ -1,30 +0,0 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class ProfileController extends Controller
{
public function editPassword(): View
{
return view('profile.password');
}
public function updatePassword(Request $request): RedirectResponse
{
$data = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'string', 'confirmed', Password::min(8)],
]);
$request->user()->update(['password' => $data['password']]);
return redirect()
->route('profile.password.edit')
->with('status', 'Your password has been updated.');
}
}

View File

@ -4,7 +4,6 @@ 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;
@ -13,9 +12,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 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
* 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
* without a team/category fall into "No Team" / "Uncategorized", sorted last.
*/
class BowlerGrid
@ -59,14 +58,14 @@ class BowlerGrid
$months = self::months();
$rows = $kpis->map(function (Kpi $kpi) use ($months, $start) {
$monthEntries = self::kpiMonthEntries($kpi, $months, $start);
$rows = $kpis->map(function (Kpi $kpi) use ($months) {
$cells = self::kpiCells($kpi);
return [
'type' => 'kpi',
'item' => $kpi,
'cells' => self::kpiCells($kpi, $monthEntries),
'chart' => KpiChart::build($kpi, $monthEntries, $months),
'cells' => $cells,
'chart' => KpiChart::build($kpi, $cells, $months),
];
})
->concat($projects->map(fn (Project $p) => ['type' => 'project', 'item' => $p, 'cells' => self::projectCells($p, $months, $start)]));
@ -104,66 +103,22 @@ class BowlerGrid
}
/**
* 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}>
* @return array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}>
*/
private static function kpiMonthEntries(Kpi $kpi, array $months, string $start): array
{
$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
private static function kpiCells(Kpi $kpi): array
{
$cells = [];
foreach ($monthEntries as $key => ['entry' => $entry, 'carriedForward' => $carriedForward]) {
// Entries are ordered ascending, so the last write per month wins.
foreach ($kpi->entries as $entry) {
$display = $kpi->evaluation === KpiEvaluation::Milestone
? ($entry->value >= 1 ? 'Met' : 'Miss')
: $kpi->value_type->format($entry->value, $kpi->unit_label);
$tooltip = trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? " — {$entry->notes}" : ''));
$cells[$key] = [
$cells[$entry->entry_date->format('Y-m')] = [
'status' => $kpi->evaluate($entry->value),
'display' => $display,
'tooltip' => $carriedForward ? 'Carried forward from ' . $tooltip : $tooltip,
'carriedForward' => $carriedForward,
'tooltip' => trim($entry->entry_date->format('Y-m-d') . ($entry->notes ? " — {$entry->notes}" : '')),
];
}

View File

@ -9,8 +9,6 @@ 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
@ -38,25 +36,31 @@ class KpiChart
private const PAD_BOTTOM = 30;
/**
* @param array<string, array{entry: \App\Models\KpiEntry, carriedForward: bool}> $monthEntries per-month entry, with earlier entries carried forward into empty months
* @param array<string, array{status: \App\Enums\RagStatus, display: string, tooltip: string}> $cells
* @param array<int, array{key: string, label: string}> $months
* @return array<string, mixed>
*/
public static function build(Kpi $kpi, array $monthEntries, array $months): array
public static function build(Kpi $kpi, array $cells, array $months): array
{
if ($kpi->evaluation === KpiEvaluation::Milestone) {
return self::milestone($kpi, $monthEntries, $months);
return self::milestone($kpi, $cells, $months);
}
return self::line($kpi, $monthEntries, $months);
return self::line($kpi, $cells, $months);
}
/**
* @return array<string, mixed>
*/
private static function line(Kpi $kpi, array $monthEntries, array $months): array
private static function line(Kpi $kpi, array $cells, array $months): array
{
$numbers = array_map(fn ($m) => $m['entry']->value, $monthEntries);
// 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);
$thresholds = match ($kpi->evaluation) {
KpiEvaluation::TargetBand => [
$kpi->target - $kpi->yellow_tolerance,
@ -92,66 +96,41 @@ class KpiChart
$gridlines[] = ['y' => $y($tick), 'label' => self::fmtTick($tick, $kpi)];
}
// 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 + line segments (gaps split the path).
$points = [];
$solidRuns = [];
$dashedLegs = [];
$run = [];
$prev = null;
$lastText = null;
$segments = [];
$current = [];
foreach ($months as $i => $month) {
$item = $monthEntries[$month['key']] ?? null;
if (! $item) {
if (count($run) > 1) {
$solidRuns[] = $run;
$entry = $byMonth[$month['key']] ?? null;
if (! $entry) {
if (count($current) > 1) {
$segments[] = $current;
}
$run = [];
$prev = null;
$current = [];
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' => $carried ? 'Carried forward from ' . $title : $title,
'title' => $entry->entry_date->format('M Y') . ': '
. $kpi->value_type->format($entry->value, $kpi->unit_label)
. ' — ' . $status->label(),
'note' => $entry->notes,
'carriedForward' => $carried,
];
$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];
$current[] = [$px, $py];
}
if (count($run) > 1) {
$solidRuns[] = $run;
if (count($current) > 1) {
$segments[] = $current;
}
$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);
$paths = array_map(
fn ($seg) => 'M' . implode(' L', array_map(fn ($p) => "{$p[0]},{$p[1]}", $seg)),
$segments
);
// Selective labeling: the value at the line's end only.
$endLabel = null;
@ -160,7 +139,7 @@ class KpiChart
$endLabel = [
'x' => $last['x'] + 9,
'y' => $last['y'] + 4,
'text' => $lastText,
'text' => $kpi->value_type->format($byMonth[array_key_last($byMonth)]->value, $kpi->unit_label),
];
}
@ -173,7 +152,6 @@ class KpiChart
'refLines' => $refLines,
'gridlines' => $gridlines,
'paths' => $paths,
'carriedPaths' => $carriedPaths,
'lineColor' => self::LINE,
'points' => $points,
'endLabel' => $endLabel,
@ -237,8 +215,13 @@ class KpiChart
/**
* @return array<string, mixed>
*/
private static function milestone(Kpi $kpi, array $monthEntries, array $months): array
private static function milestone(Kpi $kpi, array $cells, 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);
@ -246,21 +229,18 @@ class KpiChart
$markers = [];
foreach ($months as $i => $month) {
$item = $monthEntries[$month['key']] ?? null;
if (! $item) {
$entry = $byMonth[$month['key']] ?? null;
if (! $entry) {
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' => $item['carriedForward'] ? 'Carried forward from ' . $title : $title,
'title' => $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met'),
'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; months without an entry carry the most recent
KPI value or project status forward (the tooltip marks it). 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.
</p>

View File

@ -40,32 +40,20 @@
stroke-linecap="round" stroke-linejoin="round" />
@endforeach
{{-- 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>. --}}
{{-- Points, status-colored with a 2px surface 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="{{ $pointFill }}" stroke="{{ $pointStroke }}" stroke-width="2" />
fill="{{ $point['color'] }}" stroke="#ffffff" 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="{{ $pointFill }}" stroke="{{ $pointStroke }}" stroke-width="2">
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2">
<title>{{ $point['title'] }}</title>
</circle>
@endif
@ -90,29 +78,22 @@
<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="{{ $markerFill }}" stroke="{{ $markerStroke }}" stroke-width="2" />
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />
<text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle"
font-size="10" font-weight="700" fill="{{ $glyphFill }}">{{ $marker['glyph'] }}</text>
font-size="10" font-weight="700" fill="#ffffff">{{ $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="{{ $markerFill }}" stroke="{{ $markerStroke }}" stroke-width="2" />
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />
<text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle"
font-size="10" font-weight="700" fill="{{ $glyphFill }}">{{ $marker['glyph'] }}</text>
font-size="10" font-weight="700" fill="#ffffff">{{ $marker['glyph'] }}</text>
</g>
@endif
@endforeach

View File

@ -61,7 +61,6 @@
<a href="{{ route('teams.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Teams</a>
<a href="{{ route('categories.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Categories</a>
<a href="{{ route('api-keys.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">API Keys</a>
<a href="{{ route('profile.password.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Change Password</a>
</div>
</div>

View File

@ -1,43 +0,0 @@
@extends('layouts.app')
@section('title', 'Change Password — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-2 text-2xl font-bold">Change password</h1>
<p class="mb-6 text-gray-600">Update the password for your account.</p>
<form method="POST" action="{{ route('profile.password.update') }}">
@csrf
@method('PUT')
<div class="mb-4">
<label for="current_password" class="mb-1 block font-semibold">Current password</label>
<input id="current_password" type="password" name="current_password" required autofocus autocomplete="current-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('current_password')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="password" class="mb-1 block font-semibold">New password</label>
<input id="password" type="password" name="password" required autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('password')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="password_confirmation" class="mb-1 block font-semibold">Confirm new password</label>
<input id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Update password</button>
</div>
</form>
</div>
@endsection

View File

@ -7,7 +7,6 @@ use App\Http\Controllers\CategoryController;
use App\Http\Controllers\GroupAdminController;
use App\Http\Controllers\KpiController;
use App\Http\Controllers\KpiEntryController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectEntryController;
use App\Http\Controllers\TeamController;
@ -82,10 +81,6 @@ Route::middleware('auth')->group(function () {
Route::put('/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
// Profile
Route::get('/profile/password', [ProfileController::class, 'editPassword'])->name('profile.password.edit');
Route::put('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password.update');
// API keys
Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api-keys.index');
Route::post('/api-keys', [ApiKeyController::class, 'store'])->name('api-keys.store');