Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save nandosb/78393e5fda9b5925f2960adf972b114e to your computer and use it in GitHub Desktop.

Select an option

Save nandosb/78393e5fda9b5925f2960adf972b114e to your computer and use it in GitHub Desktop.
# Jot Down: Campaign XRAY — CSV Download for Current Campaign
# Jot Down: Campaign XRAY — CSV Download for Current Campaign
Author: mailto:fernando.serrano@yalo.com
Doc Type: Jot Down
Feature: Campaign XRAY — CSV Download
Quarter: 2026-Q2
## References
Source: Manual input (free-text request) — "Add a button to download the information for the current campaign from the Campaign XRAY app. FE only, no new BE requests."
Related Linear project: [Campaigns X-Ray](https://linear.app/yalo/project/e9ff5883-957c-46f9-b099-09ec51f9026c)
## Author / Date / Status
- **Author:** [fernando.serrano@yalo.com](mailto:fernando.serrano@yalo.com)
- **Date:** 2026-05-06
- **Status:** Draft
---
## 1. Architectural Decisions
| Decision | Options Considered | Chosen Approach | Rationale |
| --- | --- | --- | --- |
| Where the button lives | Detail view only / List view only / Both | **Detail view only** (`CampaignXRayDetail.tsx`) | Matches the user's literal request ("current campaign") and the data already in scope on that screen. List-view export is out of scope. |
| Export format | CSV / JSON / Both | **CSV** | Stakeholders open exports in Sheets/Excel; a single human-readable file is enough. JSON revisit if devs ask later. |
| File generation library | papaparse / hand-rolled / `react-file-download` (already in deps) | **`react-file-download`** (already a dep, currently unused) for the download trigger; a small in-house CSV serializer for the string itself | No new dependency. The serializer is a ~30-line util that handles RFC-4180 quoting + UTF-8 BOM so Excel renders accents correctly. |
| Activity / reasons rows source | Currently loaded only / fan-out re-fetch all pages / skip rows | **Currently loaded only** | Honors "no new BE request" — `CampaignActivityTable` already aggregates reasons client-side from `useGetCampaignDeliveryStats`. We export exactly what's on screen. |
| Filename pattern | name slug + timestamp / sendId + date / campaignId only | **`campaign-xray_<campaign-name-slug>_<YYYYMMDD-HHmm>.csv`** | Human-readable, sortable in Finder/Explorer, includes campaign identity. Slugify util to be added under `helpers/`. |
| Feature flag rollout | No flag / boolean flag / multi-variant flag | **Boolean flag, account-level rollout** via existing [Split.io](http://Split.io) infra | Lets us ship behind a kill-switch and roll out per-customer. Reuses the existing `useFeatureFlagTreatment` hook (`apps/studio/src/hooks/useFeatureFlagTreatment.ts`) with `useProvider: true` so the Split key is the customer/account slug. |
| Split key | userEmail / customerSlug / botSlug | **customerSlug** (account-level) | The existing hook supports `userEmail` (default) or `customerSlug` via `useProvider: true`. `botSlug` is not supported by the hook today, and account-level matches how other Studio splits roll out. Recorded as the closest implementable mapping of "orgId / botSlug" given the current infra. |
| Filter behavior | Export all reasons regardless of UI filters / Export only what user has filtered | **Export only currently filtered rows** | The activity table already supports stage + reason filters. "Download what you see" is the least surprising behavior and avoids an awkward "download unfiltered" mode. |
| GTM coverage | None / click only / click + success/error | **Click only** — `campaign-xray_export_clicked` | Small surface, low ambiguity, easy to wire on first cut. We already track page view; we don't need outcome events for a synchronous client-side download. |
---
## 2. Overall Solution
**What we're building.** A single "Download CSV" button on the Campaign XRAY detail page (`/campaigns/x-ray/:campaignId`). When the user clicks it, we serialize the in-memory campaign + delivery-stats data into a CSV file and trigger a browser download. No network calls. No backend changes.
**End-to-end flow (user perspective):**
1. User opens a campaign detail page in Campaign XRAY.
2. The two existing React Query hooks (`useGetCampaign`, `useGetCampaignDeliveryStats`) load campaign metadata and delivery stages.
3. A new "Download CSV" `IconButton` (file-download icon) sits in the header area next to the existing layout/error toggles.
4. Button is disabled while either query is loading. Hidden entirely if the Split treatment is `off`.
5. Click → we build a CSV string from the cached query data + the current activity-table filter state → trigger download via `react-file-download` → push a GTM event.
**End-to-end flow (data):**
```
useGetCampaign(campaignId) \
useGetCampaignDeliveryStats(sendId) → buildCampaignXRayCsv(...) → fileDownload(csv, filename)
activity-table filter state (lifted up) /
```
The CSV is a single file with three logical sections separated by blank lines:
- **Section A — Campaign metadata** (key/value rows): name, type, status, goal slug, audience name, channel, send type, send date, send id, created by, created at (UTC + local), notification template name, plus flattened template params, hidden params, and automation step params.
- **Section B — Delivery funnel stages** (table rows): for each stage in the depth-first walk of `campaignDeliveryStats.series[0].stages`, one row per stage with `parent stage`, `stage name`, `quantity`. Walks `children[]` recursively.
- **Section C — Activity (reasons)** (table rows): the same `(stage, reasonType, amount)` rows the user currently sees in `CampaignActivityTable`, after applying the active stage filter and reason search query.
---
## 3. Database Model Changes
**N/A — no database changes.** This is a frontend-only feature that consumes data already loaded by existing React Query hooks. No new collections, no schema migrations.
---
## 4. API Specifications
**N/A — no new or modified API endpoints.** The feature reuses the existing reads:
| Hook | Endpoint (existing) | Used for |
| --- | --- | --- |
| `useGetCampaign({ id })` | Existing campaign detail GET (commerce-manager-bff) | Section A of the CSV |
| `useGetCampaignDeliveryStats(sendId, botSlug)` | Existing delivery-stats GET (commerce-manager-bff) | Sections B and C of the CSV |
No additional calls. No retries. No new query keys.
**Acceptance criteria for data sourcing:**
- ✅ The download produces output with zero new network requests issued at click time (verifiable via DevTools Network tab).
- ✅ The data in the CSV is consistent with what is rendered on screen at the moment of click.
---
## 5. System Design
**Affected services / repos:**
- `frontend-platform` → `apps/studio` (the only repo touched).
**Components touched / added:**
```
apps/studio/src/containers/Engagement/CampaignsError/
├── CampaignXRayDetail.tsx (modified — add button + handler)
├── CampaignActivityTable.tsx (modified — lift filter state up via props)
├── helpers/
│ ├── buildCampaignXRayCsv.ts (new)
│ ├── buildCampaignXRayCsv.test.ts (new)
│ ├── slugifyCampaignName.ts (new)
│ ├── slugifyCampaignName.test.ts (new)
│ ├── csvSerialize.ts (new — RFC-4180 + UTF-8 BOM helper)
│ └── csvSerialize.test.ts (new)
├── constants/
│ └── AnalyticsEvents.ts (modified — add CAMPAIGN_XRAY_EXPORT_CLICKED)
└── (no new components — the button is an `IconButton` inline)
```
New Split entry:
- `apps/studio/src/constants/SplitNames.ts` → add `CAMPAIGN_XRAY_CSV_EXPORT = 'campaign_xray_csv_export'`.
**Sequence of operations (click handler):**
1. Read `campaign` and `campaignDeliveryStats` from the already-mounted React Query hooks (passed as props/refs from `CampaignXRayDetail`).
2. Read the activity-table filter state (`selectedStage`, `searchQuery`) — lifted up to `CampaignXRayDetail` so the click handler can see it.
3. Call `buildCampaignXRayCsv({ campaign, deliveryStats, activityFilters, locale })` → returns `{ csv: string, filename: string }`.
4. Call `fileDownload(csv, filename, 'text/csv;charset=utf-8')` from `react-file-download` (the lib accepts a string + filename + MIME).
5. `Analytics.pushEvent({ event: 'campaign-xray_export_clicked', campaignId, sendId, customerSlug, botSlug, author, timestamp })` via the existing `useAnalytics` hook in `apps/studio/src/containers/Engagement/hooks/useAnalytics.tsx`.
No state machine, no async work, no error path beyond a try/catch that surfaces a Snackbar/Toast on serialization failure (extremely unlikely — defensive only).
---
## 6. Service Interactions
**N/A — no inter-service communication is added.** The feature is entirely client-side; no new messages, queues, or events leave the browser.
---
## 7. Dependencies
- **`react-file-download@0.3.5`** — already present in `apps/studio/package.json` (line ~208), currently unused. Provides `fileDownload(data, filename, mimeType)`. No version bump.
- **`@yalo/commons` Analytics** — existing.
- **`@splitsoftware/splitio-redux`** — existing, used through `useFeatureFlagTreatment`.
No new dependencies, no version bumps, no shared library updates.
---
## 8. Feature Flag ([Split.io](http://Split.io))
| Field | Value |
| --- | --- |
| **Split name** | `campaign_xray_csv_export` |
| **Treatments** | `on`, `off` |
| **Default treatment** | `off` |
| **Service / Repo** | `frontend-platform` → `apps/studio` |
| **Evaluation point** | `CampaignXRayDetail.tsx` — at the top of the component, via `useFeatureFlagTreatment(SplitNames.CAMPAIGN_XRAY_CSV_EXPORT, { useProvider: true })`. The button is conditionally rendered when the treatment is `'on'`. |
| **Key** | Customer (account) slug — `customers.customerSelected.slug`, supplied by the existing hook when `useProvider: true` |
| **Treatment mapping** | `'on'` → render the Download CSV `IconButton`. `'off'` (and any other value) → do not render the button. |
| **Fallback behavior** | If the Split SDK can't reach Split (timeout, offline, init not complete), `selectTreatmentValue` returns `'control'` / `undefined`. Code MUST treat anything other than `'on'` as `off` — i.e. button hidden. This matches the safe / current behavior (no button today). |
**Constants to add** in `apps/studio/src/constants/SplitNames.ts`:
```tsx
CAMPAIGN_XRAY_CSV_EXPORT = 'campaign_xray_csv_export',
```
[**Split.io](http://Split.io) platform setup (manual, by whoever owns Split):**
- Create the split with treatments `on` / `off`, default `off`.
- Allowlist target customer slugs as the rollout proceeds.
---
## 9. Deployment Stages
| Stage | What ships | Risks | Mitigation |
| --- | --- | --- | --- |
| 1. Code merge with flag `off` | All code (button + helpers + tests + Split entry) merges to `main` and ships to staging/prod with the split defaulting to `off` everywhere | Code that's deployed but unreachable still introduces bundle weight | The new helpers add < 5 KB gzipped; CSV string is built lazily on click, not on render |
| 2. Internal QA | Allowlist Yalo's internal customer slug(s) on Split → treatment `on` for the QA team only | QA finds a regression | Roll back is just flipping the treatment to `off` for the allowlisted slug — no redeploy |
| 3. Pilot | Allowlist 1–3 friendly customer slugs | Pilot customer hits an unexpected data shape (e.g. a campaign with 5 000 reason rows produces a multi-MB file) | Built-in: file size is bounded by what the page already renders. If a complaint comes in, flip the slug back to `off`. |
| 4. General availability | Set the split's default to `on` | None expected | The flag remains in code so we keep a kill switch; remove the flag in a follow-up PR after one full sprint of GA stability |
**Rollback plan (any stage):** flip the Split treatment back to `off` for the affected key(s). No code revert required. No data state to clean up.
---
## 10. Testing Strategy
### Unit tests (Jest + RTL, following `.claude/skills/test-patterns.md` via `test-patterns-enforcer`)
**`csvSerialize.test.ts`** — pure function tests:
- Quotes any field that contains `,`, `"`, `\n`, or `\r`.
- Escapes embedded `"` by doubling.
- Prepends UTF-8 BOM (``) so Excel renders Spanish/Portuguese accents correctly.
- Joins rows with `\r\n` per RFC-4180.
- Empty input produces a single trailing newline (no crash).
**`slugifyCampaignName.test.ts`**:
- Lowercases, replaces whitespace with `-`, strips diacritics, drops non-`[a-z0-9-]` characters.
- Truncates to 60 chars.
- Empty / undefined input returns `'campaign'`.
**`buildCampaignXRayCsv.test.ts`**:
- Returns the expected three-section CSV string for a fixture from `constants/campaignDataMock.ts` + `constants/campaignDeliveryStatsMock.ts` (already in repo).
- Honors the `activityFilters.selectedStage` parameter (rows for other stages excluded).
- Honors the `activityFilters.searchQuery` parameter (case-insensitive substring on `reasonType`).
- Filename matches `^campaign-xray_[a-z0-9-]+_\d{8}-\d{4}\.csv$`.
- Walks nested `stages[].children[]` depth-first (verify with a fixture that has nested children).
- Renders `notificationTemplateParams`, `notificationTemplateHiddenParams`, `automationStepParams` as flattened key/value rows.
- Treats missing optional fields as empty strings, never `undefined`.
**`CampaignXRayDetail.test.tsx`** (new tests on the modified file):
- When Split treatment is `'on'` → button renders.
- When Split treatment is `'off'` / `undefined` / `'control'` → button does not render.
- Click while either query is loading is a no-op (button is `disabled`).
- Click invokes `fileDownload` once with the expected MIME and a non-empty CSV string. Mock `react-file-download` per file.
- Click pushes the `campaign-xray_export_clicked` analytics event with `campaignId`, `sendId`, `customerSlug`, `botSlug`, `author` populated. Mock `@yalo/commons` per the standing exception (see `.claude/CLAUDE.md` § Analytics).
**`CampaignActivityTable.test.tsx`** (regression on the lifted-state refactor):
- Existing filter and search behavior still work when state lives one level up.
- Existing copy-to-clipboard behavior still works.
### Integration / e2e
- Manual smoke in staging: open a campaign with at least one reason row, click the button, verify the CSV opens cleanly in Sheets and Excel (BOM check), verify the filename matches the pattern, verify accents render.
- DevTools Network tab: zero new requests at click time.
### Acceptance criteria — testable conditions for "done"
1. A new "Download CSV" `IconButton` (file-download icon, with tooltip `Download CSV`) is visible on the Campaign XRAY detail page header **only when** Split `campaign_xray_csv_export` evaluates to `'on'` for the current customer slug.
2. Clicking the button while data is still loading does nothing (button is `disabled`).
3. Clicking the button after data has loaded triggers a browser download whose filename matches `campaign-xray_<slug>_<YYYYMMDD-HHmm>.csv`.
4. The downloaded file opens in Google Sheets and in Microsoft Excel without character corruption (UTF-8 BOM verified).
5. The CSV contains three logical sections — campaign metadata, delivery funnel stages (depth-first), and activity reasons — exactly matching what is on screen given the user's current activity-table filters.
6. Zero new network calls are issued by the click (verified via Network tab).
7. A `campaign-xray_export_clicked` analytics event is pushed once per click with `campaignId`, `sendId`, `customerSlug`, `botSlug`, `author`, `timestamp`.
8. With Split treatment `'off'` / `'control'` / `undefined`, the button is **not rendered** (DOM-absent, not just hidden).
9. Lint, typecheck, unit tests pass on `yarn workspace @yalo/studio lint` / `test:ci` / `build`. Coverage on touched files does not drop.
10. PR stays under 500 lines (CI warn threshold) and well under 1500 (CI fail threshold) — see `.claude/CLAUDE.md` § PR Size.
---
## 11. Frontend Components
### Component inventory
| Component | File | Change | Notes |
| --- | --- | --- | --- |
| `CampaignXRayDetail` | `apps/studio/src/containers/Engagement/CampaignsError/CampaignXRayDetail.tsx` | **Modified** | Add Split-flag check, add Download CSV `IconButton` to the header `Stack` (line ~295), wire `onClick` handler, lift `selectedStage` • `searchQuery` from `CampaignActivityTable` so the handler can read them. |
| `CampaignActivityTable` | `apps/studio/src/containers/Engagement/CampaignsError/CampaignActivityTable.tsx` | **Modified (refactor)** | Accept `selectedStage`, `onSelectedStageChange`, `searchQuery`, `onSearchQueryChange` as props. Existing copy-to-clipboard behavior remains unchanged. |
| `buildCampaignXRayCsv` | `apps/studio/src/containers/Engagement/CampaignsError/helpers/buildCampaignXRayCsv.ts` | **New** | Pure function. Signature: `(args: { campaign: ICampaign; deliveryStats: ICampaignStatsChart; activityFilters: { selectedStage: string; searchQuery: string }; locale: string }) => { csv: string; filename: string }`. |
| `slugifyCampaignName` | `helpers/slugifyCampaignName.ts` | **New** | `(name: string \ |
| `csvSerialize` | `helpers/csvSerialize.ts` | **New** | `(rows: string[][]) => string` — RFC-4180 + UTF-8 BOM. |
| `ANALYTICS_EVENTS` | `constants/AnalyticsEvents.ts` | **Modified** | Add `CAMPAIGN_XRAY_EXPORT_CLICKED: 'campaign-xray_export_clicked'`. |
| `SplitNames` | `apps/studio/src/constants/SplitNames.ts` | **Modified** | Add `CAMPAIGN_XRAY_CSV_EXPORT = 'campaign_xray_csv_export'`. |
### Layout / placement
The button slots into the existing header `Stack` of `CampaignXRayDetail.tsx` (around lines 286–321 in the current file), to the right of the layout/error-caption toggles:
```
[campaign name + channel chip] [Error captions toggle] [Layout toggle] [⬇ Download CSV]
```
Use `IconButton` from `@engyalo/design-system` (consistent with `CampaignActivityTable`'s clipboard button), `Tooltip` for the label, FontAwesome `fa-solid fa-download` icon class (consistent with the existing `fa-solid fa-copy` / `fa-compress` / `fa-expand` usage in this file).
Disable when either `isLoading` or `isCampaignDeliveryStatsLoading` is true. Hide entirely when the Split treatment is not `'on'`.
### Payload shapes (already loaded — no new requests)
```tsx
// from useGetCampaign
interface ICampaign {
name: string;
type: string;
status: string;
goal?: { slug: string };
audience?: { name: string };
sendRule?: { scheduleData?: { date?: string } };
notificationTemplate?: { name: string };
summary?: Array<{ id: string }>; // sendId = summary[0].id
createdBy: string;
createdAt: string; // ISO
notificationTemplateParams?: Array<{ key: string; value: string }>;
notificationTemplateHiddenParams?: Array<{ key: string; value: string }>;
automationStepParams?: Array<{ key: string; value: string }>;
}
// from useGetCampaignDeliveryStats — see types/CampaignsBff/CampaignDeliveryStats.ts
interface ICampaignStatsChart {
campaignName: string;
sendType: 'single-send' | 'recurring';
campaignType: 'manual' | 'intelligent';
channel: string;
series: Array<{
date: string;
stages: IStage[];
}>;
}
interface IStage {
name: string;
quantity: number;
details: { reasons: Array<{ type: string; amount: number }> };
children: IStage[];
}
```
### Integration points with backend APIs
None added. The feature is read-only against the existing React Query cache populated by `useGetCampaign` and `useGetCampaignDeliveryStats`. See Section 4.
### CSV layout (concrete spec for `buildCampaignXRayCsv`)
```
Campaign
Field,Value
Name,<campaign.name>
Type,<campaign.type>
Status,<campaign.status>
Goal,<campaign.goal.slug>
Audience,<campaign.audience.name>
Channel,<deliveryStats.channel>
Send type,<deliveryStats.sendType>
Send date,<campaign.sendRule.scheduleData.date>
Send ID,<campaign.summary[0].id>
Created by,<campaign.createdBy>
Created at (UTC),<campaign.createdAt>
Created at (local),<moment.utc(campaign.createdAt).local().format('LLLL')>
Notification template,<campaign.notificationTemplate.name>
Template param: <key>,<value> ← repeated per notificationTemplateParams entry
Hidden param: <key>,<value> ← repeated per notificationTemplateHiddenParams entry
Automation step param: <key>,<value> ← repeated per automationStepParams entry
<blank line>
Delivery funnel
Parent stage,Stage,Quantity
<parent or empty>,<stage.name>,<stage.quantity>
…depth-first walk of stages[] and children[]…
<blank line>
Activity (reasons) — filtered
Stage,Reason,Amount
<row.stage>,<row.reasonType>,<row.amount>
…filtered by activityFilters.selectedStage and activityFilters.searchQuery…
```
### GTM Events
| Event Name | Trigger |
| --- | --- |
| `campaign-xray_export_clicked` | User clicks the Download CSV button on `CampaignXRayDetail`. Payload includes `campaignId`, `sendId`, `customerSlug`, `botSlug`, `author`, `timestamp`. Pushed via the existing `useAnalytics().trackEvent(...)` hook. |
---
## Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
| --- | --- | --- | --- |
| Campaigns with very large reason lists produce multi-MB CSVs | Low | Slow browser pause during serialization | Serializer is O(n) and runs on click only. The cap is whatever fits in the existing in-memory React Query cache — i.e. the page already loads it. |
| Excel mis-renders accents | Medium if BOM is forgotten | Stakeholders complain | UTF-8 BOM is part of the serializer with a unit test asserting its presence. |
| Template params contain newlines or commas | Medium | Broken CSV row | Serializer quotes fields containing `"`, `,`, `\r`, `\n`. Unit-tested. |
| Split SDK init race makes the button briefly invisible after page load | Low | Cosmetic flash | `useFeatureFlagTreatment` returns `undefined` during init; we treat that as `off` and render nothing — same outcome as flag-off, no flash. |
| Activity filters change between read and click | Low | CSV mismatches the on-screen view | Filter state is read inside the click handler synchronously, so the CSV always reflects the table state at the moment of click. |
---
## Out of Scope
- Any backend endpoint additions or modifications.
- Exporting the campaign **list** (`CampaignsXRayViewer.tsx`).
- Exporting per-recipient activity rows (the activity table aggregates reasons, not individual users; per-recipient export would require BE work).
- JSON / Excel-binary / PDF export formats.
- Re-fetching paginated stages or reasons that aren't already on screen.
- Email-the-CSV-to-yourself flows (covered separately under Linear MKT-335).
- A reusable `<DownloadCSVButton>` in `commerce-ui-components` (defer until a second consumer appears).
---
## Notes for the implementing agent
- **Worktree first**: per `.claude/CLAUDE.md` (frontend-platform) § Worktree Isolation, run `yarn wt-add <branch>` before editing. Never edit in the main directory.
- **Routing**: this page already uses `@reach/router`. Do not introduce `useParams` from `react-router-dom`.
- **Analytics**: use the existing `useAnalytics` hook (`apps/studio/src/containers/Engagement/hooks/useAnalytics.tsx`) — its `trackEvent` already enriches with `accountId`, `botSlug`, `author`. You only need to add the event-name constant in `constants/AnalyticsEvents.ts` and call `trackEvent(ANALYTICS_EVENTS.CAMPAIGN_XRAY_EXPORT_CLICKED, { campaignId, sendId, timestamp })`.
- **TypeScript / test patterns**: per `.claude/CLAUDE.md`, delegate to `typescript-patterns-enforcer` and `test-patterns-enforcer` subagents before writing the new files.
- **PR size budget**: estimated ~250–350 lines including tests. Comfortably under the 500-line CI warn threshold.
- **Linear ticket title**: `feat(studio): add CSV export to Campaign XRAY detail — <TICKET-ID>`. Create the ticket in Linear's Commerce team if one doesn't exist; CI requires `[A-Z]+-\d+` in the PR title.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment