{{-- Stakeholder Report — Page 4 (Impact Journey) "Not a tracker — a vision that becomes proof." One artifact, three lens views, tense-driven. Same bars, targets, and people stay on screen; the framing and tense change around them. Vision (Day 1, always available) — targets only, "will" Progress (unlocks with any snapshot) — captured + remaining, "is becoming" Impact (unlocks when targets met) — achieved, "did" Bookends both authored, both present Day 1: STARTED (the world / the problem) → the journey → DIFFERENT (the impact) Human meaning is AUTHORED on the canvas (item.description / item.assumptions / item.conclusion), never generated — §7 rule 12. A concatenated narrative is a fabrication when this ends up in a funder's hands. Per-lens content is rendered server-side and CSS-toggled by the parent wrapper's data-active-lens attribute — so the "same component, words & fill change" reads as one journey maturing, not hard cuts. Vars in: $logicModel null | {narrative, coverageMatrix, projectLinks, linkedGoals, ...} $hasLM bool $scope 'strategy' | 'program' --}} @if (! $hasLM)
{{ __('stakeholder.ij.no_lm_title') }}
{{ __('stakeholder.ij.no_lm_hint') }}
@else @php $stages = $logicModel['coverageMatrix']['stages'] ?? []; $projectLinks = $logicModel['projectLinks'] ?? []; $linkedGoals = $logicModel['linkedGoals'] ?? []; $outputItems = $stages['outputs']['items'] ?? []; $outcomeItems = $stages['outcomes']['items'] ?? []; $impactItems = $stages['impact']['items'] ?? []; // Metric aggregator (unchanged from before; comment retained for context). $metricFor = function ($item) use ($projectLinks, $linkedGoals) { $itemId = (int) (((array) $item)['id'] ?? 0); $links = $projectLinks[$itemId] ?? []; $goals = []; foreach ($links as $link) { if (($link['linked_entity_type'] ?? '') !== 'goal') continue; $gid = (int) ($link['linked_entity_id'] ?? 0); if (isset($linkedGoals[$gid])) $goals[] = $linkedGoals[$gid]; } if (count($goals) === 0) return null; $current = array_sum(array_column($goals, 'currentValue')); $target = array_sum(array_column($goals, 'endValue')); $unit = $goals[0]['metricType'] ?? 'number'; $byDate = []; foreach ($goals as $g) { foreach (($g['snapshots'] ?? []) as $s) { $day = substr((string) $s['date'], 0, 10); if (! isset($byDate[$day])) $byDate[$day] = 0.0; $byDate[$day] += (float) $s['value']; } } ksort($byDate); $snapshots = []; foreach ($byDate as $day => $value) $snapshots[] = ['date' => $day, 'value' => $value]; $arr = (array) $item; // Meaning source per §1 of the authored-meaning spec: // why_this_matters — the primary source (authored, nullable) // No fallback to conclusion — conclusion narrows to "as measured // by" methodology only, going forward. Mining it for meaning is // exactly the double-duty that broke Page 2 earlier. return [ 'id' => $itemId, 'label' => trim((string) ($arr['description'] ?? '')), 'meaning' => trim((string) ($arr['why_this_matters'] ?? '')), 'measuredBy' => trim((string) ($arr['conclusion'] ?? '')), 'current' => $current, 'target' => $target, 'unit' => $unit, 'snapshots' => $snapshots, ]; }; $anySnapshots = false; $collectMetrics = function (array $items) use ($metricFor, &$anySnapshots) { $metrics = []; foreach ($items as $it) { $m = $metricFor($it); if ($m === null) continue; if (count($m['snapshots']) > 0) $anySnapshots = true; $metrics[] = $m; } return $metrics; }; $outMetrics = $collectMetrics($outputItems); $ocMetrics = $collectMetrics($outcomeItems); $anyHit = false; foreach (array_merge($outMetrics, $ocMetrics) as $m) { if ($m['target'] > 0 && $m['current'] >= $m['target']) { $anyHit = true; break; } } $progressUnlocked = $anySnapshots; $impactUnlocked = $anyHit; // Default lens = the most advanced state the data supports. A page // with snapshots defaults to Progress (not Vision) so a returning // reader lands on the current reality — Vision is reachable via // the toggle for the "share the promise" flow. $defaultLens = $impactUnlocked ? 'impact' : ($progressUnlocked ? 'progress' : 'vision'); $fmt = function ($value, string $unit) { $value = (float) $value; if ($unit === 'percent') return rtrim(rtrim(number_format($value, 1, '.', ''), '0'), '.').'%'; if ($value == floor($value)) return number_format($value, 0, '.', ','); return number_format($value, 1, '.', ','); }; // Detects whether the authored label starts with the target number // (e.g. "1,200 screenings completed" for target 1,200). Used to // suppress the redundant "target N" subtitle on Vision — the label // is already the promise. $labelContainsTarget = function (string $label, float $target, string $unit) use ($fmt): bool { if ($target <= 0) return false; $formatted = $fmt($target, $unit); // strip commas for a looser match too $stripped = str_replace(',', '', $formatted); $labelStripped = str_replace(',', '', $label); return stripos($labelStripped, $stripped) !== false; }; // ── STARTED bookend text. // Authored ONLY. Sourced from the Impact item's `starting_picture` // (a dedicated field for the world today, before this work). No // synthesis, no fallback to narrative concatenation — an empty state // is more honest than a fabrication, and this artifact ends up in // funders' hands. $startedText = ''; if (count($impactItems) > 0) { $startedText = trim((string) (((array) $impactItems[0])['starting_picture'] ?? '')); } // ── DIFFERENT bookend: authored impact title + authored meaning. // Meaning source: why_this_matters ONLY. No fallback to conclusion // (which is now "as measured by" methodology). No fallback to // assumptions (that's for the theory-of-change assertion, a // different concept from the funder-facing meaning). $differentTitle = ''; $differentMeaning = ''; if (count($impactItems) > 0) { $impArr = (array) $impactItems[0]; $differentTitle = trim((string) ($impArr['description'] ?? '')); $differentMeaning = trim((string) ($impArr['why_this_matters'] ?? '')); } // ── Arc statement (Vision beat 2). The ONE place a light // concatenation is correct: assembled from authored labels, capped // at 3 producing + 2 achieving, two sentences maximum. Never the // §8 rule-1 canvas dump. Nothing generated — every word is a label // a human wrote on the canvas. $capProducing = array_slice($outMetrics, 0, 3); $capAchieving = array_slice($ocMetrics, 0, 2); $producingLabels = array_values(array_filter(array_map( static fn ($m) => trim((string) $m['label']), $capProducing ))); $achievingLabels = array_values(array_filter(array_map( static fn ($m) => trim((string) $m['label']), $capAchieving ))); $producingSentence = $producingLabels === [] ? '' : implode('. ', $producingLabels).'.'; $achievingSentence = ''; if (count($achievingLabels) === 1) { $achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $achievingLabels[0]).'.'; } elseif (count($achievingLabels) >= 2) { // Natural join: "X and Y" for two, "X, Y, and Z" for three (only // if the cap is ever raised). if (count($achievingLabels) === 2) { $joined = $achievingLabels[0].' '.__('stakeholder.ij.beat_arc_and').' '.$achievingLabels[1]; } else { $joined = implode(', ', array_slice($achievingLabels, 0, -1)) .', '.__('stakeholder.ij.beat_arc_and').' '.end($achievingLabels); } $achievingSentence = sprintf(__('stakeholder.ij.beat_arc_leading_to'), $joined).'.'; } $arcStatement = trim($producingSentence.' '.$achievingSentence); // Only the "Logic Model canvas" phrase links out — the surrounding nudge // sentence stays plain text. Built once, injected via sprintf %s below. $lmCanvasLink = ''.e(__('stakeholder.ij.nudge_link')).''; @endphp
{{-- Header --}}
{{ __('stakeholder.ij.header_title') }}
{{ __('stakeholder.ij.header_sub') }}
{{-- Lens toggle — shows only when >=2 lenses exist for the reader --}} @if ($progressUnlocked || $impactUnlocked)
@endif {{-- ── VISION lens: three beats. Complete on day one, by design. Beat 1 (world today) and beat 3 (world delivered) carry the same authored text as the STARTED/DIFFERENT bookends below — the block-level lens toggle means only one shows at a time, so there is no duplication for the reader. --}}
{{ __('stakeholder.ij.beat_today_lb') }}
@if ($startedText !== '')
{{ $startedText }}
@else
{{ __('stakeholder.ij.bookend_started_empty') }} {!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}
@endif
{{ __('stakeholder.ij.beat_arc_lb') }}
@if ($arcStatement !== '')
{{ $arcStatement }}
@else
{{ __('stakeholder.ij.beat_arc_empty') }} {!! sprintf(e(__('stakeholder.ij.beat_arc_empty_nudge')), $lmCanvasLink) !!}
@endif
{{ __('stakeholder.ij.beat_delivered_lb') }}
@if ($differentTitle !== '')
{{ $differentTitle }}
@if ($differentMeaning !== '')
{{ $differentMeaning }}
@endif @else
{{ __('stakeholder.ij.bookend_different_empty') }} {!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}
@endif
{{-- ── PROGRESS + IMPACT: the same bookends flanking the arc with live tracks. Hidden entirely on the Vision lens by the block-level toggle above. --}}
{{-- STARTED bookend — authored problem text or honest empty state. NO narrative dump. NO generated summary. --}}
{{ __('stakeholder.ij.bookend_started') }}
@if ($startedText !== '')
{{ $startedText }}
@else
{{ __('stakeholder.ij.bookend_started_empty') }} {!! sprintf(e(__('stakeholder.ij.bookend_started_nudge')), $lmCanvasLink) !!}
@endif
{{-- The Arc --}} @if (count($outMetrics) > 0 || count($ocMetrics) > 0)
{{ __('stakeholder.ij.arc_label') }}
@foreach ([ ['key'=>'outputs', 'items'=>$outMetrics, 'title'=>__('stakeholder.ij.arc_producing'), 'icon'=>'fa-boxes-stacked'], ['key'=>'outcomes', 'items'=>$ocMetrics, 'title'=>__('stakeholder.ij.arc_achieving'), 'icon'=>'fa-chart-line'], ] as $group) @if (count($group['items']) === 0) @continue @endif
{{ $group['title'] }}
@foreach ($group['items'] as $m) @php $n = count($m['snapshots']); $growthState = $n <= 1 ? 'baseline' : ($n === 2 ? 'before_after' : 'full_arc'); $hitTarget = $m['target'] > 0 && $m['current'] >= $m['target']; $peak = max($m['target'], $m['current'], 1); foreach ($m['snapshots'] as $s) $peak = max($peak, (float) $s['value']); $barH = fn ($v) => max(4, min(50, (int) round(($v / $peak) * 50))); $labelHasTarget = $labelContainsTarget($m['label'], $m['target'], $m['unit']); @endphp
@if ($m['meaning'] !== '') {{-- Meaning leads. The metric is evidence. "as measured by {label}" reads as the receipt underneath — same rule as Page 2's verdict + read pattern, one level down. --}} {{ $m['meaning'] }} {{ __('stakeholder.ij.as_measured_by') }} {{ $m['label'] }} @if ($m['measuredBy'] !== '') — {{ $m['measuredBy'] }} @endif @else {{-- No authored meaning yet: today's behavior — the authored label leads, optional methodology below. No regression. --}} {{ $m['label'] }} @if (! $labelHasTarget && $m['target'] > 0) {{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }} @endif @if ($m['measuredBy'] !== '') {{ __('stakeholder.ij.as_measured_by') }} {{ $m['measuredBy'] }} @endif @endif
{{-- ONE persistent bar per metric. Track = the target (always visible, same shape all lenses). Fill = current progress, animates on lens change via CSS transition. Vision → fill 0%. Progress → fill current/target. Impact → fill 100%. The morph IS the story. --}} @php // Cap the visual fill % — over-target still reads as full, // "we did this" carries the over-delivery in the value label. $progressPct = $m['target'] > 0 ? min(100, ($m['current'] / $m['target']) * 100) : 0; // Fill values per lens — the JS uses these dataset attrs // to swap width + label text on lens change. $visionFill = 0; $progressFill = $progressPct; $impactFill = 100; $visionLabel = __('stakeholder.ij.fill_lbl_vision'); $progressLabel = sprintf(__('stakeholder.ij.fill_lbl_progress'), $fmt($m['current'], $m['unit'])); $impactLabel = sprintf(__('stakeholder.ij.fill_lbl_impact'), $fmt(max($m['current'], $m['target']), $m['unit'])); @endphp
{{ $defaultLens === 'vision' ? $visionLabel : ($defaultLens === 'impact' ? $impactLabel : $progressLabel) }} {{ __('stakeholder.ij.target_lbl') }} {{ $fmt($m['target'], $m['unit']) }}
{{-- ─── VERDICTS — tense-aware, encouragement via structure ── --}} {{-- Vision: future tense. --}} {{ __('stakeholder.ij.v_will_be_measured') }} {{-- Progress: present-becoming tense. One framing for both units — "N% of the way" reads truthfully for counts and percents alike, and stays consistent down the column regardless of metric type. --}} {{-- One progress color for every in-progress row: the text is uniformly "N% of the way", and the percentage itself conveys how far along. A silent amber↔blue flip at a hidden 75% threshold made identically-worded rows look different for no visible reason, so the dot stays consistent down the column. --}} @if ($n === 0) {{ __('stakeholder.ij.v_no_snapshots_yet') }} @elseif ($hitTarget) {{ __('stakeholder.ij.v_hit_target_progress') }} @elseif ($m['target'] > 0) {{ sprintf(__('stakeholder.ij.v_pct_of_way'), (int) round(($m['current'] / $m['target']) * 100)) }} @else {{ __('stakeholder.ij.v_trending') }} @endif {{-- Impact: past tense. Only truthful for hit-target rows. --}} @if ($hitTarget) {{ __('stakeholder.ij.v_we_did_this') }} @else {{ __('stakeholder.ij.v_not_yet_impact') }} @endif
@endforeach
@endforeach {{-- Structural encouragement — only when the claim can be reconstructed from what's on screen (§7 rule 8 / v6 Fix 2). Progress-lens "one more completes the arc" needed a period cadence the page doesn't render yet; dropped until we do. --}} @if (! $progressUnlocked)
{{ __('stakeholder.ij.chip_first_period') }}
@endif
@else
{{ __('stakeholder.ij.no_metrics_title') }}
{{ __('stakeholder.ij.no_metrics_hint') }}
@endif {{-- DIFFERENT bookend — authored impact + supporting meaning. Meaning is the emotional payload: WHY this matters to the people it affects. Never generated. --}}
{{ __('stakeholder.ij.bookend_different') }}
@if ($differentTitle !== '')
{{ $differentTitle }}
@if ($differentMeaning !== '')
{{ $differentMeaning }}
@endif {{-- Tense hints — one per lens, framing shifts with capture state. --}} {{ __('stakeholder.ij.different_tense_vision') }} {{ __('stakeholder.ij.different_tense_progress') }} {{ __('stakeholder.ij.different_tense_impact') }} @else
{{ __('stakeholder.ij.bookend_different_empty') }} {!! sprintf(e(__('stakeholder.ij.bookend_different_nudge')), $lmCanvasLink) !!}
@endif
{{-- /[data-lens-block=tracks] --}}
@endif