# BW Lead Attribution Intelligence — Session Log

Append-only history of development sessions. Newest entries at the top.

---

## 2026-04-13 — Pre-v1 test sweep + fixes (1.0.0)

**Goal:** Full end-to-end browser test of the plugin before cutting v1. User
wants to graduate from pilot status and start work on a v2 Pro feature
(Submission History — designed but deferred).

**Coverage:**
- Settings tab render: all sections render, defaults seed correctly,
  Parameter Aliases shows the split Source/Medium inputs + "other" textarea,
  the other four sections show as textareas. Screenshot captured.
- URL simulator (Test tab): explicit UTM, source-only, click-ID (gclid,
  fbclid), non-standard alias (src=/med=), direct visit, custom dim (mt=
  exact) — all resolve correctly.
- Channel mapping fix verified: `{bw:source} : */referral` rule parses and
  matches. Previously broken from 0.7.0 → 0.8.1, fixed in 0.8.2.
- Live capture flow on `/gf-test/?utm_source=google&utm_medium=cpc&...`:
  visits stored, last/original tracked, summary renders with the 0.8.1
  format (lowercase keys, custom dim in extras line, Channels list).
- Gravity Forms merge-tag population: `input_17` and `input_18` on the form
  both populate with the full `{bw:summary}` output. No console errors.
- Help tab: custom dim (`match_type`) appears as a dynamic row once saved.
- Form Fields tab save path: hidden passthrough inputs carry the combined
  `parameter_aliases` string; sanitize() falls through to the non-split code
  path without wiping data. Verified by saving and re-checking the option.
- Validation: adding `summary : foo, bar` in Parameter Aliases triggers the
  reserved-name error. Added `match_type : mt, match, utm_match_type` saves
  cleanly and shows up in Help tab.

**Bugs found:**

1. **Critical — source/medium separator trimmed on save.** Defaults to `" / "`
   but after any save of the Settings tab, `sanitize_text_field()` trimmed the
   spaces leaving `"/"`. `{bw:source_medium}` then rendered as `google/cpc`
   instead of `google / cpc`. User-visible regression affecting the default
   output. Fix: new `sanitize_separator()` helper that uses `strip_tags()`
   (not `wp_strip_all_tags()` — that also trims!) and removes control chars
   while leaving regular spaces alone. Verified: ` / ` round-trips through
   save, and the live summary now shows `source_medium: google / cpc`.

2. **Minor — reserved-name ghost row.** Saving a Parameter Aliases row with a
   reserved label (`summary : foo`) emitted the validation error but still
   wrote the row to the database. The parser silently dropped it at render
   time, so the UI stayed clean, but the stored option carried a zombie.
   Changed `validate_parameter_aliases()` to return a cleaned string that
   strips invalid rows; sanitize() now uses the cleaned version as the final
   stored value. Error notice still surfaces so users know a row was dropped.

3. **Minor — Test tab simulator labeled custom dims with `custom.` prefix.**
   `admin-test.js` hadn't been updated when we dropped the `custom.` prefix
   from merge tags in 0.8.0. Fixed to show `{bw:match_type}` so the Test tab
   matches the Help tab and actual merge-tag usage.

**Features deferred to v2 (Pro):**
- **Submission History** — new tab + Gravity Forms Entries integration +
  CSV export + server-side submission storage. Designed out in this session;
  requires a privacy-posture shift (client-side-only → writes to DB) so
  gated behind a "Pro" opt-in feature. Full decision points in the earlier
  thread before this test sweep.

**Left off at:** All three fixes in place, all scans clean, version bumped to
1.0.0. Ready for release.

## 2026-04-13 — Channel label parser bug (0.8.2)

**Bug:** User reported that a visit resolved to `auth.demoing.info / referral`
as the channel instead of just `auth.demoing.info`, despite the default rule
`{bw:source} : */referral` being present.

**Root cause:** `parse_labeled_list()` split each line at the FIRST `:` via
`explode(':', $line, 2)`. For a label containing the substitution token
`{bw:source}`, this ate the colon inside the braces — label became `{bw` and
the "values" were `source} : */referral`. The pattern parser couldn't make
sense of that, so the rule silently dropped and `resolveChannel()` fell back
to the raw `source + separator + medium` string.

Bug has been present since 0.7.0 (when channel mappings were introduced). Only
surfaced now because during the 0.8.0/0.8.1 testing, the first visits that
would have triggered this specific default rule were generic referrer visits
(previous tests used direct/utm/organic flows, none of which hit
`{bw:source} : */referral`).

