Commit Graph
53 Commits
Author SHA1 Message Date
Joel Brock d3b88d92c2 Timeline: place dots by co-op stage at the event date
Each date-field event is now plotted on the lane corresponding to the Framework Stage the co-op was in on the field's stored date, resolved from the activity stream's stage transitions. Date_Opened is pinned to Stage 5 as the journey anchor. Events that pre-date any known transition fall back to the field's section rank so they still surface somewhere. Extracts the STAGE_RANK map (previously inline in ReportView) into lib/stageRank.ts alongside the new buildStageRankAtDate resolver factory.
2026-06-11 13:56:27 -07:00
Joel Brock ade938eaaa Survey copy: clarify subtitle and rename submit-another button
Subtitle now tells respondents they can skip fields they do not know. Post-submit CTA says Submit an update since the same form is the ongoing update channel, not a one-off survey.
2026-06-11 13:47:35 -07:00
Joel Brock 8dfbbea0ba Report: drop Stage 0 lane, flip 5→1 axis, fix Actual label clipping
DateTimeline now skips section rank 0 (intake) and renders stage lanes
top-to-bottom 5→1, matching how the framework actually numbers stages.

MembershipChart's latest-Actual label was placed at the circle's
x + 6, which overflowed the SVG when the most-recent point landed near
the right padding. Anchor it to xOf(maxT) - 4 with textAnchor="end",
mirroring the Goal label so both endpoint labels stay inside the chart.
2026-06-11 13:39:42 -07:00
Joel Brock b65bc6d0e0 File redirect route: mint fcs JWT in extension, link from staff report
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.
2026-06-10 10:47:34 -07:00
Joel Brock 50e719e1a9 Staff report: fold Y1 Monthly Sales Target row into the M matrix
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.
2026-06-10 09:29:42 -07:00
Joel Brock 63e73e7fe6 Staff report: pass Civi-signed file URLs through to clicks
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.
2026-06-10 09:29:42 -07:00
Joel Brock 229ef51537 Staff report: monthly Y1 matrix, value emphasis, submitter header
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.
2026-06-10 09:03:19 -07:00
Joel Brock bd859ce906 Form: required submitter info section above the stage pathway
Captures the form-filler's name and email on every check-in. Both fields
are required; values write back to Check_in_data__organizing_.Survey_completed_by
and Survey_completed_by_email on the activity, giving us a per-submission
record of who filled out which check-in.

Implementation:
- config/form.ts: new submitterInfo section (rank -1) at the head of the
  sections array. rank -1 keeps it out of the past/current/future stage
  pathway computation.
- components/EngagementForm.tsx: filter the submitter section out of
  sectionsToRender and render it directly with FieldRenderer inside a
  bordered card above the stage list. The fields still flow through RHF
  registration, onInvalid scroll-to-error, and the onSubmit visibility
  filter the same as any other field.

