Skip to content

feat(reports): stakeholder report — Phase 1 shell (deck, header, navigation)#3648

Merged
marcelfolaron merged 34 commits into
feature/report-screensfrom
feat/stakeholder-report
Jul 19, 2026
Merged

feat(reports): stakeholder report — Phase 1 shell (deck, header, navigation)#3648
marcelfolaron merged 34 commits into
feature/report-screensfrom
feat/stakeholder-report

Conversation

@gloriafolaron

Copy link
Copy Markdown
Contributor

Phase 1 of the stakeholder report — the board-facing view of the Logic Model, at strategy and program scope only (project keeps existing /reports/project, per §6.2).

Design source: Docs/STAKEHOLDER-REPORT-REQUIREMENTS.md
Mockup: Docs/board-report-swipe.html
Companion plugin PR: Leantime/plugins@feat/stakeholder-report

What's in

app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php — the plugin-neutral deck shell owning:

  • Persistent report header (subject, period, updated, computed status verdict with visible provenance — §3, never a bare verdict)
  • Global controls (period picker, print, back-to-Board)
  • Tab bar (Overview / Logic Model / Resources & Coverage / Programs) with swipe + arrow-key + arrow-button navigation
  • Panel sizes to the active page (no dead space on short pages)
  • Layout discipline (§2): every grid track minmax(0,1fr), every child min-width:0 — fits 12–13" laptops with zero horizontal scroll
  • Print stylesheet (§7): expands the 4 pages into one flowing snapshot; screen-only affordances hidden

30 stakeholder.* language keys in app/Language/en-US.ini for shell copy.

What's real vs placeholder

  • Page 1 · Overview — KPI band wired to ReportEngine::buildReport() stats + deltas (via the nested $report key — the §8 shape verified against origin/feature/report-screens). Placeholder card below marks where "peak this period" hero, needs-attention block, ToC narrative, and theory-health strip land next.
  • Page 2 · Logic Model — placeholder. Renders honest "No Logic Model canvas authored yet" empty state when logicModel === null.
  • Page 3 · Resources & Coverage — placeholder. Blocked on §10.d decision + ResourcesGateway wiring + buildCoverageMatrix upgrade landing together.
  • Page 4 · Programs & Narrative — placeholder. Renders "No programs" empty state at strategy scope when none exist.

§8 shape adherence

Templates read via the exact shape verified in requirements §8. Key access at $strategyReport['report']['stats'] (nested), logicModel guarded as nullable everywhere. Program-scope stubs logicModel=null for Phase 1 — LM fetch at program level is Phase 2.

What's NOT in

  • Real Page 2 / 3 / 4 content (phased for reviewability — one PR per page)
  • Owner override write-path for status verdict (Phase later)
  • The Report view toggle on the Logic Model canvas page (small addition once shell is validated)
  • Delete of Marcel's old partials/reportBody.blade.php files — left intact so the old surface is still available while the deck is validated

Test plan

  • Load /strategyPro/report on a strategy — deck renders, tabs switch on click, keyboard arrows work, swipe works on touch, header + KPI band show real stats
  • Load /pgmPro/report on a program — same behavior
  • Print preview shows all 4 pages expanded, screen-only affordances hidden
  • Viewport 1280px: no horizontal scroll (§2)
  • Verdict shows real provenance (from metrics · X/Y goals on track or no goal data yet)
  • Page 2 with no LM canvas: shows the empty-state invite

🤖 Generated with Claude Code