**Fix:** New `find_label_separator()` helper walks the line tracking `{...}`
brace depth. Returns the position of the first `:` at depth 0. The parser now
uses this instead of `explode(':', $line, 2)`, so labels with internal braces
like `{bw:source}` round-trip cleanly.

**Verified:**
- `{bw:source} : */referral` → label=`{bw:source}`, values=`*/referral` ✓
- `google/cpc : gclid, gclsrc` → unchanged (no braces, still splits at first `:`) ✓
- `Google Ads : google/cpc, google/ppc` → unchanged ✓
- `Direct : (direct)/(none)` → unchanged (parens aren't braces) ✓

Ran a `parse_labeled_list()` + `parse_channels()` smoke test in the live
container against the default channels text; all three sections parsed as
expected. Only channels was affected — the other sections (parameter_aliases,
referrer_classification, click_ids) never use brace tokens in labels.

## 2026-04-13 — Summary polish + source/medium split (0.8.1)

**Goal:** Round out the 0.8.0 unified-format work with two follow-ons that came out
of the first live test:

1. The `{bw:summary}` block only surfaced the built-in extras (campaign/term/
   content/adgroup). Custom dimensions weren't appearing anywhere even though the
   plugin was capturing them.
2. Source and Medium are required dimensions — if the user accidentally deletes or
   renames those rows in the Parameter Aliases textarea, attribution silently
   breaks. They also worried about case sensitivity: a typed `Source` row wouldn't
   match lowercase URL params.

**Done — summary format:**
- New helper `formatExtras(v)` in `capture.js` that collects non-blank values from
  campaign/term/content/adgroup plus every custom dimension and returns them as
  `key: val` pairs. Keys are lowercased and emitted verbatim so custom dims like
  `match_type` slot in naturally.
- Attribution blocks (`Converted via` / `Originally found via`):
  - Source/medium line lowercased: `source: google | medium: cpc`.
  - Extras line uses `formatExtras()` so custom dims appear alongside standard
    tags: `campaign: test | match_type: test match type`.
- Stats block:
  - `Visits: 4` → `Visits - 4` (only the first label uses the dash — rest stay on `:`).
  - New `Channels: A, B, C` line listing distinct channels from the journey,
    walked newest-first and deduped (so last-touch channel appears first).
- `tagInfoLine` rewritten:
  - Returns `Tag Info - none` only when source is effectively default
    (`(direct)`, `(not set)`, empty), medium is effectively default (`(none)`,
    `(not set)`, empty), AND there are no extras.
  - Otherwise shows source (parens stripped) and medium (omitted entirely if it's
    `(none)`) followed by all non-blank extras.
  - New `stripParens()` helper.

**Done — Parameter Aliases UI:**
- Source and Medium promoted to dedicated `<input>` fields in the Settings tab.
  Labels are uneditable; only the comma-separated parameter list is editable.
- Everything else (campaign, term, content, adgroup, custom dims) stays in a
  single textarea labeled "Other dimensions & custom parameters".
- Internally everything still flows into one `parameter_aliases` string. The
  sanitize callback detects the split form (presence of
  `parameter_aliases_source` / `_medium` / `_other` keys) and rebuilds the
  combined string. If the user clears source or medium, defaults are used — they
  cannot accidentally nuke attribution by leaving those fields blank.
- Added a filter in the rebuild to drop any user-typed `source :` / `medium :`
  rows from the "other" textarea so those two labels can never appear twice.
- Other tabs (Form Fields) still save through the combined `parameter_aliases`
  hidden input — the sanitize callback falls through to the old path when the
  split keys aren't present.
- Help tab's dynamic custom-dim listing unchanged — already fed from
  `parse_parameter_aliases()`.

**Left off at:** Code changes complete, all scans clean. Ready for live retest.

**Notes:**
- Didn't touch the default (non-detailed) journey view — it already just prints
  `DATE - channel -> landing page`, which still reads fine.
- Custom dimension values that match `(not set)` are skipped on the extras line.
  The resolver never sets them to `(not set)` — unset custom values are empty
  strings — but filtering both covers whatever future defaults we introduce.
- The Channels line is omitted when there are no visits (fresh state).

## 2026-04-13 — Unified mapping format (0.8.0)

**Goal:** Collapse all five mapping-style settings (Parameter Aliases, Referrer
Classification, Click-ID Inference, Channel Mappings, Custom Dimensions) onto one
consistent format so users only need to learn one mental model, and so adding new
categories (e.g. an "ai" referrer class for openai.com / claude.ai / gemini) is just
data entry, not code changes.

**Decisions:**
- **Format:** `label : value1, value2, ...`, one rule per line, walked top-to-bottom.
  Label on the left is always the *result*; values on the right are the *inputs* that
  match. Channel Mappings already used this — every other section conforms to it now.
- **Merge Custom Dimensions into Parameter Aliases.** Rows with a standard key
  (`source`, `medium`, `campaign`, `term`, `content`, `adgroup`) populate the built-in
  dimensions; any other label is a custom dimension.
- **Custom dim merge tags are unprefixed.** `{bw:match_type}` instead of
  `{bw:custom.match_type}`. Validated on save: custom keys can't collide with reserved
  merge tag names (`source_medium`, `channel`, `first_source`, `summary`, etc.). Chose
  this over the namespaced form because tags are prettier and the reserved set is small.
- **Click-ID orientation flip.** Chose `google/cpc : gclid, gclsrc` over
  `gclid : google/cpc` for cross-section consistency (label is the result). The user
  initially pushed back on the flip; consistency won.
- **Referrer Classification renamed to "Default Referrer Classification"** with an
  explicit note that it's a fallback only — explicit UTMs and click-IDs always win.
  This was always the behavior; now the name and description match.
- **No migration.** 0.7.x never shipped outside the test site, so the old keys are just
  dropped. Testing site was cleared manually.

**Done:**
- `class-bw-lead-ai-settings.php`:
  - Removed old per-dimension CSV keys + `custom_dimensions` + `organic_sources` /
    `social_sources`. New keys: `parameter_aliases`, `referrer_classification`,
    `click_ids`, `channels`.
  - New `STANDARD_ALIAS_KEYS` and `RESERVED_TAG_NAMES` constants.
  - New generic `parse_labeled_list()` helper driving all four sections.
  - New `parse_parameter_aliases()` → `{ standard, custom }` split.
  - New `parse_referrer_classification()` → ordered `[{medium, sources}]`.
  - Rewrote `parse_click_ids()` for the flipped format, still emitting the same
    `[{param, source, medium}]` shape the JS consumes.
  - `validate_parameter_aliases()` checks for duplicate labels and custom keys that
    collide with reserved merge tags, surfacing problems via `add_settings_error`.
  - New default texts for each section in the unified format.
- `class-bw-lead-ai-frontend.php` `client_config()`:
  - Uses `parse_parameter_aliases()` → `dimensions` + `customDimensions`.
  - New `referrerClasses` array replaces the separate `organicSources` /
    `socialSources`.
  - Dropped the `csv_to_array` helper.
- `assets/js/capture.js`:
  - `referrerClass()` walks the generic `referrerClasses` list.
  - `resolveMergeTag()` custom-dim branch no longer requires a `custom.` prefix —
    any unknown tag is looked up in `state.last.custom`.
- `class-bw-lead-ai-admin.php`:
  - Settings tab completely rewritten: single textarea per section, descriptions
    updated, "Default Referrer Classification" rename, Custom Dimensions section
    removed. Added a top-of-form description explaining the shared format.
  - Help tab "All available merge tags" table dynamically lists each defined custom
    dimension with its merge tag + populating params. The generic `{bw:custom.key}`
    row is gone.
- `class-bw-lead-ai-merge-tags.php` `register_tags()` appends custom dims to the
  Gravity Forms dropdown alongside the built-in tags.
- `assets/js/admin-test.js` shows custom dims as `{bw:<key>}` instead of the old
  `{bw:custom.<key>}` form.
- Version bumped 0.7.0 → 0.8.0, CHANGELOG updated.

**Left off at:** Code changes complete, cleanup-scan and test-plugin clean. Security
scan shows only the pre-existing warning on admin.php:73 (`$_GET['tab']` — already
sanitized). Ready for live retest on the test site (user will clear existing settings).

**Notes:**
- The hidden-input loop on the Form Fields tab iterates `defaults()`, so multiline
  values (`parameter_aliases` etc.) pass through as hidden inputs with newlines
  preserved via `esc_attr`. Verified in passing — if we ever see corrupted settings
  after saving from the Form Fields tab, this is where to look.
- Help tab uses `BW_Lead_AI_Settings::get()['parameter_aliases']` (PHP 5.4+ array
  dereferencing). Plugin minimum is PHP 7.4, so fine.
- The click-ID parser emits one flattened row per param for compatibility with the
  existing JS lookup. Users see one row per result; the JS still sees one row per
  param.

## 2026-04-13 — Channel mappings (0.7.0)

**Goal:** Let users map raw `source / medium` combinations to friendly channel labels
("Google Ads", "Email", "Direct", etc.) and use those labels at the top of the
attribution summary and in the journey visit headers.

**Done:**
- Settings: new `channels` multiline option. Format per rule:
  `Label : source1/medium1, source2/medium2, ...`. Wildcards via `*`. Label supports
  `{bw:source}` / `{bw:medium}` substitution so a single rule like
  `{bw:source} : */referral` gives per-source labels.
- `BW_Lead_AI_Settings::default_channels_text()` seeds sensible defaults covering
  Google/Bing/Facebook/LinkedIn/TikTok/YouTube/Twitter ads, organic search per engine,
  Email, Social, Display, Affiliate, Direct, Referral, and Unknown buckets.
- `BW_Lead_AI_Settings::parse_channels()` turns the multiline config into an ordered
  list of `{ label, patterns: [{source, medium}, ...] }` rules.
- Frontend config exposes `channels` to the capture script.
- `capture.js`:
  - New `resolveChannel(visit)` — walks rules top-to-bottom, first match wins, falls
    back to raw `source + sep + medium` if nothing matches.
  - `buildSummaryText` uses `resolveChannel()` for `Converted via - ...`,
    `Originally found via - ...`, and the detailed/default journey visit headers.
  - Data lines switched to `:` separators throughout
    (`Source: google | Medium: cpc`, `Campaign: X | Keyword: Y | ...`) — these were
    mixed ` - ` / `:` before.
  - `campaignDetails()` reverted to plain `Key: val` list (no leading dash).
- New merge tags `{bw:channel}` and `{bw:first_channel}` registered in JS resolver
  and PHP `BW_Lead_AI_Merge_Tags::TAGS`.
- Admin settings UI: new "Channel Mappings" collapsible section with textarea +
  description.
- Help tab merge-tag reference: added `{bw:channel}` and `{bw:first_channel}` rows.
- Version bumped 0.6.1 → 0.7.0, CHANGELOG updated.

**Left off at:** Code changes complete, all scans clean, ready for live retest.

**Notes:**
- Channel resolution is case-insensitive — rule patterns and visit values are both
  lowercased before comparison. Labels are emitted as configured (original casing).
- Direct visits are stored with `source='(direct)'`, `medium='(none)'`, so the Direct
  default rule uses that literal pair. `(not set)` / `(unknown)` rules exist for the
  very rare cases where nothing resolves.
- `{bw:source}` / `{bw:medium}` in labels are substituted client-side by
  `expandChannelLabel()`. Not a full template engine — just literal string replace.
- The default summary still shows raw source/medium only on line 2 of each
  attribution block; that gives CRM importers the machine-readable values alongside
  the human label.

## 2026-04-13 — Summary v2 format polish (Unreleased)

**Goal:** Tweak the new `{bw:summary}` / `{bw:summary_detailed}` layouts based on
live testing. Make the attribution block easier to scan, surface the raw
source/medium per visit, and flag conversions inline in the detailed journey.

**Done:**
- `buildSummaryText` rewrite in `assets/js/capture.js`:
  - Attribution block uses ` - ` label separators and adds an explicit
    `Source - X | Medium: Y` line under each touchpoint (`Converted via`,
    `Originally found via`).
  - `Tagged:` stat renamed `Tagged Visits:`.
  - Dropped `Sources:` line (redundant with per-visit journey rows).
  - `First visit - ... | Last visit: ...` uses matching leading-dash format.
- Default journey: one line per visit, `DATE - source / medium -> landing page`.
  Removed numeric index (`1.`, `2.`) and `--` separators.
- Detailed journey: pages list is flush against the visit header (no `Pages:`
  header, no indentation). Each visit gets a `Tag Info - ...` line showing
  source/medium/campaign/keyword/content (or `Tag Info - none` for untagged).
  Submissions are flagged inline: `HH:MM - url *submitted*`.
- `campaignDetails()` helper refactored to emit leading-label format
  (`Campaign - spring-sale | Keyword: shoes | ...`) so it matches the new
  attribution block.
- New `tagInfoLine()` helper for the detailed-journey Tag Info line.
- CHANGELOG updated under [Unreleased].

**Left off at:** Code changes complete. Still on version 0.6.0 + Unreleased
entries — no version bump yet. User to visually re-test the form summary on
`bw-plugins.demoing.info/gf-test/`.

**Notes:**
- Submission flagging relies on `recordSubmission()` firing in the capture-
  phase `submit` listener. If the user still sees no `*submitted*` markers
  after re-testing with cleared storage, check whether Gravity Forms' AJAX
  mode is swallowing the submit event before our capture handler runs.
- Did NOT touch `resolveMergeTag()` — the raw merge tags (`{bw:source}`,
  `{bw:summary}` etc.) keep the same string outputs, only the composite
  summary format changed.

## 2026-04-12 — Summary v2 + submission tracking (Unreleased)

**Goal:** Rework `{bw:summary}` to the new CRM-ready layout and add a detailed variant
that lists every page visited inside each touchpoint. Also track form submissions per
visit so sessions that convert more than once show each conversion on its own visit.

**Done:**
- Capture script:
  - Visits and views now carry a `ts` (ms) so views can be grouped per visit.
  - New `bw_lai_submits_*` store with `recordSubmission()` — deduped so GF's multi-phase
    submit doesn't create duplicates. Submit listener records before re-populating fields.
  - Rewrote `buildSummaryText` to the new layout: `Campaign: X | Keyword: Y` on one line,
    `Landed on:` / `First landed on:`, `Submitted on:`, `Sources:` unique list, and
    `Days to Conversion:` stat.
  - New `opts.detailed` branch prints `Pages:` per visit with `HH:MM -- url` entries and
    a `submitted --` flag for submissions. Views matching a submission URL in the same
    visit are suppressed (submission stands in for the view).
  - View storage cap raised 10 → 50.
- Registered `{bw:summary_detailed}` merge tag (PHP + JS resolver).
- Admin "All merge tags" table lists the new tag.
- CHANGELOG updated under [Unreleased].

**Left off at:** Code changes complete; not yet version-bumped or released.

**Notes:**
- `last.ts` is only set on new visits, so `Days to Conversion` won't render for stored
  state captured before this change — graceful degradation.
- Dedup rule for detailed pages: if a submission URL matches any view URL in the same
  visit, all matching views drop out so only the submission entry remains.
- Submissions are associated by `visitTs`, not by clock time — so if the user pauses
  between landing and submitting, the submission still lands on the right journey row.

## Format

```
## YYYY-MM-DD HH:MM — <dev name>

**Goal:** What we set out to do
**Done:** What got done
**Left off at:** Where we stopped
**Notes:** Anything worth remembering
```

---

## 2026-04-12 — Summary redesign + submit_page (0.6.0)

**Goal:** Redesign the `{bw:summary}` output for CRM readability, add submission page tracking.

**Done:**
- Redesigned `{bw:summary}` for Salesforce textarea fields: structured sections
  (Lead Attribution / Journey), conversion source at top, first-touch comparison when
  different, stats line, numbered journey with only non-empty dimensions.
- Added `{bw:submit_page}` merge tag — resolves live from `window.location` at form
  submission time (not from stored state). Answers "which page did they convert on?"
- Summary now includes "Submitted from:" line using the live page URL.
- Updated Help tab decision tree and reference table with new tags.
- Bumped to 0.6.0. All scans pass.

**Left off at:** 0.6.0 checkpoint complete. Pending rian's manual review and testing.

**Notes:**
- `{bw:last_page}` = landing page of the most recent visit (stored).
- `{bw:submit_page}` = page URL at form submission time (live). These are different:
  last_page is where they landed, submit_page is where they filled out the form.
- Cloudflare CDN caches JS aggressively — version bump changes the `?ver=` query string
  which busts cache for end users, but during dev you may need hard refresh.

---

## 2026-04-11 — Bug fixes, UI improvements, performance (pre-0.6.0)

**Goal:** Fix critical and quality bugs found during the 0.5.0 deep review, improve admin UI,
optimize performance of the capture engine.

**Done (bugs):**
- **Bug #1 (critical):** Fixed mid-session re-tagging. Tagged visits with a new source or
  campaign now bypass the session gate and update `last` + visit history. `original` is never
  overwritten. This enables correct last-touch attribution for retargeting flows.
- **Bug #2:** Referrer hostname matching changed from `indexOf` (substring) to exact/suffix
  match with dot boundary. Prevents "mybloggoogles.com" matching "google".
- **Bug #3:** `getParams()` now uses `indexOf('=')` + `slice` instead of `split('=')` so
  values containing `=` are preserved in full.
- **Bug #4:** Default `fbclid` mapping changed from `cpc` to `social` since Facebook appends
  fbclid to all outbound clicks, not just paid ads.
- Fixed MutationObserver using stale state — now calls `buildState()` on each mutation.
- Fixed potential timestamp collision in visit/view storage keys (Date.now + counter).
- Removed dead `URL_LC` variable.

**Done (performance):**
- Moved capture.js from `<head>` to footer — no longer blocks page rendering.
- Batched summary counter updates into one localStorage read-write (was 2-4).
- Debounced MutationObserver via requestAnimationFrame.

**Done (UI):**
- Test tab: inline URL simulator (type URL → see classification without navigating).
- Test tab: resolved values and summary as clean key/value tables instead of raw JSON.
- Test tab: clear storage confirmation dialog.
- Test tab: visit history and raw storage in collapsible `<details>`.
- Settings tab: collapsible sections with `<details>/<summary>`.
- Form Fields tab: prominent info notice for GF users.
- Help tab: "Which merge tag should I use?" decision tree + full reference table.
- Help tab: "How attribution works" section.
- UTM Builder: "Open tracked URL" button per item.
- New merge tag `{bw:last_page}`.
- New `BWLeadAI.simulateUrl()` API method.

**Left off at:** All changes implemented. Next: browser testing, then version bump + scans + release.

**Notes:**
- Attribution model confirmed with rian: `original` = first touch, `last` = last touch,
  `visits` = full journey. Design supports future first-click / last-click / multi-touch
  attribution reporting.
- Existing installs that saved settings will keep the old `fbclid|facebook|cpc` — only new
  installs get the corrected default. Noted in KNOWN-ISSUES.
- `resolveVisit()` now accepts optional params/landingPage args for simulation without
  touching storage. This is the foundation for the inline simulator.

---

## 2026-04-11 — Phase 1 rebuild (0.5.0)

**Goal:** Rebuild the legacy `bw-user-analytics` plugin under a new name with improved
architecture, code quality fixes, Gravity Forms merge-tag integration, a live admin test
panel, and configurable click-ID + custom-dimension support.

**Done:**
- Bumped to 0.5.0 (starting baseline for the rewrite).
- New main file `bw-lead-ai.php` with constants and bootstrap.
- Core classes in `includes/`: Plugin, Settings, Frontend, Admin, Merge_Tags, REST.
- Vanilla-JS capture engine in `assets/js/capture.js` with:
    - Strict resolution cascade (explicit → click-ID → referrer → direct)
    - Configurable click-ID inference table
    - Custom dimensions
    - Capped visit / view history (first 5 + last 5)
    - Cookie fallback when localStorage is unavailable
    - Client-side merge-tag substitution for GF hidden fields via DOM scan + mutation
      observer + submit-capture
- Admin UI with Settings / Form Fields / UTM Builder / Test / Help tabs.
- Test tab JS (`admin-test.js`) that reads the same browser storage the front-end writes.
- UTM builder JS (`utm-builder.js`) with live preview + copy-to-clipboard.
- Merge-tag registration with Gravity Forms (passthrough on server, replacement client-side).
- REST endpoint `/bw-lead-ai/v1/links` (manage_options required).
- `SPEC.md`, `ARCHITECTURE.md`, `CHANGELOG.md` updated to match.

**Left off at:** Phase 1 implementation complete on disk; scans + release pending.

**Notes:**
- Legacy `bw-user-analytics` extracted at `/srv/apps/bw-plugins/bw-user-analytics/` — left
  in place for reference; to be deleted later on rian's call.
- No migration path — per rian, new clients only.
- Combined source/medium is delivered via merge tags, not a dedicated form-target row.
- Phase 2 roadmap (WP admin reporting on a custom table) is the next major.

## 2026-04-11 — scaffold

**Goal:** Create initial plugin scaffold
**Done:** Scaffolded via tools/new-plugin.sh at version 0.1.0
**Left off at:** Ready to begin development
**Notes:** Standard BW plugin structure. Docs stubs in place, awaiting real content.