The staff report auto-discovers these fields via CustomField.get since
they live in Check_in_data__organizing_, so the field history shows up
in the report with no extra wiring.
2026-06-10 09:03:19 -07:00
Joel Brock 2e1244aa47 Staff report: hide section anchor nav when framed in Civi
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.
2026-06-09 17:22:27 -07:00
Joel Brock 38f4738eca Staff report: fix infinite iframe-grow loop when framed in Civi
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.
2026-06-09 17:14:54 -07:00
Joel Brock 1364917ada Form: confirm dialog before submitting check-in
Adds a native window.confirm() inside onSubmit, after the uploads-in-flight
guard and before the submitting-state flip. Runs only after react-hook-form
validation passes, so users who hit Submit on an incomplete form still see
the existing field-level errors via onInvalid rather than a confusing
"are you sure?" prompt. Cancelling leaves the form state untouched.
2026-06-09 13:44:33 -07:00
Joel Brock 4ca3c194d7 Staff report: CSP frame-ancestors + frame-mode + WebForm-mw Civi extension
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.
2026-06-05 17:42:35 -07:00
Joel Brock b548b6425b Staff report: compact rows, anchor nav, Civi file links, Y1 matrix
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.
2026-06-05 17:28:17 -07:00
Joel Brock d7a1396640 Staff file proxy: harden against SVG XSS and SSRF
- 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).
2026-06-05 17:02:42 -07:00
Joel Brock f889212296 Staff report: StaffReportView client component 2026-06-05 16:35:15 -07:00
Joel Brock d737950a3d Report: extract Loading/Empty/Error states to components/report/ 2026-06-05 14:43:19 -07:00
Joel Brock 5cc60467a9 Report: extract MembershipChart to components/report/ 2026-06-05 14:21:36 -07:00
Joel Brock cc2a17e7a0 Report: extract DateTimeline to components/report/ 2026-06-05 14:11:25 -07:00
Joel Brock 5f5c1d6a61 Report: trim unused FieldHistory re-exports from ReportView 2026-06-05 13:23:28 -07:00
Joel Brock 89e0c7a7ea Report: extract FieldHistory primitives to components/report/ 2026-06-05 13:22:24 -07:00
Joel Brock b474cb8004 Copy: route contact prompts to Chris @ FCI; warmer form subtitle 2026-06-05 11:37:43 -07:00
Joel Brock 9702eaa077 Footer: replace privacy blurb with copyright line; tidy thank-you and contact copy 2026-06-05 11:18:32 -07:00
Joel Brock 2400931a04 File upload pipeline: wire end-to-end via APIv4 File.create
Closes the file-upload gap. Files now actually land in CiviCRM (verified
empirically against the live Civi instance via spike scripts).

Spike findings (see scripts/spike-file-upload.mjs):
  - APIv4 Attachment is NOT exposed on this Civi
  - APIv4 File + EntityFile ARE exposed; File.create accepts inline
    base64 `content` and returns a usable file id
  - Custom file fields store the file id directly in the custom column,
    so EntityFile linkage is unnecessary for this use case
  - Round-trip via Contact.update + Contact.get .file_name join verified
    on a real org contact

Pipeline:

  Renderer (FileField) picks up onChange  →
    POST /api/upload (multipart) with file + cid + cs + fieldRef  →
      verifyChecksum, MIME allowlist + magic-byte sniff, 5 MB cap  →
        civi.File.create({ file_name, mime_type, content: base64 })  →
          returns { id, file_name }  →
            renderer stores in RHF state via setValue
  Form submit  →
    POST /api/submit (JSON) with the {id, file_name} value  →
      submit detects the file shape and writes the id as the value of
      the activity/contact custom field

File changes:

  app/api/upload/route.ts
    Replaced the 501 stub with the real File.create call. Comment
    documents that EntityFile linkage is intentionally skipped and that
    orphan cleanup is owned by a CiviCRM scheduled job.

  app/api/submit/route.ts
    For type:"file" values shaped as {id, file_name}, write the id as
    the custom field value (activity or contact, depending on the
    civiField / civiContactField the field declares).

  components/fields/FieldRenderer.tsx
    Replaced the bare <input type=file> register() with FileField, an
    upload-on-pick subcomponent. The native input is NOT register()'d:
    its FileList value was the original bug. FileField owns its
    uploading + error state and writes {id, file_name} via setValue on
    success. Submit is blocked upstream while uploads are in flight.

  components/StageSection.tsx, components/EngagementForm.tsx
    Thread setValue, cid, cs, and an onUploadStateChange callback
    through to FieldRenderer. EngagementForm tracks uploads-in-flight
    count; onSubmit refuses to submit while the count is > 0.

  config/form.ts
    Promotes Certificate_of_Incorporation from readonly to a real
    file field now that the pipeline works.

  app/api/data/route.ts
    Drops the readonly carveout that was only needed while the
    certificate was readonly.

  scripts/list-civi-entities.mjs (new)
    APIv4 entity probe + APIv3 attachment-API probe. Used to determine
    that File (not Attachment) was the right entity on this Civi.

  scripts/spike-file-upload.mjs (new)
    The actual end-to-end test that proved out the pipeline before
    wiring. Safe to re-run on any Civi instance during future audits.

Not in this change:
  - Orphan attachment cleanup (CiviCRM scheduled job, Civi admin scope)
  - Per-field MIME allowlists (single global list for v1)
  - S3 / presigned-URL path for >5 MB files (deferred; capped at 5 MB
    today to stay under Amplify Lambda's 6 MB sync payload limit)
2026-06-05 07:48:57 -07:00
Joel Brock 8ecf64c79b Stage 0: org-contact custom fields (Food_Co_op_Organizing)
Adds four fields from the Organization Contact's Food_Co_op_Organizing
custom group to the Stage 0 (always-visible) section:

  Date Incorporated                (date, editable)
  Name on Incorporation Certificate (text, editable)
  Certificate of Incorporation     (readonly; see note)
  Equity share                     (currency, editable)

These live on the Organization Contact record, not on the Check-in
activity, so they read/write through a different code path:

  - FieldConfig gains civiContactField, mutually exclusive with civiField
  - /api/data extends the org Contact.get select to include them and
    merges values into the prefill payload keyed by form-side name
  - /api/submit splits incoming values: contact-bound fields go through
    Contact.update (run first), activity-bound fields stay in the
    Activity.create call (run second)
  - FieldRenderer readonly branch now detects file-shaped values
    ({id, file_name}) and displays the filename rather than [object Object]

Certificate_of_Incorporation is wired readonly only: the form's
file-upload pipeline is not actually wired end-to-end (FileList drops
at JSON.stringify in onSubmit; no /api/upload endpoint exists). A
follow-up will close that gap.

Also adds scripts/inspect-org-custom-fields.mjs, a one-off introspection
script for dumping CustomField metadata when wiring a new group.
2026-06-04 17:37:36 -07:00
Joel Brock b120075ca2 Rebrand: Co-op Check-in -> Co-op Survey in user-facing copy
Updates the tool product name across the app UI (header, page title,
section labels, form buttons, success/error states, report stat labels),
README, deployment docs, and the CiviCRM email template guidance.
Custom domain references move from check-in.fci.coop to survey.fci.coop
(DNS update still required).

The underlying CiviCRM "Check-in (organizing)" activity type, custom
group machine names (Check_in_data__organizing_), health-check ids,
and the internal org_engagement_check_in form id are unchanged --
those are CiviCRM contract surfaces, not product copy.
2026-06-04 17:12:03 -07:00
Joel Brock 9bc4bbb732 Field groups: tighter style + apply to report
Form: dropped the boxed card treatment for FieldGroupCard in favor
of a leaf-tinted left rule + small uppercase mini-label. Eats only
~14px of horizontal space (border + pl-3/sm:pl-4) instead of
~32-40px for the previous bg-tinted card with px-4/sm:px-5 on both
sides, so the inner 2-col grid keeps more breathing room for the
fields themselves.

Report: same field-group concept now applies to ReportSection.
Grouped FieldHistoryRows render together inside a leaf-tinted left
rule with a small label above. Walk preserves the declared field
order — a group is emitted at its first member's position; the
other members are skipped when the loop later reaches them. Mixes
cleanly with the existing MembershipChart inline insertion and the
divide-y rhythm of standalone rows.
2026-05-21 16:51:00 -07:00
Joel Brock 8766de5ed0 Copy: "member(s)" → "member-owner(s)" in user-facing strings
Sweeps every active label / help / intro / visible paragraph in the
app to use 'member-owner' terminology consistently. Stub option
labels for the Capital Stack (Member equity / Member loans) updated
on the label side; option `value:` strings stay as the CRM-side
stored values. Sentinel comparisons in ReportView's chart legend
updated to track the new field labels (otherwise the `(custom
label)` parenthetical would print spuriously even at default).

Untouched on purpose:
  - Civi machine names (`Members__current_`, `Member_*`,
    `*_Member*`) — wire-level identifiers, must match Civi.
  - Option-group `value:` strings — CRM-stored values, must match.
  - NCG_Member / INFRA_Member option labels — these refer to a
    co-op's membership in distributor networks, not member-owners.
  - Commented-out fields and technical comments referencing Civi
    field names.
2026-05-21 14:08:06 -07:00
Joel Brock 5e7774795b Style anchors with underline + leaf color for visibility
Body-copy mailto links ("Chris @ FCI") and the chrome nav link
were rendering as plain text, hard to spot. Added an @layer base
rule that gives every `a[href]` a 1px underline at 2px offset and
the FCI Seed Grant green, with a subtle hover thicken. Tailwind
utilities still win on a per-element basis (the nav link keeps its
`text-ink-soft` color, the brand-image wrapper opts out via
`no-underline`).
2026-05-21 13:46:51 -07:00
Joel Brock de53fde1f7 Submit bar: animated stage-progress pills mirroring the header
Bar's secondary line is now a horizontal cluster of the same six
pills used in the page header, followed by the viewed stage label.
Active pill expands and gains a slow halo (rail-pulse keyframe) when
in the bar; same component runs without the pulse in the static
header. Width transitions smoothly between ranks as the user scrolls,
so the indicator visibly tracks progress through the form.

StageProgress now takes an optional `pulse` prop and tightens its
transition timing for nicer scroll-driven animation.
2026-05-21 12:41:14 -07:00
Joel Brock 7e5b1e1197 Sticky submit bar: show org name + currently viewed stage
Repurpose the previously-empty left side of the floating submit bar.
Top line is the org name; secondary line updates as the user scrolls
so the currently viewed stage is always visible even after the
top-of-form header has scrolled out of sight.

IntersectionObserver with a top-biased rootMargin tracks which
section is in view; topmost intersecting section wins ties.
Submit-state feedback (error / in-flight) still takes priority over
the viewing/draft text when active.
2026-05-21 12:30:54 -07:00
Joel Brock 2ca2d378a4 Add sync-help-from-civi script + field-group rendering
Two additions, both touching the form-config story:

1. scripts/sync-help-from-civi.mjs

   Diffs per-field help text in config/form.ts against CustomField rows
   in CiviCRM and (with --write) updates the file in place. Reads env
   from .env.local via Node's --env-file flag. Run as `npm run sync-help`
   or `npm run sync-help -- --write`. A --debug mode prints the parser's
   field list without calling Civi.

   Rationale: this form is low-traffic and help text doesn't change
   often once in production. A manual one-off sync is leaner than
   coupling every page load (or every build) to a Civi API call.

2. fieldGroups: visual clustering of related fields within a section

   New optional FieldGroupConfig overlay on StageSectionConfig — pure
   presentation, names existing fields by name so submit/visibility
   logic walks them unchanged. StageSection.tsx pulls grouped fields
   out of the standalone per-field grid and renders each group as its
   own bordered card with an optional heading. Stage 2 now clusters
   Market Study, Pro Forma, Business Plan, and Board Self Assessment
   (each a date + upload pair) into their own cards.
2026-05-21 12:27:22 -07:00
Joel Brock 9460e8320f WIP: form copy and field visibility adjustments 2026-05-21 12:11:23 -07:00
Joel Brock 709d9bfd8b WIP: form copy and field visibility adjustments 2026-05-21 11:17:06 -07:00
Joel Brock f0530ed337 FCI theming refresh, contact identity fields, Amplify secrets fix
- Remap globals.css tokens to FCI brand palette (Eggplant #801d7f,
  Spring Pea #96bc33, Seed Grant #679038, Squash #c9ad2d, FCI gray
  #4b5657). Existing leaf-* / clay-* class names preserved.
- Switch body font to Open Sans (FCI's free fallback for Museo Sans).
  Headings keep Fraunces.
- Add contact identity (first name, last name, email) as readonly
  fields at the top of Stage 0. /api/data fetches via APIv4
  Contact.get with email_primary.email join; values flow through
  FormDataPayload.contact and into the form's evalState so the
  readonly renderer displays them. Draft restore re-applies them so
  a stale local draft can't override.
- amplify.yml: fetch Amplify Secrets from SSM Parameter Store when
  they don't arrive as build-shell env vars (the common failure mode
  behind "Refusing to run in production without CIVI_*"). Adds a
  length-only diagnostic echo and a hard-fail guard so a missing
  required var stops the build with a clear message instead of
  bundling empty strings and crashing the SSR Lambda at runtime.
2026-05-20 16:46:44 -07:00
Joel Brock d88f1229ae Report: stair-step actual line, drop sparklines, move chart into Stage 0
- Actual member line now uses the same carry-forward step pattern as
  the goal line — between measurements the chart holds the prior value
  instead of interpolating diagonally, and the final value extends flat
  to the right edge. Eliminates the apparent dips that arose when
  diagonal interpolation crossed missing periods or low intermediate
  values.
- Per-field Sparkline (and its isNumericField / formatScalarText
  helpers) removed entirely. Expanding 'earlier entries' now just shows
  the chronological list. Curated multi-metric charts (like the
  Membership chart) are the path forward for trend visualization.
- Membership chart relocated from the top-of-report band into the
  Stage 0 ('Check-in (organizing)') section, rendered inline after
  whichever of Members__current_ / Member_Goal_for_current_Stage
  appears last in the section's fields-with-history list. Naturally
  scopes the chart to wherever those questions live (no double-render
  if config later moves them). Chart props refactored to take field
  + history pairs directly instead of walking the full sections array.
2026-05-19 16:45:53 -07:00
Joel Brock ae4e47025c Report: dedicated Membership chart (goal vs actual over time)
New MembershipChart card sits between the DateTimeline and the section
accordions, rendering whenever Members__current_ or
Member_Goal_for_current_Stage has any historical data.

- Actual member count: smooth leaf-700 polyline with a faint leaf-500
  area fill underneath, dots at every measurement, an emphasized dot
  on the most recent point with the value labeled inline.
- Goal: dashed clay-600 step line — each goal value is treated as a
  target that holds until the next update, then extends flat to the
  right edge of the chart. Dots at each update; the most-recent goal
  value labeled at the right.
- Y-axis: niceYTicks picks 3–5 round-number ticks (snapped to
  1/2/2.5/5/10 × 10^N) spanning [min(0, dataMin), dataMax]; faint
  gridlines + tabular-num labels on the left. Anchoring at 0 keeps
  growth-from-small-base readable.
- X-axis: reuses generateAxisTicks for adaptive month/year stepping,
  matching the timeline above. Today gets a dashed clay vertical
  guide when in range.
- Header: title + subtitle + an inline 'NNN of MMM target · X to go'
  callout in tabular-nums, color-coded (clay-700 if behind goal,
  leaf-700 if above).
- Legend at the bottom with line+dot chips for both series.
2026-05-19 16:37:01 -07:00
Joel Brock 00e10a992f Timeline: hover/focus tooltips, stage 0 lane, adaptive date axis
Three improvements to the report's DateTimeline:

1. Tooltips on every dot. Each dot is now a focusable span (tabIndex,
   role=img, full aria-label). A small ink-tinted card appears above
   the dot on mouse hover or keyboard focus, showing field label,
   formatted date, stage rank, and an 'Opened' marker for Date_Opened.
   Anchor flips to left/center/right based on the dot's position so
   tooltips don't overflow the row at the edges.

2. Plot every date field, including stage 0. The previous version
   skipped Stage 0 dates (Internal_Startup_Assessment_Date,
   Date_Closed_Folded). Now there are six swim lanes (0-5) instead
   of five. Stage 0 gets bg-leaf-200 so the gradient extends one
   step lighter.

3. Adaptive month/year x-axis under the lanes. generateAxisTicks
   picks a 'nice' interval based on the visible span: 1mo / 2mo /
   3mo / 6mo / 1yr / 2yr. January-bordered ticks include the year
   so the reader has anchors. Today gets its own labeled clay tick
   when it falls in range.
2026-05-19 16:32:50 -07:00
Joel Brock 814b560363 Report: sparkline charts on numeric history + stage-grouped date timeline
Two visual additions to the read-only activity report.

Sparkline: when the user expands earlier-entries on a numeric field
(number/currency/percent) with two or more numeric points, the
expansion now leads with a 240x56 inline SVG trend chart — chronological
polyline, faint area fill, small dots at every measurement, a slightly
larger emphasized dot on the most recent point. Min and max captions
sit beneath in tabular-nums, formatted in the field's native style
(currency uses Intl, percent appends %, etc.). Non-numeric fields are
unchanged.

DateTimeline: a new card between the context header and the section
accordions. Walks every date-type field in stage sections 1-5 (Stage 0
omitted as it isn't a stage in the journey sense), pulls each field's
most-recent entered date, and lays the events out in five horizontal
swim lanes — one per stage rank, labeled at the left. Time axis
spans from the earliest event to max(latest event, Date_Opened).
Stage 5's Date_Opened is rendered as a larger clay-700 dot with a
heavier ring so it reads as the journey's anchor at the right end.
A faint clay-300 dashed vertical line marks 'today' if it falls
within the range. Color scale across stages is leaf-300 / leaf-500
/ leaf-600 / leaf-700 / clay-700 — a sprout-to-fruit gradient that
matches the existing palette. Empty stage rows still draw their lane
line at half opacity so the structure stays readable. SR-only event
list provides screen-reader access to all plotted dates with their
labels.

Stub payload enriched with four cross-stage date entries so the
timeline has content in dev preview.
2026-05-19 16:16:57 -07:00
Joel Brock 9209d6dc02 Prefill file fields with their file_name joined from CiviCRM
CiviCRM APIv4 file custom fields return a bare file id by default; an
extra '.file_name' join is required to get the human-readable filename.
Both the form prefill walk (lib/prefill.ts) and the report walk
(app/api/report/route.ts) now request '<civiField>.file_name' for every
file-type field alongside the primary value, and wrap the prefill into
a { id, file_name } object so downstream UI has both. Falls back to
file_name undefined when the join returns null (eg orphaned id).

The form's FilePriorIndicator already accepts the object shape, so it
now shows the filename inline. The report's FormattedValue gets a
matching case: renders the file_name string if present, falls back to
'Attachment #<id>' when only the id came through.
2026-05-19 15:51:35 -07:00
Joel Brock a8ed6073a9 File fields: surface a prior-attachment indicator from RHF prefill
The earlier 'Currently on file' indicator hung off readonlyValue, which
is sourced from evalState (only carries current_stage). For file fields
the prefill lives in RHF state, not in evalState, so the indicator
never fired.

Replaced with a FilePriorIndicator subcomponent that subscribes to the
field's RHF value via useWatch and renders a small leaf-50 banner with
a paperclip glyph when a previous attachment is present. Falls silent
the moment the user picks a new file (RHF value becomes a FileList).
Filename is derived from whatever shape Civi returned — bare string,
object with file_name/name/label/filename, or numeric file id (generic
message in that case).
2026-05-19 15:48:10 -07:00
Joel Brock ba88eb0165 Add /report — read-only activity history view
Mirrors the form's IA and Field Almanac aesthetic; same auth (cid/cs
checksum) so the org owner who can fill the form can also view its
history.

New routes:
- GET /api/report — verifies checksum, resolves the org via the
  Primary Contact relationship, fires Contact.get + Activity.get +
  OptionValue.get in parallel. For every form field with a civiField,
  walks all the org's Check-in (organizing) activities and collects
  every non-empty value into a sorted-DESC history list. Returns
  ReportPayload (orgName, currentStage, activities, fieldHistory,
  options). Has stub-mode payload for env-less local dev.
- /report — page entry; same layout shell (SiteHeader + SiteFooter,
  3xl page width). Eyebrow "Activity report - Co-op organizing".

ReportView component:
- ReportContextHeader: large org name, progress dots + uppercase
  "Current stage" eyebrow + the Civi option *label* on its own line
  at display-font xl/2xl leaf-800 (matches the form's header). Below
  it a 3-up stat band: total check-ins, fields tracked, date span.
- One accordion card per stage section, in stage-rank order. Only
  sections that have at least one field-with-entries render — past,
  current, or "future-with-data" all welcome; truly empty stages stay
  hidden so the page is calm.
- Same journey rail (md+) and mobile stem (md-) with past =
  check-filled-leaf, current = filled-leaf-with-ring, future = dashed
  hollow ring; solid leaf line vs dashed muted between markers.
- Within each card: divide-y rows. Field label and help on the left,
  most-recent value on the right in display-font lg leaf-800, dated
  beneath with an "{N} earlier entries" disclosure that expands a
  small vertical timeline (date on left, value on right).
- FormattedValue handles currency (Intl), percent, number (tabular
  nums), date (long, timezone-safe for YYYY-MM-DD), boolean (Yes/No),
  select/readonly (resolved via option group), multiselect (handles
  array or delimited string), file (filename), text-like (as-is).
- Loading / empty / error states match the form's treatments.

types/form.ts: new FieldHistoryEntry, ActivitySummary, ReportPayload.
The fieldHistory map keys by FieldConfig.name and only includes fields
that have at least one non-empty entry.
2026-05-13 12:33:26 -07:00
Joel Brock 04e69ca04c Header: show Civi stage label prominently; restore current_stage readonly in Stage 0
- New STAGE_OPTION_GROUP_ID constant (=75) added to config and consumed
  by both the in-card readonly and the header. Because the field carries
  optionGroupId, /api/data auto-includes the Stage option group in its
  parallel fetch (via the existing optionGroupIds derivation).
- SubmissionContextHeader: dropped the small inline 'Stage Organizing'
  line. Replaced with an uppercase 'Current stage' eyebrow next to the
  progress dots, followed by a display-font 20/24px leaf-toned label
  underneath. Resolves the stored option value ('Organizing') to its
  Civi option label ('Stage 1 — Convene & Prepare') via the new
  resolveStageLabel helper; falls back to the raw value if the option
  group hasn't loaded.
- Stage 0 'Check-in (organizing)' section: re-added a current_stage
  readonly field at the top. With optionGroupId set, FieldRenderer's
  readonly branch resolves the value to the Civi label for display.
2026-05-13 12:09:24 -07:00
Joel Brock b4e80517a7 Derive current stage from most-recent stage-bearing activity
Stage authority moves from Organization.Food_Co_op_Organizing.Stage to the
most recent Check-in (organizing) activity whose Stage custom field is set.
Staff create these activities manually to mark transitions; org-owner form
submissions no longer write the Stage field at all, so they cannot override
a staff-set transition.

- /api/data: removed the Contact.get for org-side Stage; added an
  Activity.get filtered to ACTIVITY_TYPE_NAME + ACTIVITY_STAGE_FIELD IS
  NOT EMPTY, ordered by activity_date_time DESC, id DESC, limit 1.
  Fallback when no such activity exists: Inquiry (rank 0). Org-name
  lookup, stage activity, prefill, and option-group fetch all run in
  parallel via Promise.all.
- /api/submit: removed the stageAtSubmission read + the
  [ACTIVITY_STAGE_FIELD] write on the activity record. The form's
  activities are stage-null by design now.
- config/form.ts: dropped the stage_at_submission readonly field (no
  longer being set or displayed). Kept ACTIVITY_STAGE_FIELD export — it's
  now used by /api/data to find stage-bearing activities. Updated the
  current_stage field comment to reflect the new source.
- components/EngagementForm.tsx: dropped stage_at_submission from
  evalState (no longer referenced by any visibility rule or readonly
  display).

Org.Food_Co_op_Organizing.Stage remains in CiviCRM for staff list views;
the middleware no longer reads or writes it. No backfill required —
orgs without a stage-bearing activity simply read as Inquiry.
2026-05-13 11:41:25 -07:00
Joel Brock 18b7a67fa1 Swap inline 'Sown' SVG for the real FCI brand logo
Drops the stylized SVG mark in favor of public/fci-logo.png (the official
Food Co-op Initiative logo, including its wordmark). Because the logo now
contains the 'Food Co-op Initiative' text, the duplicate display-font
wordmark is removed; the 'Co-op Check-in' eyebrow stays as a quiet hairline
divider to the right (sm+ only, hidden on mobile to keep the header tight).
2026-05-11 13:42:06 -07:00
Joel Brock 762605f04b Compact the journey rail to reclaim form width
Gutter shrinks from md:pl-20 (80px) to md:pl-12 (48px) — a 32px (40%) gain
back to the form column. Markers, pulse halo, and 'Now' pill scale down to
match so the rail still reads at a glance:

- Marker container: -left-14 w-12 -> -left-9 w-7
- Past/future markers: h-6 -> h-5
- Current marker: h-7 ring-4 -> h-6 ring-2; rank label 11px -> 10px
- 'Now' pill: 9px -> 8px; tracking and offset re-balanced for the shorter
  drop from the smaller marker bottom
- Rail-pulse keyframe shadow radius: 8px -> 6px to suit the smaller disc
2026-05-11 13:37:28 -07:00
Joel Brock 9103ccaf9d Locked future-stage cards + journey rail
Future stages now render as preview-only "look ahead" cards instead of being
hidden. A user at stage 2 can see headers and contents for stages 3, 4, 5,
but those sections are visibly locked and uneditable.

Locked-card treatment:
- Dashed-rule border, paper-2 fill, no shadow — visually quieter than
  active cards
- "Upcoming" pill in the header with a small lock glyph
- Muted stage rank mark (dashed badge, low-opacity icon)
- Panel content wrapped in fieldset[disabled] so every form control inside
  is natively non-interactive, with an opacity tweak for affordance
- "A look ahead" banner explaining that fields will become editable when
  the co-op reaches this stage

Section visibleWhen is still consulted on submit, so locked-stage values
never get written back to CiviCRM even if data is prefilled.

Journey rail:
- Vertical rail (md+) in a new left gutter; each card carries an aligned
  marker. Past stages = filled leaf circle with check; current = filled
  leaf disc with rank number, leaf-100 halo ring, and a subtle rail-pulse
  box-shadow animation (motion-safe). A "Now" pill sits beneath the
  current marker. Future stages = dashed hollow ring with lock glyph.
- Connector segments between markers are solid leaf when the next stage
  is past-or-current, dashed muted when future — so the transition from
  "traveled" to "ahead" reads at the right place in the journey.
- Mobile fallback: a small vertical stem in the gap between adjacent
  cards, styled the same way (solid vs dashed) so the progression cue
  still reads on narrow viewports.
2026-05-11 13:05:34 -07:00
Joel Brock a804650f65 Audit short-term: currency preview, date bounds, success destination, safe-area
- H3: Live currency preview below currency inputs shows the value formatted
  with thousands separators (en-US, USD) using Intl.NumberFormat. Skipped
  inside matrix cells to keep the Y1 monthly table compact.
- M1: Date inputs now apply min/max bounds. Default window is 1900-01-01 to
  2100-12-31; per-field override via FieldConfig.min/max as ISO strings.
- H6: On successful submit, replace the form with a SuccessDestination card
  (large checkmark, org name, "Submit another" + "safe to close" affordance).
  Prevents accidental duplicate submits from back-button / autofill replay.
- M6: Sticky submit bar respects iOS safe-area-inset-bottom.

FieldRenderer now takes a control prop so the currency preview can subscribe
to its single field via useWatch without re-rendering the whole form.
2026-05-11 09:48:03 -07:00
Joel Brock 0d84b9654b Polish pass: removed lastTouched dead code, selective useWatch perf, token sweep, required-field messages, scroll-to-first-error, file prefill display, matrix sticky shadow 2026-05-09 22:49:25 -07:00
Joel Brock 656bf7fd0a Visual identity: Field Almanac — Fraunces+DM Sans, OKLCH cream/leaf/clay palette, paper texture, hand-drawn stage icons, draft auto-save, stage progress dots 2026-05-09 21:48:45 -07:00
Joel Brock da3e48a874 Add matrix-group display for monthly/quarterly time-series fields in Stage 5 2026-05-09 21:29:21 -07:00