marcelfolaron and others added 11 commits July 13, 2026 13:35
Board-packet style reporting built for cross-level rollups (#reporting):

- New ReportEngine service/repository in Reports: session-free, bulk,
  project-id-set based queries powering project/plan/strategy reports
  (milestone buckets w/ history-derived completion dates, goal rollups,
  status updates, needs-attention, due-date slippage, effort, deltas)
- ReportPeriod value object: quarter presets in user TZ, custom ranges,
  prior-period math, shareable query strings
- New /reports/project status report screen (HTMX period swap,
  print-optimized); legacy /reports/show stays as Delivery Metrics tab
- zp_tickets.outcomeImpact: retrospective milestone outcome narrative,
  captured inline on the report and editable in the milestone dialog
- zp_goal_history + write hooks + daily snapshot: goal values become
  chartable over time (previously overwritten in place)
- Shared report partials (reports::partials.*) + <x-global::periodpicker>
  consumed by the PgmPro/StrategyPro report screens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design pass on the report family (feedback: milestones were buried in
table cells and the boxedHighlight tiles / dense tables were hard to parse):

- New reports.css component styles: quiet card-based stat tiles (muted
  label, semibold value, directional deltas), disciplined tables (muted
  headers, tabular right-aligned numbers, row separators), card-based
  milestone rows, calmer needs-attention treatment (left accent border
  instead of a red-tinted box)
- Plan report is milestone-first: completed (with outcomes + task
  drill-down), in-flight, and coming-up milestone sections across all
  child projects lead the page; the project matrix shrinks to a compact
  per-project summary (status, progress, next milestone, goals, hours)
- Strategy report gains a 'Milestones & outcomes this period' list
  (outcome statements, no tasks)
- Numbers trimmed via Number::format (18 not 18.00); goal metrics read
  '18 of 40 graduates'; empty linked-milestone column drops out
- Stat tile deltas are semantic: direction x whether up is good

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ects, shared blade primitives

Introduces app/Core/Resources — a small contract layer that lets a
plugin provide Resources data (people allocations, budget lines,
dependencies at the program level) to the rest of the app without core
knowing which plugin is providing.

Follows the WorkStructure precedent verbatim: core owns the contract
+ registry + typed value objects; the plugin (PgmPro, in the companion
plugin PR) owns the concrete implementation, storage, and UI.

What's in this PR
─────────────────

app/Core/Resources/
  Contracts/ResourcesGateway
    Interface with two methods (getForProjects, getForProgram) that
    every consumer of Resources data goes through. Returns typed
    ResourceSummary objects, not raw arrays.
  Models/ResourceSummary + PersonAllocation + BudgetLine + Dependency
    Immutable value objects. ResourceSummary::empty() distinguishes
    "plugin registered but nothing authored" from "no plugin
    registered" (which is null from the registry).
  Services/ResourcesRegistry
    Singleton. First plugin to register wins; a second registration
    from a different class is logged and refused — silently
    overwriting would produce a data-source ambiguity nobody notices
    until reports disagree.
  ResourcesServiceProvider
    Binds the registry as a singleton. Nothing seeded — an install
    without a Resources plugin gets a registry that resolves to null,
    which every consumer handles as the honest "not installed" state.

app/Views/Templates/components/ (4 shared Blade primitives)
  avatar-stack   — overlapping avatar row
  collapsible    — animated collapsing section
  metric-cell    — label + value + optional trend
  proportion-bar — segmented capacity/budget bar
  These were phase-0 resource-adjacent but are genuinely reusable
  (the reporting design will consume them for tables/tiles too).
  Tailwind v4 tw: prefix converted to master's v3 tw-.

Wired into app/Core/Configuration/laravelConfig.php next to the
WorkStructure provider (same layer of the stack).

Unit tests
──────────
tests/Unit/app/Core/Resources/
  ResourcesRegistryTest    — null-when-empty, first-wins, idempotent
                             re-register (4 tests)
  ResourceSummaryTest      — utilization edge cases (zero-divide,
                             over-allocation), isEmpty semantics
                             (7 tests)
Full unit suite: 681 tests, 1700 assertions, all passing. PHPStan
clean. Pint clean.

Companion PR (plugin repo) implements the ResourcesGateway in PgmPro
and includes the Resource Allocation tab UI, ties into StrategyPro
via a WorkStructure lm_inputs → people|budget|dependency mapping,
and prepares the reporting seam for the ReportEngine integration
that lands after core PR #3643.

Architectural rationale
───────────────────────
Why not put the whole thing in the plugin
  Resources are a concept the ReportEngine (core) needs to know about
  to render a Resources section. Baking a hardcoded reference to
  PgmPro into core would be wrong. The gateway/registry pattern
  lets core define the concept and any plugin implement it, matching
  how WorkStructure does the same for structures and how
  ContentTemplates does the same for template libraries.

Why not events/filters instead of a formal contract
  Contract + typed value objects give consumers (the ReportEngine,
  future UI callers) a real IDE-navigable, PHPStan-checkable surface.
  Filters would work but push the shape into runtime string keys
  that break silently when the plugin changes.

Why "first plugin wins" instead of a chain
  There is exactly one Resources provider per install in v1
  (PgmPro). If a second plugin ever wants to provide, that's a real
  product decision (which numbers do reports believe?) that
  shouldn't be silently resolved by registration order.

Reporting integration
─────────────────────
Not in this PR. The seam is a one-liner in
ReportEngine::buildReport() (from feature/report-screens, PR #3643):

    $resources = $this->resourcesRegistry->resolve()?->getForProjects($projectIds);
    return [..., 'resources' => $resources, ...];

Lands as a follow-up commit on that branch (or as a separate PR after
it merges) so this PR stays reviewable independently of the reporting
work.

Doc followup outside this repo
──────────────────────────────
The stakeholder reporting requirements doc §4 Data-origin map should
be updated to describe the Resources source as layered:
  - primary: structured via ResourcesGateway (this PR + companion)
  - fallback: narrative from Logic Model Inputs (existing behavior)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Corresponds to plugins@8af22f23 (feat: layer V2 plan-vs-actual overlay
onto Resource Allocation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ResourcesRegistry docblock: honest "same-class replaces / cross-class refused"
  wording, matching the code (was "no-op" which contradicted the test).
- BudgetLine::$spent non-nullable — providers commit to a value (0.0 when
  no actuals tracked). Removes the "unknown vs zero" ambiguity that leaked
  into totalSpent + budgetUtilization().
- Delete unused speculative primitives: avatar-stack, collapsible,
  metric-cell, proportion-bar. None had consumers; collapsible also
  depended on Alpine (not loaded in this codebase).
- ResourcesRegistryTest: assert Log::warning() fires on cross-class
  refusal (contract, not a silent behavior).
- ResourceSummaryTest: exercise the previously-dead makeDependency() via
  a "deps-only isEmpty" case; drop the now-non-null spent hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ser V2

Corresponds to plugins@3e67a12e — honest empty/stub states across all
three sections and the userId === 0 no-actuals V2 treatment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Corresponds to plugins@4b556372.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Corresponds to plugins@d45c0345 — the "backward" seeding direction
(people + budget from child projects) landing across service, HxController,
UI, JSON-RPC, and MCP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ResourcesGateway docblock: drop the class-name reference to a
  ReportEngine that doesn't exist on master. Rewords the consumer
  example generically until the report engine lands.
- Rename test test_reregistering_same_provider_class_is_idempotent
  → test_reregistering_same_provider_class_replaces_instance. The
  behavior is deliberately last-write-wins (state changes), not
  idempotent in the strict sense. The old name misdescribed what was
  under test.
- Test file header rewritten to match: same-class REPLACES; different
  class refused.

PR description separately updated (out-of-band) to match the final
BudgetLine.spent non-null contract — the "unknown actuals" ambiguity
is deliberately not encoded at model level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gation)

The board-facing stakeholder report: the Logic Model canvas read out as a
4-page swipeable deck. Strategy and program scopes only (per §6.2 —
project keeps existing /reports/project).

New:
- app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php
  The plugin-neutral deck shell. Persistent header with computed status
  verdict + provenance line (§3), global controls, tab bar with 4 tabs
  (Overview / Logic Model / Resources & Coverage / Programs), horizontal
  swipe + arrow-key + arrow-button navigation. Sizes the panel to the
  active page (no dead space on short pages). Layout discipline: every
  grid track minmax(0,1fr), every child min-width:0, so the deck fits
  12–13" laptops with no horizontal scroll (§2).
- Print stylesheet (§7): expands the 4 pages into one flowing snapshot,
  hides tab bar / arrows / period picker / view toggle.

Phase 1 renders page 1 (Overview) with the real KPI band bound to
ReportEngine::buildReport() stats + deltas from the nested $report key
(§8 shape verified against origin/feature/report-screens). Pages 2–4
render placeholder cards for phased implementation:
  - p2 Logic Model — requires LM canvas fetch + card standard + connection
    badges from zp_logicmodel_health (via plugin service; core never reads
    the plugin table directly per §6.7)
  - p3 Resources & Coverage — blocked on §10.d resolution + ResourcesGateway
    wiring + buildCoverageMatrix upgrade
  - p4 Programs & Narrative — program rows + status narrative

Also: 30 stakeholder.* language keys under app/Language/en-US.ini for the
shell copy.

Companion plugin PR: [feat/stakeholder-report on plugins repo] with the
two template shims that include this partial.

Design source: Docs/STAKEHOLDER-REPORT-REQUIREMENTS.md
Mockup:        Docs/board-report-swipe.html

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gloriafolaron
gloriafolaron requested a review from a team as a code owner July 14, 2026 15:34
@gloriafolaron
gloriafolaron requested review from broskees and removed request for a team July 14, 2026 15:34
gloriafolaron and others added 4 commits July 14, 2026 11:55
Two changes to the deck shell, matching the mockup (Docs/board-report-swipe.html):

1. Header sub-line is now semantic — "Strategy report · Q2 2026 · last
   closed period · updated Jul 14" — reading WHY this period is being
   shown, not the raw date range (which repeats in the picker below).

2. Period picker collapsed from Marcel's 4-pill row into ONE button
   showing the current period + range, click opens a dropdown:
     Last quarter    (default — how did we do?)
     This quarter    (how are we doing now?)
     Next quarter    (what's coming?)
     ─────────
     Custom          [From] – [To] [Apply]

   The dropdown is defensively styled against Bootstrap's global
   anchor/input styles (explicit font-size, height, padding on every
   text/input element) so the app CSS doesn't inflate it.
   Outside-click dismisses; jQuery UI datepicker attaches to the two
   inputs when available (same helper Marcel's periodpicker uses).

Also adds paired plugin pointer bump: both StrategyPro and PgmPro Report
controllers now default to ReportPeriod::lastQuarter() when no explicit
period params, so the picker button opens on the honest default state.

Language: 7 new stakeholder.period.* keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eory health

Split the deck's four pages into partials for reviewability. Page 1
(Overview) is now real; pages 2/3/4 land in follow-up phases.

Overview (page-overview.blade.php):
- KPI band: value-first with delta as a colored pill (up = green,
  down = red), the "/6" subscript letter-spaced so the digit doesn't
  smoosh against the slash, all three rows center-aligned.
- Peak-this-period hero: ranks completed-this-period milestones by
  outcomeImpact presence (structural closure > any recent close); shows
  the top pick with owner-override link ("Recommended · change"). If no
  outcomeImpact, quiet "no outcome narrative yet" note.
- Needs Attention: names the actual at-risk goals, overdue milestones,
  and silent projects (was just counts). Grouped by kind, capped at 3
  per group with a "+ N more" fallback.
- Theory of Change narrative: colored per-stage spans stitched from the
  LM canvas. Small ⓘ hover popover exposes the color code (5 stages +
  one-line descriptions).
- Theory-health strip: warns on fragile connections from
  zp_logicmodel_health; calm "all ok" state when nothing flagged.

Page-lm / page-resources / page-programs: stubbed with real data
plumbing where the shape supports it (5-stage board reads items from
$logicModel.coverageMatrix.stages using SHORT stage keys per
Logicmodelcanvas::STAGES + BOX_TO_STAGE; coverage matrix renders
per-stage linkage tallies with unaligned-columns callout for
off-strategy work; program rows + status narrative from programRows
+ programUpdates).

Also:
- Header sub-line no longer duplicates the date range (picker button
  shows it). Reads: "Strategy report · last closed period · updated
  Jul 14, 2026".
- Compact period picker: one button ("Last quarter · Apr 1 – Jun 30,
  2026") that opens a dropdown with the 3 quarter presets + a custom-
  range mini-form. Preset name, not "Q2 2026", because Leantime doesn't
  let companies define fiscal quarters.
- Smart default: /strategyPro/report and /pgmPro/report default to
  ReportPeriod::lastQuarter() when no explicit params — board reports
  care about "how did we do?" more than "how are we doing?".

Language: ~60 new stakeholder.* keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Peak-this-period hero: real recommendation from completed milestones
  with outcomeImpact preferred.
- Needs Attention: names the actual at-risk goals / overdue milestones /
  silent projects (was counts).
- KPI band: value-first with delta pill (up = green, down = red), /X
  subscript letter-spaced. Hover any cell for a drill-down of the items
  behind the count.
- Theory of Change: colored per-stage narrative + info-icon color legend
  popover. Collapsible via chevron; default state is collapsed, expand
  state persists in localStorage.
- Theory Health: rebuilt as a chain of 5 stage chips + 4 colored
  connectors between. Each connector carries a status badge (24px white
  circle, 14px colored icon: fa-circle-check / fa-circle-exclamation /
  fa-triangle-exclamation / fa-circle-question, colored to match the
  connector line). Summary reads positive-first ("3 solid · 1 needs work").
- Fragile-link callout: severity-sorted so critical leads over warning,
  crit gets red tint (warning stays amber). "Assumption unproven" pill
  sits inline as a soft-tinted chip with hover explainer. "Also fragile"
  chip row lists secondary fragile links below.
- Info-icon component generalized (was ToC-only, reused for Theory Health).
- Fixed a double-escape bug: replaced $tpl->escape (htmlentities → turns
  unicode into &rarr; etc.) with plain Blade {{ }} escaping across all
  stakeholder partials. Now unicode names / arrows / accents render clean.

Header + picker:
- Merged the global picker bar into the tab row (saved a row of vertical
  space). Picker now sits with the tab arrows on the right.
- Removed date range duplication from the sub-line (picker button already
  shows it). Sub-line now reads "Strategy report · last closed period ·
  updated Jul 14".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New actionsMenu partial: shared three-dot menu for both plugin templates,
  scoped-styled to override the app's global .pagetitle dropdown CSS (which
  resolves --main-titles-color to white inside the pageheader). Uses the
  Leantime canvas headerEditDropdown pattern for right-anchored positioning.

- KPI drill: click-to-open (was hover-to-open — hover was retaining and
  reading as "stuck"). Single-open behavior; click outside dismisses. Hover
  now just visually focuses the card. Discoverability: "SEE LIST ↓" chip
  visible in the top-right, chevron rotates when open.

- Goals-on-track drill: lists ON-TRACK goals (the count's referent), not
  at-risk ones (those live in Needs Attention).

- Hours logged drill: per-project breakdown, largest first.

- KPI denominators: Completed shows N/M (of total milestones), Overdue
  shows N/M (of open milestones). Matches the /X pattern Goals already used.

- Milestones ambiguity fix: label was "completed this period" (unclear
  what). Now "milestones completed this period" + drill header says
  "Milestones completed" — explicit.

- Plugin pointer bump: paired StrategyPro/PgmPro Controllers +
  Hxcontrollers + Templates commit for the verdict override write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Maintainer review (advisory — merge authority stays with @marcelfolaron / @broskees) · diff read in full at head 6cc2852

1. Intent: Phase-1 shell of the stakeholder report deck — a plugin-neutral 4-page Blade deck (deck.blade.php + page-overview/page-lm/page-resources/page-programs partials + actionsMenu + 212 stakeholder.* language keys), reused by StrategyPro and PgmPro. Page 1 (Overview) is wired to ReportEngine::buildReport(); pages 2–4 render real data where available and honest empty-states otherwise. Companion plugin branch feat/stakeholder-report.

2. Change requests (mostly view-layer + honest placeholders — but one real bug and a coupling issue):

  1. ⛔ Bug — truncation expression is malformed (page-overview.blade.php, peak-hero body). The line reads:
    <p>{{ mb_strlen($peakBody > 260 ? mb_substr($peakBody, 0, 257).'' : $peakBody) }}</p>
    Two problems: (a) $peakBody > 260 compares a string to an int (always evaluates the string as 0 > 260 → false), so truncation never fires; (b) the whole ternary is wrapped in mb_strlen(...), so the <p> renders the character count (an integer) instead of the text. This should almost certainly be {{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody, 0, 257).'…' : $peakBody }}. As written the hero body shows a number. Please fix + add the manual check to the test plan.
  2. ⛔ Submodule bump present on a core PR (app/Plugins 289d476…bc37f99…). This is the same coupling pattern flagged on feat(core): Resources contract surface — gateway, registry, value objects, shared blade primitives #3645 (which bumps to d45c0345) — and it's a different target commit than feat(core): Resources contract surface — gateway, registry, value objects, shared blade primitives #3645's, so if both merge, the pointer races. A core deck PR shouldn't carry an app/Plugins bump to an unmerged plugin commit; drop it and do the pointer bump as the explicit last step after the plugin side merges. Confirm which of bc37f99 / d45c0345 is the intended final pointer.
  3. page-lm reads a nested shape ($logicModel['coverageMatrix']['stages']) that must match what the engine returns (page-lm.blade.php). The comment notes stages are keyed by short names (inputs/activities/…) not the lm_* box names, and Page 3 repeats the assumption. Since the base branch is feature/report-screens (feat(reports): period-based status report screens + shared report engine #3643, still in review), confirm the buildReport() output shape is frozen — if feat(reports): period-based status report screens + shared report engine #3643's coverageMatrix/stages keys change during its review, three templates here silently render empty. This is the §8-shape-adherence risk the PR body itself calls out.
  4. Heavy inline <style> blocks per partial (deck ~180 lines CSS, page-overview ~200) — fine for a phased shell, but as pages land this duplicates tokens (--rd-* vars are redefined in page-lm/page-resources). Not a blocker; note it for the eventual consolidation into reports.css so the same var set isn't maintained in five places.
  5. (minor) actionsMenu.blade.php posts to /hx/{strategyPro|pgmPro}/report/setVerdict — the verdict-override write path. PR body says owner override is "Phase later," but this menu ships the POST affordance now. Confirm the hx/.../setVerdict HxController exists on the companion branch, else the three-dot menu's Set-Verdict items 404 silently.

3. Acceptance checklist (must pass before merge):

  1. Bug in CR Documentation #1 fixed — peak-hero renders the (truncated) narrative text, not a character count — verified by loading a report with a completed milestone that has an outcomeImpact narrative.
  2. Load /strategyPro/report and /pgmPro/report: deck renders, tabs/keyboard/swipe work, KPI band shows real ReportEngine stats, print preview expands all 4 pages, 1280px viewport has no horizontal scroll (§2). Empty-states render for no-LM / no-programs.
  3. Depends on feat(reports): period-based status report screens + shared report engine #3643 (base branch) — merge order: feat(reports): period-based status report screens + shared report engine #3643 (core engine) → this PR, with the buildReport()/coverageMatrix shape confirmed frozen (CR Create Email Notification Class (including user settings to opt out) #3). Submodule bump removed (CR Gitignore #2). This is a view-layer PR (no schema/API change); the test gate is the manual render matrix in checklist Gitignore #2, since Blade templates have no unit coverage here.

4. CI status: ⚠️ Can't read check-run status this cycle — confirm Pint + PHPStan level 5 + full unit suite green on 6cc2852 in the UI; treat any red/skipped job as blocking. PHPStan level 5 should catch the CR #1 type issue (string > int) — if it's currently green, verify the analyser actually covers .blade.php compiled output, because that expression is a real defect. No automated test accompanies the view logic (expected for a template shell) — the manual render matrix is the gate.

Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees.

gloriafolaron and others added 9 commits July 14, 2026 15:23
…feat/stakeholder-report

# Conflicts:
#	app/Plugins
…lastModified

Adds four optional fields to the Dependency value object so a stakeholder
report can surface WHO is chasing each external commitment, WHEN a
decision is expected, WHY it matters, and how fresh the information is.

Fields are nullable and default to null — older resource canvas items
without these fields hydrate cleanly and render without the annotations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ysis

Per-project analyzer that joins three independent estimates of the work
ahead with the capacity a ResourceSummary already exposes:

  1. Budgeted hours  — sum(planHours) on open tickets. Explicit but often blank.
  2. Effort hours    — sum(storypoints) * hoursPerPoint on open tickets.
                       Always populated when sizing is used; a t-shirt-size proxy.
  3. Available hours — sum(person allocation to this project) * weeks in the
                       report period.

Emits per-project rows carrying scope, capacity, gap, verdict (critical /
tight / balanced / buffer), a trust signal (budgeted / effort / mixed /
none) that flags when budgeted and effort disagree materially, and three
rebalance recommendations with their math — extend timeline, add people,
or cut scope in points. aggregateByProgram rolls the per-project rows up
to the program level for the strategy report, recomputing every derived
field against the aggregate so a program reads as its own state rather
than the worst child's.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ollup, hours/days toggle

Rewrites the stakeholder report's Resources page (Page 3) into a
resource-centric story:

- Aggregate cards (People / Budget / Dependencies) — the 30-second read
  from ResourceSummary totals.
- Per-program breakdown table with inline expand to reveal the child
  project list. At program scope, the same table renders per-project.
- Capacity vs. Demand cards, one per program (strategy scope) or per
  project (program scope). Each card reads a computed verdict and shows
  scope two ways with a divergence flag, capacity with its formula,
  balance visualization, and rebalance recommendations with math.
- Dedicated Dependencies section rendering each external commitment
  with owner, decision date, notes, and last-updated timestamp. The
  soonest tentative decision is surfaced as an urgency banner.
- Hours / Days display toggle at the top of the page, persisted via
  localStorage. Server always renders hours; JS swaps data-hours
  attributes to a day equivalent when toggled.

Extracts capacity-card.blade.php as a reusable partial so program and
project cards share the same render.

Also drops the tentative-dependencies chip from the residual gaps
section since it duplicates the Dependencies section names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ependency hydration

Pulls in two plugin commits:
  - PgmPro Dependency hydration for owner/dueDate/notes/lastModified
  - PgmPro + StrategyPro report controllers wired to CapacityAnalyzer
    and program rollup vars

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…etation pattern

Full rewrite against the design plan mockups. Board audience: understanding-first,
never editing. Structure:

  1. Belief — one grey sentence, "If we deliver on {activities}, we'll produce
     measurable reach — and that reach will {impact}." Capped ~220 chars.
  2. Producing (Outputs) — evidence, loud: verdict + templated read + rows +
     Show-the-breakdown.
  3. Achieving (Outcomes) — same treatment.
  4. What it's for (Impact) — purpose, honestly long-horizon.
  5. Where it's at risk — one weakest health badge, single sentence.

Interpretation pattern — the through-line:
  - Verdict badge derived from the READ (not a second aggregation).
  - Templated read branches on contributor count (1 / 2 / 3+); never
    freeform. Materiality inverts correctly — 5% share = "low concern",
    77% = "the driver, not a footnote".
  - Exception line cites the specific item it's off on ("behind on 300
    referrals to primary care") — the bridge that reconciles read + rows.
  - Never two lines about the same entity.
  - Single-contributor sentence scoped to its stage ("on the outcomes here")
    so a program appearing in both cards reads as scope, not contradiction.
  - Never names a non-entity — unresolved-project goals are filtered from
    contributors and surfaced as a muted data-quality note.

Data safety rules (learned expensively):
  - Never splice a computed value into an authored label. Description text
    is verbatim — no prefix, no regex.
  - Goal record is the sole source of current/target when a goal is linked.
  - Unit is a known field on the goal; inferred once per item from the
    label (percent/currency/number). Unit-mismatched goals are dropped
    from the aggregate and logged, never silently mixed.
  - Guard rails: target <= 0 or ratio > 5x → render no ratio, log warning.
    No percentage over 100 ever reaches a funder.
  - Percent metrics never render "of Y planned" (percent-of-percent reads
    as nonsense).

Row status vocabulary is one map, one casing: on track / in progress / at
risk / behind. Row status class is tied to the goal RAG (same source as
badge) so rows and badge cannot contradict each other on the same card.

Provenance in a tooltip ("Set by {author} · {date}") on every row status
— report gets exported and printed, instructional text has no place.

Chrome: white cards, stage color as 3px left border + eyebrow + icon
accent only. No background washes, no cream. One system across the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… verdict color

Two small deck improvements:
  - Active page persists via localStorage under lt.stakeholderReport.activePage,
    so a refresh (or returning to the report) lands on the last-viewed page
    rather than always Overview. Applies to tab click, arrow keys, and swipe.
  - Verdict dot map adds an "inprogress" entry (#3F72B0) — matches the new
    In progress verdict state the strategy/program report templates emit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment

Pulls in two plugin commits:
  - StrategyPro enrichment of linkedGoals (unit, project/program metadata, provenance)
  - StrategyPro + PgmPro header verdict follows the read's materiality call

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old bar drew a single "demand" fill sized to the reference demand,
with an available marker sitting on top. Visually quieter than the words
around it — the verdict pill said "191h short" but the bar looked
uniformly filled.

New geometry lands the shortfall at the same visual weight as the verdict:
  - Supply segment (0 → available)   in green    — "what you've got"
  - Deficit segment (available → needed) in the verdict color (amber for
    tight, red for critical) — "what you're missing"
  - Marker stays AT the available position with the CAPACITY label above

Deficit segment carries a tippy tooltip ("Shortfall: 191h past available
capacity") on hover.

Closes v1 non-negotiable #9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · re-reviewed at head c4236dda (advanced past 6cc2852) · full diff read

1. Intent: Opened as the "Phase-1 shell (deck, header, navigation)," but the scope has grown substantially since my last pass — it now also carries Page 2 (page-lm.blade.php, 820 lines), Page 3 (page-resources.blade.php, 1154 lines), a new CapacityAnalyzer service (428 lines), the Dependency model extended with 4 nullable fields (owner/dueDate/notes/lastModified), and ~457 lines of stakeholder.* language keys.

2. Change requests:

  1. ⛔ CR Documentation #1 from my last review is STILL unresolved — the peak-hero truncation bug. page-overview.blade.php (peak-hero body) still reads:
    <p>{{ mb_strlen($peakBody > 260 ? mb_substr($peakBody, 0, 257).'' : $peakBody) }}</p>
    Both defects remain: (a) $peakBody > 260 compares a string to an int (evaluates as 0 > 260 → always false, truncation never fires); (b) the whole ternary is wrapped in mb_strlen(...), so the <p> renders a character count, not the narrative. Should be {{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody, 0, 257).'…' : $peakBody }}. Note page-programs.blade.php does this correctly (if (mb_strlen($text) > 220) $text = mb_substr(...)) — same PR, inconsistent.
  2. ⛔ New CapacityAnalyzer (428 lines) ships zero tests. It's the analytical engine behind the Page-3 capacity cards — analyzeProjects (budgeted-vs-effort-vs-available gap math, trustSignal, verdict thresholds, recommend levers) and aggregateByProgram (unique-people roll-up, recomputed verdict). The value-object math (ResourceSummaryTest) is covered; this new board-facing engine is not. Per the test gate, a logic-heavy service feeding board capacity numbers is a blocker without coverage — minimum: analyzeProjects gap/verdict for a known ticket set, and aggregateByProgram unique-people + roll-up totals (a person on two child projects counts once).
  3. CapacityAnalyzer::isDone() treats status <= 0 as done ("treat -1 and 0 as closed by convention"). Leantime status maps are per-project; a project whose status 0 is a live column would have open work misclassified as done (or vice-versa), silently skewing the demand side of every capacity card. Confirm this against the same statusType-resolution path fix(tickets): record status→Done in history + report real last-updated #3650/feat(tickets): getMyClosedTicketsForRange for period close-lists #3647 use rather than a hardcoded <= 0.
  4. Submodule bump present — and now points at a third commit. app/Plugins 289d476…1385801… (= No logo in the email #73 head). My last review saw bc37f99; feat(core): Resources contract surface — gateway, registry, value objects, shared blade primitives #3645 bumps to d45c034. Three PRs, three different targets ⇒ guaranteed pointer race. Drop the bump; do a single pointer bump as the explicit last step after the plugin side merges.
  5. Scope drift vs. the stated plan. The PR body says "one PR per page for reviewability," but this now carries Pages 2–3 in full plus a new analytical service. Consider splitting Page 3 + CapacityAnalyzer into their own PR so the capacity logic/security surface gets a focused review instead of riding in with the shell.
  6. CapacityAnalyzer::DEFAULT_HOURS_PER_POINT = 4.0 is a constant param default; the language string stakeholder.rc.cap.points_help says "Configurable per company." Confirm the per-company config is actually wired, or soften the copy so it doesn't promise a control that isn't there.

3. Acceptance checklist (must pass before merge):

  1. CR Documentation #1 fixed — peak-hero renders the (truncated) narrative text, not a character count — verified against a completed milestone carrying an outcomeImpact narrative.
  2. CapacityAnalyzer tests present (CR Gitignore #2) + isDone status resolution confirmed (CR Create Email Notification Class (including user settings to opt out) #3) — the merge gate for the capacity surface.
  3. Merge order feat(reports): period-based status report screens + shared report engine #3643feat(core): Resources contract surface — gateway, registry, value objects, shared blade primitives #3645Milestone page shows empty after adding a new milestone #72feat(reports): stakeholder report — Phase 1 shell (deck, header, navigation) #3648, submodule bump removed (CR Create Installation Script/Routine #4); manual render matrix at 1280px / print / empty-states (the Blade view layer has no unit coverage, so this is the gate for the templates).

4. CI status: ⚠️ Can't read check-run status this cycle — confirm Pint + PHPStan level 5 + full unit suite green on c4236dda in the UI; treat any red/skipped job as blocking. PHPStan level 5 should catch CR #1's string > int — if the suite is green, verify the analyser actually covers compiled .blade.php output, because that expression is a real defect. Test gate NOT met — the new CapacityAnalyzer is the high-priority coverage gap (CR #2).

Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees.

gloriafolaron and others added 7 commits July 15, 2026 13:19
…aning fields

Page 4 (Impact Journey) — new page replacing the Programs & Narrative
stub. Three lens views (Vision / Progress / Impact), tense-driven, one
persistent bar per metric that morphs on lens change (fill width +
label crossfade, 700ms cubic-bezier easing, 80ms stagger between
metrics, prefers-reduced-motion respected). Progress and Impact lenses
unlock as the journey earns them (any snapshot → Progress; any hit
target → Impact). Bookends both authored + present Day 1: STARTED
(the world today) → the measurable arc → DIFFERENT (the world when
delivered). Growth states per metric: baseline / before-after / full
arc.

Authored-meaning fields on Logic Model canvas items — two nullable
text columns (why_this_matters, starting_picture) via migration
30522 + dbVersion bump. Fields wired through the Canvas editor with
stage-gated visibility (Impact gets both; Outcome/Output get
why_this_matters only). Impact items deliberately have no suggest
control — the friction is the feature. Outputs/Outcomes get a
client-side Suggest button that drafts from the item's own text;
silence when the source is too thin to draft honestly.

Report reads authored text only — no synthesis, no fallback chain
that mines conclusion for meaning (conclusion narrows to "as measured
by" methodology only). Page 4 renders meaning-first when
why_this_matters is authored, falling back to today's label-first
behavior when empty (no regression). Bookend empty states name the
specific canvas field to author.

Blueprints patch allowlist extended with the two new columns.
CanvasItemsApplier passes through why_this_matters / starting_picture
from YAML templates. Deck tab renamed Programs → Impact Journey with
a compass icon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ram notes

The one place on Page 1 where a human says WHY and what they're doing
about it. Everything else on the page is computed; this is the closest
thing to a decision the system holds.

Renders authored zp_comment notes (module='project') scoped to the
period, portfolio-level first (strategy scope) or the program's own
thread (program scope), then per-program newest-first. Cap 4 total,
oldest drop off. Each note: bold entity name — verbatim text — muted
date. Notes are trimmed of stray HTML from rich-text editors but
never truncated mid-sentence and never summarized.

Silent when zero notes exist — no apology, no prompt. Missing status
notes aren't a gap in the artifact; they just mean nobody wrote one.

Placement: full-width strip under the p1-topband (peak hero + needs-
attention), before the ToC narrative. Same reading order as the
existing right-column pattern.

Deck passes the new strategyUpdates + programUpdates + programRows
through to page-overview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rage table

Page 2 gets a small block above the drift note that lists milestones
completed this period that never got linked to an outcome — reads
authored task-stats where present, otherwise renders the completion
itself. Nothing generated; the row's job is to point at unlinked work
so it can be tied back to the model.

Page 3 gets a Coverage table above Capacity that answers the question
Capacity can't reach: does each focus area actually have people or
money behind it? Per Output/Outcome item, walks goal links → owning
projects, then scopes ResourceSummary to those projectIds. Verdicts
(covered/thin/gap) never assert against unauthored resources — the
ambiguity line spells the two meanings out when a gap exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ut coverage

Snapshots (Step 1)
- getGoalValueAt(id, asOf): last snapshot on or before the date.
- getGoalValuesAtSeries(ids, dates): the three-year-arc query.
- goals:backfillHistory CSV command; imported rows are indistinguishable
  from nightly captures.
- Write path, hook, and reports:goalValueSnapshot scheduler untouched.
  The existing sparse writes are correct — the read makes "missing days
  don't matter" transparent.

Coverage table (Step 2) — cut
- ResourcesGateway attributes only at project/program scope, not to
  strategy goals. Walking the tree would badly overstate. With one
  dimension left, the verdict just restated the count under a header
  asking a question the table couldn't answer. Removed.

Vision lens (Step 3)
- Three beats, not a table: authored `starting_picture` → measurable
  arc as one prose statement (authored labels, cap 3 producing + 2
  achieving) → authored `why_this_matters`. No tracks, no bars, no
  "will be measured".
- On Vision → Progress/Impact, the arc block hides and metric rows
  stagger in (~80ms). prefers-reduced-motion → instant.

Small ones (Step 4)
- Removed the "link to an outcome" CTA — nothing consumed
  ?linkMilestone. A dead link belongs anywhere but the honesty page.
- StrategyPro submodule bumped to clean the community-health seed so
  `conclusion` holds methodology only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The button hinted at a "coming soon" PDF exporter. No exporter exists;
the CTA promised something the app can't deliver. Same class as the
dead link on Page 2 — a placeholder button belongs anywhere but the
funder-facing artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Demand

Previously the strategy report ran per-person over-allocation checks in
Gaps, then reported "Resources look aligned" whenever no individual
exceeded their own capacity — even when Capacity vs Demand above was
flagging programs as tight. Two aggregations, one page, contradicting
reads for the same audience.

At strategy scope the report now:
- drops the per-person over-allocation and idle-capacity checks (wrong
  altitude for a portfolio audience — those belong on the program
  report where a manager can act),
- escalates each tight/critical program from CapacityAnalyzer into a
  matching gap row so the two blocks agree,
- rescopes the empty-state detail to name what's actually being checked
  ("No program tight on capacity …") instead of the broad claim.

Per-person checks still run at program scope, where a manager IS the
right audience for individual hour math.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Security
- Drop misleading @api from the three history helpers on Goalcanvas
  repo. JSON-RPC only wraps services, so @api on a repo is noise —
  and one of the three (insertGoalHistoryRows) is a mass writer with
  no permission check by design; a docblock warns not to expose it
  over any user-reachable surface without a service wrapper.
- CSV backfill now rejects itemIds that aren't actually goals before
  writing. Adds filterGoalItemIds() as the integrity check the history
  table can't enforce via FK. Bad rows report a warning; nothing lands.

Safety
- getGoalValueAt normalizes $asOf to UTC before comparison. DB times
  are always UTC per convention; a caller passing a user-tz DateTime
  no longer silently drifts by hours.
- insertGoalHistoryRows chunks to 500 rows per INSERT (max_allowed_
  packet safety) and wraps the run in a transaction so a mid-import
  failure rolls back cleanly.
- Backfill emits a loud note on success — re-runs duplicate rows, and
  the nightly job is where ongoing captures belong.

Efficiency
- getGoalValuesAtSeries no longer fires (goals × dates) separate
  SELECTs. One query per goal capped at the latest requested date,
  buckets in PHP with a carry-forward walk. A 20-goal × 12-date arc
  goes from 240 round-trips to 20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · re-reviewed at head dfc236b (advanced past c4236dd; merge-base of the delta is c4236dd, so this is a strict fast-forward of new commits) · delta diff read.

1. Intent: Since my c4236dd pass the branch has taken on backend/schema scope beyond the "Phase-1 shell" title — a new goals:backfillHistory console command (CSV → zp_goal_history), a dbVersion bump 3.5.21 → 3.5.22 (new migration surface), two new canvas columns (why_this_matters, starting_picture) wired through the Blueprints repo, and the ResourcesServiceProvider/ResourcesGateway contract layer registered in laravelConfig.php. This is no longer a pure view-layer PR.

2. Change requests:

  1. ⛔ CR Documentation #1 (peak-hero truncation bug) is STILL unresolved — third pass. The new commits touch backend/schema only; page-overview.blade.php was not in this delta, so <p>{{ mb_strlen($peakBody > 260 ? mb_substr($peakBody,0,257).'…' : $peakBody) }}</p> still (a) compares string-to-int (0 > 260 → truncation never fires) and (b) wraps the ternary in mb_strlen(...) so the hero renders a character count, not the narrative. Fix to {{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody,0,257).'…' : $peakBody }} — matching how page-programs.blade.php already does it correctly.
  2. ⛔ CR Gitignore #2 (CapacityAnalyzer, 428 lines, zero tests) is STILL unresolved. Not touched in this delta. Its gap-math / verdict thresholds / aggregateByProgram roll-up remain a behavior change with no unit coverage — blocker per the test-gate rule.
  3. ⛔ NEW — schema migration ships without a visible test or migration note. AppSettings::$dbVersion goes 3.5.21 → 3.5.22 (app/Core/Configuration/AppSettings.php) and Blueprints.php now selects/fills zp_canvas_items.why_this_matters + .starting_picture — implying a 30522 migration adding those columns. The migration file itself is past the diff truncation: confirm the 30522 migration exists, is additive/nullable (backward-compatible), and has a down/rollback path. A dbVersion bump with no migration, or a non-nullable column on an existing table, will break upgrades on populated installs.
  4. ⚠ NEW — goals:backfillHistory command (205 lines) ships zero tests and is non-idempotent by design. BackfillGoalHistoryCommand.php calls two new repo methods (GoalcanvasRepo::filterGoalItemIds, insertGoalHistoryRows); re-running the same CSV duplicates rows (the command warns, but nothing prevents it). Add at least a unit test around filterGoalItemIds (the non-goal-id rejection is the data-integrity guard) and the CSV row-validation path.
  5. ✅ Migration-collision note (resolves prior carry-forward): bumping to 3.5.22 sidesteps the earlier 3.5.21/30521 collision with feat(oidc): mobile SSO bridge — one-time-code → bearer token exchange (generic OIDC) #3637/feat(reports): period-based status report screens + shared report engine #3643. But now confirm ordering — if feat(reports): period-based status report screens + shared report engine #3643 owns 30521 and this owns 30522, they must merge in ascending dbVersion order, and any recommit of feat(oidc): mobile SSO bridge — one-time-code → bearer token exchange (generic OIDC) #3637 must not also claim 30522.

3. Acceptance checklist (must pass before merge):

  1. CR Documentation #1 fixed (peak-hero renders truncated text, not a count) and CR Gitignore #2 addressed (CapacityAnalyzer has unit coverage of its verdict/gap math) — both carried unresolved across three reviews.
  2. 30522 migration confirmed present, additive + nullable, with rollback; upgrade smoke-tested on a populated 3.5.21 DB (backward-compat gate, since this now changes schema).
  3. CI green — Pint + PHPStan level 5 + full unit suite — with a new test covering filterGoalItemIds/CSV validation for the backfill command; treat any red/skipped job as blocking.

4. CI status: ⚠️ Check-runs API returned total_count: 0 for dfc236bno checks are reporting on the current head. Confirm in the UI whether the pipeline simply hasn't run yet or isn't triggering on this branch; a head with zero checks must not merge. PHPStan level 5 should flag the CR #1 string > int expression — if PHPStan is green, verify it actually analyzes compiled Blade output, because that defect is real. The migration (CR #3), the backfill command (CR #4), and CapacityAnalyzer (CR #2) are all behavior/schema changes with no accompanying automated test in the diff — that is the blocking gap.

Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees.

gloriafolaron and others added 2 commits July 15, 2026 15:17
…ests

Companion to plugins#72 review follow-ups. Bumps the submodule pointer
to include the CR#1/#2/#4 fixes and adds coverage the maintainer flagged
as the merge gate.

Behaviors under test:
- getForProjects empty-input paths (no projectIds, no resource canvas).
- Stub filtering — people AND budget stubs excluded from totals AND from
  the summary arrays (matches the tab's teamStats split).
- Multi-canvas aggregation across a strategy's programs.
- seedPeopleFromChildProjects idempotency by userId (skip-when-present
  contract that keeps re-runs safe).
- seedBudgetFromChildProjects idempotency by projectId.

Testable-subclass pattern avoids mocking a full query-builder chain:
the SUT's new protected findResourceCanvasIds() is overridden with a
canned list, repo/service collaborators stubbed with anonymous classes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…merge

Adds coverage for plugins#73 CR#4: a dependency canvas item authored
before owner/dueDate/notes/lastModified existed must hydrate cleanly
with those slots nulled — no undefined-index warning, Page 3 renders
its empty state.

Bumps the plugins submodule to feat/stakeholder-report HEAD, which now
merges plugins#72's fixes into #73's branch — the marcel-specified
merge order (#72#73) applied in one place so the pointer bump on
this core branch reflects the state after both PRs land.

CR#3 (a): a project appearing under two programs isn't double-counted.
Confirmed structurally — zp_projects.parent is a single-value column,
so a project has exactly one program parent by schema. No test written
against a case the DB can't produce.

CR#3 (b): access scoping on the strategy view. The programChildMap is
built from zp_projects.parent joined with the strategy's already-access-
scoped programSummaries — no cross-tenant leakage possible from that
query alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces the first-pass (“Phase 1”) stakeholder report UI shell and supporting domain infrastructure (Resources gateway contract + capacity analysis, goal-history utilities, and new authored-meaning fields on Logic Model canvas items) to enable board-facing reporting surfaces.

Changes:

  • Adds the stakeholder report deck (header + period picker + tabbed/swipe navigation) and new Page 2 (Logic Model) + Page 4 (Impact Journey) templates, plus extensive new i18n copy.
  • Introduces a core ResourcesGateway contract + registry and a new CapacityAnalyzer for resource/capacity-driven reporting sections.
  • Extends goal history querying and adds a CSV backfill command; adds new Logic Model authored-meaning fields (why_this_matters, starting_picture) across persistence and template application.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/Unit/app/Plugins/PgmPro/Domain/Resources/Services/ResourceStructureServiceTest.php Adds unit coverage for PgmPro resource aggregation + idempotent seeders.
tests/Unit/app/Core/Resources/Services/ResourcesRegistryTest.php Validates single-provider registry behavior (first-wins across classes).
tests/Unit/app/Core/Resources/Models/ResourceSummaryTest.php Adds arithmetic/edge-case tests for ResourceSummary utilization + emptiness.
app/Language/en-US.ini Adds Logic Model authored-meaning labels and extensive stakeholder report copy.
app/Domain/Tickets/Services/Tickets.php Adds range-based closed-ticket query + “commented/supported” tickets query.
app/Domain/Tickets/Repositories/Tickets.php Adds getTicketIdsCommentedByUser() helper for ticket-comment lookups.
app/Domain/Reports/Templates/partials/stakeholder/page-programs.blade.php Adds (currently unused) Programs & Narrative page partial.
app/Domain/Reports/Templates/partials/stakeholder/page-lm.blade.php Adds large Logic Model read-out page with rollups, reads, and drift sections.
app/Domain/Reports/Templates/partials/stakeholder/page-impact-journey.blade.php Adds Impact Journey page with lens toggle and animated bar morphing.
app/Domain/Reports/Templates/partials/stakeholder/deck.blade.php Adds stakeholder deck shell (header, tabs, period picker, navigation/print).
app/Domain/Reports/Templates/partials/stakeholder/capacity-card.blade.php Adds capacity-vs-demand card renderer used by resources/capacity sections.
app/Domain/Reports/Templates/partials/stakeholder/actionsMenu.blade.php Adds stakeholder report actions menu (verdict override + print).
app/Domain/Reports/Services/CapacityAnalyzer.php Adds capacity analysis service joining tickets demand vs resource allocations.
app/Domain/Logicmodelcanvas/Templates/canvasDialog.blade.php Adds authored-meaning fields to Logic Model canvas item edit dialog.
app/Domain/Logicmodelcanvas/Controllers/EditCanvasItem.php Persists authored-meaning fields when editing canvas items.
app/Domain/Install/Repositories/Install.php Adds migration 30522 for new canvas item columns.
app/Domain/Goalcanvas/Repositories/Goalcanvas.php Adds goal value “as-of” lookups + bulk series fetch + bulk insert + id filter.
app/Domain/ContentTemplates/Services/Appliers/CanvasItemsApplier.php Allows templates to populate authored-meaning fields on inserted canvas items.
app/Domain/Blueprints/Repositories/Blueprints.php Exposes new canvas item fields through the blueprints repository surface.
app/Core/Resources/Services/ResourcesRegistry.php Adds core singleton registry for the single Resources provider implementation.
app/Core/Resources/ResourcesServiceProvider.php Registers the Resources registry singleton in the container.
app/Core/Resources/Models/ResourceSummary.php Adds core resource summary value object + utilization helpers.
app/Core/Resources/Models/PersonAllocation.php Adds core person allocation value object.
app/Core/Resources/Models/Dependency.php Adds core dependency value object with optional context fields.
app/Core/Resources/Models/BudgetLine.php Adds core budget line value object.
app/Core/Resources/Contracts/ResourcesGateway.php Defines the Resources provider contract for plugins.
app/Core/Configuration/laravelConfig.php Registers the new ResourcesServiceProvider.
app/Core/Configuration/AppSettings.php Bumps dbVersion to 3.5.22.
app/Command/BackfillGoalHistoryCommand.php Adds CSV backfill command for zp_goal_history.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +376 to +379
{{-- ═══ Page 2 — Logic Model read-out ═════════════════ --}}
<div class="rd-page">
@include('reports::partials.stakeholder.page-lm', compact('logicModel', 'hasLM', 'report'))
</div>
Comment on lines +2898 to +2917
/**
* Migration 30522: adds two authored-meaning fields to canvas items.
*
* why_this_matters — the human change (Outcome and Impact items).
* starting_picture — the world today, before the work (Impact only).
*
* Both nullable, both additive. `conclusion` is untouched — going forward
* it narrows to "as measured by" methodology only. The report's Impact
* Journey page (Page 4) reads `why_this_matters` as the meaning lead
* where present, falling back to today's rendering when absent.
*/
public function update_sql_30522(): bool|array
{
try {
if (Schema::hasTable('zp_canvas_items')) {
if (! Schema::hasColumn('zp_canvas_items', 'why_this_matters')) {
Schema::table('zp_canvas_items', function (Blueprint $table) {
$table->text('why_this_matters')->nullable()->after('conclusion');
});
}
Comment on lines +306 to +311
<div class="rd-tabs hideOnPrint">
<button type="button" class="rd-tab on" data-page="0" onclick="rdGo(0)"><i class="fa fa-gauge-simple-high"></i> {{ __('stakeholder.tab.overview') }}</button>
<button type="button" class="rd-tab" data-page="1" onclick="rdGo(1)"><i class="fa fa-diagram-project"></i> {{ __('stakeholder.tab.logic_model') }}</button>
<button type="button" class="rd-tab" data-page="2" onclick="rdGo(2)"><i class="fa fa-people-arrows"></i> {{ __('stakeholder.tab.resources_coverage') }}</button>
<button type="button" class="rd-tab" data-page="3" onclick="rdGo(3)"><i class="fa fa-compass"></i> {{ __('stakeholder.tab.impact_journey') }}</button>

Comment on lines +2525 to +2555
$commentedIds = $this->ticketRepository->getTicketIdsCommentedByUser($userId, $from, $to);
if (empty($commentedIds)) {
return [];
}

// Access-scoped universe — never returns a ticket outside the user's
// project access, so intersecting against it makes the comment lookup
// safe regardless of what was commented on historically.
$accessible = $this->ticketRepository->simpleTicketQuery($userId, null);
if (! is_array($accessible) || empty($accessible)) {
return [];
}
$byId = [];
foreach ($accessible as $ticket) {
$byId[(int) $ticket['id']] = $ticket;
}

$commentedLookup = array_flip($commentedIds);
$out = [];
foreach ($byId as $id => $ticket) {
if (! isset($commentedLookup[$id])) {
continue;
}
// "Supported", not "yours": drop tickets you're the editor of.
if ((string) ($ticket['editorId'] ?? '') === (string) $userId) {
continue;
}
$out[] = $ticket;
}

return array_values($out);
Comment on lines +350 to +356
private function isDone(array $t): bool
{
// Numeric statuses vary per project; treat -1 and 0 as closed/archived by convention.
$status = (int) ($t['status'] ?? 0);

return $status <= 0;
}
Comment on lines +111 to +123
$ts = strtotime($rawDate);
if ($ts === false) {
$errors[] = "line {$lineNo}: unparseable dateRecorded '{$rawDate}'";

continue;
}

$rows[] = [
'itemId' => $itemId,
'value' => (float) $rawValue,
'userId' => ($idxUser !== false && isset($record[$idxUser]) && $record[$idxUser] !== '') ? (int) $record[$idxUser] : null,
'dateRecorded' => date('Y-m-d H:i:s', $ts),
];
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · head advanced to c2b74f05 since my dfc236b pass (PR touched 19:31 UTC, after that review).

⚠️ Transparency note: I could not machine-read the dfc236b…c2b74f05 delta this cycle (the commit API paginated back to branch history, not the new tip). So this pass does not invent file:line refs for the new commit — it (a) confirms the head moved past my last review, and (b) re-states the three CRs that were unresolved at dfc236b and which no commit I could read has addressed. Please treat item 5 as the action for the parts I couldn't diff.

1. Intent: Stakeholder report — the board-facing 4-page deck (Overview / Logic Model / Resources & Coverage / Programs) at strategy + program scope. Since the original "Phase-1 shell" title, the PR has accreted Pages 2–3, the CapacityAnalyzer service, a goals:backfillHistory command, and a dbVersion 3.5.21→3.5.22 migration surface — it is no longer view-layer-only.

2. Change requests (carried unresolved from dfc236b — re-confirm against the new head):

  1. ⛔ CR Documentation #1 — peak-hero truncation bug (fourth pass). page-overview.blade.php: {{ mb_strlen($peakBody > 260 ? mb_substr($peakBody,0,257).'…' : $peakBody) }} still (a) compares string-to-int ($peakBody > 2600 > 260, truncation never fires) and (b) wraps the ternary in mb_strlen(...), so the hero renders a character count, not the narrative. Fix to {{ mb_strlen($peakBody) > 260 ? mb_substr($peakBody,0,257).'…' : $peakBody }} — the way page-programs.blade.php already does it. If a commit in the delta I couldn't read fixed this, disregard.
  2. ⛔ CR Gitignore #2CapacityAnalyzer (428 lines) has zero unit tests. Its gap-math / verdict thresholds / aggregateByProgram roll-up + the isDone() status <= 0 convention (should resolve via the same statusType path fix(tickets): record status→Done in history + report real last-updated #3650/feat(tickets): getMyClosedTicketsForRange for period close-lists #3647 use, not a hardcoded <= 0) remain a behavior change with no coverage — blocker per the test gate.
  3. ⛔ CR Create Email Notification Class (including user settings to opt out) #3 — schema migration back-compat. AppSettings::$dbVersion 3.5.21→3.5.22 implies a 30522 migration adding zp_canvas_items.why_this_matters + .starting_picture. Confirm the migration exists, is additive + nullable, has a rollback, and smoke-tests on a populated 3.5.21 DB. A dbVersion bump with no migration, or a non-nullable column on an existing table, breaks upgrades.
  4. ⚠ CR Create Installation Script/Routine #4goals:backfillHistory (205 lines) ships no tests and is non-idempotent (re-running the CSV duplicates zp_goal_history rows). Add a unit test around filterGoalItemIds (the non-goal-id rejection guard) and CSV row validation.

3. Acceptance checklist (must pass before merge):

  1. CR Documentation #1 fixed (hero renders truncated text, not a count) and CR Gitignore #2 addressed (CapacityAnalyzer verdict/gap math covered; isDone status resolution confirmed) — both carried across four reviews.
  2. 30522 migration present, additive + nullable, with rollback; upgrade smoke-tested on a populated 3.5.21 DB (backward-compat gate).
  3. CI green — Pint + PHPStan level 5 + full unit suite — with a new test for filterGoalItemIds/CSV validation (CR Create Installation Script/Routine #4); any red/skipped job blocks.

4. CI status: ⚠️ Could not read check-runs on c2b74f05 this cycle — confirm in the UI that Pint + PHPStan L5 + full suite are green on the current head; a head with zero/failing checks must not merge. PHPStan L5 should flag CR #1's string > int — if the suite is green, verify it analyzes compiled Blade, because that defect is real.

5. Action for the un-diffed delta: please confirm which of CR #1#4 the dfc236b…c2b74f05 commit(s) touched. If any are now resolved, note it here and I'll clear it next sweep.

Merge order (unchanged): #3643#3645#72#3648#73, submodule pointer bump as the explicit last step.

Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated 6 comments.

Comment on lines +376 to +379
{{-- ═══ Page 2 — Logic Model read-out ═════════════════ --}}
<div class="rd-page">
@include('reports::partials.stakeholder.page-lm', compact('logicModel', 'hasLM', 'report'))
</div>
Comment on lines +307 to +311
<button type="button" class="rd-tab on" data-page="0" onclick="rdGo(0)"><i class="fa fa-gauge-simple-high"></i> {{ __('stakeholder.tab.overview') }}</button>
<button type="button" class="rd-tab" data-page="1" onclick="rdGo(1)"><i class="fa fa-diagram-project"></i> {{ __('stakeholder.tab.logic_model') }}</button>
<button type="button" class="rd-tab" data-page="2" onclick="rdGo(2)"><i class="fa fa-people-arrows"></i> {{ __('stakeholder.tab.resources_coverage') }}</button>
<button type="button" class="rd-tab" data-page="3" onclick="rdGo(3)"><i class="fa fa-compass"></i> {{ __('stakeholder.tab.impact_journey') }}</button>

Comment on lines +99 to +110
$rawTickets = $this->ticketsRepo->getAllByProjectId($pid) ?: [];

// Normalize — the repo hydrates rows into Tickets model objects; we
// want plain array rows for uniform key access downstream.
$tickets = array_map(
fn ($t) => is_object($t) ? (array) $t : (array) $t,
$rawTickets,
);

// Filter to open tickets only — DONE work doesn't need capacity.
$openTickets = array_values(array_filter($tickets, fn ($t) => ! $this->isDone($t)));

Comment on lines +111 to +113
$ts = strtotime($rawDate);
if ($ts === false) {
$errors[] = "line {$lineNo}: unparseable dateRecorded '{$rawDate}'";
Comment on lines +158 to +175
$rejected = [];
$rows = array_values(array_filter($rows, static function ($row) use ($validItemIds, &$rejected): bool {
if (! isset($validItemIds[(int) $row['itemId']])) {
$rejected[(int) $row['itemId']] = true;

return false;
}

return true;
}));

if ($rejected !== []) {
$io->warning(sprintf(
'%d row(s) skipped — itemId not found or not a goal: %s',
count($rejected),
implode(', ', array_keys($rejected))
));
}
Comment on lines +2452 to +2456
public function getMyClosedTicketsForRange(?int $userId = null, ?string $from = null, ?string $to = null): array
{
$from = $from ?: date('Y-m-d');
$to = $to ?: date('Y-m-d');
// Tolerate a reversed range rather than returning nothing.
Conflicts resolved:
- AppSettings.php: keep dbVersion 3.5.22 (this branch adds migration 30522).
- Install.php: keep migration 30522 (canvas item why_this_matters +
  starting_picture fields); registered in the migrations array.
- Dependency.php: keep the superset model (adds optional owner, dueDate,
  notes, lastModified fields alongside the base fields).
- Tickets.php: take report-screens' expanded getMyClosedTicketsForRange
  (adds session-user fallback + IDOR guard + today-cached bounds).
- Goalcanvas.php: keep this branch's goal-history query surface
  (getGoalValueAt, getGoalValuesAtSeries, insertGoalHistoryRows,
  filterGoalItemIds).
- en-US.ini: keep the added stakeholder.* i18n block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@marcelfolaron
marcelfolaron merged commit b4d2d43 into feature/report-screens Jul 19, 2026
1 check passed
@marcelfolaron
marcelfolaron deleted the feat/stakeholder-report branch July 19, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants