{{-- Stakeholder Report — Page 2 (Logic Model read-out) Fully rewritten against the punch-list. Behavior contracts: 1. Goal-as-truth. If an item has any linked goal (projectLinks entry with linked_entity_type='goal'), the goal record supplies BOTH current and target values. The item description is display label only — never parsed for a number in that case. Items with no goal link show no denominator and no percent. 2. Guard: if aggregate target <= 0 or current/target > 5, drop the ratio (impossible math never reaches a funder) and Log it. 3. Templated read: max 2 lines per stage, one going-well + one exception, materiality-weighted at 20%. Vocabulary is fixed — no freeform prose. 4. Belief line is ONE sentence, ≤220 chars, built from first 3 activities + first impact. 5. Risk box: one weakest health badge, single sentence, single period. 6. Layout: max-width 900px, min-width:0 on every grid child, no horizontal blowout at 1280px. 7. Status color map is fixed — at-risk is white bg + inset ring, never cream. Vars in: $logicModel null | {canvasId, narrative, stageProgress, healthBadges, coverageMatrix, projectLinks, linkedGoals, projectMeta} $hasLM bool $scope 'strategy' | 'program' --}} @php // Empty-state trigger is keyed on ITEM COUNT, not just board existence. // A blank Logic Model board (0 items) makes $hasLM true but leaves the // read-out hollow; the reverse flow itself creates a board on start, so // gating only on board-existence would strand users in an empty skeleton. // Treat "no board" and "board with no items" identically — both land on // the populate-from-your-work empty-state below. $lmStages = $hasLM ? ($logicModel['coverageMatrix']['stages'] ?? []) : []; $lmHasContent = false; foreach ($lmStages as $lmStage) { if (count($lmStage['items'] ?? []) > 0) { $lmHasContent = true; break; } } // Existing linked work in this strategy, for the empty-state's "we found // your work" line. programRows carry type + projectCount; program rows // count as programs, and projectCount sums to total leaf projects (direct // + under programs). Empty at program scope, so the line self-hides there. $lmProgramCount = 0; $lmProjectCount = 0; foreach ($programRows ?? [] as $lmRow) { // programRows carries stdClass rows (object[], like page-programs), so // cast before array access to avoid "Cannot use object as array". $lmRow = (array) $lmRow; if (($lmRow['type'] ?? '') === 'program') { $lmProgramCount++; } $lmProjectCount += (int) ($lmRow['projectCount'] ?? 0); } // Bold the counts so the totals read as totals. Every piece is server-side // (ints + translated nouns, both e()-escaped), so the {!! !!} render below // carries no user input. $lmFoundParts = []; if ($lmProgramCount > 0) { $lmFoundParts[] = ''.$lmProgramCount.' '.e(__($lmProgramCount === 1 ? 'stakeholder.lm.found_program' : 'stakeholder.lm.found_programs')); } if ($lmProjectCount > 0) { $lmFoundParts[] = ''.$lmProjectCount.' '.e(__($lmProjectCount === 1 ? 'stakeholder.lm.found_project' : 'stakeholder.lm.found_projects')); } $lmFoundStr = implode('·', $lmFoundParts); @endphp @if (! $lmHasContent)

{{ __('stakeholder.lm.empty_title') }}

