feat(reports): stakeholder report — Phase 1 shell (deck, header, navigation)#3648
Conversation
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>
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 → 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>
|
Maintainer review (advisory — merge authority stays with @marcelfolaron / @broskees) · diff read in full at head 1. Intent: Phase-1 shell of the stakeholder report deck — a plugin-neutral 4-page Blade deck ( 2. Change requests (mostly view-layer + honest placeholders — but one real bug and a coupling issue):
3. Acceptance checklist (must pass before merge):
4. CI status: Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees. |
…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>
|
Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · re-reviewed at head 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 ( 2. Change requests:
3. Acceptance checklist (must pass before merge):
4. CI status: Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees. |
…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>
|
Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · re-reviewed at head 1. Intent: Since my 2. Change requests:
3. Acceptance checklist (must pass before merge):
4. CI status: Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees. |
…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>
There was a problem hiding this comment.
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
ResourcesGatewaycontract + registry and a newCapacityAnalyzerfor 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.
| {{-- ═══ Page 2 — Logic Model read-out ═════════════════ --}} | ||
| <div class="rd-page"> | ||
| @include('reports::partials.stakeholder.page-lm', compact('logicModel', 'hasLM', 'report')) | ||
| </div> |
| /** | ||
| * 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'); | ||
| }); | ||
| } |
| <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> | ||
|
|
| $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); |
| 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; | ||
| } |
| $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), | ||
| ]; |
|
Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · head advanced to
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 2. Change requests (carried unresolved from
3. Acceptance checklist (must pass before merge):
4. CI status: 5. Action for the un-diffed delta: please confirm which of CR #1–#4 the 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. |
| {{-- ═══ Page 2 — Logic Model read-out ═════════════════ --}} | ||
| <div class="rd-page"> | ||
| @include('reports::partials.stakeholder.page-lm', compact('logicModel', 'hasLM', 'report')) | ||
| </div> |
| <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> | ||
|
|
| $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))); | ||
|
|
| $ts = strtotime($rawDate); | ||
| if ($ts === false) { | ||
| $errors[] = "line {$lineNo}: unparseable dateRecorded '{$rawDate}'"; |
| $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)) | ||
| )); | ||
| } |
| 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. |
6a13f8d to
3a8558b
Compare
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>
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.mdMockup:
Docs/board-report-swipe.htmlCompanion 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:minmax(0,1fr), every childmin-width:0— fits 12–13" laptops with zero horizontal scroll30
stakeholder.*language keys inapp/Language/en-US.inifor shell copy.What's real vs placeholder
ReportEngine::buildReport()stats + deltas (via the nested$reportkey — the §8 shape verified againstorigin/feature/report-screens). Placeholder card below marks where "peak this period" hero, needs-attention block, ToC narrative, and theory-health strip land next.logicModel === null.ResourcesGatewaywiring +buildCoverageMatrixupgrade landing together.§8 shape adherence
Templates read via the exact shape verified in requirements §8. Key access at
$strategyReport['report']['stats'](nested),logicModelguarded as nullable everywhere. Program-scope stubslogicModel=nullfor Phase 1 — LM fetch at program level is Phase 2.What's NOT in
partials/reportBody.blade.phpfiles — left intact so the old surface is still available while the deck is validatedTest plan
/strategyPro/reporton a strategy — deck renders, tabs switch on click, keyboard arrows work, swipe works on touch, header + KPI band show real stats/pgmPro/reporton a program — same behaviorfrom metrics · X/Y goals on trackorno goal data yet)🤖 Generated with Claude Code