Each stage-transition activity now becomes a horizontal range on its lane: start = the activity's date, end = the next activity with a higher stage rank, or extending to today if still in effect. Milestone date-field dots overlay on top of the ranges. Activity dots at each range start carry a tooltip with the activity subject. Added computeStageRanges in lib/stageRank.ts and switched DateTimeline to take activities directly (deriving both the rank resolver and the ranges internally). Wide year-spanning timelines now show their full extent even when no milestone dates have been entered.
Path 1 (APIv4 Attachment.get + select url) shipped but didn't fix the
Firebase\JWT decode crash — the deployed Civi version either omits `url`
from Attachment.get or returns it without the fcs param. Falling back to
the bare /civicrm/file?id=X URL hits the same JWT null crash.
Path 2: route file clicks through a tiny redirect endpoint in the Civi
extension instead. The extension runs PHP on Civi, has access to the
crypto.jwt service, and mints the same shape of token Civi's own file
URL builder uses ({exp, "civi.file": <id>}) before 302-redirecting to
the canonical /civicrm/file URL.
Civi extension changes:
- New CRM/WebformMw/Page/File.php — resolves eid from civicrm_entity_file
if not supplied, signs a 7-day JWT via Civi::service('crypto.jwt'),
redirects.
- xml/Menu/webform_mw.xml — registers civicrm/webform-mw/file. Requires
`access CiviCRM` (the user is already authenticated in the parent Civi
tab when they click the link).
Frontend (StaffReportView.tsx, FieldValue):
- When Attachment.get's url is missing, fall back to the new extension
route instead of bare /civicrm/file. Attachment.get's url remains the
fast path when present.
Deploy: admin needs to push the updated extension files to the Civi
server, then Disable/Enable webform-mw (or cv flush) so the new menu
route registers in civicrm_menu.
The Y1 Monthly Sales Target fields don't match the Y1_M<n>_<metric> regex
the monthly matrix collector uses to discover rows:
M1 -> Y1_Monthly_Sales_Targets (no _M1 suffix; trailing 's')
M2 -> Y1_Monthly_Sales_Targets_M2 (plural with _M2)
M3 -> Y1_Monthly_Sales_Target_M2 (Civi name says _M2 but the value
represents M3; pre-existing
schema error)
M4..M12 -> Y1_Monthly_Sales_Target_M<n>
Hardcode a period->civi-field-name map (Y1_MONTHLY_SALES_TARGET_FIELDS)
so the monthly matrix can pick these up alongside the regex-matched
Y1_M<n>_Actual_Sales / Y1_M<n>_Transactions rows. The M3->_M2
irregularity is called out inline so a future reader doesn't "fix" it
into a regression.
Civi serves uploaded files at /civicrm/file?id=X&eid=Y&fcs=<JWT>; the fcs
is a JWT signed with the site key. Without it, Civi's file handler crashes
on a null JWT decode (Firebase\JWT\JWT::decode argument null). We don't
have the site key on the Next.js side, so let Civi mint the URLs for us.
Backend (/api/staff/report):
- Add file_name selects for org-side file fields (Certificate of
Incorporation and friends) so org files have names alongside URLs.
- Collect every file id referenced by activity and org custom fields.
- Call APIv4 Attachment.get with select: ["id", "url"] to fetch signed
URLs in one round trip. Build a urlByFileId map.
- Org-side file values are now wrapped in { id, file_name, url } shape
matching the activity-side files (previously bare file ids that the
frontend couldn't render).
- Activity-side file values gain a url property from the map.
- If Attachment.get doesn't expose url on this Civi version, the call is
caught and we fall through to bare URLs without fcs (no regression).
Frontend (FieldValue):
- Prefer v.url when present, normalizing absolute and relative shapes
against CIVI_BASE_URL.
- Fall back to /civicrm/file?reset=1&id=X if url wasn't provided.
Five related refinements to the staff report:
1. Surface latest submitter. Pull the most recent non-empty
Survey_completed_by / Survey_completed_by_email values from the
Check_in_data__organizing_ history and render them just below the org
stats in the report header. Email is a mailto: link. Hidden when both
values are empty.
2. Y1 monthly matrix. Generalize the Y1 matrix collector to detect either
Y1_Q<n>_<metric> or Y1_M<n>_<metric> field-name patterns. Stage 5 now
renders the quarterly table (when present) followed by the monthly
table (when present); each table auto-labels its columns Q1..Qn or
M1..Mn from the data, and the caption reflects the cadence. Adding a
new Y1_M<n>_<metric> field in Civi extends the columns automatically.
3. Larger field value. The latest value in each CompactFieldRow is now
font-display text-xl text-leaf-800 (previously text-[13px] text-ink-soft).
Makes the current number the dominant element in each row.
4. Smaller right-aligned earlier-entries toggle. The "N earlier entries"
button moves out of the inline date line onto its own row beneath the
"as of <date>" caption, right-aligned, in a 10px link style.
5. Right-aligned expanded entries. When earlier entries are unhidden,
each row now shows date on the left and the value on the right, mirroring
the active value's right alignment. Values render in font-display text-base
text-ink-soft so they visually echo the latest value while being clearly
demoted in size and color. The list is constrained to max-w-[24rem] with
ml-auto so it sits under the active value column rather than spanning the
full row.
The iframe in the Engagement Report tab is sized to fit content, so it
has no internal scroll context. Clicking an anchor link inside it
changes the URL hash but the iframe content doesn't move and the user
has to scroll the outer CiviCRM page manually. Rather than coordinate
cross-frame scroll with the parent, just skip rendering the anchor
strip when framed. Standalone view is unchanged.
The framed report posts its content height to the parent so the Civi tab
can resize the iframe to fit. Two pieces interacted badly:
- The root layout sets html.h-full and body.min-h-full, so documentElement
and body heights track the iframe's viewport height.
- The parent template sets iframe.height = postedHeight + 24 every time
a height message arrives.
The combination produced an unbounded feedback loop: parent grows the
iframe by 24px, viewport grows, document height grows, ResizeObserver
fires, we post the new height, parent grows by another 24px. The outer
CiviCRM page scrollbar visibly shrank each cycle.
Fix on the report side (no extension change needed): when framed, override
html height to auto and body min-height to 0 so the document decouples
from the viewport. Observe body (the actual content), measure
body.scrollHeight, and skip posting when the value is unchanged. Original
styles are restored on unmount so route changes back to the standalone
view still work.
App side:
- Per-route CSP: /staff/report now sets frame-ancestors 'self'
<CIVI_BASE_URL origin> and drops X-Frame-Options so the CiviCRM
extension can iframe it. All other routes keep frame-ancestors
'none' + X-Frame-Options: DENY via a path-negation source.
- Staff page recognises ?frame=1 and renders without SiteHeader/
SiteFooter so it fills the iframe cleanly.
- StaffReportView posts its scrollHeight to the parent window via
postMessage when framed; the Civi tab listens and auto-resizes
the iframe (no nested scrollbar). Anchor strip drops its sticky
positioning in frame mode since there's no internal scroll.
CiviCRM extension (civi-extension/webform-mw/, key webform-mw):
- info.xml + main hook file (webform_mw.php) implementing
hook_civicrm_tabset to add an 'Engagement Report' tab to
Organization contact-view pages.
- CRM/WebformMw/Page/Tab.php + Smarty template render an iframe
pointing at <WEBFORM_MW_APP_URL>/staff/report?org=<cid>&key=&frame=1,
with a postMessage listener that validates event.origin against
the configured app URL before resizing.
- Config via PHP constants in civicrm.settings.php (WEBFORM_MW_APP_URL,
WEBFORM_MW_STAFF_KEY) or matching env vars. Help banner shown when
unconfigured.
- README documents install, config, behaviour, security caveats.
UX iteration after first live look:
- Sticky anchor strip below the header with a chip per section (incl.
Submissions) so staff can jump around a long page.
- Compact one-line rows that show only the latest value; multi-history
fields get a muted 'N earlier entries' toggle that reveals the rest
inline. Same affordance for file fields.
- Empty fields collapse under a single 'N empty fields' toggle per
section instead of taking a row each.
- Stage 5: Y1_Q<n>_<metric> fields render as a read-only matrix table
(rows: metrics; columns: Q1..Q4) matching the form's matrix layout.
File proxy (/api/staff/file) deleted. APIv4 Attachment isn't exposed
on this Civi instance (per the June upload spike), which is why the
previous proxy returned broken images. Staff are already authenticated
to Civi when they arrive here, so file fields now render as outbound
links to CIVI_BASE_URL/civicrm/file?reset=1&id=<id> and the browser
uses the staff session. No more proxy auth, no more SSRF surface to
harden, no broken images.
CIVI_BASE_URL flows from the staff page (server component) into the
client as a prop. No secret material crosses the boundary.
- Allowlist inline MIME types (png/jpeg/gif/webp/pdf only); everything
else, including SVG and HTML, served as application/octet-stream
with content-disposition: attachment.
- X-Content-Type-Options: nosniff and a restrictive CSP on every response.
- Validate the upstream URL Civi returns: must match CIVI_BASE_URL origin
before we attach basic-auth creds and follow it. redirect: manual to
prevent off-host hops.
- Drop SVG from the client's inline-image list (server forces download).