{{ __('stakeholder.lm.empty_body') }}
@if (($scope ?? '') === 'strategy') @if ($lmFoundStr !== '')
{!! sprintf(e(__('stakeholder.lm.empty_found')), $lmFoundStr) !!}
@endif {{ __('stakeholder.lm.empty_cta') }}
{{ __('stakeholder.lm.empty_hint') }}
@endif
@else @php $stages = $logicModel['coverageMatrix']['stages'] ?? []; $columns = $logicModel['coverageMatrix']['columns'] ?? []; $unaligned = $logicModel['coverageMatrix']['unalignedColumns'] ?? []; $healthBadges = $logicModel['healthBadges'] ?? []; $projectLinks = $logicModel['projectLinks'] ?? []; $linkedGoals = $logicModel['linkedGoals'] ?? []; $projectMeta = $logicModel['projectMeta'] ?? []; $activityItems = $stages['activities']['items'] ?? []; $outputItems = $stages['outputs']['items'] ?? []; $outcomeItems = $stages['outcomes']['items'] ?? []; $impactItems = $stages['impact']['items'] ?? []; $isEvaluated = fn ($item) => isset($projectLinks[(int) (((array) $item)['id'] ?? 0)]) && count($projectLinks[(int) (((array) $item)['id'] ?? 0)]) > 0; // Belief line — templated, ≤220 chars. $activityDescs = array_slice( array_values(array_filter(array_map(fn ($it) => trim((string) (((array) $it)['description'] ?? '')), $activityItems))), 0, 3 ); $activitiesSummary = ''; if (count($activityDescs) > 0) { $lowered = array_map(fn ($s) => (mb_strtolower(mb_substr($s, 0, 1)) . mb_substr($s, 1)), $activityDescs); $activitiesSummary = count($lowered) === 1 ? $lowered[0] : (count($lowered) === 2 ? $lowered[0] . ' and ' . $lowered[1] : $lowered[0] . ', ' . $lowered[1] . ', and ' . $lowered[2]); } $impactSummary = ''; if (count($impactItems) > 0) { $impactSummary = trim((string) (((array) $impactItems[0])['description'] ?? '')); // lowercase first char for grammar if ($impactSummary !== '') $impactSummary = mb_strtolower(mb_substr($impactSummary, 0, 1)) . mb_substr($impactSummary, 1); } $beliefLine = ''; if ($activitiesSummary !== '' && $impactSummary !== '') { $beliefLine = sprintf(__('stakeholder.lm.belief_full'), $activitiesSummary, $impactSummary); } elseif ($activitiesSummary !== '') { $beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary); } if (mb_strlen($beliefLine) > 220 && $activitiesSummary !== '') { $beliefLine = sprintf(__('stakeholder.lm.belief_activities_only'), $activitiesSummary); } // Infer the item's expected metric unit ONCE from its authored label. // Cached in $itemExpectedUnit so render never regexes. `%` → percent, // `$` → currency, anything else → number. This is the label's contract; // goals whose metricType disagrees are dropped from the item's rollup // (rather than silently pretending unrelated units are the same thing). $itemExpectedUnit = []; $inferItemUnit = function (string $desc): string { $d = trim($desc); if ($d === '') return 'number'; if (str_contains($d, '%')) return 'percent'; if (str_contains($d, '$')) return 'currency'; return 'number'; }; foreach (array_merge($outputItems, $outcomeItems, $activityItems, $impactItems) as $it) { $iid = (int) (((array) $it)['id'] ?? 0); if ($iid > 0) $itemExpectedUnit[$iid] = $inferItemUnit((string) (((array) $it)['description'] ?? '')); } // Goal-based aggregate for an item. Only goals whose unit matches the // item's expected unit contribute — mismatched goals are logged and // dropped (safety over silent lying). Returns null when no matching // goals exist, or the ratio is impossible. $itemGoalAggregate = function ($item) use ($projectLinks, $linkedGoals, $itemExpectedUnit) { $itemId = (int) (((array) $item)['id'] ?? 0); $expectedUnit = $itemExpectedUnit[$itemId] ?? 'number'; $links = $projectLinks[$itemId] ?? []; $goals = []; $mismatched = 0; foreach ($links as $link) { if (($link['linked_entity_type'] ?? '') !== 'goal') continue; $gid = (int) ($link['linked_entity_id'] ?? 0); if (! isset($linkedGoals[$gid])) continue; $g = $linkedGoals[$gid]; $gUnit = ($g['metricType'] ?? 'number') === 'percent' ? 'percent' : (($g['metricType'] ?? 'number') === 'currency' ? 'currency' : 'number'); if ($gUnit !== $expectedUnit) { $mismatched++; \Illuminate\Support\Facades\Log::info('page2: unit mismatch on link', [ 'itemId' => $itemId, 'expected' => $expectedUnit, 'goalId' => $gid, 'goalUnit' => $gUnit, ]); continue; } $goals[] = $g; } if (count($goals) === 0) return null; $current = array_sum(array_column($goals, 'currentValue')); $end = array_sum(array_column($goals, 'endValue')); $hasTarget = $end > 0; $ratio = $hasTarget ? ($current / $end) : null; if ($ratio !== null && $ratio > 5) { \Illuminate\Support\Facades\Log::warning('page2: implausible ratio', ['itemId' => $itemId, 'current' => $current, 'target' => $end]); $hasTarget = false; } $anyRisk = false; $allOnTrack = true; foreach ($goals as $g) { if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') $anyRisk = true; if ($g['status'] !== 'status_ontrack') $allOnTrack = false; } return [ 'goals' => $goals, 'current' => $current, 'end' => $end, 'hasTarget' => $hasTarget, 'pct' => $hasTarget ? min(100, (int) round($ratio * 100)) : 0, 'metricType' => $expectedUnit, 'ragClass' => $anyRisk ? 'risk' : ($allOnTrack ? 'ok' : 'wip'), 'mismatched' => $mismatched, ]; }; // Format a metric respecting its type (percent vs number). $fmtMetric = function ($value, string $metricType) { $value = (float) $value; if ($metricType === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%'; if ($value == floor($value)) return number_format($value, 0, '.', ','); return number_format($value, 1, '.', ','); }; // Strip leading numeric token from label when it's redundant with a goal // aggregate. "1,200 screenings completed" → "screenings completed". $stripLeadingNumber = function (string $desc): string { if (preg_match('/^[\d][\d,\.]*(?:%|\+|)?\s+(.+)$/u', trim($desc), $m)) { return trim($m[1]); } return trim($desc); }; // Stage-level aggregate — total contributions across items in the stage. $stageAgg = function (array $items) use ($isEvaluated, $itemGoalAggregate) { $total = 0.0; $end = 0.0; $anyRisk = false; $rolled = []; foreach ($items as $item) { if (! $isEvaluated($item)) continue; $a = $itemGoalAggregate($item); if ($a === null) continue; $itemId = (int) (((array) $item)['id'] ?? 0); $rolled[$itemId] = $a; $total += $a['current']; $end += $a['end']; if ($a['ragClass'] === 'risk') $anyRisk = true; } $pct = $end > 0 ? min(100, (int) round(($total / $end) * 100)) : 0; return ['rolled' => $rolled, 'pct' => $pct, 'anyRisk' => $anyRisk, 'total' => $total, 'end' => $end]; }; // Per-program contribution across a stage — for Show-the-breakdown and // the templated read. Returns {contributors, unresolvedShare}. // // A goal is UNRESOLVED when its owning project is missing, has no // parent program, or is itself the strategy — those are never // contributors (a strategy is not its own program; a null bucket is // not a colleague). Unresolved value is tracked separately so the // page can render an honest data-quality note without personifying // the null bucket as a named entity. $stageProgramRollup = function (array $items) use ($isEvaluated, $itemGoalAggregate, $projectMeta) { $perProgram = []; $unresolvedCurrent = 0.0; $unresolvedEnd = 0.0; foreach ($items as $item) { if (! $isEvaluated($item)) continue; $a = $itemGoalAggregate($item); if ($a === null) continue; $itemId = (int) (((array) $item)['id'] ?? 0); $itemDesc = trim((string) (((array) $item)['description'] ?? '')); foreach ($a['goals'] as $g) { $pid = $g['projectId']; $progId = $g['programId']; // Unresolved: no project, no program, or the "project" is // actually a strategy row (which happens when a goal lives // on the strategy's own canvas). $isUnresolved = ($pid === null) || ($progId === null) || (($g['projectType'] ?? '') === 'strategy'); if ($isUnresolved) { $unresolvedCurrent += $g['currentValue']; $unresolvedEnd += $g['endValue']; continue; } $key = $progId; if (! isset($perProgram[$key])) { $perProgram[$key] = [ 'id' => $progId, 'name' => $projectMeta[$pid]['programName'] ?? ('#'.$progId), 'current' => 0.0, 'end' => 0.0, 'anyRisk' => false, 'allOnTrack' => true, 'projects' => [], 'riskItems' => [], // {id, description} for citation in exception line ]; } $perProgram[$key]['current'] += $g['currentValue']; $perProgram[$key]['end'] += $g['endValue']; if ($g['status'] === 'status_atrisk' || $g['status'] === 'status_offtrack') { $perProgram[$key]['anyRisk'] = true; // Track the ITEM this contributor caused risk on — the read // uses it to make exception lines cite what's off. $alreadyTracked = false; foreach ($perProgram[$key]['riskItems'] as $ri) { if ($ri['id'] === $itemId) { $alreadyTracked = true; break; } } if (! $alreadyTracked) { $perProgram[$key]['riskItems'][] = ['id' => $itemId, 'description' => $itemDesc]; } } if ($g['status'] !== 'status_ontrack') $perProgram[$key]['allOnTrack'] = false; // Project row aggregation inside program. Skip strategy-typed // projects (defense-in-depth: already filtered above). if (($g['projectType'] ?? '') === 'strategy') continue; $found = false; foreach ($perProgram[$key]['projects'] as &$pj) { if ($pj['id'] === $pid) { $pj['current'] += $g['currentValue']; $pj['end'] += $g['endValue']; if ($g['status'] === 'status_atrisk') $pj['ragClass'] = 'risk'; $found = true; break; } } unset($pj); if (! $found) { $perProgram[$key]['projects'][] = [ 'id' => $pid, 'name' => $projectMeta[$pid]['name'] ?? ('#'.$pid), 'current' => $g['currentValue'], 'end' => $g['endValue'], 'ragClass' => $g['status'] === 'status_atrisk' ? 'risk' : ($g['status'] === 'status_ontrack' ? 'ok' : 'wip'), ]; } } } // Renormalize shares over RESOLVED contributors only. Unresolved // value doesn't dilute the lead's share — it's tracked separately // as a data-quality signal. $resolvedTotal = array_sum(array_column($perProgram, 'current')); foreach ($perProgram as &$prog) { $prog['pct'] = $resolvedTotal > 0 ? (int) round(($prog['current'] / $resolvedTotal) * 100) : 0; $prog['ragClass'] = $prog['anyRisk'] ? 'risk' : ($prog['allOnTrack'] ? 'ok' : 'wip'); $prog['statusWord'] = $prog['anyRisk'] ? __('stakeholder.lm.status_word_behind') : ($prog['allOnTrack'] ? __('stakeholder.lm.status_word_ontrack') : __('stakeholder.lm.status_word_ramping')); usort($prog['projects'], fn ($a, $b) => $b['current'] <=> $a['current']); } unset($prog); uasort($perProgram, fn ($a, $b) => $b['pct'] <=> $a['pct']); $grandTotal = $resolvedTotal + $unresolvedCurrent; $unresolvedShare = $grandTotal > 0 ? (int) round(($unresolvedCurrent / $grandTotal) * 100) : 0; return [ 'contributors' => array_values($perProgram), 'unresolvedShare' => $unresolvedShare, 'hasUnresolved' => $unresolvedCurrent > 0 || $unresolvedEnd > 0, ]; }; // Templated read — max 2 lines. Vocabulary is fixed per punch-list §2. // BRANCHES ON CONTRIBUTOR COUNT: // 1 → single-contributor sentence (scoped to the stage — "on outputs // here", not bare "on this" — so cross-card contradictions read // as legitimate scope differences) // 2 → lead is carrying + second adds the rest // 3+ → lead + weighted exception, materiality threshold 20% // Exception lines CITE THE ITEM the contributor is off on — the row // status and the read reconcile via the item name. $renderRead = function (array $programs, string $scopeLabel) { $n = count($programs); if ($n === 0) return []; $statusLead = fn ($p) => match ($p['ragClass']) { 'ok' => '' . e($p['statusWord']) . '', 'risk' => '' . e($p['statusWord']) . '', default => '' . e($p['statusWord']) . '', }; // ── Single-contributor branch: no shares, no driver language. // Scoped ("outputs here" / "outcomes here") so a program appearing // in both cards with different statuses reads as legitimate scope, // not contradiction. if ($n === 1) { $only = $programs[0]; $type = $only['ragClass'] === 'risk' ? 'risk' : ($only['ragClass'] === 'ok' ? 'good' : 'watch'); return [[ 'type' => $type, 'html' => sprintf( __('stakeholder.lm.read_only_program_scoped'), e($only['name']), e($scopeLabel), $statusLead($only) ), ]]; } $lead = $programs[0]; $second = $programs[1] ?? null; $lines = []; // Helper: name the specific item this contributor is off on. // Cites the first risk item; if none, empty string skips the // "on X" clause. $riskItemName = function (array $prog): string { $items = $prog['riskItems'] ?? []; return count($items) > 0 ? (string) ($items[0]['description'] ?? '') : ''; }; $exceptionLine = function (array $prog, bool $critical) use ($statusLead, $riskItemName) { $item = $riskItemName($prog); $onClause = $item !== '' ? sprintf(__('stakeholder.lm.read_on_item_clause'), e($item)) : ''; $tmpl = $critical ? 'stakeholder.lm.read_critical_cited' : 'stakeholder.lm.read_watch_cited'; // Order: NAME is STATUS_WORD on ITEM — but at X% ... // statusLead comes BEFORE onClause; onClause carries its own // leading space so tokens don't run together. return [ 'type' => $critical ? 'risk' : 'watch', 'html' => '' . e($critical ? __('stakeholder.lm.critical_label') : __('stakeholder.lm.watch_label')) . ': ' . sprintf( __($tmpl), e($prog['name']), $statusLead($prog), $onClause, $prog['pct'] ), ]; }; // ── Two-contributor branch: lead + second. // If second is the exception, DROP the "adds the rest" clause — // otherwise it names the second twice (benign in line 1, behind // in line 2). Never two lines about the same entity. if ($n === 2) { $secondIsException = $second['ragClass'] !== 'ok'; $goingWellHtml = sprintf( __('stakeholder.lm.read_going_well'), e($lead['name']), $lead['pct'], $statusLead($lead) ); if (! $secondIsException) { $goingWellHtml .= sprintf(__('stakeholder.lm.read_second_clause'), e($second['name'])); } $lines[] = ['type' => 'good', 'html' => $goingWellHtml]; if ($secondIsException) { $lines[] = $exceptionLine($second, $second['pct'] >= 20); } return $lines; } // ── 3+ contributor branch: lead + weighted exception. $exception = null; foreach ($programs as $p) { if ($p['ragClass'] !== 'ok') { $exception = $p; break; } } $leadIsException = $exception !== null && $exception['id'] === $lead['id']; if (! $leadIsException) { $lines[] = [ 'type' => 'good', 'html' => sprintf( __('stakeholder.lm.read_going_well'), e($lead['name']), $lead['pct'], $statusLead($lead) ), ]; } if ($exception !== null) { $lines[] = $exceptionLine($exception, $exception['pct'] >= 20 || $leadIsException); } return $lines; }; // Weakest fragile link for the Risk block — one, not four. $fragileLink = null; $riskPref = ['risk' => 0, 'warning' => 1]; foreach ($healthBadges as $badge) { $s = $badge['health_status'] ?? ''; if (! isset($riskPref[$s])) continue; if ($fragileLink === null || $riskPref[$s] < $riskPref[$fragileLink['health_status']]) { $fragileLink = $badge; continue; } // Tie-break on risk_level (higher = worse). if ($s === $fragileLink['health_status'] && ((int) ($badge['risk_level'] ?? 0)) > ((int) ($fragileLink['risk_level'] ?? 0))) { $fragileLink = $badge; } } @endphp
{{ __('stakeholder.lm.page_subhead') }}
{{-- Belief — one templated sentence --}}
{{ __('stakeholder.lm.believe_label') }} @if ($beliefLine !== '') {{ $beliefLine }} @else {{ __('stakeholder.lm.belief_empty') }} @endif
{{-- Stage card renderer — one call for Outputs, one for Outcomes. --}} @foreach ([ ['key' => 'outputs', 'items' => $outputItems, 'frame' => __('stakeholder.lm.frame_producing_outputs'), 'icon' => 'fa-boxes-stacked', 'cls' => 'outputs'], ['key' => 'outcomes', 'items' => $outcomeItems, 'frame' => __('stakeholder.lm.frame_achieving_outcomes'), 'icon' => 'fa-chart-line', 'cls' => 'outcomes'], ] as $stage) @php $stageItems = array_values(array_filter($stage['items'], $isEvaluated)); if (count($stageItems) === 0) continue; $agg = $stageAgg($stage['items']); $rollupResult = $stageProgramRollup($stage['items']); $rollup = $rollupResult['contributors']; $scopeLabel = $stage['key'] === 'outputs' ? __('stakeholder.lm.scope_outputs') : __('stakeholder.lm.scope_outcomes'); $readLns = $renderRead($rollup, $scopeLabel); // Badge derives from the READ's outcome, not from item-level rollup. // This makes the badge the read's headline — a reader can't catch // the card contradicting itself (v4 Fix 4). // No resolved contributors → "no evidence yet" // Any Critical line → "At risk" // Any Watch line only → "In progress" // All going-well → "On track" $readTypes = array_column($readLns, 'type'); if (count($rollup) === 0) { $badgeCls = 'pending'; $badgeLbl = __('stakeholder.lm.no_evidence_yet'); } elseif (in_array('risk', $readTypes, true)) { $badgeCls = 'risk'; $badgeLbl = __('stakeholder.lm.verdict_at_risk'); } elseif (in_array('watch', $readTypes, true)) { $badgeCls = 'wip'; $badgeLbl = __('stakeholder.lm.verdict_in_progress'); } else { $badgeCls = 'ok'; $badgeLbl = __('stakeholder.lm.verdict_on_track'); } @endphp
{{ $stage['frame'] }}
{{ $badgeLbl }}
@if (count($readLns) > 0)
@foreach ($readLns as $ln)
{!! $ln['html'] !!}
@endforeach
@elseif ($rollupResult['hasUnresolved']) {{-- No resolved contributors, only unresolved: don't render a read; render the not-linked note alone. --}}
{{ __('stakeholder.lm.not_linked_note') }}
@endif @if (count($readLns) > 0 && $rollupResult['unresolvedShare'] >= 10) {{-- Data-quality note, muted, non-prose. Not a bullet, not in the read's voice. --}}
{{ sprintf(__('stakeholder.lm.unresolved_note'), $rollupResult['unresolvedShare']) }}
@endif
@foreach ($stageItems as $item) @php $itemId = (int) (((array) $item)['id'] ?? 0); $arr = (array) $item; // Label is ALWAYS the authored description, verbatim // — never prefixed, never regex-parsed for a number. $label = trim((string) ($arr['description'] ?? '')); // Aggregate carries only unit-matched goals (see // itemGoalAggregate). When present, we can safely // render the current value as its own element beside // the label — no splice. $a = $agg['rolled'][$itemId] ?? null; $currentDisplay = ''; if ($a !== null) { // Row status = goal RAG (same as the contributor // rollup that feeds the badge). Not a pct // threshold — a row and its card must agree. if ($a['ragClass'] === 'risk') { $rowCls = 'risk'; $rowLbl = __('stakeholder.lm.status_at_risk'); } elseif ($a['ragClass'] === 'ok') { $rowCls = 'ok'; $rowLbl = __('stakeholder.lm.status_on_track'); } else { $rowCls = 'wip'; $rowLbl = __('stakeholder.lm.status_in_progress'); } $currentDisplay = sprintf(__('stakeholder.lm.so_far'), $fmtMetric($a['current'], $a['metricType'])); } else { $rowCls = 'pending'; $rowLbl = __('stakeholder.lm.status_pending'); } @endphp @php // Provenance for the row status — surfaced as a // tooltip on hover so the reader can trace it back to // a person and date without instructional prose on // the page. Format: "Set by {Author} · {Date}". Falls // back to a generic note when author/date is missing. $statusBasis = ''; if ($a !== null && count($a['goals']) > 0) { $sourceGoal = null; foreach ($a['goals'] as $ag) { if ($sourceGoal === null || (string) ($ag['modified'] ?? '') > (string) ($sourceGoal['modified'] ?? '')) { $sourceGoal = $ag; } } $author = trim((string) ($sourceGoal['authorName'] ?? '')); $dateStr = ''; $mod = (string) ($sourceGoal['modified'] ?? ''); if ($mod !== '') { try { $dateStr = (new \DateTimeImmutable($mod))->format('M j'); } catch (\Exception $e) {} } if ($author !== '' && $dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who_when'), $author, $dateStr); elseif ($author !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_who'), $author); elseif ($dateStr !== '') $statusBasis = sprintf(__('stakeholder.lm.status_basis_when'), $dateStr); } @endphp
{{ $label }} @if ($currentDisplay !== '') {{ $currentDisplay }} @endif
{{ $rowLbl }}
@endforeach
@if (count($rollup) > 0)
{{ __('stakeholder.lm.show_breakdown') }}
@php // Same contributor-count branch as the read: // n = 1 → no bar, no percent, no "100%" tautology // n ≥ 2 → name + count + share bar + share % + status $contribCount = count($rollup); @endphp @foreach ($rollup as $prog) @php $showProjects = array_slice($prog['projects'], 0, 4); $moreProj = max(0, count($prog['projects']) - 4); $projCount = count($prog['projects']); $nProjectsLbl = $projCount === 1 ? __('stakeholder.lm.n_project_one') : sprintf(__('stakeholder.lm.n_projects'), $projCount); $pstatLbl = $prog['ragClass'] === 'risk' ? __('stakeholder.lm.status_at_risk') : ($prog['ragClass'] === 'ok' ? __('stakeholder.lm.status_on_track') : __('stakeholder.lm.status_in_progress')); @endphp
@if ($contribCount === 1)
{{ $prog['name'] }}
{{ $nProjectsLbl }}
{{ $pstatLbl }}
@else
{{ $prog['name'] }}
{{ $nProjectsLbl }}
{{ $prog['pct'] }}%
{{ $pstatLbl }}
@endif @if (count($showProjects) > 0)
@foreach ($showProjects as $pj)
{{ $pj['name'] }}
@endforeach @if ($moreProj > 0)
+ {{ $moreProj }} {{ __('stakeholder.lm.more_word') }}
@endif
@endif
@endforeach
@endif
@endforeach {{-- Impact — one aim, not a list --}} @if (count($impactItems) > 0) @php $primaryImpact = trim((string) (((array) $impactItems[0])['description'] ?? '')); @endphp @if ($primaryImpact !== '')
{{ __('stakeholder.lm.for_what_label') }}
{{ $primaryImpact }}
{{ __('stakeholder.lm.impact_horizon') }}
@endif @endif {{-- Risk — one weakest fragile link, single sentence, single period --}} @if ($fragileLink !== null) @php $assumption = trim((string) ($fragileLink['assumption_text'] ?? '')); $assumption = rtrim($assumption, '.!?'); $connector = trim((string) ($fragileLink['connector_label'] ?? '')); @endphp
{{ __('stakeholder.lm.risk_label') }} @if ($assumption !== '') {{ __('stakeholder.lm.risk_leap_intro') }} {{ $assumption }}. @elseif ($connector !== '') {{ __('stakeholder.lm.risk_generic_intro') }} {{ $connector }}. @endif @if (empty($fragileLink['has_data'])) {{ __('stakeholder.lm.risk_no_evidence') }}{{ __('stakeholder.lm.risk_keep_honest') }} @endif
@endif {{-- ── Also this period — real completed work with NO LM link. Item-grain drift (unalignedColumns names program-grain drift; this is one level finer). Each row is a completion that touched real numbers but maps to nothing in the model — a link-me-to-an-outcome invitation, not a scold. Silent when nothing is unlinked. --}} @php $completedThisPeriod = (array) ($report['milestones']['completed'] ?? []); // Build the set of milestone IDs any LM item links to. $linkedMilestoneIds = []; foreach ($projectLinks as $itemLinks) { foreach ($itemLinks as $link) { if (($link['linked_entity_type'] ?? '') === 'milestone') { $linkedMilestoneIds[(int) $link['linked_entity_id']] = true; } } } // "Something real to show" per the spec — a metric, a count, OR // a completion. A completed milestone IS a completion, so a // headline is sufficient; task stats are optional decoration. // Filter out anything with no headline (a bare row is noise). $alsoThisPeriod = []; foreach ($completedThisPeriod as $ms) { $mid = (int) ($ms->id ?? 0); if ($mid === 0 || isset($linkedMilestoneIds[$mid])) continue; $headline = trim((string) ($ms->headline ?? '')); if ($headline === '') continue; $taskStats = (array) ($ms->taskStats ?? []); $doneCount = (int) ($taskStats['done'] ?? 0); $totalCount = (int) ($taskStats['total'] ?? 0); $metric = $totalCount > 0 ? sprintf(__('stakeholder.lm.also_metric_tasks'), $doneCount, $totalCount) : ''; $alsoThisPeriod[] = [ 'id' => $mid, 'headline' => $headline, 'metric' => $metric, 'projectId' => (int) ($ms->projectId ?? 0), 'canvasId' => (int) ($logicModel['canvasId'] ?? 0), ]; } $alsoCount = count($alsoThisPeriod); $alsoShown = array_slice($alsoThisPeriod, 0, 3); $alsoMore = max(0, $alsoCount - 3); @endphp @if ($alsoCount > 0)
{{ __('stakeholder.lm.also_label') }}
@foreach ($alsoShown as $row) {{-- Plain drift note: a milestone that closed this period but doesn't map to a Logic Model outcome. No CTA — the app doesn't currently have a one-click "link a milestone to an outcome" flow, and a dead link on the honesty page is worse than no link. When the flow lands, wire it here. --}}
{{ $row['headline'] }}@if ($row['metric'] !== '') · {{ $row['metric'] }}@endif
@endforeach @if ($alsoMore > 0)
{{ sprintf(__('stakeholder.lm.also_more'), $alsoMore) }}
@endif
@endif {{-- Off-strategy drift (strategy scope) --}} @if (($scope ?? '') === 'strategy' && count($unaligned) > 0) @php $unalignedNames = array_map( fn ($id) => $columns[$id]['name'] ?? ('#'.$id), array_slice($unaligned, 0, 5) ); $moreDrift = max(0, count($unaligned) - 5); @endphp
{{ __('stakeholder.lm.drift_label') }}: {{ sprintf(__('stakeholder.lm.drift_hint'), count($unaligned)) }} {{ implode(', ', $unalignedNames) }}@if ($moreDrift > 0) +{{ $moreDrift }} @endif
@endif
@endif