Introduction
The Mind Oasis Creative Performance OS is a single-page HTML dashboard that connects directly to Airtable via the REST API. It allows the marketing team to manage campaigns, brief designers, review creatives, and monitor system health — all without leaving the browser.
The tool consists of two files: index_Main file.html (the dashboard) and docs_Main file.html (this documentation). Both are hosted on GitHub Pages.
Pages & navigation
The dashboard has 9 main pages. Nav order — from left to right — is Home · Planning · Campaigns · Designer View · Review · More ▾, with right-anchored controls (notifications, name chip, theme toggle, connection status, MOA GPT, Admin).
The 5 primary work pages live in the top nav. Four reference / utility pages (Library, Asset Library, UTMs, Documentation) collapse into a single More ▾ dropdown — they don't take up nav space until you need them, and the right-side Admin button is guaranteed to stay visible at any viewport width. An aggregate badge on the More button counts unread items in the dropdown (Assets pending review + UTMs total).
Before the dashboard renders, a full-screen Login overlay shows on first visit with a time-of-day greeting and Mind Oasis brand identity. Sign in or Skip → dashboard loads.
docs_Main file.html.Other top-nav elements (right side):
- Global search — searches across campaigns, creatives, library items.
- 🔔 Notification bell — global unread-comment count, popover panel listing each creative with unread comments. See Notifications.
- User identity chip — inline name input; persists to
localStorageasmind_oasis_user. Drives comment author + activity log "By" field. - 💬 MOA GPT — direct link to the Mind Oasis knowledge-base assistant at
knowledge-base-one-delta.vercel.app. Opens in a new tab (target="_blank",rel="noopener"). Lives in the nav next to the Admin button, separated by the same vertical divider so it reads as a launcher to an external tool rather than an internal page. - ● Connection status dot May 2026 — 10px dot to the right of the user chip. Sage = live, amber = degraded (recent error but a recent success too), red = down (no recent success), grey = unknown / checking. Click to open a floating panel showing success/error counts, last-success/last-error times, current token source (bundled vs custom), and quick-action buttons (Reconnect / Configure token / Clear saved token). Updated automatically by every
atFetch+atPatchcall.
🏠 Home page
The default landing page when anyone opens the dashboard. Designed as an editorial cover first, then a mission-control dashboard — magazine feel at the top, working numbers below. Logo in the top-left nav also returns here.
Layout, top to bottom
- "What's changed since last visit" pill — only renders when you've been away more than a minute and something new has happened. Shows a count like "✨ Since you last visited (2h ago): 3 approvals · 4 other changes · 1 comment →". Click jumps to Planning's recent-activity feed. Last-visit timestamp stored in
localStorageasmind_oasis_home_last_visit. - Smart insight banner — single auto-generated observation chosen by priority. Order: Overdue (red) → Stuck in Needs changes ≥3 (yellow) → Unassigned ≥10 (yellow) → Designer over capacity > 50/week (yellow) → Approval drought (no approvals in 14 days) → Healthy fallback (green). Each insight has its own click-CTA that jumps to the relevant view with the right filter pre-applied.
- Editorial hero — eyebrow line (
MIND OASIS · CREATIVE PERFORMANCE OS · WK 19 · MAY 2026), time-of-day greeting ("Good morning." / "Good afternoon." / "Good evening."), the giant hero number with the active-creatives count, a pulsing live-dot indicator next to it, a trend chip below (↑ 5 vs last week), a context line, the tagline "One team. One canvas.", and an optional milestone celebration pill when total approved crosses 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, or 10000. - 4 mission-control cards in a responsive grid:
- Today's Focus — count of "Ready for review" creatives. Click → opens Review.
- Bottleneck — status with the most stuck items + age of oldest one (e.g. "42 in 'Ready for review' · oldest 4 days"). Click → opens Review.
- Team Workload — this week — per-designer count bars (Maike / Mendy / Unassigned) with green ≤ 30, yellow 31–50, red > 50. Click → opens Planning.
- Upcoming Launches — mini-calendar with day/month tile for the next 3 campaigns by Start Date. Click → opens Planning.
- Bottom row — live activity ticker (left, scrollable, last 8 events) + quick actions (right: New campaign · Open Designer view · Browse Library · Run diagnostics). New campaign uses the same modal as the Campaigns page — workflow consistency guaranteed.
Atmospheric details
- Time-of-day ambient gradient — body class switches between
.tod-morning/.tod-afternoon/.tod-evening/.tod-nightwith smooth 1s transitions. Background tints change subtly through the day. - Hero number animates 0 → value on every render (ease-out cubic, 600ms).
- Live dot — small green dot next to the hero number with pulsing opacity + expanding ring. Signals "live data."
- All cards lift on hover with a sage-green border + 2px translateY.
Data sources
Home reuses Planning's cached data (window._planningCamps, window._planningCrs). On first load it fetches Campaigns + Creatives + Activity Log + Comments in parallel. Subsequent visits read from cache and re-render in <50ms. Approvals trend, milestone count, and ticker all pull from the Activity Log table.
📅 Planning & Workload
One page, three sub-tabs. Built for the marketing lead to answer two questions instantly: "Where is everyone right now?" and "Do we have room for a new campaign in week X?"
Top summary banner
One line above the sub-tabs: "120 active creatives (next 30 days) · 38% approved · 0 overdue · 109 unassigned creative(s) across 1 campaign(s)". Refreshes on every load. Color-codes overdue (red) / unassigned (yellow) when non-zero.
Sub-tab 1: Project Summary Default
- 6 KPI tiles — Active campaigns · Completed (informational only) · Blocked (campaigns with "Needs changes") · Overdue · Unassigned creatives (with campaign count sub-line) · My active (uses signed-in name; static when not signed in).
- Interactive tiles get the pointer cursor + green hover border + click-to-filter behavior. Informational tiles are visually dimmed (82% opacity, no hover, default cursor) — see at-a-glance which ones do something.
- Weekly activity chart — grouped bar chart, 12 weeks. Filter dropdown switches between All activity / Campaigns only / Creatives only / Approvals only. Tracks 4 series: Campaigns created · Campaigns started · Creatives created · Creatives approved (from Activity Log type=status with "approved" in summary). Smart Y-axis: tight when sparse, nice-round otherwise.
- Recent activity feed — merges Comments table + Activity Log table + localStorage entries, deduped by
${by}|${summary}. Each row clickable → opens the related creative's briefing. - Designer tasks — overdue + My next deadlines reports side by side. The "My next deadlines" panel adapts: shows your queue when you have assignments, falls back to "Upcoming team deadlines" otherwise.
Sub-tab 2: Timeline
Gantt-style 6-week rolling view with one bar per (designer × campaign) combo. Bars span from Start Date back 7 days (or from the campaign's separately-set prep start, when present).
- Navigation toolbar — ◀◀ / ◀ / Today / ▶ / ▶▶ buttons plus ⇥ Jump to next campaign (scans every campaign's Start/End dates and snaps the window to the next one with data). Date-range label on the right shows the visible window.
- ISO week numbers in every column header (
Wk 19 · 4 May) — Monday-first per the NL convention, week 1 contains the first Thursday of the year. - Per-row weekly count pills under each designer's name (
Creatives due / week →): one pill per visible week. Green ≤ 30, yellow ≤ 50, red > 50. Hover any pill for the exact week label + count + capacity context. - Bar contents — campaign name, total count, thin green progress bar (% Approved), 💬N inline open-comments badge, ⚠N needs-changes badge, ⚠ red flag if overdue.
- Hover any bar → mini-popover with full status breakdown, days-remaining, % approved, designer.
- Click any bar → opens that creative's briefing scrolled to the comments thread.
- Drag & drop reassign — drag any bar from one designer's row to another → confirmation popup → batch PATCH the Designer field on every creative in that campaign, in batches of 10. Logs one bulk-reassign activity entry.
- Filters — Designer (with "My week" using signed-in identity) · Platform · Status · Color-by (Status mix / Platform / LoB).
- Today line — vertical red rule + "TODAY" label rendered if today falls inside the visible window. Auto-scroll on load centers today.
Sub-tab 3: Capacity
8-week × designer heatmap. Each cell counts creatives whose Start Date (assets-due) falls in that week, per designer. Cells show N / 50 (count + cap) plus the percentage of the soft cap. Color bins:
- Green ≤ 30 (comfortable)
- Yellow 31–50 (busy)
- Orange 51+ (over)
- Red ≫ 75 (heavily over)
Same navigation toolbar (◀◀ / ◀ / Today / ▶ / ▶▶ + Jump to next campaign) as Timeline, with an independent anchor so navigating one doesn't disturb the other. Capacity thresholds live in PL_CAP_GREEN = 30 and PL_CAP_YELLOW = 50 constants — tunable in one place.
Effort estimation — deliberately skipped
Complexity tag on the Campaign (Simple / Standard / Complex) is the lightest possible upgrade — not per-asset effort.Designer workload chart + one-click rebalance May 2026
On Project Summary, below the activity chart, sits the Designer workload · 8-week trend — each designer gets a row with 8 bars (past 7 weeks + this week). Bar height = deliverables due (creatives with that designer + standalone briefs' Deliverable Count). Colour-coded sage ≤30 / amber 31-50 / red >50.
- Overload hint banner appears at the top of the chart whenever any cell is amber/red. Mentions exact counts and explicitly invites "Click any amber or red bar below to rebalance."
- Click an amber/red bar → Rebalance capacity popover opens in the middle of the screen. Lists every creative + standalone brief that designer has due that week. Each row has a Move to → dropdown showing other designers + their current load this week (colour-coded so you can see who has spare).
- One click on a dropdown → atPatch fires → toast confirms → row fades out → chart re-renders. Activity Log records the move.
- Capacity-around-this-week strip at the bottom of the popover shows every designer's current load as a chip — so you can see immediately who has room.
Two flows for rebalancing — pick whichever fits the moment:
| Flow | When |
|---|---|
| Click on workload-chart bar | You see a red week and want to triage multiple briefs at once. Popover lists all, batch-reassign. |
| Drag a bar on Timeline | Quick single-campaign move. Drag vertically between designer rows. For paid campaigns the batch-PATCH applies to every linked Creative; for standalones the Campaign's Designer field is patched directly. |
Reschedule with approval flow May 2026
Need to move a campaign or brief to a new week? The dashboard splits this into a request + an approval step so dates don't shift silently.
Request
Open any briefing modal → 📅 Reschedule button in the footer. Modal shows current dates locked, pickers for the new Start / End, and an optional reason field. Submit → toast: "Reschedule request sent — awaiting Review approval". Stored as a single JSON-encoded string on the Pending Date Change field on the Campaign:
{
"newStart": "2026-07-01",
"newEnd": "2026-07-31",
"reason": "shoot postponed by 1 week",
"requestedBy": "Mendy",
"requestedAt": "2026-05-18T10:00:00.000Z"
}
Until approved, the campaign's actual Start Date / End Date stay untouched. The campaign gets a visible ⏳ pending dates amber pill: on the Briefs table, in the briefing modal title, and in the rebalance / workload views.
Approve / reject (Review page)
Review page gained a new tab 📅 Date changes with a count badge. Gated on the approve permission via data-perm — Designers and Viewers don't see it.
- Card layout per request: campaign name + brief-type badge · who requested + when · current dates vs proposed dates side-by-side · reason quote · Reject / ✓ Approve.
- Approve → confirm dialog → atPatch sets the campaign's
Start Date+End Dateto the proposed values, clearsPending Date Change. Activity Log records the approval. - Reject → optional reason prompt → clears
Pending Date Change. Activity Log records the rejection with the reason so the requester can see why. - Open brief button on each card jumps straight into the briefing modal for context.
Cancel-pending (self-service)
The requester can re-open the Reschedule modal and hit Cancel pending to retract their own request without waiting for a reviewer. Useful when plans change again before approval.
Required Airtable schema addition
| Field | Type | Notes |
|---|---|---|
Pending Date Change | Long text | JSON-encoded request. Tracked by the schema-drift detector. Forward-compatible — if missing, the modal surfaces a clear toast explaining the setup. No data loss. |
Campaigns
New campaign form
The new campaign modal covers all fields required by the automation script. Field options below match the verified Airtable schema (May 2026):
content.airtable.com/uploadAttachment endpoint)COUNTRY_LANG_MAP and written directly on createBRANDED / UGC) · Variations (1–10) · Lenght (06s / 15s / 20s / 30s / 60s — video only) · Carousel # assets (2–5, carousel only) · Bullet next to phone (7-Day FREE TRAIL / START TODAY / €3,99)POST the new campaign with Assets generated: false explicitly (in case the field defaults to true), then issues a follow-up PATCH for Ready to generate: true if the user ticked "Mark as ready". The PATCH is needed because the Creative-input automation only fires on update, not on create.ENG-only linked-record filter
The Hooks / Hooks Variation / Master Caption / USP dropdowns in the new-campaign modal show only ENG variants of each item — never NL, DE, FR, or BE-FR rows. This is intentional and scales as the library grows: with 5+ languages per concept, an unfiltered list quickly becomes unbrowsable.
Why this is safe: the generation script doesn't use the campaign's selected Hook/USP/Caption as a literal text source. It uses it as a concept anchor — at run time the script looks up the right NL / DE / FR / BE-FR translation by matching the Concept name (Hook Concept, USP Concept, Caption Concept) against records in the target language, with ENG as the fallback. So picking the ENG version IS the correct high-level choice — it auto-resolves to every language variant the campaign needs.
Cross-language safety: the country-language filter (ncDdFilterByLangs) always treats ENG items as visible, regardless of which campaign languages are selected. So picking Netherlands (which adds NL) won't empty the dropdowns — ENG concepts stay listed.
Graceful fallback: if a library table somehow has zero ENG entries, the filter falls through and shows the full list rather than an empty dropdown. So the modal can never go blank by surprise.
Disclaimers: not in any new-campaign dropdown — the automation script picks the right disclaimer row at generation time based on Language + Currency + Bullet next to phone.
Campaign briefing
Click any campaign row to open the full briefing modal. Shows all 5 sections: Campaign details, Targeting, Creative strategy, Copy & hooks (with real text resolved from linked records), Creative progress bar. Footer contains: Close · Edit campaign · Duplicate campaign.
Edit campaign
The Edit campaign button in the briefing footer opens the new-campaign modal in edit mode:
- Modal title becomes "Edit campaign", sub-title shows the campaign name being edited.
- Save button label becomes "Update campaign"; the Save-as-Draft button is hidden.
- All form fields (chips, selects, dates, linked-record dropdowns) are pre-filled with the existing record's values.
- On submit the dashboard does
PATCHinstead ofPOST, using the same retry/option-stripping logic as create. - If a new Example file is dropped, it replaces the existing attachment.
- The internal
_ncEditingIdstate distinguishes edit from create.openNewCampaignModal()resets it back to null when called fresh.
Regenerate creatives
The ↻ Regenerate creatives button appears in the briefing-modal footer only for campaigns that already have Assets generated = true. (For not-yet-generated campaigns it's hidden — you can just tick Ready to generate normally.)
What it does, in one click:
- Confirms with the user (lists the destructive consequences — feedback, asset URLs and review state on existing creatives are lost).
- Fetches every Creative row linked to this campaign via
FIND(campId, ARRAYJOIN({campaign name})). - Deletes them in batches of 10 (Airtable's DELETE-many limit).
- PATCHes the Campaign with
Assets generated = false,Ready to generate = false, and clearsGeneration warning— clearing the script's pre-flight guards. - Brief 500 ms pause, then a second PATCH with
Ready to generate = true— this is the value-change that fires the Creative-input automation. - Cache invalidated, dashboard reloads.
Designer view
Top tabs: All · Maike · Mendy. All is the default and shows every creative including those with no Designer assigned — so newly-generated creatives are visible immediately even before someone gets assigned. The Maike / Mendy tabs filter to that specific designer's queue.
Row layout
Each row shows the Creative ID, status badge, an inline Designer picker (pill-style dropdown with custom chevron), an optional "Changes needed" indicator, file name, platform pill, language pill, size pill, carousel slide pill ▦ N/M for carousels, and the 💬 N open-comments badge.
Fast designer assignment — no modal needed
Assign all → Maike / Mendy / ✕ clear button cluster. One click + confirm → batch PATCH every creative in that campaign (10 at a time). Logs one bulk activity entry.localStorage (mind_oasis_designer_collapsed). Toolbar has Collapse all / Expand all shortcuts.Original features
▦ N/M pill — slide N of M total. Lets designers tell which slide they're on when otherwise-identical rows exist.Briefing modal
Click any row to open the briefing. The modal contains the campaign metadata, technical specs, the Example asset preview (click to zoom), feedback textarea, Asset/Work file URL drop zones, a Designer dropdown (next to Status — change either without opening another modal), and the comments thread.
Comments & threads on creatives
Conversations live with the creative. Replaces the Slack/email back-and-forth that used to happen out-of-band. Surfaces on the creative briefing modal as a threaded panel, and as a yellow 💬 N badge on Designer view cards so it's clear which creatives have pending discussion.
How it works
localStorage as mind_oasis_user — survives reloads. Each comment is tagged with whoever's name is set when posting.✓ Resolve on any open comment to mark the thread done. Resolved threads are hidden by default — toggle "Show resolved" to view them. Reopen with ↺ Reopen if needed.💬 N pill. Yellow when there are open comments you've already seen, red with a dot when there are unread comments (created after you last viewed the creative).Unread tracking
The dashboard tracks per-creative "last viewed" timestamps in localStorage (key mind_oasis_last_seen). When you open a creative briefing, that creative is marked as seen for you. Any comment with Created > your last-seen time counts as unread and turns the badge red. Each browser/user has its own tracking — Maike's unreads don't bleed into Mendy's.
Author colors — deterministic from name
Any name maps to a stable color via a small hash, so the same person gets the same avatar color everywhere. You don't need to predefine names — type a name once, it picks a color; type it again next week, same color.
Existing Feedback field — what changes?
The legacy Feedback field on Creatives is still used by the generation script (e.g. it writes "⚠️ No approved caption for NL, ENG used as fallback") and remains the textbox for reviewer's "Needs changes" notes. Comments are the human conversation layer on top — open-ended back-and-forth that doesn't replace the structured feedback workflow.
getCurrentUser() — the rest of the system doesn't change.Phase 3 — reply threading, edit, reactions, attachments, keyboard
↩ Reply. An inline reply box opens under the parent. Replies are stored with Parent: [parentCommentId] and rendered indented with a left rule. Nested replies render recursively, so deeper conversations stay visually grouped.✎ Edit. Inline textarea replaces the message; ⌘/Ctrl+Enter saves, Esc cancels. PATCHes the Message field and (if the field exists) sets Edited at so a small "edited" tag appears next to the timestamp.+ 😊 pill to open the picker (👍 👀 ✅ ❤️ 🙏 🎉). Click any emoji to toggle yourself on/off. Reactions live in a single long-text Reactions field as JSON: {"👍":["Tim","Maike"]} — no extra table needed.↳ Make reply of… (retroactive re-parenting)
Every top-level (no-Parent) comment shows a ↳ Make reply of… action on hover. Click it → modal picker lists every other comment on the same creative; click any candidate → the dashboard PATCHes that comment's Parent field to the chosen one and the thread re-renders with the row now indented under its new parent. Cycle protection: the comment itself and all of its descendants are filtered out of the candidate list, so you can't make a parent into a child of its own subtree. Mostly used to clean up comments that were posted as top-level before the Parent field was properly configured as a linked record.
Admin diagnostic — Parent field schema check
The admin Tools tab now runs test_comments_parent_field_linked as part of the Workflows suite. The check verifies that Comments.Parent is a real Linked record → Comments field, with two strategies:
- Fast path — if any existing comment in the cache has
Parentpopulated, that's proof the field works. Instant PASS, no side effects. - Probe — if no comments have Parent yet, the check POSTs a marker row (Author
schema-probe, Message__parent_field_probe__ (auto-deleted)) with Parent set to an existing comment. If Airtable accepts → PASS and the probe row is deleted immediately. If Airtable rejects with aParent-specific error → FAIL with exact fix steps.
Cleanup runs in a finally block so the probe row is always removed, even on network or auth errors. Run all tests on the Admin → Tools page to surface schema drift before users hit it.
🔔 Notification bell (top nav)
A bell icon sits in the top nav next to the user chip. A red badge shows the global unread count (sum across every creative the current user hasn't seen yet). Click the bell to open a popover that lists every creative with unread comments — most recent first. Each row shows the latest author's avatar, the creative's primary ID, a short message snippet, the relative time, and a +N pill if there's more than one unread comment on that creative. Clicking a row opens the briefing modal for that creative (which automatically marks it as seen, clearing the row from the panel). A Mark all as seen button in the header clears every unread in one click.
The badge is driven by the same per-user localStorage last-seen map that powers the inline 💬 N pill on Designer / Review rows — opening a creative anywhere clears its unread state everywhere. The cache is primed on page load (boot calls refreshAllComments()), so the badge is accurate without needing to visit Designer or Review first.
How fetching works — client-side cache
Per-creative comment queries can't use Airtable's filterByFormula because ARRAYJOIN({Creative}) on a linked-record field returns primary-field labels (e.g. MO-04589), not the underlying recXXX IDs — so a FIND(creativeId, ARRAYJOIN({Creative})) filter never matches. Instead the dashboard fetches all comments once into window._allComments, then filters client-side by scanning each record's Creative array. Counts/unreads are recomputed from the same cache. Every write (post / resolve / reopen) awaits refreshAllComments() before re-rendering, so new comments appear in the thread immediately. refreshAllCommentCounts is kept as a thin alias so old call sites keep working.
Author field defaults to a Single select with options Maike/Mendy/Marketing/Reviewer/Other. Now that any name is supported, change the field type to Single line text in Airtable — otherwise typing a name not in the select list will hit Insufficient permissions to create new select option when posting.🔔 Notifications bell
Top-nav bell icon (between the global search and the user chip) with a red unread-count badge. Click → popover panel lists every creative with unread comments, sorted most-recent first.
What "unread" means
Tracked per-user via localStorage key mind_oasis_last_seen — a map of { creativeId → ISO timestamp }. A comment is "unread" if its Created time is later than the current user's last-seen timestamp for that creative. Opening a creative's briefing auto-stamps it as seen. Each browser/user has its own tracking — Maike's unreads don't bleed into Mendy's.
The popover panel
- Header —
Notifications · N unread+ a Mark all as seen button (when there's unread). - Rows — one per creative with unread comments. Latest author avatar (deterministic color from name hash), author name, "on MO-04xxx" (creative primary ID),
+Npill when multiple unread on that creative, relative time, 2-line snippet of the latest message. - Empty state — "🔔 No unread comments. You're all caught up."
- Click a row → opens that creative's briefing modal scrolled directly to the comments thread, marks it seen, clears the row from the panel.
Creative-name resolution
When the panel opens, it may initially render recXXX IDs (because allCreatives isn't loaded until you visit Designer or Review). A background batch fetch resolves the real MO-04xxx labels via OR(RECORD_ID()='rec1',…) filterByFormula (up to 50 IDs per request), caches them in window._creativeLabelCache (persisted to localStorage), and re-renders the panel. Subsequent opens are instant from cache.
Inline 💬 badge consistency
The notification bell's badge is driven by the same _recomputeCommentCounts function that powers the inline 💬 N pill on Designer / Review rows. So opening a creative anywhere clears its unread state everywhere. Page-boot calls refreshAllComments() automatically so the bell is accurate immediately — you don't have to visit Designer/Review first.
📜 Activity Log
Team-wide audit trail of every meaningful action the dashboard performs. Writes go to both localStorage (instant, offline-tolerant) and the Airtable Activity Log table (team-wide, persistent). Reads prefer Airtable so other users' actions surface in your feed.
What gets logged
| Action | Type field | Creative | Campaign |
|---|---|---|---|
| Designer assigned / changed / unassigned (briefing modal) | designer | ✓ | ✓ |
| Designer assigned via inline dropdown (Designer view row) | designer | ✓ | ✓ |
| Designer bulk-assigned (campaign header buttons) | designer | — | ✓ |
| Designer drag-reassigned (Planning timeline) | designer | — | ✓ |
| Status changed (briefing modal) | status | ✓ | ✓ |
| Asset URL saved (auto moves status → Ready for review) | status | ✓ | ✓ |
| Work-file URL saved | action | ✓ | ✓ |
| Feedback left (preview of text in summary) | action | ✓ | ✓ |
| Campaign created | action | — | ✓ |
| Campaign duplicated | action | — | ✓ |
| Campaign deleted (record + linked creatives) | action | — | — |
| Regenerate creatives (clear + re-trigger automation) | regen | — | ✓ |
| Library item archived (Set to Do not use) | action | — | — |
| Library item restored to Active | action | — | — |
| Library item permanently deleted | action | — | — |
Recent activity feed
The Project Summary's "Recent activity" panel merges three sources: Airtable Comments table, Airtable Activity Log table, and localStorage activity log (for entries that haven't yet synced to Airtable). De-duped by ${by}|${summary}. Sorted by time desc, top 12.
Required Airtable setup
Activity Log in the base, with these fields:
Action ID— autonumber (primary)Type— single select with options:designer·status·regen·comment·action·etc.Summary— long textBy— single line textCreative— linked record → CreativesCampaign— linked record → CampaignsCreated— created time (auto)
data.records:write scope on the base AND access to the Activity Log table specifically — most PATs that pre-date the table need to be reconfigured to "All current and future tables in this base" (or have the new table explicitly added to the allowed list).
Library item management — Archive / Restore / Delete
Every library item (Hook / Hook Variation / Caption / USP / Disclaimer) can be retired without breaking existing campaign references, or permanently deleted with a usage check. Actions live in the library-item detail modal footer.
🚫 Set to "Do not use" (archive)
PATCHes the row's Status field to Do not use. The new-campaign modal's linked-record dropdowns filter to Active / Approved only, so archived items disappear from future campaign creation but existing references stay intact. If the Do not use option doesn't exist on the table's Status field yet, the dashboard surfaces a clear alert with exact steps to add it in Airtable.
↻ Restore to Active
Schema-aware. Samples existing records to detect which "active" status this specific table uses (some tables have Active+Approved+Draft, some only Approved+Draft). Tries detected values first, then falls through to Approved → Active → Draft in order. Stops on first success; clear error message if none work.
🗑 Delete (with usage check)
Pre-flights by scanning Campaigns for any linked-record reference to this row:
- If in use — blocked. Alert lists the first 8 campaigns linking to it, recommends "Set to Do not use" instead, and gives exact steps to force-delete if really needed.
- Not in use — normal confirm. OK → DELETE → reload.
- Disclaimers — special case. The Disclaimer Library isn't directly linked from Campaigns (the automation script picks rows at generation time based on Language + Currency + Bullet). So usage can't be auto-verified. The dashboard shows a warning explaining this and requires typing
DELETEin a second prompt.
Visual state
Items with status Do not use render on the Library page with diagonal stripes + 55% opacity — spot retired items at a glance.
Required Airtable setup
Do not use as a Status option on all five library tables: Hooks, Hooks Variations, Captions, USP Library, Disclaimer Library. Case-sensitive; lowercase except the leading "D". Until added, the Archive action surfaces a clear alert pointing to the exact fix. Delete works without it.Shoots & talent
Shoots are a sibling table to Asset Library, used to group assets by photo/video session. Manage shoots from Asset Library via the 📸 Shoots button (top-right of the page) which opens openManageShootsModal().
+ New shoot button next to the Shoot dropdown opens the quick shoot modal, then returns to the asset upload with the new shoot pre-selected.Models (comma-separated names) and Influencer (name / @handle), independent of the shoot.Buyout expiry date · Buyout channel (Social / Web / Stores) · Buyout countries (Global, Europe, NL, BE, DE, FR, UK, ES). Surfaced in admin alerts when nearing expiry.openQuickNewShoot() is currently defined twice in the source (the second definition overrides the first). Worth deduplicating in the next clean-up pass.Global search
The 🔍 search box in the top nav (#global-search) searches across all major tables and routes the user to the relevant page with the search term pre-filled. Backed by globalSearch(), results render inline below the input.
campaign name. Click → Dashboard with name pre-filtered.Creative ID. Click → Designer view with ID pre-filtered.Hook Concept. Click → Library / Hooks tab.Caption Concept. Click → Library / Captions tab.USP Concept. Click → Library / USPs tab.Asset name. Click → Asset Library with search pre-filled.Admin
The Admin page is accessible via the separate button on the right side of the navigation bar. It has 5 tabs: Overview, Data quality, Alerts & audit, Activity log, and Tools. Test automation is a panel inside the Tools tab — not a tab in its own right.
loadAdmin() now resets every section (Health highlights, Stats, Campaign health, Performance, Data quality rows, Alerts, Audit checks, Activity log) back to its skeleton-pulse state at the start of each fetch. Previously only the stats grid got a spinner and the other sections kept showing stale data while the API call was in flight — now the whole page visibly reloads. Part of the project-wide loader sweep — every page (Home, Planning, Campaigns, Designer, Review, Library, Assets, Admin) now uses the same .skeleton + sk-line / sk-circle / sk-bar shimmer system on every load.Overview
Always-visible health highlights (8 coloured cards): Airtable connection, Campaign readiness, Generation errors, Approval rate, Library health, Ready to generate, Assets in library, Campaigns expiring soon. Below: stats grid and campaign health table.
Data quality
10 checks on all campaigns — missing platforms, languages, content type, type, hooks, caption, USP, generation errors. Each row shows which campaigns are affected (clickable badges) and an action button (Auto-fix or Open Airtable).
Alerts & audit
Two columns side by side. Operational alerts: campaigns without dates, expiring buyouts, campaigns ending within 7 days, generated with 0 approvals, ready-to-generate but incomplete. Audit checks: duplicate names, unused hooks/captions, orphan creatives, assets without shoots.
Activity log
Last 7 days of activity across all tables. Filterable by type (Campaigns/Creatives/Assets/Hooks/Captions). Each item shows type badge, name, contextual detail and timestamp.
Tools
Schema drift detector May 2026
New panel in Admin → Tools. Calls GET /v0/meta/bases/{base}/tables and compares the live Airtable field schema against an EXPECTED_SCHEMA dict that lists every field the dashboard reads or writes. Renders per-table cards highlighting missing fields (red pills) and extra fields not tracked (grey pills). Requires schema.bases:read on the PAT — the wizard requests this scope by default.
Activity log retention May 2026
New panel in Admin → Tools. Configurable cutoff (default 90 days). Scans all Activity Log entries via paginated reads, shows total / older-than-cutoff / date-range stats, and offers a confirm-then-batch-delete (10 records per request — Airtable's max per DELETE). Permanent action; recommend exporting CSV first for a backup. Keeps the table fast as the dashboard ages.
Test automation
140 automated tests across 5 suites. Run all or per-suite from Admin → Tools. Live progress bar, per-test results with ✓/⚠/✗ and duration in ms.
Market Moments (10): helpers · badge render · tab wiring · dropdown · health classifier · coverage slot · onboarding step · home row · timeline band · briefing banner · retrospective
F11–F15 workflow: quick-duplicate · bulk designer + moment · approval bottleneck · retrospective · library search
Login + UX dialogs (4): overlay wired · uses .show class · greeting adapts to time · password toggle
UTM Codes (24): builder canonical filename · URL emits 4 utm_* params · parser round-trip · page DOM · auto-match · QR modal · MY_RITUALS landing (explicit + default) · v2 channel defaults (18 channels, olv medium, mindoasis.com domain) · META unified (source=meta, no IG/FB split, UGC via Campaign Name) · Search Branded parser (country-only, funnel preset) · compound channel paths (Google_Search/App/Display/Discovery, DV360_Video/Display, Apple_Search) · AppIntro abbreviated pattern (Meta + Google Display _GOOGLE suffix) · Meta UGC kebab-case (parse + build round-trip) · bulk-gen platforms-first channel routing · bulk-gen Status=Active default · bulk-gen Campaign Name = real campaign · briefing button refresh helper · backfill migration helper · creative picker prefills · creative picker country resolution chain (Language→Country + Campaign.Countries fallback, ENG→UK heuristic) · country → language auto-fill · bulk generator · CSV export · filename lock
Airtable schema
Eleven tables in base appsvmrC4MqL6mdBV: Campaigns, Creatives, Hooks, Hooks Variations, Captions, USP Library, Disclaimer Library, Asset Library, Shoots, Comments, Activity Log. Field names below match the dashboard's expectations exactly — typos and casing matter.
Campaigns table
Field order below matches the Airtable field-config panel exactly (verified May 2026, 40 fields including 1 primary + 1 duplicate). The hidden badge marks fields toggled off in the default view — these are filled by formulas, lookups, or the generation script and are not user-edited.
| # | Field | Type | Options / Notes |
|---|---|---|---|
| 1 | campaign name | Text | Primary field. Duplicate check on creation. |
| 2 | campaign name offical | Formula | ⚠ Typo in field name (offical). Slug = {LoB}_{platforms}_{State of Mind}_{Content type}_{Distribution Type}, optionally suffixed with _{Variations}. Excludes language/country. |
| 3 | Example | Attachment | Single attachment, uploaded via drag & drop in the new-campaign modal. |
| 4 | platforms | Multiple select | Lowercase. Auto-generated: Meta · Google App · Google Display · Youtube · DV360 Display · DV360 Video · Apple Search Ads. Manual-only (script skips): PR · Stores · Google Search. |
| 5 | LoB | Multiple select | Brand · At Home · App · Boutiques |
| 6 | Distribution Type | Single select | Paid · Organic · Owned |
| 7 | State of Mind | Multiple select | Generic · Focus · Recharge · Relax · Sleep |
| 8 | Content type | Multiple select | Still · Video · Carousel |
| 9 | Variations | Single select | Options: 1–10. Used by campaign name offical formula. |
| 10 | Lenght | Single select | ⚠ Intentional typo in field name. Options: 06s, 15s, 20s, 30s, 60s. Same options on Creatives. Only used when Content type includes Video. |
| 11 | Carousel # assets | Single select | 2–5. Matches the Carousel Asset # options on Creatives. Script does Number(name) to convert. |
| 12 | Countries | Multiple select | Verified Airtable options: Netherlands, Belgium, Germany, Austria, France, UK (2-letter code), Ireland, Sweden, Denmark, Finland, Poland, Romania, Hungary, Bulgaria, Croatia, Estonia, Latvia, Lithuania, Luxembourg, Portugal, Italy, Slovenia. Mostly full country names — UK is the lone abbreviation. Matches the Country → Languages script's COUNTRY_LANGUAGE_MAP keys exactly. |
| 13 | Languages | Multiple select | Auto-filled by the Country → Languages script (and by the dashboard's direct write on create). Options: ENG, NL, BE-FR, DE, FR. The dashboard's VALID_CAMPAIGN_LANGUAGES whitelist filters its writes to only these values. |
| 14 | Type | Multiple select | Options: BRANDED (uppercase) · UGC. Case-sensitive. |
| 15 | Funnel | Multiple select | TOFU · MOFU · BOFU |
| 16 | Hooks | Linked → Hooks | Multi. Script iterates. |
| 17 | Hook angle hidden | Lookup | Pulls Hook angle from linked Hooks. |
| 18 | Hooks Variation | Linked → Hooks Variations | Singular field name (not "Variations"). Script tries variations before base hooks. |
| 19 | USP | Linked → USP Library | Multi. Script iterates. |
| 20 | USP Text hidden | Lookup | Pulls USP Text from linked USP. |
| 21 | Call to action | Checkbox | When checked, the script populates Creatives' Call to Action URL. |
| 22 | Landing page | Single select | App Stores · App PDP · At Home PDP · Experience PDP · TBD |
| 23 | CTA URL hidden | Formula | Likely computes the URL from Landing page + Call to action. ⚠ Generation script also has its own URL_MAP for this — two sources of truth. Reconcile. |
| 24 | App Store Logo's hidden | Formula | ⚠ Apostrophe in the field name. Read on the briefing modal at index.html:2205 via f["App Store Logo's"] — apostrophe forces double-quoted JS keying. |
| 25 | Bullet next to phone | Single select | Options: 7-Day FREE TRAIL, START TODAY, €3,99. ⚠ "TRAIL" → should be "TRIAL". European decimal. |
| 26 | Master Caption | Linked → Captions | Single record. Script reads [0]. |
| 27 | Caption Label hidden | Lookup | Pulls from linked Master Caption. Not currently read by the dashboard. |
| 28 | Caption ID hidden | Lookup | Pulls from linked Master Caption. |
| 29 | Soundscape Index hidden | Number | Managed by generation script. Cycles 1 → 2 → 3 → 1. Reset to 1 at start of generation. |
| 30 | Start Date | Date | "Assets due" hint in the new-campaign modal. |
| 31 | End Date | Date | Drives the "Campaigns expiring soon" health card (≤ 7 days out). |
| 32 | Status | Single select | Planning · Live · Paused · Completed. Distinct from the Creatives Status (review state). |
| 33 | Ready to generate | Checkbox | Triggers Automation 1 (Creative input). |
| 34 | Assets generated | Checkbox | Set to true on success. Script refuses to regenerate when true. |
| 35 | Creatives numbers | Linked → Creatives | Reverse link to all generated Creatives for this Campaign. |
| 36 | Estimated creatives | Number | Calculated and written by the script. |
| 37 | Generation warning | Long text | Errors and success messages from the script (Dutch + ✅/⚠️/❌ prefix). |
| 38 | Hooks copy hidden | Linked record | Likely an archival snapshot of the Hooks selection. Not read by the dashboard. |
| 39 | USP Library copy hidden duplicate | Linked record | ⚠ Two fields with this same name exist on the table. Code addressing by name resolves to one unpredictably. Delete one. |
| 40 | USP Library copy hidden duplicate | Linked record | ⚠ Duplicate of #39 — same name. Airtable distinguishes them by internal ID only. |
- Duplicate
USP Library copyfield. Two identically-named linked-record fields exist. Any code addressing either by name (e.g.fields['USP Library copy']) will resolve to one of them unpredictably. Decide which to keep, delete the other. - Apostrophe in
App Store Logo's. Forces double-quoted JS keys throughout the dashboard and breaks any tooling that treats apostrophe as a string delimiter. Rename toApp Store Logosif you ever do a sweep. - Two CTA-URL sources. Airtable has a
CTA URLformula on Campaigns; the generation script computes its own viaURL_MAPand writesCall to Action URLon Creatives. If the formulas drift, generated Creatives and the briefing display can disagree. Decide which is canonical and remove the other. - Spelling cleanup ("offical" / "Lenght" / "TRAIL"). Three field/option spelling errors are baked into the schema. Doable as a coordinated rename — every dashboard reference and the generation script need to update in lockstep.
Variationswrites now use bare-string single-select shape (was array[{name}]).Typechips nowBRANDED/UGC(was the wrongBranded/Promotional/Retargetingset).Bullet next to phonechips on the new-campaign modal now7-Day FREE TRAIL/START TODAY/€3,99(wasYes/No).Carousel # assetstrimmed to2–5(was2–10) to match Airtable.Lenghtdropdown uses06s(was6s) to match Airtable.- LoB filter uses
Boutiques(wasBoutique). - Linked records to Campaigns sent as bare-string arrays — see "REST API quirks" below.
- Disclaimer Library writes for
Bullet next to phonekept as3,99(no €) — that table has its own option set, distinct from Campaigns.
"Hooks": ["recXXX"]. The documented "Hooks": [{id: "recXXX"}] object form is rejected with Value "[object Object]" is not a valid record ID. The dashboard's linkedRecordWrites loop in saveNewCampaign always uses the bare-string form. Note: the Scripting API (used in automation scripts) still accepts the object form fine — only the REST API path on this specific base is picky.Creatives table
Generated by the "Automation Campaign → Creative input" script — one row per platform × state of mind × content type × size × asset # × language × type combination. Field types and option lists below were verified against the Airtable field-config panel (May 2026).
Lookup vs Single Select matters for the script: fields tagged lookup auto-fill from the linked Campaign or the linked library record — the script does not write to them. Fields tagged "Single Select" are written directly via getSelect(), so option-name mismatches between Campaigns and Creatives will throw.
| Field | Type | Notes |
|---|---|---|
Creative ID | Formula | "MO-" + zero-padded {ID} → e.g. MO-04589. Auto-computed. |
ID | Autonumber | Drives the Creative ID formula. Auto-incremented per row. |
campaign name | Linked → Campaigns | Parent campaign. Script writes [{id: recordId}] via Scripting API. |
File name | Formula | Slug = {Creative ID}_{campaign name}_{platforms}_{Languages}_{Size}_{Distribution Type}[_{Variation}]. Auto-computed. |
Work File name | Formula | Slug = WF-{campaign name}_{platforms}_{Content type}_{Size}_{Languages|ALLLANG}. Auto-computed. |
Status | Single select | To do · In progress · Ready for review · Needs changes · Approved. Watched by Automation A (Notify campaign manager) and B (Notify designer). |
Designer | Single/Multi select | Maike · Mendy. Used by Designer view filter. |
| Platform & format — set by generation script (Single Select unless noted) | ||
platforms | Single select | Meta, Google Search, Google App, Google Display, Youtube, DV360 Display, DV360 Video, Apple Search Ads, PR, Stores. (Manual platforms PR/Stores/Google Search are listed as options but the script never writes them.) |
Languages | Single select | ENG, NL, BE-FR, DE, FR. Same options as the Campaigns Languages field. |
Type | Single select | BRANDED, UGC. |
Content type | Single select | Still, Video, Carousel. |
Size | Single select | 9:16, 4:5, 16:9, 1:1, 300x250px, 320x480px, Landscape, App icon 1024x1024px, iPhone screenshot (5x), App preview video 1920x180px, 728x90px, 160x600px, 300x600px, 320x50px, 970x250px. Script writes one of these per platform/content combo per the SIZE_MAP. |
File type | Single select | PNG, JPG, MP4, MP4 - max 10-20 MB, HTML. Script writes JPG/MP4/HTML/PNG. |
Max file size | Single line text | Platform-specific cap (e.g. "30 MB", "150 KB"). Plain string from getMaxFileSize. |
Lenght | Single select | ⚠ Typo. Options: 06s, 10s, 15s, 20s, 30s, 60s. Only written when Content type includes Video. |
Carousel Asset # | Single select | Options: 2, 3, 4, 5 (matches Campaigns' Carousel # assets). Script writes the total carousel size on every slide of a carousel. |
Slide position | Single select | Options: 1, 2, 3, 4, 5. Script writes the slide's position within the carousel (1, 2, 3, …). Wrapped in isSelectField — script skips silently if the field is missing. |
Image phone | Single select | Options: 4 mental states - English, 4 mental states - Dutch, 4 mental states - German, 4 mental states - French. Mapped per language. |
Soundscape | Single select | 12 options: Focus 1–3, Recharge 1–3, Relax 1–3, Sleep 1–3. Cycles via Campaign's Soundscape Index. |
Call to Action URL | Single line text | From Landing page → URL_MAP, only when Call to action is checked. |
| Lookups — auto-fill from links, script does NOT write | ||
Distribution Type lookup | Lookup → Campaigns | Auto-fills from the linked Campaign's Distribution Type. Script's isSelectField guard skips writing. |
Funnel lookup | Lookup → Campaigns | Auto-fills from linked Campaign. |
State of Mind lookup | Lookup → Campaigns | Auto-fills from linked Campaign. |
Variation lookup | Lookup → Campaigns | Auto-fills from linked Campaign's Variations field. Used in the File name formula. |
Hook angle lookup | Lookup → linked Hook | Auto-fills from the Hook record this Creative is linked to. |
Caption Text lookup | Lookup → linked Caption | Auto-fills from the Caption record this Creative is linked to. |
| Copy resolved from libraries — set by script | ||
Caption | Linked → Captions | Resolved by Caption Group + Languages + Approved (ENG fallback). Script writes via Scripting API [{id: ...}]. |
USP | Linked → USP Library | Resolved by USP Group + Language + Approved (ENG fallback). |
USP's | Long text | Plain-text copy of the resolved USP's text, written alongside the link. |
Hook | Linked → Hooks | Set when no variation was found — uses Hook Group + Language + Approved. |
Hook Variations | Linked → Hooks Variations | Preferred over Hook when an Approved variation is linked on the Campaign. |
Hook text | Long text | Plain-text body of the resolved hook. |
Header | Long text | From the resolved hook. |
Subheader | Long text | From the resolved hook. |
| Designer / reviewer fields | ||
Asset File URL | URL | Set by designer. Watched by the Auto-update Creative Status automation → flips Status to "Ready for review" on any change (including clearing). |
Asset File Upload | Attachment | Alternative to Asset File URL — direct file upload. Also watched by the Auto-update Creative Status automation. |
Asset Work File URL | URL | Source file (Figma, AE, etc.). |
Feedback | Long text | Reviewer notes. Also used by the script to flag ENG-fallback captions. |
Hooks table
Reusable creative hooks. Driven by LIB_TABLES.hooks in the dashboard and resolved per-language by the generation script.
| Field | Type | Notes |
|---|---|---|
Hook | Text | Primary field — the actual hook copy |
Hook Concept | Text | Short label / theme — used as the search key in dashboard |
Hook Group | Text | Required by generation script. Joins Hooks across languages — script matches by Group + Language + Status="Approved" |
Language | Single select | Used for language-fallback resolution. ENG is the universal fallback. |
Header | Text | Copied verbatim onto generated Creatives |
Sub header | Text | Copied verbatim onto generated Creatives |
Hook angle | Single select | Editorial angle classification — copied to Creatives |
Hook type | Single select | e.g. Question, Statement, Stat |
Funnel | Multiple select | TOFU · MOFU · BOFU |
Status | Single select | Draft · Approved. Only Approved hooks are eligible for generation. |
Hooks Variations table
Per-Hook variations. Preferred over base Hooks when linked on a Campaign — the script tries variations first.
| Field | Type | Notes |
|---|---|---|
Hook | Text | Primary field — variation copy |
Hook Concept | Text | Inherited / matched to parent Hook |
Variation | Text | Variation label |
Header | Text | Copied to generated Creatives |
Sub header | Text | Copied to generated Creatives |
Hook angle | Single select | Same vocabulary as Hooks |
Status | Single select | Draft · Approved. Script only uses Approved variations. |
Captions table
Master captions linked from Campaigns. Driven by LIB_TABLES.captions; resolved per-language by the generation script using Caption Group.
| Field | Type | Notes |
|---|---|---|
Caption | Long text | Primary field — the caption body |
Caption Concept | Text | Short label — used as search key in dashboard |
Caption ID | Formula / Text | Stable identifier |
Caption Group | Text | Required by generation script. Joins Captions across languages — script matches by Group + Languages + Status="Approved", with ENG fallback |
Languages | Multiple select | Drives language filter on dashboard dropdowns AND drives script matching |
State of Mind | Multiple select | Generic · Focus · Recharge · Relax · Sleep |
Status | Single select | Draft · Approved. Only Approved captions match in generation; ENG fallback may still trigger if no language match. |
USP Library table
Unique selling points, filtered by Line of Business in the dashboard, resolved per-language by the script using USP Group.
| Field | Type | Notes |
|---|---|---|
USP Text | Long text | Primary field — the USP copy |
USP Concept | Text | Short label — search key in dashboard |
USP Group | Text | Required by generation script. Joins USPs across languages — script matches by Group + Language + Status="Approved", with ENG fallback |
Language | Single select | Used by both dashboard dropdown filter and script matching |
LoB | Single/Multi select | Brand · At Home · App · Boutiques. Drives LoB filter. |
Status | Single select | Draft · Approved. Only Approved USPs match in generation. |
Disclaimer Library table
Legal/compliance disclaimers. Driven by LIB_TABLES.disclaimers.
| Field | Type | Notes |
|---|---|---|
Disclaimer Text | Long text | Primary field — the disclaimer body |
Disclaimer Concept | Text | Short label — search key |
Disclaimer label | Text | Display label rendered on the creative |
Language | Single select | Per-language disclaimer text |
Currency | Single select | Price disclaimers use this for formatting |
Status | Single select | Draft · Active |
Asset Library table
Stock and shoot assets reusable across campaigns. Linked to Shoots.
| Field | Type | Notes |
|---|---|---|
Asset name | Text | Primary field |
Asset type | Single select | Photo · Video · Raw · Other |
Asset URL | URL | External link (Drive / Dropbox) |
Thumbnail preview | Attachment | JPG/PNG, max 5MB. Drag & drop upload. |
Shoot | Linked → Shoots | Parent shoot record (optional) |
Models | Text | Comma-separated names |
Influencer | Text | Name or @handle |
LoB | Multiple select | Brand · At Home · App · Boutiques |
State of Mind | Multiple select | Generic · Focus · Recharge · Relax · Sleep |
Tags | Text | Comma-separated keywords for search |
Buyout expiry | Date | Surfaced in admin alerts ≤ 30 days out |
Buyout channel | Multiple select | Social · Web · Stores |
Buyout countries | Multiple select | Global, Europe, NL, BE, DE, FR, UK, ES |
Notes | Long text | Free-form notes |
Status | Single select | Draft · Approved · etc. |
Shoots table
Photo/video sessions that group Asset Library records.
| Field | Type | Notes |
|---|---|---|
Shoot name | Text | Primary field. Required when creating from the asset modal. |
Shoot date | Date | Used for sort-desc in the shoots list |
Location | Text | e.g. Amsterdam |
Photographer | Text | Name |
Model agency | Text | Agency name |
Notes | Long text | Free-form |
Comments table
One row per comment posted on a Creative. Threaded conversations between team members about a specific creative. Replaces ad-hoc Slack/email back-and-forth.
| Field | Type | Notes |
|---|---|---|
Comment ID | Autonumber | Primary field. Stable identifier (display as CM-00042 if you add a formula). |
Creative | Linked → Creatives | The creative this comment belongs to. Dashboard always writes a single-element bare-string array. |
Author | Single line text (recommended) | The name of whoever posted the comment. Set from the dashboard's localStorage identity at post time. Recommended type: Single line text so any name works (Tim, Maike, Mendy, etc.). If kept as Single select with fixed options, only those exact names will save without errors. |
Message | Long text | The comment body. Plain text in v1; rich text later. |
Created | Created time | Auto-set by Airtable. |
Status | Single select | Open (default) · Resolved. Resolved threads collapse by default. |
Resolved by | Single select | Same options as Author. Set when a user clicks ✓ Resolve. |
Resolved at | Date/time | Set when Status flips to Resolved. |
Parent | Linked → Comments | The comment this one is a reply to. When set, the dashboard renders the comment indented under its parent, building a tree. |
Attachments | Attachment | Files attached to the comment. Images render as 120×120 thumbnails inline; other file types render as a download chip. Uploaded via the content.airtable.com/.../uploadAttachment endpoint after the comment record is created. |
Reactions | Long text | Stores emoji reactions as a JSON map: {"👍":["Tim","Maike"]}. Toggled by clicking an existing pill or by opening the picker (👍 👀 ✅ ❤️ 🙏 🎉). If the field is missing, reactions are silently disabled — the rest of the thread still works. |
Edited at | Date/time (optional) | Set when an author edits their own comment via the ✎ Edit button. If the field doesn't exist, the dashboard simply updates Message without it (an "(edited)" tag only appears when this field is populated). |
Attachments (Attachment), Reactions (Long text — stores JSON), and optionally Edited at (Date/time). Posting still works without them, but attachments, reactions, and the "edited" indicator will be no-ops until they exist.Activity Log table
Team-wide audit trail. Every meaningful action the dashboard performs writes one row here (in addition to logging locally to localStorage). Drives the Recent Activity feed on Project Summary and the Home page ticker. See Activity Log feature page for the action → field mapping.
| Field | Type | Notes |
|---|---|---|
Action ID | Autonumber | Primary field. |
Type | Single select | Options (case-sensitive): designer · status · regen · comment · action · etc. |
Summary | Long text | Human-readable phrase, e.g. "assigned MO-04711 to Maike". |
By | Single line text | Signed-in user's name from the top-nav identity chip. |
Creative | Linked → Creatives | Set when the action concerns a specific creative. Bare-string array format. |
Campaign | Linked → Campaigns | Set when the action concerns a campaign (or a creative whose campaign can be resolved). Bare-string array format. |
Created | Created time | Auto-set by Airtable. |
[activity] Airtable write skipped: …), but the localStorage entry still surfaces in your own feed.Airtable automations
Five automations live in the Creative Performance Operating System base. The first one — the generation script — is the heart of the system. The other four are smaller event-driven helpers.
Automation inventory
| Name | Trigger | Action | Watches |
|---|---|---|---|
| Automation Campaign → Creative input | Record updated on Campaigns | Run script (with If Ready to generate is ✓ condition) | Field: Ready to generate |
| Automation Campaign → Country to Languages | Record updated on Campaigns | Run script | Field: Countries |
| Auto-update Creative Status to "Ready for review" | Record updated on Creatives | Update record (no condition — "Always") | Fields (2): Asset File URL and Asset File Upload — sets Status → Ready for review |
| Automation A: "Notify campaign manager — asset ready" | Record updated on Creatives | Send an email → marketing@mindoas.com | Field: Status. Condition: If Status is Ready for review |
| Automation B: "Notify designer — feedback ontvangen" | Record updated on Creatives | Send an email → marketing@mindoas.com ⚠️ not the designer | Field: Status. Condition: If Status is Needs changes |
nl-NL, Europe/Amsterdam timezone).1 · Generation script — what it does
Triggered when Ready to generate on a Campaign flips to true. Reads the campaign config, validates it, then creates one Creative record per platform × state of mind × content type × size × asset # × language × type combination.
Pre-flight guards
The script will refuse to generate (and write a Dutch-language error to Generation warning, then untick Ready to generate) if:
Assets generatedis already true on this campaign- Any of
platforms,Languages,Type,Content type,State of Mindare empty - Creatives already exist linked to this campaign (must be deleted first)
- Campaign uses an invalid platform × content-type combination (see matrix below)
Lenghtis set without selecting Video as content type, or vice-versaCarousel # assetsset without Carousel as content type, or vice-versa- Estimated creative count exceeds 300 (hard cap)
Soft warnings (generation proceeds, message saved to Generation warning):
- No Hook or Hooks Variation linked → Hook text / Header / Subheader stay empty
- Estimated count > 150 → "Let op" warning is appended
Manual-only platforms
The script skips these platforms entirely — Creatives for them must be created by hand:
PR · Stores · Google Search
Invalid platform × content-type combinations
| Platform | Forbidden content types |
|---|---|
DV360 Display | Carousel, Video |
DV360 Video | Still, Carousel |
Google Display | Video, Carousel |
Google Search | All — no creatives generated |
Youtube | Still, Carousel |
Apple Search Ads | Carousel |
Size matrix (per platform × content type)
| Platform | Video | Still | Carousel |
|---|---|---|---|
Meta | 9:16, 4:5, 1:1 | 4:5, 1:1 | 4:5, 1:1 |
Google App | 9:16, 16:9, 1:1 | 1:1, 4:5, 16:9 | — |
Google Display | — | 300×250, 728×90, 160×600, 300×600, 320×50, 970×250 | — |
Youtube | 16:9, 9:16 | — | — |
DV360 Display | — | 300×250, 728×90, 160×600, 300×600, 320×50, 970×250 | — |
DV360 Video | 16:9 | — | — |
Apple Search Ads | App preview video 1920×180px | iPhone screenshot (5x) | — |
File type & max-size mapping
| Platform / context | File type | Max file size |
|---|---|---|
DV360 Display · Google Display | HTML | 150 KB |
| Any platform · Video | MP4 | see platform |
| Any platform · Still / Carousel | JPG | see platform |
Meta | — | 4 GB (MP4) · 30 MB (image) |
Google App | — | 1 GB (MP4) · 5 MB (image) |
Youtube · DV360 Video | — | 1 GB |
Apple Search Ads | — | 500 MB (MP4) · 1 MB (PNG) |
| Default fallback | — | 4 MB |
Landing page → CTA URL map
| Landing page | CTA URL |
|---|---|
App Stores | https://mindoasis.com/downloads |
App PDP | https://mindoasis.com/app |
At Home PDP | https://mindoasis.com/athome |
Experience PDP | https://mindoasis.com/experiences |
TBD | (empty) |
Only populated when both Landing page is set and Call to action is checked.
Image phone (per language)
| Language | Image phone option |
|---|---|
NL | 4 mental states - Dutch |
DE | 4 mental states - German |
FR · BE-FR | 4 mental states - French |
| Anything else | 4 mental states - English |
Soundscape cycling
The campaign's Soundscape Index is reset to 1 at the start of generation and cycled 1 → 2 → 3 → 1 on completion. Each Creative gets a Soundscape based on its State of Mind and the current index:
Focus→ Focus - 1 / 2 / 3Recharge→ Recharge - 1 / 2 / 3Relax→ Relax - 1 / 2 / 3Sleep→ Sleep - 1 / 2 / 3Generic→ cycles all 12 (Focus 1, Focus 2, ..., Sleep 3)
Hook / Caption / USP resolution & language fallback
For every Creative the script resolves the linked content per language using grouping fields and an ENG fallback:
Caption Group → find a Caption with same group + matching Languages + Status="Approved". If none, fall back to ENG. If fallback was used, write a warning into the Creative's Feedback field.USP Group, matched by Language + Status="Approved". ENG fallback. The USP text is also copied into the Creative's USP's text field.Hook Group + Language + Approved, then ENG fallback. Always populates Hook text, Header, Subheader, Hook angle.Generation warning.Caption Group, USP Group, and Hook Group fields plus Language/Languages + Status="Approved" on every record. Hooks/Hook Variations also need Header / Sub header. These fields must exist with these exact names — see the updated schemas below.Volume math & batching
Estimated creative count = sum over (non-manual platforms) × (states) × (content types) × (sizes for that platform/content) × (asset count, =Carousel# or 1) × (languages) × (types). Records are written to Airtable in batches of 50 (API limit).
2 · Country → Languages script
Triggered when Countries on a Campaign is updated (12 runs / month at time of writing). Reads Countries, maps each country to its language via COUNTRY_LANGUAGE_MAP, deduplicates, and writes the result back to Languages. Unmapped countries fall back to ENG and a warning is logged in the Airtable run log.
Country → Language map (22 entries, current as of May 2026)
Updated by user — the UK option in the Languages field was renamed to ENG, and the script's UK-mapping key was changed from "United Kingdom" to the bare "UK" (matching the Airtable Countries option name).
| Country (Airtable option name) | Language written to Languages field |
|---|---|
Netherlands | NL |
Germany | DE |
Austria | DE |
France | FR |
Belgium | BE-FR |
UK | ENG |
Ireland · Sweden · Denmark · Finland | ENG |
Poland · Romania · Hungary · Bulgaria | ENG |
Croatia · Estonia · Latvia · Lithuania | ENG |
Luxembourg · Portugal · Italy · Slovenia | ENG |
| Anything else | ENG (with run-log warning) |
Resulting Languages set is deduplicated. Picking Netherlands + Belgium + Germany produces {NL, BE-FR, DE}. Picking UK + Ireland + Italy produces {ENG}.
Languages multi-select on Campaigns has 5 options: ENG, NL, BE-FR, DE, FR. The dashboard's VALID_CAMPAIGN_LANGUAGES whitelist filters out anything else (e.g. ES) before writing — preventing "Insufficient permissions to create new select option" errors.3 · Auto-update Creative Status
Triggered when a Creative is updated, watching two fields: Asset File URL and Asset File Upload (Attachment). The action is a no-script "Update record" that sets Status → Ready for review. The action runs Always — there is no condition checking whether the URL/upload is non-empty.
Action will run if… condition like "Asset File URL is not empty OR Asset File Upload contains attachments" to make the behaviour symmetric.4 · Notify campaign manager — asset ready (Automation A)
Triggered when a Creative is updated. Watches the Status field only. The conditional action group If Status is Ready for review gates a Send-an-email step.
marketing@mindoas.commarketing@mindoas.com — note the missing i. The rest of the system (CTA URL map in the generation script, Mind Oasis branding) uses mindoasis.com. If mindoas.com is not a real alias on your mail server, every trigger of this automation is bouncing silently. Verify with IT or check Airtable's automation run history for delivery errors.5 · Notify designer — feedback ontvangen (Automation B)
Triggered when a Creative is updated. Watches the Status field only. The conditional action group If Status is Needs changes gates a Send-an-email step.
marketing@mindoas.comCreative needs changes: {Creative ID}{File name} Needs changes. Open the dashboard to review the feedback. {Feedback team}marketing@mindoas.com (same address as Automation A). The designer never receives the feedback notification. Either the recipient should change to a designer-team mailbox (e.g. designers@mindoasis.com), or the automation should fan out — one email to marketing, one to the designer of the affected creative — using the Creative's Designer field to route.Feedback team field token. The dashboard source uses Feedback (Long text) — there's no Feedback team in the schema I've documented. Either that field was renamed in Airtable (and the dashboard needs updating to match), or there is a separate Feedback team field. Worth checking the Creatives table directly to reconcile.mindoas.com vs. mindoasis.com issue as Automation A — see the warn-box on Automation A.Operational notes from the Airtable Automations panel
Caching system
The dashboard uses a smart cache to minimise Airtable API calls. Every table fetch is cached with a fingerprint (latest record timestamp). Before any full fetch, a lightweight 1-record call checks if the data has changed.
[cache] HIT[cache] STALE or [cache] FETCHatPatch write automatically calls invalidateCache(tbl) for that table, forcing a fresh fetch next timeinvalidateAllCache()Performance & limits
📐 Brief types May 2026
The dashboard manages two kinds of work that share the Campaigns table: full paid-media campaigns with hooks/USPs/captions/automation, and lightweight standalone briefs for social-stores, organic, PR, internal, or anything else without paid-media machinery. The Brief Type single-select field on the Campaigns table discriminates between them.
The six types
| Type | What it's for | Hooks / USP / captions? | Automation triggers? |
|---|---|---|---|
Campaign — Paid Media | Meta / Google / DV360 paid-media campaigns. The current pre-existing flow. | ✓ required | ✓ yes |
Social — Stores | Social posts tied to a physical store. e.g. Instagram for Amsterdam Zuid. | ✗ not used | ✗ skipped |
Social — Organic | Owned-channel content: organic IG/TikTok/LinkedIn from the master account. | ✗ not used | ✗ skipped |
PR | Press releases, embargoed press assets, media kits. | ✗ not used | ✗ skipped |
Internal | Internal communications, decks, slack-banners. | ✗ not used | ✗ skipped |
Other | Catch-all for one-offs that don't fit elsewhere. | ✗ not used | ✗ skipped |
How to create each type
Where brief types show up in the UI
- Briefs page (was "Campaigns") — Type column with colored badge per row, plus a Brief-type filter chip in the toolbar.
- Briefing modal — paid campaigns get the full Targeting / Creative strategy / Copy & hooks sections; standalones get a focused Brief details / Description / References view.
- Designer View — a "Standalone briefs" group appears at the bottom of the page for any non-paid briefs assigned to the current designer (or All), showing deliverable count, channel, due date, and status. Click → brief details.
- Planning Timeline — standalone briefs render as one-bar-per-brief on the designer's row. Brief icon (🏬 🌱 📣 🏠 ◌) precedes the brief name, deliverable count shows as the bar's badge instead of creative-count. Color is always the brief-type color (not status-mix, since standalones have no creatives).
- Planning filters — new Brief type dropdown filters everything (timeline + capacity + summary). New Color by → Brief type option recolors bars by category.
- Planning Capacity — weekly capacity pills count each standalone brief's
Deliverable Countalongside paid campaigns' creative counts, so workload is honest across both kinds of work. - Home — a "Standalones in motion" row sits underneath the mission-control grid showing aggregate counts per Brief Type (chips). Each chip clicks through to the Briefs page pre-filtered.
- Admin → Data quality — the "Platforms set / Languages set / Hooks linked / etc." checks skip standalone briefs entirely. They'd never have those fields by design.
- Schema drift detector — the
Brief Type+ the four optional brief-only fields are part ofEXPECTED_SCHEMAso the detector verifies they exist.
Required Airtable schema changes
To enable brief types, add these fields to the Campaigns table in Airtable:
| Field | Type | Options / Notes |
|---|---|---|
Brief Type | Single select | Options (exactly as written, including em-dash):Campaign — Paid Media · Social — Stores · Social — Organic · PR · Internal · OtherDefault existing records to Campaign — Paid Media so the automation keeps running for them. |
Brief Description | Long text | Free-form description for standalone briefs. Optional. |
Channel Detail | Single line text | e.g. "Filiaal Amsterdam Zuid", "@mindoasis Instagram". Optional. |
Deliverable Count | Number (integer) | How many assets the brief expects. Used by Planning's capacity calc when no Creatives are linked. Optional, default 1. |
Reference Links | Long text | One URL per line. Renders as clickable links in the briefing modal. Optional. |
Designer | Single select | If not already present on Campaigns, add it. Options should match the Creatives.Designer field exactly (currently Maike, Mendy). |
Brief Type value default to Campaign — Paid Media in code — so the dashboard keeps rendering normally even before you've added the field in Airtable. Once the field exists, stamp all existing records on Campaign — Paid Media with an Airtable batch-update (or a one-shot script in the Scripting App).Required Comments-table addition (for brief-level discussion)
To enable the new 💬 Discussion panel inside the briefing modal for standalone briefs, add one field to the Comments table:
| Field | Type | Notes |
|---|---|---|
Campaign | Link to another record → Campaigns | Parallel to the existing Creative field. A single comment links to either a Creative or a Campaign, not both. Brief-level comments use this field; creative-level comments keep using Creative. |
Until this field exists, the first attempt to post a brief comment will show a clear toast explaining the setup. No data loss — existing creative-level comments are unaffected.
Required automation script change
Add this one-line guard near the top of the Automation Campaign → Creative input script so it skips non-paid briefs:
// Skip standalone briefs — they're not paid-media campaigns, so no creatives generated.
const briefType = (campaign.getCellValue('Brief Type') || {}).name
|| campaign.getCellValueAsString('Brief Type');
if (briefType && briefType !== 'Campaign — Paid Media') {
console.log('[automation] skipping non-paid brief:', briefType);
return;
}
Place this right after the campaign record is loaded but before the Ready to generate check. The empty/missing case falls through (treated as paid-media for backwards-compat).
Brief Type colors (used by Planning + badges)
| Type | Icon | Color |
|---|---|---|
Campaign — Paid Media | ⚡ | Sage #2d5a3d |
Social — Stores | 🏬 | Warm ochre #7a5c1e |
Social — Organic | 🌱 | Steel blue #3d6b8c |
PR | 📣 | Muted plum #6b3d7a |
Internal | 🏠 | Warm stone #5a524a |
Other | ◌ | Neutral #a09890 |
🧭 Strategic roadmap — where this tool goes next
The dashboard is no longer a glorified Airtable view. As of May 2026 it carries proprietary IP (Brief Types, brand-voice scoring, smart hook suggestions, performance integration) and supports a real workflow end-to-end. This section is the honest forward-look: what's solid, what blocks scale, and what would deepen the moat.
What makes this tool not-easily-replaceable today
- 🧠 Smart hook suggestions — every new brief surfaces top-fit hooks from the Library, ranked by Language × State of Mind × Funnel × Type match plus historical CTR (once Performance Daily fills). The longer Mind Oasis uses it, the smarter it gets.
- 🌿 Brand-voice scorer — encodes the Mind Oasis editorial voice as code: calm beats urgent, sensory beats clinical, invitation beats command. Every caption gets a 0-100 score live as you type, plus specific fix signals. New hires absorb the voice in days, not months.
- 📐 Brief Types — one tool for paid-media campaigns and standalone briefs (social-stores, organic, PR, internal, other). The competitor tools force you to pick one workflow.
- 📊 Hook performance database — Performance Daily writes per-creative metrics that aggregate up to Hook records. Over time this becomes a proprietary Mind Oasis dataset: "which hooks work for sleep content on Meta in NL?"
- 🔗 Ad-name regex matching — the
MO_recXXXnaming convention is dead-simple but bulletproof. No fragile webhooks, no broken mappings.
Scalability — what breaks as the team grows
| Risk | When | Mitigation |
|---|---|---|
atFetch(table, '', []) loads every record | ~5k records per table | Add server-side filterByFormula + pagination on Briefs and Designer-View |
| 16k-line single HTML file | 2+ devs in parallel | Build process (Vite/esbuild) + split into ES modules. ~1d setup, big maintenance dividend. |
| No real auth — token in localStorage | 10+ team members | Cloudflare Worker proxy: keeps token server-side, uses Worker JWT for per-user auth |
| Performance Daily row count | 1M+ rows | Retention pattern already in place — extend to Performance with 90d hot / aggregate-only after that |
| Mobile responsiveness | Designers on the go | Audit + breakpoint pass (1d). The luxury layer already disables tilt on touch devices. |
| Single-platform sync (Meta only initially) | Mind Oasis expands to TikTok / LinkedIn / Pinterest | Sync Config is platform-agnostic by design — adding a new platform = new sync automation + new platform-prefix in External Ad IDs. ~1d per platform. |
Future IP directions worth investing in
| IP layer | What it would be | Investment |
|---|---|---|
| AI brief-assistant | Claude API calls (proxied via Airtable Automation with stored key) for: drafting briefs from a one-line prompt, generating 5 hook variations on a concept, translating captions while preserving brand voice, diagnosing why a creative underperformed. | 3-5d build · Anthropic API key · No new infra (Airtable Automation hosts the calls). |
| Calm Calendar | Editorial calendar that respects state-of-mind cycles: Sleep content → Sunday evening · Focus → weekday mornings · Recharge → Friday afternoon. Recommends optimal launch slots per brief. | 2d build · No external services. Encodes Mind Oasis philosophy directly in the planning UI. |
| Cohort performance analysis | Slice Performance Daily by State of Mind × Audience × Content Type → discover proprietary insights ("Sleep + 30-44 women + Carousel = 4.2× baseline ROAS"). Surfacing these on Home becomes the daily-open hook. | 3d build · Requires 90d of Performance Daily data first. |
| A/B-test detector | Auto-detect when 2+ creatives are running in parallel for ≥7 days with significant CTR difference. Surface the winner on Home with a "scale this" CTA. | 2d build · Requires Performance Daily. |
| Brand-guideline scorer extension | Today: textual brand-voice scoring. Add: hex-color check on uploaded assets, font detection, image-composition score (rule of thirds, breathing room, etc.). Could use a CV model behind a Cloudflare Worker. | 5-7d build · Needs an image-analysis service. |
| Designer-fit recommendation | "Which designer historically performs best on Recharge + Video + Stores?" Reads Performance Daily aggregated by Designer to suggest the optimal assignment per brief. | 2d build · Requires 90d performance data. |
Architectural decisions to make
- Backend or no backend? Cloudflare Worker (or Vercel function) unlocks: per-user auth, webhook receivers (Slack notifications), AI-call proxy with server-side key, cron sync if Airtable Automation ever feels limiting. Cost: ~€5/month for the platform. Trade-off: one more system to maintain.
- Build process? Static-file deploy still works, but a Vite setup buys: ES-module split, TypeScript or JSDoc type-checking, automated tests in CI, source maps for production debugging. ~1d setup, pays off from week 2 onwards.
- Claude / OpenAI for AI features? Anthropic Claude is the better fit for brand-voice work — long context for the brand voice doc, careful tone control. ~€20-50/month at moderate usage.
- Real-time collaboration? Currently polling-based via Airtable. True realtime (Liveblocks, Supabase Realtime) is doable but expensive overkill for a 5-10 person team. Revisit at 20+ users.
Concrete next 30 days — recommendation
- Week 1: Add the Airtable schema (Brief Type, Performance Daily, Sync Config, Comments.Campaign). Pilot the
MO_recXXXnaming convention on 5 active Meta ads. - Week 2: Set up Meta Business App + system user. I build the Phase 1 Meta sync (Airtable Automation). Once data flows, Phase 2 UI fills in automatically.
- Week 3: Submit Google Ads API access request (3-7d wait). Mobile-responsive audit. Decide on backend (Cloudflare Worker pilot for the AI assistant).
- Week 4: If Google approved → Phase 3 sync. Otherwise: build the AI brief-assistant on top of Airtable Automation + Anthropic API.
✦ Pre-Launch Performance-laag Q3-Q4 2026 roadmap
A thin feedback-layer through the existing OS that gives designers and strategists actionable feedback before a creative is launched — channel-aware, sourced from both best-practice heuristics and Mind Oasis's own historical winners. Designed by Tim, May 2026. This section is the definitive build-plan; supersedes the older "Future IP directions" list above.
n + confidence are first-class fields. This is the reason designers will keep trusting the tool over time, and the difference between this build and the generic ad-testing tools (Pencil/Marpipe/etc.) that lose trust after 6 months.The Loop — what makes this not just a linter
The Coach is only as good as the data it learns from. The Loop is the cycle that connects every component:
- Designer creates a creative and uploads it in Designer View (exists)
- Coach analyses the asset → channel-aware scorecard + readiness summary (new, Fase 1)
- Review approves / rejects (exists) — Coach score travels along as context
- UTM links the launched asset to its performance data (exists)
- Results (CPA/ROAS per channel) flow back into
Performance_Results(new, Fase 2) - Lens mines patterns from those results → next Coach feedback is sharper (new, Fase 2)
Without the Loop the Coach is a generic best-practice linter. With the Loop it becomes a Mind Oasis-specific system that gets smarter every campaign.
Modules & where they live in the OS
| Module | Where in the OS | Why there | Phase |
|---|---|---|---|
| Coach | Designer View + Review (no new menu item) | That's where the designer already is when needing feedback | Fase 1 |
| Score / readiness | Fills existing "No score" (Library) + "PERF" column (Campaigns) | Hooks already exist in the UI — no new design | Fase 1-2 |
| Lens (patterns) | New tab under existing section, or Home widget | Analysis tool — doesn't need to be prominent in daily workflow | Fase 2 |
| Spyder (competitors) | More → new "Competitors" item | Research, separate from own workflow | Fase 3 |
| Discovery | Within Competitors or as Library filter | Extension of existing Library | Fase 3 |
| Swipe Files | More → "Swipe" or inside Competitors | Inspiration archive, low daily frequency | Fase 3 |
| Fatigue alerts | Home (bottleneck-style) + Planning | Where the team already looks at running work | Fase 2 |
Net effect on navigation: exactly one new top-level item ("Competitors" under More). Everything else slots into pages that already exist. Critical discipline — anything else makes the OS feel busy.
Critical guardrails — what Claude Code must NOT break
- Existing Airtable tables, fields, views — only add, never rename/remove
- UTM naming convention
MO_[Channel]_[Platform]_...— everything hangs off it - Review statuses (Ready for review / Approved / Needs changes) and their logic
- Existing navigation routes and components
- Existing styling — reuse design tokens, never introduce a 2nd UI language
- Additive: new code in new modules/files, plugged into existing extension points
- New Airtable fields get a
perf_orcoach_prefix - Read + document existing structure before changing anything
- Every phase independently testable and reversible (feature flag where possible)
- Honour the existing style (cream + sage + Cormorant) to the pixel
- Competitor data ONLY via official sources: Meta Ad Library API and Google Ads Transparency Center. No scraping.
- Store creatives + metadata only — never persons (no face analysis, no name tagging) — AVG/GDPR compliant.
- Competitor conversion data does not exist publicly. Never suggest it's predicted. Days-running is the only honest winner-signal.
New Airtable tables (additive — never replace existing)
| Table | Core fields | Feeds |
|---|---|---|
Creative_Features | link→asset · channel · hook_type · format · talent_type · ugc_vs_studio · cta_type · text_density · contrast · hook_start_sec · captioned | Coach + Lens |
Coach_Reviews | link→asset · readiness · per-element status+action · source (own/best) · confident (bool) · overrule_reason · version | Loop + Lens |
Performance_Results | link→asset · channel · date · impressions · CTR · CPA · ROAS · hold_rate · fatigue_flag | Lens + PERF column |
Competitor_Ads | brand · channel · creative_url · format · first_seen · days_running · angle_tag (no PII) | Spyder + Coach context |
Winning_Patterns | channel · attribute · avg CPA/ROAS · n (sample size) · confidence | Coach feedback + Lens |
Why n + confidence are required: the Coach may only claim "your data says X" when there's enough observations. n determines whether a pattern surfaces as "certain" or "limited certainty". Prevents pseudo-confidence — the failure mode of every existing ad-testing tool.
Phased build — what's achievable when
| Phase | What | Output | Honest difficulty |
|---|---|---|---|
| Fase 0 Foundation | Read & document existing OS structure · Create new Airtable tables (empty) · Extract design tokens to shared style file · 2-week observation period — watch how team uses current OS | "Current state + integration plan" doc, signed off by Tim before any code | ~1 week + 2 weeks observation |
| Fase 1 The Coach | Coach panel in Designer View + Review · Specs validator (ratio · duration · file size · text density via OCR) · Source + uncertainty labels · Overrule button + reason capture · Fills "No score" in Library | Designers get real per-element feedback. Data collection starts. | Days to a few weeks — interface + heuristics are well-scoped |
| Fase 2 Loop + Lens | Performance feedback per channel (start with Meta CSV, then Google Search/Display/App, DV360, Apple Search) → Performance_Results linked via UTM · Auto-tagging with fixed taxonomy · Lens patterns → fills PERF column · Fatigue detection | Coach now grounds itself in YOUR winners. PERF column comes alive. | Real work — 6 channel APIs normalising is the hardest integration. Plan 6-10 weeks. |
| Fase 3 Competitor intel | Spyder: 3-5 brand watchlist → 15-50 via Meta Ad Library API · Competitor context in Coach · Discovery over own winners · Swipe Files · CPA/ROAS as honest ranges · Scene-level video analysis (CV, separate sub-project) | Full pre-launch layer including market context. Honest about what we don't know. | Spyder is operationally heavy (~0.5 day/week ongoing). Scene analysis is a CV project on its own. |
Scope-honesty matrix — what's buildable now vs. what needs patience
| Component | Status | Why |
|---|---|---|
| Coach UI + scorecard | Now | Interface + heuristics, well-scoped |
| Specs / ratio / text density | Now | OCR + file checks work for ~70-80% of cases; expect 20% needing manual re-check |
| Swipe Files | Now | Visual layer on Airtable |
| Spyder (own selection) | Fase 3 | Official API, rate limits, ongoing maintenance |
| Lens / patterns | After data | Needs filled Performance_Results. Realistic: 6-12 months of data accumulation with 3 active campaigns before statistical signal emerges. |
| CPA/ROAS prediction | After data | Only honest with enough own history |
| Scene-level video analysis | Later | Computer vision — separate sub-project |
| Competitor conversion prediction | Never | Data isn't public. Would be pseudo-confidence. |
Sharpening — points I'd push back on in the plan
- Fase 2 is heavily underestimated. "Six channel APIs normalising" = 6-10 weeks of focused ETL + reconciliation engineering. The Meta MCP attempt this week (4 retries, 0 data) is a preview of how hard one channel can be. Start with one channel via CSV-drop before committing to 6.
- Lens has a chicken-and-egg problem. With ~3 active campaigns, you'll need 6-12 months of data before Lens shows meaningful per-channel patterns. The Coach will fall back to "best practice" feedback most of year 1 — which the plan tries to avoid. Acknowledge this in user-facing copy.
- OCR "reliable" is partly true. Works for 70-80% (text on flat background, single-frame statics). Fails on motion video, stylised text, gradient overlays, text-as-image. Build in an "OCR uncertain — please verify" status; never a hard "text density: 32%" claim.
- Overrule is gold — promote it. The plan mentions it in one bullet. It's actually the most valuable data source — labelled "Coach was wrong because X". Build a "Coach disagreements" dashboard in Fase 2 → the heuristics that get overruled most are the ones to update. Cheaper than ML.
- Where do Coach heuristics come from? Not addressed. Options: hand-curated JSON, LLM-call to a vetted prompt library, or both. Decide before Fase 1 starts — otherwise the Coach says "best practice from 2023" and loses trust in 3 months.
- Measure the Coach itself. Without a control group (creatives that don't see Coach feedback), there's no way to prove the Coach lifts performance. Add a "Coach applied" boolean per creative in Fase 1 → in Fase 2 compare CPA distributions. Otherwise you're betting blind.
- Spyder is ~0.5 day/week ongoing ops. Meta Ad Library API queries per-Page-ID, no search. 15-50 brand watchlist means manually identifying 15-50 Page-IDs, monitoring for renames/deletes, rate-limit management. Build for 3-5 brands first, validate the dataflow, then scale.
- Fatigue threshold needs a concrete definition. Proposal: "7-day-rolling CPA > 1.3× lifetime CPA, for a creative active >14 days." Without an explicit cutoff, alerts will be either noisy or silent.
Sequencing — what comes first
- Deployment live (Netlify or internal host) so the team can access the OS without running a local server.
- Adoption ≥5 daily users for 2-4 weeks. If only Tim + 2 designers use it, there's no Performance_Results to feed Lens for a year. Adoption first, Coach second.
- One channel (Meta) CSV → Performance_Results working end-to-end. Prove the Loop for one campaign with one channel before committing to 6.
What this means for the existing roadmap
The presentation's Q3 ("Performance loop") and Q4 ("Creative intelligence") were sketched in May; this plan is the detailed elaboration of those:
| Presentation slide 19 (May) | Bouwplan equivalent |
|---|---|
| Q3 — GA4 attribution pull, Meta + DV360 cost sync, CTR/CPI per creative | Fase 2 — Performance_Results + Lens (scoped to start with Meta CSV) |
| Q3 — Hook performance scoring | Fase 2 — Lens patterns (broader: every creative attribute, not just hooks) |
| Q4 — Winning-hook recommendations, A/B variant suggestions | Fase 3 — Discovery over own winners + Coach with data-context |
| (not previously named) | Spyder — competitor intelligence layer (new) |
| (not previously named) | The honesty rule — permanent constraint across all phases (new) |
The Bouwplan is not a new direction — it's a sharper, more disciplined version of what was already on the roadmap, with Spyder + the honesty rule as new additions.
🗓 Market Moments May 2026
A social asset isn't always part of a campaign. Sometimes it's about Moederdag, Black Friday, or a product launch that pulls together work across paid, social, PR and email. Market Moments are the cross-channel anchors that group all of that together.
Brief Type × Moment — orthogonal axes
Examples: Moederdag campaign on Meta = Brief Type Campaign — Paid Media + Moment Moederdag 2026. Moederdag in-store social = Brief Type Social — Stores + Moment Moederdag 2026. Always-on retention paid = Brief Type Campaign — Paid Media + no moment.
Required Airtable schema
New table: Market Moments
| Field | Type | Notes |
|---|---|---|
Moment name | Primary (text) | "Moederdag 2026", "Black Friday 2026" |
Moment Type | Single select | Holiday · Seasonal · Cultural · Product Launch · Brand · Trend · Always-on |
Anchor Date | Date | The day the moment lands (e.g. 11 May 2026 for Moederdag) |
Lead Time Start | Date | When briefs should start being created — for planning ahead |
Description | Long text | Tone-of-voice notes, context, what makes this moment important |
Reference Links | Long text | Inspiration URLs, previous editions, mood-boards. One URL per line. |
Status | Single select | Planned · In production · Live · Completed |
Briefs | Link → Campaigns (reverse) | Auto-populated reverse-link from the Campaigns.Market Moment field |
New field on Campaigns: Market Moments
Linked record → Market Moments (plural — matches the table name). Optional. Allows multiple links (one campaign can serve multiple moments — though usually just one).
Where Moments show up in the UI
- New Brief modal: 🗓 Market Moment dropdown next to LoB. Sorted by anchor date; completed moments listed last with a strike-through feel.
- Briefing modal title: Moment shown as a colored pill next to the Brief Type badge.
- Campaigns table: Moment pill rendered inline on the brief name. New "🗓 Moment" filter dropdown alongside Brief Type.
- Planning → 🗓 Moments tab: New sub-tab — calendar view of every moment grouped by month, with brief-count per moment.
- Moment drill-in panel: Click any moment card → side modal showing every linked brief grouped by Brief Type (paid / social / PR / etc.) with status badges. "+ Brief for this moment" button pre-fills the New Brief modal.
Moment-type colors (used everywhere)
| Type | Icon | Color |
|---|---|---|
Holiday | 🎉 | Cranberry |
Seasonal | 🍂 | Warm ochre |
Cultural | 🏳️🌈 | Plum |
Product Launch | 🚀 | Sage |
Brand | ✦ | Steel blue |
Trend | ⚡ | Amber |
Always-on | ∞ | Warm stone |
Lead-time awareness May 2026
Per moment, we compute a health level: overdue · urgent · behind · on-track · in-prod · future · done · undated. The rules:
- overdue: Anchor date passed, status not Completed → red.
- urgent: Anchor within 14 days and zero linked briefs → red, pulsing dot on card.
- behind: Lead Time Start passed and zero linked briefs → amber.
- in-prod: Lead Time Start passed and briefs exist → green.
- on-track / future: default — neutral text.
- done: Status = Completed → muted.
Urgent · overdue · behind moments roll up to a banner at the top of the Planning → 🗓 Moments tab — one click per chip jumps straight to the drill-in. Moment cards on the calendar show a pulsing dot when urgent.
Coverage hint on New Brief May 2026
When you pick a moment in the + New brief modal, a hint appears under the dropdown: "in 5d · No briefs linked yet — yours will be the first." Lead-time-passed warnings surface in amber so you know upfront if you're already late.
Moment pills in Designer View May 2026
Designer page (and the Standalone briefs panel underneath) renders the linked moment(s) as a pill next to the campaign / brief name. Designers see the why for every row at a glance — no more guessing whether something is part of a moment push.
Moments on the Home page May 2026
The Home dashboard now reflects moment urgency in two places:
- Smart insight banner. If a moment is overdue, urgent, or behind on lead-time, that becomes the top insight on the page — above per-creative overdue counts. The CTA jumps straight into the moment's drill-in panel.
- Moments-needing-attention row. A new chip-row underneath the mission-control grid lists up to 6 at-risk moments. Each chip = one click → drill-in. Pulsing red dot for overdue/urgent · amber outline for behind on lead-time.
The row only renders when there's risk to flag — Home stays calm when nothing's burning.
Moments in the weekly digest May 2026
The weekly digest (Admin → Tools → 📅 Weekly digest) gains two new Markdown sections, both derived from the live moments cache:
- 🗓 Moments needing attention — overdue/urgent/behind moments. Each row is the name + the risk label ("3d overdue", "in 5d · 0 briefs", "lead-time passed · 0 briefs").
- 🌱 Moments — next 30 days — calendar view of every moment with an anchor date in the next month + brief count. Useful for the team's Monday-morning planning.
Moment-aware empty states May 2026
The Designer view and the Briefs page now surface urgent moments inside their empty states. If Maike has nothing on her plate, the empty-state still says: "By the way, these moments need briefs." Strategic risk doesn't hide just because a designer's queue happens to be clean. Powered by a new _urgentMomentsExtra() helper that the emptyState() renderer accepts via an extra field.
Moments band on Timeline May 2026
The Planning → Timeline tab now renders Market Moments as a horizontal band row at the top, above the designer rows. Each moment with an anchor date inside the visible 6-week window paints as a colored band that spans Lead-Time-Start → Anchor Date. The anchor date itself is rendered as a thicker vertical pin so you can see the launch moment precisely. At-risk moments (anchor close + 0 briefs, or lead-time passed + 0 briefs) get a red accent. Click any band → drill-in panel. The band row is omitted entirely when no moments overlap the current window — no empty placeholder.
Moment context in the briefing modal May 2026
Opening a brief that's linked to a moment now shows a prominent moment context band directly under the modal header — type icon, name in Cormorant serif, countdown ("in 5d", "today", "3d overdue") color-coded by urgency, anchor date, and a lead-time-passed warning when applicable. Click the band → close brief → open the moment drill-in. No more buried mini-pills.
Past-moments retrospective May 2026
At the bottom of the Moments tab, a collapsible 📊 Past moments — what worked section lists every Completed or past-anchor moment with a one-card summary: brief count, ship rate (% of linked briefs marked Completed), lead-time planned vs. used (with efficiency %), and how many designers were involved. The section header rolls up grand totals. Use it for retrospective planning — "Black Friday last year took 23 days of lead time, we planned 14 — bump the planning window."
Starter-brief generator May 2026
The moment drill-in panel gets a new ✨ Generate starter briefs action. Pick a moment (e.g. Black Friday 2026), click the button, and you get a modal where you check the channels you want covered (Paid, Stores, Organic, PR, Internal) and set a quantity per channel. Hit Generate → one Airtable POST per brief, all pre-linked to the moment, status Planning, due date = anchor minus 1 day (so work is done before launch), start date = lead-time-start.
Each brief lands in your Campaigns table named {Moment name} — {Brief Type} (e.g. "Black Friday 2026 — Social — Stores"). Quantities above 1 get suffixed #2, #3 etc. Activity log gets one entry per brief. Errors per-row don't block the rest. Modal closes on full success and re-opens the moment detail panel so you immediately see the newly-linked briefs.
Why this matters: a moment touches 4-5 channels on average. Before this, you'd open the New Brief modal 5 times. Now: one click, 5 pre-filled briefs, ready for designer assignment.
UTM Codes v3 — May 2026 (correction sheet)
Every paid + owned-media asset that leaves the building needs a UTM-tagged URL so GA4 can attribute clicks, sessions, and installs back to the right channel, campaign, and creative. We've encoded the latest convention from "Mind Oasis App x GK - Deliverables (1).xlsx" — v3 May 2026 correction sheet — into the dashboard: 18 channels grouped Paid / Owned, the new AppActivation campaign marker, a Theme dimension (ComingSoon / AppIntro / UGC / Generic), and utm_campaign=App_July_2026 as the new paid-app launch default.
- New
AppActivationcampaign marker in the filename. Appears between funnel and theme for Meta (MO_Meta_iOS_TOFU_AppActivation_ComingSoon_...) and between platform and funnel for Google App (MO_Google_App_iOS_AppActivation_Prospecting_...). The parser handles both orderings; the builder emits the correct one per channel. - Theme is now a first-class segment. ComingSoon · AppIntro · UGC · Generic. UGC is a compound (
UGC_[Mood]_[Audience]_[Concept], e.g.UGC_Relax_Parent_AlwaysOn). Generic in MOFU adds a Mood sub-token (Generic_Sleep,Generic_Focus). - iOS / Android in PascalCase in filenames (was lowercase
ios). The builder now outputs Apple/Google canonical casing. utm_campaignURL parameter default =App_July_2026for all paid-app channels (wasmindoasis_app_intro). Matches the value Ramona uses in the "UTM -setup" sheet column H. Owned-media defaults unchanged (mindoasis_app_launchfor Email/PR,mindoasis_myrituals_25pctfor In-Store/loyalty).- UGC kebab-case still works for the legacy short form (
UGC_Focus+ variant=year →mindoasis-app-ugc-parents-focus-2026). The v3 compound UGC (4+ segments likeUGC_Relax_Parent_AlwaysOn) uses the standard underscore filename — kebab-case trigger is now restricted to the 2-segment legacy form only. - UTM_THEMES enum updated from
['Generic','Relax','Sleep','Focus','Recharge','UGC','Coming_soon','App_intro','Other']to['ComingSoon','AppIntro','UGC','Generic','Sleep','Relax','Focus','Recharge','Other']— canonical PascalCase per spec. - Phase 1 vs Phase 2 distinction. Phase 1 (App launch July 2026) filenames include
AppActivation. Phase 2 (post-launch) filenames drop it — both parse cleanly. The parser is permissive on both forms. - Fixed spacing/parentheses issues in UGC filenames from earlier GK deliveries. Example:
UGC_Relax / Parent / AlwaysOn→UGC_Relax_Parent_AlwaysOn;(English)suffix removed. - Meta MOFU Android relabel. Phase 1 Android MOFU filenames were incorrectly labelled as iOS in the earlier spec. Corrected in v3; parser unaffected (both spellings parse identically).
Filename anatomy — v3 spec
| Channel family | Pattern | Example |
|---|---|---|
| Meta TOFU | MO_Meta_[iOS|Android]_TOFU_AppActivation_[ComingSoon|AppIntro]_[Video|Static|Carousel]_[N]_[Country]_[Language] | MO_Meta_iOS_TOFU_AppActivation_ComingSoon_Video_1_NL_ENG |
| Meta MOFU Generic | MO_Meta_[iOS|Android]_MOFU_AppActivation_Generic_[Sleep|Relax|Focus|Recharge]_[Video|Static|Carousel]_[N]_[Country]_[Language] | MO_Meta_iOS_MOFU_AppActivation_Generic_Sleep_Video_1_NL_ENG |
| Meta UGC (TOFU or MOFU) | MO_Meta_[iOS|Android]_[TOFU|MOFU]_AppActivation_UGC_[Mood]_[Audience]_[Concept]_[Variant]_[Country]_[Language] | MO_Meta_iOS_TOFU_AppActivation_UGC_Relax_Parent_AlwaysOn_Video_1_NL_ENG |
| Google App | MO_Google_App_[iOS|Android]_AppActivation_[Prospecting|Retargeting]_[Country]_[Language] | MO_Google_App_iOS_AppActivation_Prospecting_NL_ENG |
| Google Search | MO_Google_Search_[Branded|NonBranded]_[Country] (no language — creatives are text-free) | MO_Google_Search_Branded_NL |
| Meta UGC legacy kebab (still supported) | mindoasis-app-ugc-[audience]-[theme]-[year] | mindoasis-app-ugc-parents-focus-2026 |
| Phase 2 (no AppActivation) | Same as above, minus the AppActivation segment | MO_Meta_iOS_TOFU_UGC_Relax_Parent_AlwaysOn_Video_1_NL_ENG |
Theme vocabulary (v3)
| Theme | When to use | Sub-segments |
|---|---|---|
ComingSoon | Pre-launch teasers (Meta TOFU only) | — |
AppIntro | App introduction / awareness (Meta TOFU only) | — |
UGC | User-generated content style | [Mood]_[Audience]_[Concept] — e.g. Relax_Parent_AlwaysOn, Sleep_Menopausal_WhiteNight, All_Menopausal_PermissionToPause, Focus_Parent_MorningRush, Recharge_Parent_Multitasker, Focus_Menopausal_BrainFog |
Generic | Non-UGC mood content (Meta MOFU only) | [Mood] — Sleep, Relax, Focus, Recharge |
Attribution Context — UTM ↔ OneLink dual-attribution model
Mind Oasis runs two parallel attribution systems: standard UTM (for GA4 web analytics) and OneLink (AppsFlyer mobile-attribution for app installs). The OS encodes which path each UTM follows via the Attribution Context field — so marketers can see at a glance whether a UTM is functionally tracked or naming-only.
| Context | When it applies | How attribution works | OS auto-detect |
|---|---|---|---|
| 📊 web_via_smart_script default | ~95% of touchpoints — Google App, Apple Search, DV360, YouTube, Display, Email, In-Store QR, Website, App Rituals, Organic | User clicks UTM URL → lands on mindoasis.com → AppsFlyer Smart Script reads UTM params → converts to OneLink → user is redirected to app-install with attribution intact | All channels except META and PR |
| 📱 meta_sdk | Meta paid app-install (TOFU/MOFU, UGC, AppIntro, ComingSoon) | Meta-AppsFlyer SDK integration handles attribution directly via the Meta app. The UTM URL is for Ads Manager naming/reporting only — users don't click it for tracking purposes | Channel = META |
| 🌐 web_only | PR press releases, info pages without app component | No app-install funnel — UTM tracks web sessions only in GA4 | Channel = PR |
- Bulk-gen auto-fills the field based on channel when creating UTMs from creatives.
- New UTM modal has an "Attribution" dropdown that auto-defaults from channel (sage hint "· auto from channel" shows when not overridden); user can manually pick a different context if needed.
- UTM detail modal shows the badge with icon + label using the context's accent color. If the Airtable field is empty, badge shows the inferred value with "inferred from channel" hint.
- UTM page filter includes Attribution dropdown — narrow the list to Smart Script / Meta SDK / Web only.
- CSV export includes Attribution Context as a column.
Attribution Context (Single select) to the UTM Codes table with options:
web_via_smart_script— color: sagemeta_sdk— color: Meta blueweb_only— color: amber
Smart Script either works on a landing page or it doesn't. Once validated per page, every UTM that lands there inherits the working behavior. Per-UTM testing is overkill — validate ONCE per page and document the result.
| Landing page | Used by | Validation | Owner |
|---|---|---|---|
mindoasis.com/download | Most paid touchpoints (Google App, Apple Search, DV360, YouTube, Display, Meta non-SDK paths) | Test UTM URL → check AppsFlyer dashboard records install with utm_source/medium/campaign preserved | Mr Analytics + dev |
mindoasis.com/myrituals | Email + In-Store Flyer + In-Store Backwall + App Rituals + MY_RITUALS + Website Rituals | Same as above | Mr Analytics + dev |
mindoasis.com/app | Meta Organic — Posts/Influencers (Mind Oasis page) | Same as above | Mr Analytics + dev |
mindoasis.com/ (homepage) | PR — Press Release | Test session attribution — no app install expected, so just verify utm params land in GA4 | Mr Analytics |
| Meta SDK path (no landing page) | Meta paid app-install (TOFU/MOFU) | Validate via Meta Ads Manager + AppsFlyer console — confirm the Meta-AppsFlyer SDK integration reports installs with Campaign Name preserved | Mr Analytics + Meta-AppsFlyer dev integration |
- The v2 utm_campaign defaults below show
mindoasis_app_intro. These are nowApp_July_2026in the code — the table below has been kept as historical reference but the per-channelcampaignproperty on eachUTM_CHANNELSentry has been updated toApp_July_2026for paid-app channels. Owned-media defaults are unchanged. - The "Naming convention" table further down shows v2-era patterns (Meta extended with
App_introinstead ofAppActivation_ComingSoon). The v3 patterns are the new authoritative versions — see the "Filename anatomy" table immediately above.
- Landing-page domain unified to
mindoasis.com(wasmindoasis.app). Four canonical paths:/download,/myrituals,/app,/. - 18 channels grouped Paid / Owned. Meta stays unified (no IG/FB/UGC split — team decision); Google split into Play Store / Display / YouTube / Search / Discovery / App; In-Store split into Flyer + Backwall.
- New channels added: APPLE_SEARCH (Apple Search Ads), GOOGLE_PLAYSTORE, GOOGLE_DISCOVERY, ORGANIC_MO (organic posts/influencers), WEBSITE_RITUALS (local block + PDP), APP_RITUALS (Two Tile), PR (press release), INSTORE_BACKWALL.
- utm_medium normalized: Video channels (YouTube + DV360 Video) now use
olvinstead ofvideo(matches Ramona's GA4 setup). - utm_source per channel: Meta =
meta(unified — IG/FB not split), Email =rituals_newsletter(wasklaviyo), In-Store =instore_flyer/instore_backwall, Organic =ig_organic, Rituals owned =rituals_website/rituals_pdp, PR =press. - utm_campaign defaults per channel:
mindoasis_app_intro(paid),mindoasis_app_intro_ugc(Meta UGC via Campaign Name "UGC_…"),mindoasis_app_launch(Email + PR),mindoasis_myrituals_25pct(In-Store + App Rituals + MY_RITUALS).
Channel reference (v2)
Each Channel key has a fixed default for utm_medium / utm_source / utm_campaign / landing-page. These auto-fill when you create a UTM — you can override any field per-record. Backward-compatible: every old key (META, GOOGLE_SEARCH, etc.) is preserved and resolves correctly for existing records.
META channel with utm_source=meta. The Excel spec suggests splitting Meta into ig (Instagram) + fb (Facebook) as separate utm_source values, but the Mind Oasis team treats Meta as one channel for attribution purposes — so META_IG / META_FB are NOT separate channels in the dashboard. UGC is also handled within the unified META channel: set Campaign Name to start with UGC_ (e.g. UGC_Focus, UGC_Relax) and the dashboard automatically (a) emits utm_campaign=mindoasis_app_intro_ugc and (b) generates the kebab-case filename mindoasis-app-ugc-{audience}-{theme}-{year} (audience comes from the Platform field).| Channel key | Label | medium | source | campaign default | Landing |
|---|---|---|---|---|---|
| Paid media | |||||
META | Meta — Feed/Stories/Reels (incl. UGC) | cpc | meta | mindoasis_app_intro · _ugc when Campaign starts with UGC_ | /download |
GOOGLE_PLAYSTORE | Google — Play Store | cpc | mindoasis_app_intro | /download | |
GOOGLE_DISPLAY | Google — Display | display | mindoasis_app_intro | /download | |
YOUTUBE | Google — YouTube | olv | youtube | mindoasis_app_intro | /download |
GOOGLE_SEARCH | Google — Search | cpc | mindoasis_app_intro | /download | |
GOOGLE_DISCOVERY | Google — Discovery | discovery | mindoasis_app_intro | /download | |
GOOGLE_APP | Google — App Campaigns | cpc | google-app | mindoasis_app_intro | /download |
DV360_DISPLAY | DV360 — Display Banner | display | dv360 | mindoasis_app_intro | /download |
DV360_VIDEO | DV360 — Display Video | olv | dv360 | mindoasis_app_intro | /download |
APPLE_SEARCH | Apple Search Ads | cpc | apple | mindoasis_app_intro | /download |
| Owned media | |||||
EMAIL | Email + Layers | rituals_newsletter | mindoasis_app_launch | /myrituals | |
INSTORE | In-Store — Flyer (QR) | qr | instore_flyer | mindoasis_myrituals_25pct | /myrituals |
INSTORE_BACKWALL | In-Store — Backwall | qr | instore_backwall | mindoasis_myrituals_25pct | /myrituals |
ORGANIC_MO | Meta Organic — Mind Oasis | social | ig_organic | mindoasis_app_intro | /app |
WEBSITE_RITUALS | Website Rituals | website | rituals_website | mindoasis_app_intro | /myrituals |
APP_RITUALS | App Rituals — Two Tile | website | rituals_pdp | mindoasis_myrituals_25pct | /myrituals |
PR | PR — Press Release | pr | press | mindoasis_app_launch | / |
MY_RITUALS | My Rituals (loyalty) | crm | myrituals | mindoasis_myrituals_25pct | /myrituals |
Naming convention (utm_content / filename)
Patterns differ slightly per channel — the dashboard handles all of them. Last segments (country + language) are always uppercase 2–4 letter codes. No spaces, no file extensions.
| Channel family | Pattern | Example |
|---|---|---|
| Meta (Generic theme) | MO_AppIntro{Theme}_{Variant}_{Format}_{Language} | MO_AppIntroGeneric_Video1_4_5_ENG |
| Meta TOFU/MOFU (extended) | MO_Meta_{ios|android}_{TOFU|MOFU}_{Campaign}_{Variant_N}_{Country}_{Language} | MO_Meta_ios_TOFU_App_intro_Video_1_NL_ENG |
| Meta UGC | mindoasis-app-ugc-{audience}-{theme}-{year} (kebab-case) | mindoasis-app-ugc-parents-focus-2026 |
| Google Search Branded | MO_Google_Search_Branded_{Country} (no language — text-free creatives) | MO_Google_Search_Branded_NL |
| Google Search Non-Branded | MO_Google_Search_NonBranded_{Country} | MO_Google_Search_NonBranded_UK |
| Google App Campaigns | MO_Google_App_{iOS|Android}_{Prospecting|Retargeting}_{Country}_{Language} | MO_Google_App_iOS_Prospecting_NL_ENG |
| YouTube (16:9 video) | MO_AppIntro{Theme}_{Variant}_16_9_{Language} | MO_AppIntroRelax_Video1_16_9_NL |
| Google Display (banner) | MO_AppIntro{Theme}_Static1_{Size}_{Language}_GOOGLE | MO_AppIntroSleep_Static1_4_5_FR_GOOGLE |
| DV360 Banner (HTML5) | MO_AppIntro{Theme}_{Size}_{Language} | MO_AppIntroFocus_320_480_FR |
| DV360 Video | MO_AppIntro{Theme}_Video1_{Size}_{Language} | MO_AppIntroSleep_Video1_300_250_ENG |
Themes, languages, countries
- Themes (5): Generic · Relax · Sleep · Focus · Recharge
- Languages (4 core): ENG · NL · DE · FR · (plus BE-FR, IT, ES, PL kept as legacy options)
- Countries (5 core): NL · BE · UK · DE · FR · (plus ES, IT, PL, EU kept as legacy options)
- Funnels: TOFU · MOFU · BOFU · Branded · NonBranded · Prospecting · Retargeting · AlwaysOn
- Variants: Video_1, Video_2, Static_1, Static_2, Carousel_1, plus size codes (4_5, 1_1, 16_9, 9_16, 300_250, 320_480, 1920_1080).
Airtable setup
Create a new table UTM Codes with these fields:
| Field | Type | Notes |
|---|---|---|
utm_content | Single line text | Primary field = filename |
Channel | Single select | 18 options — see Channel reference table above (META, GOOGLE_PLAYSTORE, GOOGLE_DISPLAY, YOUTUBE, GOOGLE_SEARCH, GOOGLE_DISCOVERY, GOOGLE_APP, DV360_DISPLAY, DV360_VIDEO, APPLE_SEARCH, EMAIL, INSTORE, INSTORE_BACKWALL, ORGANIC_MO, WEBSITE_RITUALS, APP_RITUALS, PR, MY_RITUALS). Meta is intentionally unified (no IG/FB/UGC split). |
Platform | Single line text | Optional — channel-specific (ios, android, branded, flyer, etc.) |
Funnel | Single select | TOFU, MOFU, BOFU, Branded, NonBranded, Prospecting, Retargeting, AlwaysOn |
Campaign Name | Single line text | App_intro, Coming_soon, UGC, etc. |
Variant | Single line text | Video_1, Static_2, Carousel_1, 300_250 — distinguishes assets within one campaign |
Country | Single select | NL, BE, DE, FR, UK (+ legacy: ES, IT, PL, EU) |
Language | Single select | ENG, NL, DE, FR (+ legacy: BE-FR, IT, ES, PL) |
Landing Page | URL | https://www.mindoasis.com/download · /myrituals · /app · / |
utm_source / utm_medium / utm_campaign | Single line text | Auto-derived per channel — see Channel reference table |
Status | Single select | Draft · Active · Archived |
Notes | Long text | Optional |
Where UTMs show up in the dashboard
- UTMs nav tab. Top-level page next to Asset Library. By campaign view: collapsible campaign cards with channel-mix chips (APPLE_SEARCH 24 · META 48 · YOUTUBE 16), nested channel sub-groups within each campaign card (color-coded headers + URL count), top summary banner ("120 UTMs · 1 campaign"). Flat list view available as alternative. Filter chips (Channel · Country · Language · Status · Search), CSV export, copy-all-URLs, QR code per row, click-to-detail.
- + New UTM modal. Link to creative search at the top — type 2+ chars (filename or Creative ID) → dropdown shows matching creatives → click to auto-fill channel/platform/variant/country/language/funnel from that creative. Cascading dropdowns below: pick channel → platform options narrow (Meta → instagram/facebook/ios/android/UGC audiences · Google Search → branded/nonbranded · etc.). Country → Language auto-fill with smart lock (pick NL → Language auto-fills NL; manual override locks; clear to "—" unlocks). Variant/campaign/country/language → filename auto-builds in real time. Live URL preview at the bottom. Status defaults to Active. Notes textarea starts compact (1 row, drag to expand).
- Briefing modal UTM panel. Auto-matches the creative's
File nameto a UTM record'sutm_content. If matched: shows channel pill, URL, copy / QR / detail buttons. If not matched: "+ Create UTM" CTA pre-fills the New UTM modal from the creative's structured fields (not just filename). - Briefing modal UTMs button. Footer button shows live count:
UTMs [N](red badge = N creatives still need a UTM) orUTMs [N](green badge = all linked). Click → bulk-create UTM records for every linked creative. Button refreshes correctly after bulk-gen completion (no more stuck "Creating UTMs… N/N" state). - Auto-link via filename + explicit linked-record. The dashboard treats
Creatives.File name↔UTM Codes.utm_contentas the implicit join key. When you use the Link to creative picker, an explicitCreativelinked-record field is also written if your UTM Codes table has it (forward-compatible — no-op if the field doesn't exist).
Channel vs Platform — what's the difference?
Common confusion: both names sound similar but they answer different questions about a UTM. As of May 2026, the dashboard uses "Channel" consistently across all pages — the older "Platforms" label on the Campaigns / Planning / Designer / Review filters + the New Campaign modal has been renamed to "Channel(s)" to align with the UTM page + Ramona's Excel spec + GA4 standard.
| Field | Question it answers | Example values | Always present? |
|---|---|---|---|
| Channel | "Where will the ad show?" — the advertising surface / utm_source category | META, GOOGLE_DISPLAY, YOUTUBE, DV360_VIDEO, APPLE_SEARCH, EMAIL, INSTORE, etc. | ✓ Always (1 of 18 v2 options) |
| Platform (UTM-only) | "On what device or audience?" — the sub-target within the channel | ios, android (app campaigns), or UGC audience (parents, menopausal, all) for Meta UGC | ✗ Only for app-install campaigns (META + GOOGLE_APP). Empty for everyone else. |
platforms on the Creatives + Campaigns tables — a legacy name from before the v2 unification. The UI label says "Channel" everywhere now, but the data column keeps its old name to avoid a risky schema migration. New code references use "channel" terminology; older code may still say "platforms" in variable names. Both are talking about the same data.Country resolution algorithm (creative-link auto-fill)
When you link a Creative in the New UTM modal (or click + Create UTM from the briefing modal), the Country field is resolved through a 5-step priority chain. Steps run in order until one produces a value; otherwise Country stays empty for the user to pick manually.
| # | Source | When it fires | Example |
|---|---|---|---|
| 1 | Creative.Countries | Creative has an explicit country (single or multi — first value wins) | Creative has ['DE'] → Country = DE |
| 2 | Campaign.Countries — single value | Campaign explicitly targets ONE country — most specific campaign-level signal, beats the generic Language default | Campaign Countries ['Belgium'] + Language ENG → Country BE (NOT UK, because campaign explicitly says BE) |
| 3 | Language → Country map (incl. Mind Oasis default ENG → UK) | Language is NL·DE·FR·IT·ES·PL·BE-FR or ENG — fires when there's no single-country campaign to override | Language NL → Country NL · Language ENG → Country UK (Mind Oasis default) |
| 4 | Languages field stores a country code | Google Display / Search Branded pattern: country code lives in Languages | Languages ['NL'] on Google Display creative → Country NL |
| 5a | Campaign.Countries — multi + Language=ENG | Campaign has multiple countries AND Language is ENG → prefer UK (Excel default) | Campaign [NL, DE, UK] + Lang ENG → Country UK |
| 5b | Campaign.Countries — multi fallback | Campaign has multiple countries, no UK or non-ENG language | Campaign [FR, ES] + Lang ENG → Country FR (first) |
| 6 | Empty | None of the above produces a value | User picks manually |
_nuCreativeSelect (New UTM modal picker) and _createUTMFromCreative (briefing modal "+ Create UTM" button) — guaranteed consistency.Migration: backfill Country on existing UTM records
UTMs created before the ENG → UK default (May 2026) may have empty Country fields when Language was set but Country was not. The console helper resolves Country per the algorithm above (Language map first, then Campaign.Countries fallback) and batch-PATCHes the records. Dry-run by default:
// 1. Dry-run preview
backfillUTMCountry()
// 2. If preview looks right, apply
backfillUTMCountry({dryRun: false})
Reports "✓ Backfilled Country on N UTMs" when done. Skips records where Country is already set (idempotent).
Migration: backfill Campaign Name on existing UTM records
UTMs created before the May 2026 Campaign-Name fix may have noisy Campaign Name values (filename-parse leftovers like "TEST - DO NOT DELETE_APPLE SEARCH ADS_DE_APP PREVIEW VIDEO 1920X180PX_PAID") instead of the actual Airtable campaign name. To clean up, open the browser console (F12 → Console) and run:
// 1. Dry-run preview (default — nothing gets written)
backfillUTMCampaignNames()
// 2. If preview looks right, apply the fix
backfillUTMCampaignNames({dryRun: false})
The helper resolves the proper Campaign Name via Creative.File name ↔ utm_content ↔ Creative."campaign name" link → batch-PATCHes in groups of 10. Idempotent — safe to re-run. Reports "✓ Backfilled N UTMs with the correct Campaign Name" when done.
QR codes for in-store / print
Click ⊞ QR on any UTM row → modal opens with a 240×240 PNG QR code generated from api.qrserver.com. Download as SVG for print-quality (600×600). The URL caption underneath copies cleanly to a print supplier.
Bulk export to CSV
The 📄 Export CSV button on the UTM page generates a 14-column CSV (filename · all UTM fields · full URL) of every UTM matching the current filters. Drop straight into Google Sheets or share with Ramona's GA4 setup. Filename: mind-oasis-utms-2026-05-17.csv.
UTM granularity — when is "1 per creative" correct?
The bulk generator creates one UTM per linked Creative. This is the right model for visual / asset-driven channels (Meta, Google Display, YouTube, DV360) where each ad variant gets its own click-tracking URL. But for some channels, fewer UTMs are needed:
| Channel | Granularity | How to create |
|---|---|---|
| Meta — Feed/Stories/Reels, FB, IG, UGC | Per asset (Video_1, V_2, Static_1, S_2, Carousel × language × country) | Bulk-gen from briefing modal |
| Google Display | Per banner (theme × format × language) | Bulk-gen |
| YouTube | Per video (theme × variant × language) | Bulk-gen |
| DV360 Banner / Video | Per asset (theme × size × language) | Bulk-gen |
| Google App Campaigns | Per OS × Targeting × Country × Language (~30 per campaign) | New UTM modal — manual |
| Google Search Branded / NonBranded | Per country only (creatives are text-free, no language differentiation) — 5 UTMs (NL/BE/DE/FR/UK) | New UTM modal — manual |
| Apple Search Ads | Per country (one per market) — 5 UTMs max | New UTM modal — manual |
| Email + Layers | Per send (1 UTM per email campaign) | New UTM modal — manual |
| In-Store Flyer / Backwall | Per touchpoint (1 UTM per QR location) | New UTM modal — manual |
| PR / Organic / Website Rituals / App Rituals | Per touchpoint (1 UTM per placement) | New UTM modal — manual |
Troubleshooting bulk generation
| Error / behavior | Cause | Fix |
|---|---|---|
| "Can't generate UTMs yet — N creatives have no filename" | The Airtable automation hasn't generated assets yet — Creatives only have a Creative ID (MO-04711), no File name. | Wait for the automation to run (typically <30s after marking campaign Ready to generate), or trigger it manually on the Campaigns table. |
| "Insufficient permissions to create new select option 'X'" | The PAT doesn't have schema.bases:write (which is correct — never grant it). Some Channel / Country / Language / Funnel value from the parsed filename isn't a pre-existing Single Select option. | The dashboard now auto-retries by stripping Single-Select fields. The UTM still gets created (without the rejected field). Check the UTM Codes table — the missing column will be empty. Either (a) add the missing option to the Single Select in Airtable, or (b) fix the Creative's File name to use a known value. |
| "N creative(s) have an unrecognized channel" | The creative's platforms field is blank or contains a value the dashboard doesn't recognize. | Set the platforms field to a known channel (Meta, Google Display, YouTube, DV360 Video, Apple Search Ads, etc.) on each affected creative. |
| 120 UTMs created but expected ~30 | Bulk gen creates 1 per Creative. If your campaign produces many creative variants (theme × language × country × variant), the count multiplies. | Normal for visual-heavy campaigns. Compare to Ramona's Excel — TOFU campaigns typically produce 100-150 UTMs. |
Auto-generation for new campaigns May 2026
UTMs no longer have to be created one by one. Three new flows tie UTM creation directly to campaign creation:
- New Campaign modal opt-in. The + New campaign modal grew a checkbox: 🔗 Auto-generate UTM codes — Create a UTM record per creative once the automation generates them. Default on. When you save with Ready to generate ticked, the dashboard polls the Creatives table every 5s (60s timeout) waiting for the Airtable automation to populate creatives. As soon as filenames appear → bulk UTM creation fires silently, results land as Draft status, and a toast confirms "✨ Auto-created N UTMs for the new campaign".
- Campaign briefing modal "✨ Generate UTMs" button. Footer button next to 📅 Reschedule. Shows a live count of how many creatives still need a UTM (red badge with number) or "✓ UTMs linked (N)" when all are covered. Click → confirms scope ("X new, Y already exist") → creates the missing UTMs idempotently. Re-running is safe — already-existing filenames are skipped.
- Briefing modal "+ Create UTM" CTA. Per-creative fallback when bulk gen missed one or you've imported a creative manually. Opens the New UTM modal pre-filled by parsing the creative's
File name.
Under the hood: bulkGenerateUTMsForCampaign(campId, opts) finds every creative linked to a campaign, dedupes against the existing UTM cache by slug-comparison on filename, batch-POSTs to Airtable (10 records per request), retries-without-utm_*-fields if Airtable rejects them, refreshes the cache, and re-renders the briefing UTM panel + UTMs page if visible.
UTM Codes table. The page shows an empty-state with the schema you need to add, and the briefing-modal UTM panel only renders when there's data. No errors, no broken layout — just a quiet "set this up when you're ready."What this replaces
The Excel "UTM-codes" tab approach (manual fill of column G by GoodKarma, formula in column I) becomes obsolete. Instead:
- Marketing / Mind Oasis creates the UTM in the dashboard (5 clicks, all field validation).
- The dashboard auto-generates the canonical filename + URL.
- GoodKarma delivers the asset with the matching filename — the briefing modal auto-links it.
- Ad platform manager copies the URL straight from the dashboard.
No more spreadsheet round-trips. No more "wait, what was the filename again?" Slack threads.
🔐 Login screen May 2026
First-touch experience. Full-screen overlay shown on first visit (or after signOut()) with the Mind Oasis brand identity. No real auth yet — Sign in and Skip both set localStorage.moa_logged_in = '1' and dismiss the overlay. Auth integration (SSO / Rituals SAML) lands when the infrastructure is ready.
Visual design
- Atmospheric background. Multi-layer: cream gradient base · three drifting soft-blurred orbs in sage / ochre / plum (22s / 28s / 32s loops) · SVG film-grain overlay · outer vignette. Calming, editorial feel — matches Rituals' brand language.
- Time-of-day greeting. Headline adapts: "Good morning." / "Good afternoon." / "Good evening." / "Good night." — Cormorant Garamond 46px, with the time-of-day word in sage italic.
- Glass-morphism card. 28px backdrop-blur with 160% saturation, multi-layer shadow with sage tint, top-edge sheen, decorative botanical line at the top.
- Floating-label inputs. Underline-only (no boxy borders). Sage→ochre gradient line spreads out on focus.
- Side labels. Top-left: 🌿 MIND OASIS with botanical icon. Bottom-right: CREATIVE PERFORMANCE OS · live time (HH:MM).
- Sign in button. Sage gradient (135deg) with inset highlight + multi-layer shadow. Arrow slides 4px on hover. Loading-spinner on submit.
- Skip button. Dashed border (not solid) — visually says "this is temporary". Two-line layout with sub-label "Authentication integration coming soon".
- Password show/hide toggle. Eye icon inside the password field — clicking flips
input.typebetween password and text.
Behavior
- Esc closes via Skip path. Enter submits the form.
- First-time onboarding tour auto-triggers ~400ms after the overlay dismisses (so users get the guided walkthrough on first visit).
- Dark mode aware — every color maps to the dark palette via
html[data-theme="dark"]CSS rules. - Honors
prefers-reduced-motion— orbs animation disabled, card entrance becomes a quick fade. - Mobile responsive — <480px: side labels hide, orbs use less blur, card padding shrinks.
- Manual sign-out via
signOut()on the JS console — clears the localStorage flag and reloads.
How to trigger again (for testing)
Open the browser console and run localStorage.removeItem('moa_logged_in'); location.reload(); or just call signOut(). The overlay re-shows on the next page load.
💬 UX dialog system May 2026
Drop-in replacements for the browser's native window.confirm() / alert() / prompt() — these were generic system dialogs ("localhost:8000 says…") that broke the dashboard's editorial feel. Now every confirmation, alert, and input prompt uses an in-style modal that matches the rest of the UI.
API
Three Promise-returning helpers exposed globally:
| Function | Returns | Options |
|---|---|---|
uxConfirm(opts) | Promise<boolean> | { title, message, confirmLabel, cancelLabel, danger, icon, kind } |
uxAlert(opts) | Promise<true> | { title, message, button, kind:'err'|'warn'|'info', icon } |
uxPrompt(opts) | Promise<string|null> | { title, message, defaultValue, placeholder, confirmLabel, multiline } |
Visual design
- Cream card on a blurred (8px + 140% saturation) backdrop. Multi-layer shadow with sage tint.
- Sage left-border (3px). Danger variant uses cranberry, warn uses ochre.
- Cormorant Garamond title (24px, 300 weight, italic-friendly). Body text in DM Sans 13px.
- Icon chip (44×44) on the left in the kind's color (sage/cranberry/ochre).
- Two-button footer: secondary "Cancel" + primary "OK" (or danger "Delete"). Sage gradient on primary, cranberry gradient on danger, both with inset highlight + lift-shadow.
- Close X in top-right (Esc shortcut).
Behavior
- Promise-based — use with
await. Cancel/Esc/click-outside-card all resolve tofalse(confirm),true(alert),null(prompt). - Esc cancels · Enter confirms the primary action. In multiline prompts, Enter inserts a newline; Cmd/Ctrl+Enter submits.
- Focus trap. Auto-focuses the input (prompt) or primary button (confirm/alert). Restores focus to the previously focused element after dismiss.
- Inline HTML in messages. The
messagefield accepts HTML so you can embed<strong>,<em>,<code>, line breaks, and colored spans for rich confirmations. - Smooth enter/exit — 280ms cubic-bezier on entrance, 180ms ease-out on dismiss. Honors
prefers-reduced-motion. - Dark mode aware.
Where it's used (20 sites)
All user-facing confirmation flows have been migrated:
- Planning Timeline drag-reassign (paid creatives + standalone briefs)
- Bulk delete on the Briefs page
- Bulk assign campaign → designer
- Cancel pending date-change request
- Approve / reject date change on Review page
- "Make comment a reply" — comment re-parenting
- Remove saved Airtable token
- Delete N old Activity Log entries
- Regenerate creatives for a campaign
- Library item delete (3 branches: usage unknown, blocked, clear-to-delete)
- Reset Assets-generated flag (Admin Tools — 3 steps)
Remaining ~50 native alert() calls are error-path messages (Airtable connection errors etc.) that only fire under failure and are lower-priority. They'll be migrated in a future pass.
Example
const ok = await uxConfirm({
title: 'Reassign creatives',
message: 'Move <strong>13 creatives</strong> from <em>Mendy</em> → <em>Maike</em>?',
confirmLabel: 'Move 13 → Maike',
icon: '↪',
});
if(!ok) return;
// ...proceed with patch
📊 Performance integration Phase 0 + 2 UI shipped — Phase 1 sync next
The Creative Performance OS is becoming an actual performance tool. We're integrating Meta Ads and Google Ads so per-Creative spend, CTR, CPM, ROAS, and conversions appear next to every creative — same surface as the briefing flow, no Billy-Grace-style switch-to-another-tool. This page documents the architecture, naming convention, and rollout phases.
Architecture — why a sync engine, not browser-side calls
The dashboard is a static HTML file on GitHub Pages. Browser-side calls to Meta or Google Ads APIs don't work — CORS blocks them, tokens leak instantly, and rate-quota is per-token. The actual sync runs as an Airtable Automation: scheduled server-side JS that pulls insights, matches ads to Creatives, and writes a Performance Daily row per ad × date. The dashboard then reads from the same Airtable base — no extra infra.
Meta Ads API ─┐
├──→ Airtable Automation (every 4h)
Google Ads API ─┘ • fetch insights per ad
• match ad-id → Creative
• upsert into Performance
↓ writes
Airtable base (same one as the dashboard)
• Performance Daily (new)
• Creatives.External Ad IDs (new field)
• Sync Config (new, privé view)
↓ reads
Dashboard (browser)
• Per-Creative chart in briefing modal
• Hook / USP leaderboards
• Winners-of-the-week on Home
The matching strategy — naming convention
The hardest problem isn't pulling data, it's matching a Meta/Google ad back to a Creative record. Solution: every ad's name in Ads Manager carries the Creative's Airtable record-id. The sync extracts it with regex (/\brec[A-Za-z0-9]{14}\b/) and links by id.
Canonical name format:
MO_rec123abc456def78← first uploadMO_rec123abc456def78_v2← re-upload of same creativeMO_rec123abc456def78_v3← iteration 3
The briefing modal now has a ⎘ Copy ad name button next to the new 📊 Ad tracking section. One click → canonical name on your clipboard → paste into Meta/Google. No regex to memorize, no guessing.
External Ad IDs field. The sync respects both sources.📐 Required Airtable schema additions
1. New field on the Creatives table
| Field | Type | Notes |
|---|---|---|
External Ad IDs | Long text | One ad-ID per line, format platform:numeric — e.g. meta:120208876543210 or google:12345678901. Written by the briefing modal's "Link ad" form and by the sync engine when it matches a new ad via the naming convention. The schema-drift detector already expects this field. |
2. New table: Performance Daily
Granular performance — one row per Creative × Date × Platform. Daily granularity = right balance between detail and row-count.
| Field | Type | Notes |
|---|---|---|
Composite Key | Formula (primary) | {Creative} & "_" & {Date} & "_" & {Platform} — used by the sync for idempotent upsert. |
Creative | Link → Creatives | The Creative this measurement is for. |
Date | Date | The day the metrics were recorded for. |
Platform | Single select | Meta · Google Ads |
External Ad ID | Text | Source-of-truth id (for debug + redundancy with the Creative's External Ad IDs field). |
Spend | Currency (EUR) | Money spent that day on this ad. |
Impressions | Number | |
Clicks | Number | |
CTR | Percent (formula) | {Clicks}/{Impressions} |
CPM | Currency (formula) | {Spend}/{Impressions}*1000 |
CPC | Currency (formula) | {Spend}/{Clicks} |
Conversions | Number | From pixel / conversion event. Optional but core for ROAS. |
Conversion Value | Currency | Revenue attributed to conversions. |
ROAS | Number (formula) | {Conversion Value}/{Spend} |
Last Synced At | DateTime | Stamped by the automation on every upsert. |
3. Rollup fields on the existing Creatives table
For instant per-Creative totals without browser-side aggregation. All via Rollup from Performance Daily:
| Rollup field | From → Field | Function |
|---|---|---|
Total Spend | Performance Daily → Spend | SUM |
Total Impressions | Performance Daily → Impressions | SUM |
Total Clicks | Performance Daily → Clicks | SUM |
Avg CTR | Performance Daily → CTR | AVERAGE |
Avg CPM | Performance Daily → CPM | AVERAGE |
Total Conversions | Performance Daily → Conversions | SUM |
ROAS to date | Formula | SUM({Conversion Value})/SUM({Spend}) |
4. New table: Sync Config (privé view — admin only)
Stores Meta/Google credentials per ad-account. View permission scoped so only admins see the tokens.
| Field | Type | Notes |
|---|---|---|
Service | Single select | Meta · Google Ads |
Account ID | Text | act_123456 (Meta) or 1234567890 (Google). |
Account Label | Text | Human-readable name for filters in the UI. |
Access Token | Long text (encrypted) | Long-lived (60d for Meta). Refreshed monthly by an automation. |
Refresh Token | Long text | Google Ads only — required for the OAuth refresh flow. |
Last Sync At | DateTime | For incremental sync (only fetch from this timestamp forward). |
Active | Checkbox | Pause/resume per account without deleting credentials. |
🗓 Rollout phases
| Phase | Deliverable | My effort | Your effort | Status |
|---|---|---|---|---|
| 0 — Foundation | Ad-tracking section in briefing modal · "Copy ad name" button · Manual "Link ad" form · External Ad IDs persisted to Creatives · Schema-drift coverage | 0.5d (shipped) | 0.5d (add fields + tables in Airtable) | ✓ Done in code · awaiting your schema work |
| 1 — Meta sync | Airtable Automation Sync Meta Performance runs every 4h: pulls Marketing API /insights, regex-matches rec-id from ad names, upserts Performance Daily | 1.5d | 0.5d (Meta Business App + system user + token) | Pending Phase 0 schema |
| 2 — Dashboard performance views UI shipped | Per-Creative sparkline + KPI tiles in briefing · ROAS column on Briefs page · Per-Brief aggregate in campaign briefing · Admin → Performance health tab · Designer-View ROAS pill, Library Hook-leaderboard, Home "Top performers" card → defer until real data flowing | 3-4d | — | ✓ UI ready, awaiting Performance Daily rows |
| 3 — Google Ads sync | Same pattern as Meta. OAuth one-time setup. Reports.search via Google Ads REST. Match logic identical. | 1.5d | 1-2d (Google Ads API approval — 3-7d wait) | Plan in parallel with Phase 1 |
| 4 — Smart insights | Winners leaderboard · Hook-concept performance rollup · A/B-test detector · Home insight banner ("Hook X performs 3.2x baseline") · CSV/PDF performance reports | 2-3d | — | Blocked on Phase 2 |
Total effort: ~9-12 days of dev work · ~3 days of Airtable / Meta / Google admin · 2-3 weeks doorlooptijd.
⚠ Risks & mitigations
| Risk | Mitigation |
|---|---|
| Naming convention forgotten | "Copy ad name" button in briefing modal (1-click) + Manual-link fallback for any ad that slipped through. Sync reports "unmatched ads" → admin can 1-click attach. |
| Token expiry (Meta 60d, Google refresh-token rotation) | Monthly refresh automation. Header connection-dot gets a new state "Sync expired" when token nears expiry. |
| Rate limits (Meta ~4800/hr per token, Google ~15k/day) | Incremental sync — only last 7 days, not full history. Batch ads in groups of 50. |
| Airtable row limit (50k per base on Pro) | Performance Daily inherits the Activity-Log retention pattern — entries older than 90 days pruned automatically. Lifetime totals preserved in Creatives rollups. |
| Duplicate ad IDs across platforms | Composite Key includes Platform → Meta:1234 and Google:1234 are separate rows. |
| Privacy / GDPR | Only aggregate counts, no personal data. Tokens stored in privé view of Sync Config. |
What ships in Phase 0 (already in the dashboard)
- 📊 Ad tracking section in every Creative briefing modal, sitting between the metadata block and the Comments thread.
- ⎘ Copy ad name button — one click puts
MO_recXXXon your clipboard, ready to paste into Ads Manager. - + Link ad form — Platform dropdown (Meta / Google) + numeric ID input + Enter-to-submit. Validates the ID format, dedupes, persists to
External Ad IDson the Creative. - 📈 Performance placeholder — dashed-sage tile under the form that explicitly tells the user metrics will populate once the sync engine ships. No silent "empty state" confusion.
- Activity Log entries on every link/unlink so the team has an audit trail.
- Schema-drift detector already lists
External Ad IDsinEXPECTED_SCHEMA— first drift-check after you add the field will confirm it's wired.
Your day-1 setup checklist
- Add
External Ad IDs(Long text) to the Creatives table - Create the Performance Daily table with the schema above
- Add the 7 rollup fields to Creatives
- Create the Sync Config table — leave empty for now; we'll fill in Phase 1
- Pilot the naming convention: pick 5 active Meta ads, rename them to
MO_recXXXusing the Copy ad name button. Sanity-check the format. - (Optional, kicks off Phase 3 parallel) Submit the Google Ads API access request — approval typically 3-7 days.
Once steps 1-5 are done, ping me and I start Phase 1 (Meta sync automation).
⌨ Keyboard shortcuts
The dashboard has full keyboard navigation. Shortcuts are disabled while you're typing in an input or textarea so they never collide with normal typing. Discover them anywhere by pressing ?.
Navigation
Two-key sequences with a 1.2 second timeout — press g, then the second key quickly.
| Keys | Goes to |
|---|---|
| g h | Home |
| g p | Planning |
| g c | Campaigns |
| g d | Designer View |
| g r | Review |
| g l | Library |
| g a | Asset Library |
| g m | Admin |
Actions
| Keys | What it does |
|---|---|
| ⌘ K / Ctrl K | Open global search palette — filters across campaigns, creatives, hooks, captions, USPs, disclaimers, assets, and pages. Built from in-memory caches, no extra API calls. |
| n c | New campaign — jumps to Campaigns and opens the create modal. |
| ? | Show the keyboard cheat-sheet modal. |
| Esc | Close any open modal (PAT wizard / cheat-sheet / palette / lightbox / conn panel). |
Changelog
- Rij-acties → kebab-menu: de drie losse pill-knoppen (⎘ URL / ⊞ QR / ✎) zijn vervangen door hetzelfde drie-puntjes
.row-kebab-menu dat Campaigns gebruikt. Eén nette knop per rij die opent met Open detail · Copy URL · Show QR code · Edit UTM (Edit alleen zichtbaar met edit-rechten). Identiek gedrag, identieke styling, role-gated. - Status → gedeelde badges: de losse gekleurde stip + tekst is vervangen door de globale
.badge-pills (Active = groenb-live, Draft = amberb-paused, Archived = grijsb-completed) — exact dezelfde statusvocabulaire als op de Campaigns-pagina. - Tabel → globale table-stijl: zowel de flat-list als de per-campagne gegroepeerde view renderen nu echte
<table>'s met de globaleth/td-styling en de.tbl-wrap/.tbl-header-schaal. Channel-chips hergebruiken de.badge-vorm, getint per kanaalkleur. Geen bespoke inline-tabel meer.
- Login-pagina auto-redirect (poll-mechanisme): nadat de magic-link is verzonden start de login-pagina automatisch met poll-checks op
/api/auth/meelke 2 sec, met immediate-check op visibilitychange (terug-tabben uit email). Zodra de gebruiker in een andere tab de magic-link klikt en de cookie wordt gezet, redirecten beide tabs naar het dashboard. Max-duration 15 min (matcht magic-link TTL). Lost de UX-irritatie op dat de login-tab "stuck" blijft als de email-link in een nieuwe tab opent. - Admin → Test Automation — 4 fixes:
test_api_token_validherschreven: detecteert proxy-mode i.p.v. lokale TOKEN.length-check, geeft heldere 401/500 foutmeldingentest_airtable_write_read_delete: defensievesafeJson()parser, geen "Unexpected token T" crash meer bij HTML-responses- Hardcoded
docs_Main%20file.htmlverwijzingen (3 plekken) →/docsroute. Lost docs-link-reachable + no-dead-relative-links tests op - Whitelist van tabelnamen verwijderd uit proxy (auth + role-check blijven de echte beveiliging). Lost
unknown_tableerrors op voor o.a.Campaigns - table stores advertising campaigns
- QR Studio prototype-integratie (Beta): qr-studio-prototype.html van Stijn live op
/qrroute (Vercel rewrite). "QR Studio" knop met BETA-badge in de More-dropdown van de header — opent in nieuwe tab (geen iframe, behoudt eigen design-system). Volledige integratie wacht op antwoorden op de 7 open vragen uit HANDOFF.md §10 (hosting, kort domein cos.link, AVG-bewaartermijn, auto-check frequentie, etc.) en multi-week build van redirect-dienst + database + link-checks. - Topbar toont nu ingelogde gebruiker: "Your name…" placeholder vervangen door
me.nameuit session (read-only). Avatar krijgt 2-letter initialen op groene cirkel. Hover toont email + rol als tooltip.
- Nieuwe deploy-directory:
~/Desktop/Claude/mind-oasis-deploy/bevat de volledige Vercel-stack —package.json,vercel.json,api/auth/*,api/airtable/[...path].js,lib/auth.js,public/index.html,public/login.html,README.md,.env.example,.gitignore. - Airtable PAT eindelijk server-side: de hardcoded token op regel 4121 is weg uit de productie-HTML. Server-side env var
AIRTABLE_PATwordt door de Vercel-function geïnjecteerd. Browser ziet nooit meer een token. - Fetch-interceptor i.p.v. 60× refactor: in plaats van elke fetch-call te editen, vangt een wrapper alle
api.airtable.com/v0/{BASE}/...calls op en herschrijft ze naar/api/airtable/.... Bestaande code blijft werken zonder een regel te wijzigen. - Magic-link auth via Resend: team-leden vullen hun werk-email in op
/login, krijgen een mail met een 15-minuten-geldige link, en zijn dan 7 dagen ingelogd via een HttpOnly JWT-cookie. Geen wachtwoorden, geen Google OAuth-setup. - Role-based access in de proxy: elke request wordt server-side gecheckt tegen
ROLE_CAPSinlib/auth.js. Viewers kunnen niets schrijven, editors geen schemafiles ophalen, admins alles. GET=read, POST=create, PATCH=edit, DELETE=delete — methode mapt naar capability. - Team roster als env var:
TEAM_ROSTERis een JSON-array van{email, role, name}. Iemand toevoegen = env var editen + redeploy (~30 sec). Iemand revoken = entry verwijderen + (optioneel) JWT_SECRET roteren voor instant-kill van alle sessies. - Audit log: elke mutating write (POST/PATCH/DELETE) wordt server-side gelogd met email van de actor — zichtbaar in Vercel function logs.
- Roles-overzicht (huidige defaults):
- admin — alles, inclusief schema-calls en de Upgrade Pack admin-panel
- editor — briefings/creatives/UTMs aanmaken en bewerken
- reviewer — date-changes approven en commenten
- viewer — alleen lezen
- Login-pagina: luxueus, on-brand design met Cormorant-serif "Mind Oasis" logo, gradient achtergrond, en duidelijke error-messages (expired link, revoked access, missing token).
- Bekende beperkingen v1: attachment-uploads (content.airtable.com) zijn niet ge-proxied — die werken alleen in de lokale dev-versie. Vercel-bandbreedte heeft een limiet (100GB/maand free tier, ruim voldoende voor 20-persoons team). Session-revocation is niet instant zonder JWT_SECRET rotation.
- Tim's actie-lijst voor go-live: README.md in de deploy-directory bevat een 8-stappen-plan (genereer JWT secret, Resend account, GitHub repo, Vercel project, env vars zetten, eerste deploy, team uitnodigen, custom domain optioneel). Totaal ~90 min werk.
- Verwijderd (door Tim in Airtable UI):
- History:
2-sec views,View content,Initiate checkout,Add to cart(accidentele imports, 1 record each) - Daily:
ROAS(redundant — Purchase ROAS leeft op History) - Demographics & Placement:
Conversions,Purchase ROAS(MCP retourneert deze niet per segment, alleen op ad-niveau) - Ad Sets:
Updated Date(Created Date is voldoende)
- History:
- Link-backfill: History → Meta Ad Sets relatie was leeg → 46/46 records via Ad Set ID matching gevuld. Maakt Coach-queries als "alle ads van adset X" mogelijk zonder filter-tricks.
- Strip-lists vooraf gevuld:
knownMissingFieldsSets op de History- en Daily-import functions (regels 7407, 7520) zijn pre-seeded met de verwijderde veldnamen. Volgende pull verspilt geen retry-cycle op UNKNOWN_FIELD_NAME errors. - Scheduled task prompt updated:
mind-oasis-meta-daily-pullkreeg expliciete "DELETED FIELDS" lijst in de prompt, zodat de daily-cron weet wat hij NIET moet schrijven. - Open punt: Het
Campaignlink-veld op Ad Sets wijst momenteel naar Meta Ads History (waarschijnlijk auto-gegenereerde reverse-link i.p.v. een echte Campaigns-link). 3 toevallige naam-matches zijn niet schoongemaakt — kan Tim handmatig clearen of het veld hernoemen naar "Linked Ads".
- #2 Meta daily-pull cron: Scheduled task
mind-oasis-meta-daily-pulldraait elke ochtend 08:00 lokaal. Vult Performance Daily/Demographics/Placement met yesterday-data via Meta MCP en stempeltLast Refreshed.runMetaDailyPullReminder()in de OS triggert ook in-app een toast als data >2 dagen oud is. - #3 Stale-badges:
_renderStaleBadge(maxIso, warnDays=7)rendert kleurcode (groen ≤1d · grijs ≤7d · rood >7d).checkMetaStaleness()console-helper toont per Meta-tabel de max Last Refreshed. - #5 Permission-model:
window._userRole(admin/editor/reviewer/viewer) metROLE_CAPSmap._applyPermissions()hide't alle[data-perm]elements waarvoor de huidige rol geen cap heeft. Role-toggle in Admin → Upgrade Pack panel. Persist in localStorage. - #6 Cross-table consistency checks:
runConsistencyCheck()scant: orphan UTMs zonder Creative, paid-channel Creatives zonder UTM, Daily-rows zonder parent History. Rendered als severity-coded report. - #7 Campaign→UTM auto-generate:
autoGenerateUTMsForCampaign(briefId)fired na campaign-creation. Detecteert Paid Media brief-type en draaitgenerateUTMsForBriefautomatisch. Triggert in-app notification. - #9 Notification center: 🔔 bell in nav met unread-badge.
_addNotification({type,text,link})+toggleNotifCenter()dropdown. Persist in localStorage (max 50 items, sliding window). Mark-all-read één click. - #10 Bulk-acties op UTM-pagina: Floating bulk-bar (bottom-center) verschijnt zodra ≥1 row geselecteerd. Acties: Archive · Activate · Export CSV (selection-scoped).
_utmToggleSelection(id, checked)binding voor row-checkboxes. - #11 Creative scoring:
runCreativeScoring()berekent score = CTR × hold-rate (Video 75%/Impressions) × ROAS, genormaliseerd naar 0-100. PATCH-back naar Meta Ads History ·Creative ScoreNumber-veld (toevoegen als nog niet bestaat). Logs top-20 naar console. - #12 Briefing↔Creative auto-link:
runBriefingCreativeAutoLink(dryRun=true)matcht Creative.Filename slug tegen Briefing slug (≥6 chars). Dry-run logged candidates;(false)applies PATCH op Creatives.Brief field. - #14 Extended exports:
exportLookerStudioTemplate()— JSON config met dimensions/metrics/joins voor alle 7 hoofdtabellen + 3 suggested charts (Spend×ROAS scatter, video drop-off line, top-10 score bar).exportGoogleSheetsURL()— IMPORTDATA snippet + setup-instructies naar clipboard.
#page-admin met 8 clickable stat-cards die alle helpers één-klik triggeren + role-toggle + notification-test.
- Primary CTA harmonized:
+ New UTMbutton switched vanbtn btn-primarymet inline padding-overrides naartf-btn tf-btn-primary— identiek pattern als+ New campaignen+ New briefop de Campaigns-pagina. Hoogte, font-size en hover-state nu pixel-exact gelijk. - Naming convention reference verborgen achter een toggle: de v3 naming-string (
MO_[Channel]_[OS]_[Funnel]_…) zat direct onder de toolbar als always-visible block met monospaced code-tag — visuele ruis die geen andere pagina heeft. Nu collapsed by default, toggle via nieuwe? Namingtf-btn in de toolbar. Block heeft nu surface-bg + 1px border-radius i.p.v. losse Cormorant-styled inline tekst. - Empty-state styling consistent gemaakt: "No UTM codes yet" setup-card gebruikte een gestippelde border (
border:1px dashed) die nergens anders voorkomt. Vervangen doorclass="loading-state"met solid 1px border — matcht nu Assets/Library/Review empty states.
- Meta Ads History — 14 enrichment-velden toegevoegd: Video 25/50/75/95/100% watched (drop-off curve = hook quality signal), 2-sec views, Thruplays (15-sec views), Cost per thruplay EUR, Comments, Optimization Goal, Add to cart/Initiate checkout/View content (laatste 3 leeg — niet via MCP beschikbaar), Last Refreshed. 46 records via PATCH ge-update.
- Meta Ads Demographics (nieuwe tabel) — 807 records ingeladen via
breakdowns: ["age", "gender"]MCP-call. Schema: Demographic Key (composite) · Meta Ad ID · Age (13-17/18-24/.../65+/Unknown) · Gender · Impressions/Reach/Clicks/CTR/Spend EUR/CPC EUR/CPM EUR/Link clicks/Purchase ROAS/Conversions/Frequency/Last Refreshed + Meta Ad link. - Meta Ads Placement (nieuwe tabel) — 594 records ingeladen via
breakdowns: ["publisher_platform", "platform_position"]. Schema: Placement Key · Meta Ad ID · Publisher Platform (text — Meta heeft veel options zoalsan_classic,messenger) · Platform Position (text —feed,story,reels,instagram_explore_grid_home, etc.) + standard metrics. - Meta Ad Sets (nieuwe tabel) — 14 records ingeladen via
level: adset. Bevat persona's die Tim's targeting onthullen:- DE: Wellness Seeker, Interest Rituals, Broad
- NL Pre-order: Female × Wellness Seeker, Male × Bio-Hacker, Male × High Performing Professional, Interest Rituals, Broad
- NL Lead Gen: Female × Wellness Seeker, Male × Bio-Hacker, Male × HPP
- Schema gotchas geleerd:
- Meta enum-velden waar de API nieuwe waarden kan toevoegen MOETEN Single line text zijn op deze base, niet Single Select. Anders faalt elke import met "Insufficient permissions to create new select option".
publisher_platform,platform_position,bid_strategy,optimization_goal,status— allemaal naar text geconverteerd na initiële strip.- Meta's
bid_strategyretourneert LABELS ("Highest volume") i.p.v. API constants ("LOWEST_COST_WITHOUT_CAP"). Text-veld voorkomt mismatch.
- Meta Performance Daily blijft bewust minimaal — 10 velden, 1628 rows, geen enrichment. Tim's design choice: Daily voor time-series + fatigue trends, History voor lifetime hook-quality analyse. Cleane scheiding.
- Fetch-strategie voor grote datasets: Meta's MCP heeft een ~150KB response-limit per call. Split per 15-dagen-chunks (6× voor 90 dagen) + parsen vanuit saved file. Schaalbaar voor weekly refreshes.
- Python import-pattern via direct curl + auth_request helper functions. Bevat smart-strip op
UNKNOWN_FIELD_NAMEénInsufficient permissions to create new select optionerrors. Werkt voor alle 5 tabellen.
Net effect: 3090 records aan Meta-data nu beschikbaar voor de Coach. Per ad weten we: (a) wie het werkt voor (age × gender), (b) waar het werkt (placement), (c) hoe goed de hook vasthoudt (video drop-off), (d) welke targeting persona Tim gebruikt (ad set names). Genoeg signaal om de eerste Coach-suggesties op te bouwen voor de Mind Oasis App launch in augustus. Eerste insights al zichtbaar: Image format outperformt Video op CTR (Sleeping/Unboxing 5-6%), MOFU Image+CGI Frame+Calm Mind heeft 16.82 ROAS in DE, Bio-Hacker/HPP persona's worden in Lead Gen actief getest.
592918140273491 (Rituals - Mind Oasis) responds directly to MCP read calls. The Meta Ads MCP server (mcp__e5f84eb2-…) is back online after this morning's disconnect.
- Strategic insight: The 6 Meta campaigns are NOT Mind Oasis App (launches August 2026) — they're existing Rituals retail (OUTCOME_SALES). Tim's approach: use this historical performance data as a training set to learn winning hooks/formats/audiences for the August App launch. The Coach gets to be smarter on day 1 by mining 90 days of real ad data first.
- Two new Airtable tables created by Tim:
Meta Ads History(30+ fields per ad) andMeta Performance Daily(one row per ad × day with composite keyDate + Ad). Verified field names live — many use lowercase first word (Link clicks,Post reactions,Post shares,3-sec video plays). Airtable IS case-sensitive on field names. - Two new console helpers added to the OS (next to the backfill cluster):
importMetaAdsHistory(adsData)— transforms MCP ad data and POSTs in batches of 10. Handles European number format (1.479,69 → 1479.69, 4,65% → 4.65, € 0,12 EUR → 0.12), Dutch month names (28 februari 2026 → 2026-02-28), and derives Market/Funnel/Creative Format from campaign + ad names.importMetaPerformanceDaily(dailyData)— same pattern for daily breakdown, builds composite key for dedup.
Unknown field name: "X"errors, strip that field for ALL subsequent batches (avoids retry-thrashing across thousands of records). - First import results (29 May 2026):
- Meta Ads History: 46 ads imported across 6 campaigns, total spend €15.222 over 90 days. Split: NL 23 / DE 23 · TOFU 31 / MOFU 15 · Video 25 / Image 21.
- Meta Performance Daily: ~1625 rows (filtered from 3235 total — entries without impressions skipped). Fetched in 6 chunks of 15 days to stay under the 1000-entries-per-call MCP limit.
- Top 5 historical ROAS performers (from MOFU campaigns only — TOFU doesn't track purchase):
- GK - Video - Unpacking - Frame - Nervous System (NL MOFU) — ROAS 40.53 · €67 spend
- GK - Image - CGI Frame + Calm Mind (DE MOFU) — ROAS 16.82 · €404 spend
- GK - Image - Unboxing (NL MOFU) — ROAS 11.04 · €163 spend
- GK - Video - Unpacking - Nervous System (NL MOFU) — ROAS 5.72 · €1258 spend (volume winner)
- GK - Video - Unpacking - Stress out. Calm in. (NL MOFU) — ROAS 5.68 · €483 spend
- Pattern observation: Image format has highest CTR (Sleeping/Unboxing at 5-6%) but Video commands more spend. Winning hook angles cluster around "Unpacking", "Nervous System", "Stress out. Calm in." Worth surfacing in the Coach for August.
- Still blocked:
ads_get_creativestool is in "gradual rollout" — NOT yet enabled for account592918140273491. Cannot fetch Body Text / Headline / Thumbnail URL / CTA Type yet. Columns exist in Airtable but stay empty until either (a) tool rollout reaches this account, or (b) System User token + direct Meta Graph API route is set up. The 47 ads can be backfilled in seconds once unblocked.
Weekly refresh workflow: Tim sends "refresh meta" → I fetch via MCP for last 7 days → drop as _meta_ads_data.js + _meta_daily.js on preview server → call import helpers in OS console → done in 2-3 minutes. Future automation via Cloudflare Worker + System User token (deferred).
Country → Languages Airtable automation to map Belgium → [NL, BE-FR] (was BE-FR only); (b) Tim added the Attribution Context Single Select field on UTM Codes; (c) built + ran 2 backfill helpers to bring legacy records in sync.
- Airtable Automation Campaign → Country to Languages — v3 script. Replaced the entire script body. New map values are arrays (was single values):
'Belgium': ['NL', 'BE-FR']. Other countries unchanged. Set-based dedup preserved. Plain-string Languages format used on write (verified PATCH test 28 May 2026 —{name:'X'}format returns INVALID_VALUE_FOR_COLUMN on this base). backfillCampaignLanguages()console helper built + run. Re-runs the v3 mapping on every existing Campaign in Airtable. Dry-run preview viauxAlert; idempotent. Initial helper used{name:'X'}format → 1 error / 0 updates. Fixed to plain-string format. Final run: 1/1 campaign updated (Suzanne Test 123 got NL added →BE-FR, DE, FR, NL).- Tim added
Attribution ContextSingle Select to UTM Codes with the 3 options (web_via_smart_script · meta_sdk · web_only) — see screenshot 28 May 2026. backfillUTMAttributionContext()console helper built + run. Loops every existing UTM, infers context via_resolveAttributionContext(channel, campaignName)(Meta → meta_sdk, PR → web_only, rest → web_via_smart_script), batches PATCHes 10 at a time. Dry-run preview shows distribution.- Backfill result: 124 total UTMs, 1 already had value, 123 backfilled in 15.8s, 0 errors. Distribution: 56 meta_sdk · 67 web_via_smart_script · 0 web_only (no PR UTMs in current data).
- Memory updated with v3 spec, Languages plain-string format gotcha, all backfill helpers, and the Pre-Launch Performance-laag roadmap snapshot — so this stays known across sessions.
Net effect: UTM strategy fully aligned with v3 spec + Belgium bilingual mapping in place + Attribution Context persisted on all 124 UTMs. Forward-compat already worked (badges inferred from channel), but now data is filterable + exportable + manually overrideable per record. Next campaign that has Country=Belgium will auto-trigger the new mapping; existing campaigns are caught up via the backfill.
- Language → Country auto-fill (reverse direction) voor monoculturele markten via nieuwe
_NU_LANGUAGE_TO_COUNTRYmap:NL→NL · DE→DE · FR→FR · IT→IT · ES→ES · PL→PL · BE-FR→BE.ENGbewust niet gemapped (ambigu: NL/BE/UK in Phase 1). Mirror van de bestaande Country→Language pattern, met dezelfde lock-/unlock-logica:_nuCountryUserSetflag voorkomt overschrijving als user Country handmatig heeft gezet. - Required validation voor Paid channels. Country is verplicht voor alle
group:'Paid'kanalen (META · GOOGLE_* · DV360_* · YOUTUBE · APPLE_SEARCH). Owned media (EMAIL · INSTORE* · ORGANIC_MO · WEBSITE_RITUALS · APP_RITUALS · PR · MY_RITUALS) is exempt — die zijn vaak mass-market of country-embedded in source. - Visuele cues in de modal:
- Rode
*asterisk naast "Country" label — verschijnt voor Paid channels, verdwijnt voor Owned. Dynamisch in_nuChannelChange. - Sage
· auto from languagehint naast "Country" label — verschijnt wanneer Country door taal-mapping is gevuld, verdwijnt bij user-set. - Bij submit-blokkade: red border + box-shadow op de Country select (2.4s flash) + statusbar-error met klikbare "Set Country →" link die het veld focust.
- Rode
- 2 nieuwe tests (total = 159):
test_utm_v3_language_to_country_autofill— 6 scenarios: NL→NL, DE→DE, BE-FR→BE, ENG=ambigu (geen auto-fill), user-set wint van auto, clear-and-resume.test_utm_v3_country_required_for_paid— opent modal, kiest META + lege Country + submit → verifieert dat statusbar de error toont; switcht naar EMAIL → asterisk verdwijnt + submit zou doorlaten.
Net effect: handmatige UTM aanmaak voor Paid channels kan niet meer "stilletjes" Country leeg hebben. Voor monoculturele talen vult Country automatisch — voor ENG (ambigu) moet de user expliciet kiezen tussen NL/BE/UK. Voor Owned media blijft het optioneel (geen wrijving toegevoegd waar het niet nodig is). De multi-country ENG-launch (Phase 1: NL+BE+UK) blijft schoon traceerbaar in Ads Manager + GA4.
- New constant
UTM_ATTRIBUTION_CONTEXTSwith 3 keys:web_via_smart_script(📊 default — UTM → Smart Script → OneLink at install),meta_sdk(📱 Meta paid app-install — UTM is naming-only, attribution via SDK),web_only(🌐 no app component — PR, info pages). Each has an icon + color + label for badge rendering. - Auto-detection helper
_resolveAttributionContext(channelKey, campaignName)with 3 rules:META→meta_sdk·PR→web_only· everything else →web_via_smart_script. Used at UTM-creation time (bulk-gen + manual modal) AND at display-time when the field is empty — so the badge shows correctly even if the Airtable schema lacks the new field yet (forward-compat). - Bulk-gen writes the field when creating new UTMs from campaign creatives. Auto-detected from channel. If Airtable doesn't have the column yet, the two-stage retry strips it gracefully (already extended for utm_source/medium/campaign — added
Attribution Contextto the same strip list). - New UTM modal: new "Attribution" dropdown in the field grid with auto-default behavior mirroring the Language-from-Country pattern. Hint "· auto from channel" shows in sage when value matches the channel default; disappears when user overrides. Resets on every modal open (per-session flag
_nuAttributionUserSet). On edit-existing: reads stored value, marks as user-set only if diverging from channel default. - UTM detail modal: Attribution badge. Pill-shaped badge with icon + label using the context's accent color. When the field is missing on the Airtable record, shows the inferred value with an "inferred from channel" hint in 9.5px italic — explicit signal to marketers that the badge is computed, not stored. Lives between Status and Landing page in the 2-column grid.
- UTM page: Attribution filter. New tf-group dropdown in the filter toolbar:
📊 Smart Script · 📱 Meta SDK · 🌐 Web only. Filters work even when the field isn't on Airtable yet — falls back to channel-inferred context for comparison. - CSV export now includes
Attribution Contextas a column (between Status and Landing Page). Uses stored value if present, else inferred. Headers updated from 14 → 15 columns. - 1 new test, total = 157:
test_utm_v3_attribution_context— verifies 3 contexts exist with required fields (icon/color/label), tests 10 channel→context resolutions covering Meta/PR/Google/YouTube/Apple/Email/Instore/Website/Rituals/empty-channel. Live-verified: all 28 UTM tests pass in the browser (was 27 before).
Airtable schema addition needed (forward-compat — works without it): add a Single Select field named Attribution Context to the UTM Codes table with options web_via_smart_script · meta_sdk · web_only. Once added, new UTMs will write the field automatically. Existing UTMs without the field still display the badge correctly (inferred from channel at view-time).
Smart Script validation — high-level checklist (not per-UTM): validate ONCE per landing page that AppsFlyer Smart Script is wired up correctly. 4 pages total — /download · /myrituals · /app · / (homepage). For each: load a test UTM URL → check that AppsFlyer registers an attribution event with the UTM params preserved. Per-UTM testing is overkill — if Smart Script works on the landing page, every UTM that lands there works.
- New
AppActivationcampaign marker. Appears in v3 filenames as a fixed marker for the Mind Oasis App launch campaign. Position differs per channel: Meta puts it AFTER funnel (MO_Meta_iOS_TOFU_AppActivation_ComingSoon_Video_1_NL_ENG), Google App puts it BEFORE funnel (MO_Google_App_iOS_AppActivation_Prospecting_NL_ENG). Parser uses a heuristic — extractAppActivationas a marker only when the next token is a known funnel — so both orderings work. Builder uses a per-channel branch for the same reason. - Theme as first-class segment.
ComingSoon·AppIntro·UGC·Generic. UGC is a compound (UGC_[Mood]_[Audience]_[Concept], e.g.UGC_Relax_Parent_AlwaysOn). MOFU Generic adds a Mood sub-token.UTM_THEMESconstant updated to canonical PascalCase: was['Generic','Relax','Sleep','Focus','Recharge','UGC','Coming_soon','App_intro','Other'], now['ComingSoon','AppIntro','UGC','Generic','Sleep','Relax','Focus','Recharge','Other']. - iOS / Android casing — PascalCase output. Builder now emits
iOS(Apple convention) andAndroid(Google convention) in filenames per the v3 spec. Parser still stores lowercase internally (no schema change in Airtable); the builder maps to spec-canonical case on output via a small_PLATFORM_CASElookup. utm_campaignURL parameter default =App_July_2026for paid-app channels (wasmindoasis_app_intro). Matches column H in the "UTM -setup (Ramona)" sheet. UGC auto-default becameApp_July_2026_ugc. Owned-media defaults unchanged. Applied acrossUTM_CHANNELSentries,buildUTMUrlfallback chain, andbulkGenerateUTMsForCampaignfield defaults.- UGC kebab-case trigger tightened. Previously: any campaign starting with
UGC_triggered the kebab-case builder path (mindoasis-app-ugc-...). v3 UGC compounds (4+ segments likeUGC_Relax_Parent_AlwaysOn) should use standard underscore filenames. New trigger regex:/^UGC[_-][A-Za-z]+$/i— only matches the legacy 2-segment form (UGC_Focus,UGC_Sleep) with variant=year. Legacy kebab still parses + builds correctly. - 3 new tests added (total = 156):
test_utm_parser_v3_app_activation_meta— 11 real v3 filenames covering Meta TOFU + MOFU, ComingSoon/AppIntro/Generic_X themes, UGC compound themes, AND Phase 2 (no AppActivation marker). All parse cleanly.test_utm_parser_v3_app_activation_google_app— 5 Google App filenames with AppActivation-before-funnel ordering. Round-trip through builder reproduces the exact filename (proves both parser + builder + per-channel branching work end-to-end).test_utm_v3_campaign_default_app_july_2026— verifies all 10 paid-app channels default toutm_campaign=App_July_2026, thebuildUTMUrlfallback emits it correctly, andUTM_THEMESuses spec canonical names (no legacyComing_soon/App_intro).
- 2 existing tests updated:
test_utm_builder_filename_canonicalnow expectsiOSPascalCase in output;test_utm_meta_unifiednow expectsutm_campaign=App_July_2026_ugc(wasmindoasis_app_intro_ugc). - UI naming-convention caption updated on the UTM page to reflect v3 pattern
MO_[Channel]_[OS]_[Funnel]_AppActivation_[Theme]_[Variant]_[Country]_[Language]with theme vocabulary inline as italic caption. - Backward compatibility preserved end-to-end. Every old filename in Airtable still parses correctly. Tested with:
MO_Meta_ios_TOFU_App_intro_Video_1_NL_ENG(v2),MO_Google_App_iOS_Prospecting_NL_ENG(v2),MO_Google_Search_Branded_NL(v2),mindoasis-app-ugc-parents-focus-2026(legacy kebab). All 9 unit-test scenarios pass round-trip.
Net effect: the OS now matches Ramona's v3 spec exactly. When GoodKarma delivers a creative named MO_Meta_iOS_TOFU_AppActivation_UGC_Sleep_Menopausal_WhiteNight_Video_1_DE_DE, the parser correctly identifies channel=META, platform=iOS, funnel=TOFU, campaign=AppActivation_UGC_Sleep_Menopausal_WhiteNight, variant=Video_1, country=DE, language=DE. When marketing creates a new UTM via the dashboard, the builder emits the spec-canonical filename with iOS/Android in PascalCase, AppActivation in the right position per channel, and utm_campaign=App_July_2026 in the URL. Manual UTM construction is now definitively a deprecated workflow.
- What's now formalised: 5 new additive Airtable tables (
Creative_Features · Coach_Reviews · Performance_Results · Competitor_Ads · Winning_Patterns) with mandatoryn+ confidence fields · 4-phase build (Fase 0 foundation · 1 Coach · 2 Loop+Lens · 3 Competitor intel) · explicit scope-honesty matrix (what's buildable now vs. needs patience vs. never possible) · The Loop as the connecting principle. - Pushback I added on top of the original plan: Fase 2 is heavily underestimated (six channel APIs = 6-10 weeks ETL, not "real work"); Lens chicken-and-egg (~6-12 months of data needed before patterns emerge with 3 active campaigns); OCR reliability is 70-80% not 100%; overrule data should be first-class (build "Coach disagreements" dashboard in Fase 2); heuristic source must be defined before Fase 1 starts; measure the Coach itself with a control-group toggle; Spyder is ~0.5 day/week ongoing ops; fatigue threshold needs concrete definition (proposed: 7-day-rolling CPA > 1.3× lifetime CPA, >14d active).
- Sequencing decision (the most important constraint): Fase 1 does not start until (1) deployment live, (2) ≥5 daily users for 2-4 weeks, and (3) Meta CSV →
Performance_Resultsproven end-to-end for one campaign. Anyone building the Coach without first proving the Loop is building blind. - Relationship to the existing roadmap: the Bouwplan is the detailed elaboration of presentation slide 19's Q3 ("Performance loop") and Q4 ("Creative intelligence") — not a new direction. Spyder (competitor layer) + the honesty rule are the new additions. The previous "Future IP directions" list in the Strategic Roadmap section is now marked as historical; this section is the definitive plan.
- What's still on our plate before Fase 0 begins: Meta MCP fix (or commit to CSV-route) · Netlify deployment + PAT-strip · team training session · backfill legacy UTM data · promote migration helpers from console to Admin-page buttons.
Net effect: Q3-Q4 2026 now has a real plan, not a sketch. The team knows exactly what's coming, what won't come (competitor conversion prediction — never), and the discipline that protects the OS from feature bloat as it grows. Sidebar nav updated with the new entry marked ✦ to signal "next major direction."
- Stats grid — 3 cards → 4 cards (
.grid4class). Was: a customgrid-template-columns:repeat(3,1fr)inline grid with Total/Active/Draft. Now: standard.grid4with Campaigns / Total codes / Active / Draft — exact same rhythm as the Campaigns page (Campaigns / Total creatives / Approved / In review). Campaign count uses the same Creative → Campaign resolution as the grouped view, so the number reflects real campaigns (not parser-noise prefixes). - Expand / Collapse moved into the filter-toolbar. Previously these were tucked inside a custom "summary banner" that sat between the toolbar and the campaign cards (and duplicated the totals already in the stat-cards). Now they live as
.tf-btnchips on the right side of the main filter-toolbar — exact same pattern as Designer View. The summary banner is gone. - Campaign group cards — header background now
var(--surface2). Was:background:transparentwith a hover-only colour change. Now: solid surface2 background (mirrors Designer's group headers) with hover swapping to surface. Chevron transition tightened from.25s cubic-bezier(0.32,0.72,0,1)→.12s(same as Designer). Bottom border thickened from 1px to2px var(--border2)when expanded — matches Designer exactly. - Group card title — Cormorant 19 → 17px. The editorial serif accent stays (it's a deliberate identity cue), but at 17px it reads as a row anchor instead of a section header — so it sits more naturally inside the now-surface2 chrome. Meta line moved from uppercase tracked label to plain 10px secondary text (less visual weight, more like Designer's "
X creatives · Y to do · Z approved" line). - Naming convention — moved from a lone block to a tiny toolbar caption. The
MO_[Channel]_[Platform]_[...]reference now sits as a small monospace caption directly under the filter-toolbar — no padding, no border, no card. Stays useful for scan-reference, no longer competes with the actual content.
Net effect: the UTM page now flows visually like the rest of the OS — same stat-card rhythm, same toolbar pattern, same group-card chrome. Information density is up (4 stats instead of 3 + duplicated banner) but visual weight is down (one bordered card pattern instead of two). The Cormorant serif accent on campaign names is preserved as a UTM-page identity cue.
- Bug 1:
CARROUSEL(sic — Excel typo with double-R) not matched by the variant regex. Filenames likeMO_AppIntroFocus_CARROUSEL_4_5_DE(from the Excel UTM-setup sheet, row 40+) were parsing the CARROUSEL token into the campaign segment instead of the variant. Fixed: extendedparseUTMFilename's variant regex from/^(Video|Static|Carousel|...)\d*$/ito/^(Video|Static|Carousel|Carrousel|...)\d*$/i— matches both the correct English spelling AND Ramona's Excel typo. - Bug 2 (not actually a bug, just a stale cache): The first test run reported 24/24 failures because the browser had a cached version of the page from before the latest sync. After
location.reload(true)the New Campaign modal + Campaign Briefing modal DOM elements were present and all tests passed. Lesson: test runs need an explicit hard-reload step at the start. - Verification methodology: Loaded the live preview via
mcp__Claude_Preview__preview_eval, force-reloaded, then ranTEST_SUITES.modern.tests.filter(n => n.startsWith('test_utm_'))in batches with timeout protection. Results:- UTM tests 24/24 pass (21 sync + 3 modal-openers run individually)
- Non-UTM modern tests 87/87 pass (excluding 4 heavy ones that mutate global state or hit external services)
- JS parse: clean (1.46 MB main script)
Net effect: verified empirically — not "I think it works", but "I ran the tests, watched them pass, and copied the output". CARROUSEL fix is the only behavior change; everything else from the recent UTM v2 work was already correct on the first run.
MO-04734 · Language=ENG) auto-filled Channel/Variant/Language but left Country empty and didn't preselect the Campaign. Worked correctly for Meta DE/NL creatives, broke for ENG-only YouTube/Display creatives.
- Fix 1: 5-step country resolution algorithm in both
_nuCreativeSelect(New UTM picker) AND_createUTMFromCreative(briefing modal). Priority: (1)Creative.Countries, (2) Language → Country monocultural map (NL→NL, DE→DE, FR→FR, IT→IT, ES→ES, PL→PL, BE-FR→BE), (3)Languagesfield as country code (Google Display pattern), (4)Campaign.Countriessingle-value, (5a)Campaign.Countriesmulti-value + Language=ENG → prefer UK (Excel default), (5b) multi-value fallback → first country, (6) empty (user picks manually). Country-name → code normalization built in:Netherlands → NL,Belgium → BE,Germany → DE, etc. - Fix 2: Campaign auto-select from linked Creative. Was: picking a creative filled Channel/Variant/Country/Language but the Campaign dropdown stayed "— Select a campaign…". Now: reads
Creative['campaign name']link → finds the Campaign record by ID → sets the dropdown → triggers_nuCampaignSelect()which auto-fills the campaign hint + slugified name field. - Fix 3: Platform field hidden when channel doesn't use one. Showing "In-stream / Shorts / Pre-roll" under Platform for YouTube was misleading — those placements don't appear in the Excel filename pattern. Audited all 18 channels: only
META(ios/android + UGC audiences) andGOOGLE_APP(iOS/Android) actually use a Platform segment in their filenames. Clearedplatforms: []for the other 16 channels._nuChannelChangenow hides the whole Platform field row whenplatformsis empty. - Fix 4: Live duplicate detection. A warning slot under the filename field shows "⚠ Duplicate filename — already exists as Active in 'Test - Summer campaign'. Open existing →" whenever the current filename matches an existing UTM record (via slug comparison). Click "Open existing →" to jump to the duplicate's detail modal. In edit mode, the record being edited is excluded from the dup check (no false positives).
- Fix 5: Hard duplicate confirmation on submit. Even if the user ignores the live warning and hits + Create UTM, a blocking
uxConfirmdialog appears: "A UTM with filename X already exists as Active in 'Y'. Creating another one will produce duplicate analytics rows in GA4. Continue anyway? [Cancel] [Create duplicate]". Forces an explicit decision before duplicating — protects against accidental duplicates from quick double-clicks. - Fix 6:
test_utm_creative_picker_country_resolutioncovers all 4 country-resolution scenarios: (a) Language NL → Country NL, (b) Language ENG + multi-country campaign incl UK → Country UK, (c) Language ENG + single-country campaign BE → Country BE, (d) explicitCreative.Countrieswins over campaign fallback. Suite total: 153 tests (was 152). UTM-specific: 24 tests.
Net effect: the creative-picker now produces a fully-populated UTM for the universal case where the team has only one of (Country, Language) on the creative record. The remaining unanswered field is inferred from the linked Campaign's targeting metadata — same logic that powers the Airtable Country → Languages automation. Country stays empty only when truly ambiguous (e.g. multi-country campaign with no Language hint). See UTM Codes → Country resolution algorithm for the full priority table.
- Fix 1: Bulk-gen button stuck on "Creating UTMs… 120/120". The button got set to a loading state during bulk-gen but was never reset after success. Extracted the button-refresh logic into
_refreshCampaignBriefingUTMButton(campId)— called both from the briefing modal open flow AND frombulkGenerateUTMsForCampaignafter the final POST batch. Button now correctly resets toUTMs [120](green badge — all linked) once the records are written. - Fix 2: Bulk-gen wrote
Status: 'Draft'— should be'Active'. Inconsistent with the manual + New UTM flow which already wrote Active. Logic: if a marketer hits "Generate UTMs" on a campaign, those URLs are intended for use — not draft. Switch to Archived manually when the campaign ends. - Fix 3:
Campaign Namewas filename-parse noise. Was:'Campaign Name': parts.campaign || campName— wrote things like "TEST - DO NOT DELETE_APPLE SEARCH ADS_DE_APP PREVIEW VIDEO 1920X180PX_PAID" (the heuristic-parsed filename segment) instead of "Test - Do not delete" (the actual Airtable Campaign record name). Now:'Campaign Name': campName || parts.campaign— real campaign first, parse only as last-resort fallback. This makes the UTM page's By campaign grouping show 1 clean group per real campaign instead of N groups per parsed-filename-prefix. - Fix 4: UTM page — much richer "By campaign" view. Was: flat list of cards, one per filename-prefix. Now: collapsible campaign cards (campaigns >20 UTMs start collapsed) with channel chips strip showing the channel mix (APPLE_SEARCH 24 · META 48 · YOUTUBE 16), nested channel sub-groups within each campaign (color-coded headers + URL count per channel), and a top summary banner "120 UTMs · 1 campaign". Click any campaign header to toggle the body — saves vertical space when scanning across campaigns.
- Fix 5:
backfillUTMCampaignNames()migration helper. For existing UTM records that were created before Fix 3 (with noisy Campaign Name from filename-parse), a console helper resolves the proper Campaign record name via Creative.File name ↔ utm_content ↔ Creative.campaign-name-link, and batch-PATCHes the fix. Defaults to dry-run mode — preview the changes in a modal, then re-run with{dryRun:false}to apply. Idempotent. - Fix 6: New UTM modal — Link to creative picker + smaller Notes field. Added a search box at the top of the modal: type 2+ chars (filename or Creative ID) → dropdown shows matching creatives → click to auto-fill channel/platform/variant/country/language/funnel from that creative. Explicit linkage (writes to
Creativelinked-record field if present), scalable, traceable. Notes textarea default height reduced from ~64px to 32px (rows=1, resize:vertical, min-height:32px, max-height:220px) — frees up vertical space in the modal. - +4 new tests:
test_utm_bulk_status_default_active(regression guard for Status default) ·test_utm_bulk_campaign_name_uses_real_name(regression guard for write priority) ·test_utm_briefing_button_refresh_callable(extracted function exists + no-op safe) ·test_utm_backfill_helper_callable(migration helper defined + globally accessible). Suite total: 152 tests. UTM-specific tests: 23.
Net effect: bulk UTM generation is now production-grade. The button cleanly transitions success state. New UTMs get Status=Active. Grouping on the UTM page reflects real campaign structure. Existing data can be cleanly migrated. The marketing team can run bulk-gen on a campaign and walk away — the dashboard hands them back accurate, well-organized UTM records.
- Mapping:
NL→NL·BE→NL(Flemish majority — switch to BE-FR manually for Walloon) ·DE→DE·FR→FR·UK→ENG·ES→ES·IT→IT·PL→PL·EU→ENG. Matches the Airtable Country → Languages automation map. - Lock behavior: A flag
_nuLanguageUserSettracks whether the user has manually edited the language dropdown. If they pick a language that doesn't match the country's auto-default → flag locks → future country changes won't overwrite. Clearing the language back to "—" unlocks the flag so country → language auto-fill resumes. - UI hint: A small green "· auto from country" label appears next to the Language label when the value was auto-derived. Disappears when the user takes manual control.
- +1 test:
test_utm_country_to_language_autofillexercises 6 scenarios: NL→NL, DE→DE, UK→ENG, user override locks, clearing unlocks, FR→FR after unlock. Suite total: 147 tests.
utm_source=ig vs utm_source=fb split is a mistake in Ramona's spec — Mind Oasis treats Meta as one unified channel for GA4 attribution. Reverted in one pass:
- Removed 3 channels:
META_IG,META_FB,META_UGCtaken out ofUTM_CHANNELS. Channel count back from 21 → 18. META.sourcereverted from'ig'→'meta'. New UTMs on the META channel now emitutm_source=metain GA4. Label restored to "Meta — Feed/Stories/Reels" (no more "(legacy)" hint).- UGC stays as a first-class concept — just no longer a separate Channel. UGC handling now lives in Campaign Name: prefix it with
UGC_(e.g.UGC_Focus,UGC_Relax) and the dashboard automatically (a) emitsutm_campaign=mindoasis_app_intro_ugc(new auto-default inbuildUTMUrl) and (b) generates the kebab-case filename patternmindoasis-app-ugc-{audience}-{theme}-{year}inbuildUTMFilename. Audience (parents / menopausal / all) lives in the Platform field; theme follows theUGC_prefix; year defaults to the current year. _platformToUTMChannelsimplified: all Meta variants (meta, facebook, instagram, ugc) collapse toMETA. No more 3-branch case for IG vs FB vs UGC.- Parser still detects kebab-case UGC filenames — just routes them to
channel:'META'(withcampaign:'UGC_Focus') instead of a separateMETA_UGCchannel. Existing kebab-case filenames in the Excel parse cleanly. - Tests updated:
test_utm_meta_source_is_ig→ renamed totest_utm_meta_unified(asserts source='meta', no IG/FB/UGC channels exist, UGC utm_campaign auto-defaults).test_utm_parser_ugc_kebab_casenow expectschannel='META'.test_utm_bulk_prefers_platforms_over_filename: Instagram/Facebook/Meta UGC all route to META.test_utm_v2_channel_defaults: required-channel list trimmed from 21 to 18. Suite total: 146 tests (unchanged).
Net effect: the Airtable Channel Single Select now needs only 8 new options (not 11) — META_IG, META_FB, META_UGC are gone. The team gets the same UGC behavior as before, just through a Campaign-Name convention instead of a separate dropdown.
- Fix 1:
META.sourcewas'meta', Excel uses'ig'. Critical bug — the Excel UTM -setup sheet has zero rows withutm_source=meta. Every Meta UTM uses eitherig(Instagram, primary) orfb(Facebook). Old behavior was emittingutm_source=metainto GA4 which doesn't match Ramona's channel-grouping setup. NowMETAdefaults to'ig'(most common placement) and the channel label was renamed "Meta (legacy — prefer IG/FB)" so the team is nudged toward explicitMETA_IGorMETA_FBfor new UTMs. - Fix 2: Compound channel-path detection in
parseUTMFilename. Was: parser took the first token and looked for any channel whose key contained that token. BecauseGOOGLE_PLAYSTOREcomes beforeGOOGLE_SEARCHin the channel list,MO_Google_Search_Branded_NLmis-routed toGOOGLE_PLAYSTORE. Now: longest-prefix match against a hand-curated table of compound paths (Google_Search_Branded,Google_Search_NonBranded,Google_App,Google_Display,Google_Discovery,Google_Play,DV360_Video,DV360_Display,DV360_Banner,Apple_Search,Meta_UGC,Instore_Flyer,Instore_Backwall,Website_Rituals,App_Rituals,Organic_MO). Compound paths also auto-setfunnelwhen implicit —Google_Search_Brandedpresetsfunnel:'Branded'. - Fix 3: Channel-aware tail parsing for country vs language. Was: parser always tried language first (because
NLis in bothUTM_LANGUAGESandUTM_COUNTRIES). For Search-family channels (GOOGLE_SEARCH,APPLE_SEARCH,GOOGLE_DISCOVERY) this was wrong — those creatives are text-free, so a single trailing 2-letter code is always a country, never a language. Now: parser detects 2-token tails (_NL_ENG) as country+language pair, 1-token tail is channel-aware (Search channels → country; others → language). - Fix 4:
MO_AppIntro{Theme}_{Variant}_{Format}_{Language}abbreviated pattern. Was: parser couldn't detect the MO-side abbreviated pattern (Meta paid posts in the Excel UTM -setup sheet) because there's noMetaprefix in the filename. Now: any filename whose first token matches/^AppIntro[A-Z]/auto-resolves toMETA. If the LAST token is_GOOGLE, it routes toGOOGLE_DISPLAYinstead (Google Display banner variant per spec). The variant regex was also extended from/^(Video|Static|Carousel|...)$/to/^(Video|Static|Carousel|...)\d*$/so it matchesVideo1/Static2/Carousel1(no separator) used by this pattern. - Fix 5: Meta UGC kebab-case (
mindoasis-app-ugc-{audience}-{theme}-{year}). Excel uses kebab-case for UGC filenames — completely different from the standardMO_..._..._...pattern.parseUTMFilenamenow matches this with a dedicated regex at the top, returningchannel:'META_UGC',platform:audience(parents/menopausal/all),campaign:'UGC_'+Theme,variant:year.buildUTMFilenamehandles the inverse — when channel isMETA_UGC, it produces kebab-case output instead of underscore-separated. - Bonus:
bulkGenerateUTMsForCampaignnow preferscreative.platformsover filename-parsed channel. The platforms field is the source of truth (set explicitly by marketers). Filename-derived channel is only used when platforms is empty or unrecognized. This means even ambiguous filenames (MO_AppIntroSleep_Video1_300_250_ENG— could be Meta paid OR DV360 Video depending on context) route correctly: the creative's platforms field disambiguates. - Bonus: new
_channelKeyToFilenameTokenhelper. Maps channel keys to Excel filename tokens:META → "Meta",GOOGLE_SEARCH → "Google_Search",DV360_VIDEO → "DV360_Video", etc.buildUTMFilenameuses this so the output matches what Ramona's Excel produces exactly (no moreGoogleSearchjammed-together output). - +5 new tests + 1 strengthened.
test_utm_meta_source_is_ig·test_utm_parser_compound_channels(8 cases incl. Google_App/Display/Discovery, DV360 Video/Display/Banner, Apple Search) ·test_utm_parser_app_intro_abbreviated(5 cases incl._GOOGLEsuffix routing) ·test_utm_parser_ugc_kebab_case(4 parse cases + round-trip build) ·test_utm_bulk_prefers_platforms_over_filename(14 platform-to-channel mappings). The existingtest_utm_parser_search_branded_country_onlywas upgraded from "extracts SOMETHING" to "extracts country specifically with funnel preset". Suite total now 146 tests.
Net effect: the dashboard now produces identical UTMs to Ramona's Excel for every filename pattern in production use. Hand-audited every example in the GK + setup sheets — they all parse and rebuild correctly. UTM is leading; the Excel can stop being the canonical reference.
- Bug: "Insufficient permissions to create new select option 'MO-04711'". Root cause: when a Creative had no
File nameyet (Airtable automation hadn't run), the bulk generator fell back toCreative ID(e.g.MO-04711) and tried to write that as the Channel — which Airtable rejected becauseMO-04711isn't a known Single Select option. Three fixes:- Skip Creative-ID-only filenames. Pattern
/^[A-Z]{1,4}[-_]\d+$/idetects values likeMO-04711,MO_12345and excludes them — no point writing a UTM when the asset doesn't exist yet. - Validate Channel against
UTM_CHANNELS. If the parsed channel from the filename isn't in the 21-channel whitelist, fall back to_platformToUTMChannel(creative.platforms). If that also fails, skip the creative with a clear reason. - Two-stage retry on Single-Select rejections. When Airtable returns "Insufficient permissions to create new select option", the bulk POST now retries with all Single-Select fields stripped (Channel, Funnel, Country, Language, Status). The UTM still gets created — just without the rejected field, which the team can fill in manually if needed.
- Skip Creative-ID-only filenames. Pattern
- Actionable error messages. The toast no longer dumps Airtable's raw error JSON. Instead: "Can't generate UTMs yet — 12 creatives have no filename. Wait for the Airtable automation to populate File name on each creative, then try again." Three distinct messages for the three skip reasons (no filename, unrecognized channel, all-existing).
- Smarter country/language fallback inside bulk gen. If the parser misses country or language (e.g. the filename doesn't end in
_NL_ENG), the generator now readsCreative.CountriesandCreative.Languagesdirectly (taking the first value if multi-select). Same fallback chain as_createUTMFromCreative. - New docs section: UTM granularity. Added a table to UTM Codes explaining when "1 UTM per Creative" is correct (visual channels: Meta, Display, YouTube, DV360) versus when it over-generates (text-driven: Google Search, Apple Search Ads, Email, In-Store, PR — those should use the + New UTM modal manually with 1 UTM per country/touchpoint). Plus a troubleshooting table mapping common errors to their root cause + fix.
Net effect: bulk UTM generation no longer silently fails on a single bad creative. The flow either produces UTMs successfully or gives the marketing team an actionable message about what to fix before retrying.
- Channels: 10 → 21. Each has explicit defaults wired into
UTM_CHANNELS(medium, source, campaign, landing, group). The channel dropdown in the New UTM modal + the filter on the UTMs page now use<optgroup>to separate Paid media from Owned media. See the UTM Codes section for the full reference table.- New paid channels: META_IG, META_FB, META_UGC (split from monolithic META), GOOGLE_PLAYSTORE, GOOGLE_DISCOVERY, APPLE_SEARCH.
- New owned channels: INSTORE_BACKWALL (split from INSTORE), ORGANIC_MO (organic posts/influencers), WEBSITE_RITUALS (local block + PDP), APP_RITUALS (Two Tile), PR (press release).
- Existing keys preserved for backward-compat — old UTM records still resolve. Only the defaults changed.
- Domain:
mindoasis.app→mindoasis.com. Four canonical landing paths per Ramona's spec:/download(paid app campaigns),/myrituals(loyalty + in-store + email),/app(organic),/(PR press release).UTM_LANDING_PRESETSupdated, plus the landing-page resolver in_createUTMFromCreativerewritten to match every path the spec uses. Existing records with the oldmindoasis.appURL still work (the explicit Landing Page field always wins). - utm_medium normalized: Video channels (YouTube + DV360 Video) now use
olv(Online Video) instead ofvideo— matches the actual GA4 channel grouping Ramona set up. - utm_source split per platform: Meta IG =
ig, Meta FB =fb(was bothmeta). Email =rituals_newsletter(wasklaviyo). In-Store Flyer =instore_flyer, Backwall =instore_backwall(was bothinstore). Organic =ig_organic. Rituals owned =rituals_website/rituals_pdp. PR =press. - utm_campaign defaults per channel:
mindoasis_app_intro(paid app campaigns),mindoasis_app_intro_ugc(Meta UGC),mindoasis_app_launch(Email + PR),mindoasis_myrituals_25pct(loyalty / in-store / app rituals). The dashboard falls back to these defaults whenCampaign Nameis empty — old behavior of slugifying Campaign Name still wins when it's set. - Smarter platform→channel mapping.
_platformToUTMChannel(platformVal)rewritten with 20+ regex branches: an Instagram-only platform routes to META_IG (not generic META), Apple Search Ads goes to APPLE_SEARCH (was incorrectly mapped to GOOGLE_APP), In-Store Backwall has its own branch, etc. Most specific match wins. - New filename patterns documented: Each channel family has its own pattern in the v2 spec — see the Naming convention table on the UTM Codes page. Google Search Branded is country-only (no language, since the creatives are text-free); Meta UGC uses kebab-case (
mindoasis-app-ugc-parents-focus-2026); Google Display banners get a_GOOGLEsuffix; DV360 banners include the format size (320_480,300_250). - +1 test:
test_utm_v2_channel_defaults. Asserts all 21 v2 channels exist with non-emptylanding/medium/source/campaign/group, that YouTube + DV360_VIDEO useolv, and that every landing is onmindoasis.com. The existingtest_utm_my_rituals_landing_pagegot an extra assertion: when no Landing Page is provided, the channel default (mindoasis.com/myrituals) kicks in. Suite total now 141 tests.
Net effect: the dashboard is now the single source of truth for UTM construction across every Mind Oasis touchpoint — paid social, programmatic, search, email, in-store, owned web, PR. Ramona's Excel can stop being the canonical reference; the dashboard generates the same URLs by construction, with zero manual lookup.
- More dropdown wasn't opening. The new More ▾ menu in the header was clipped invisible because the parent
<nav>hadoverflow:hidden— the dropdown rendered, then got immediately cropped to zero height. Fixed by splitting the rule tooverflow-x:clip; overflow-y:visible— horizontal scroll still prevented, vertical overflow now allowed so the menu can extend below the bar. Same pattern reused for any future header dropdowns. - + Create UTM on a creative now auto-fills the whole modal. Was: clicking + Create UTM from a creative's briefing modal opened the New UTM modal with everything blank — you'd manually re-enter channel, country, language, funnel, variant, campaign even though all of it was already on the creative + linked campaign records. Now:
_createUTMFromCreative(creativeId)pulls structured data from the creative + its linked campaign, with filename-parse as a last-resort fallback. Pre-fills channel (fromCreative.platformsvia_platformToUTMChannel), country (fromCreative.Countries→Campaign.Countries→ filename), language (fromCreative.Languages), funnel (fromCreative.Funnel→Campaign.Funnel→ Brief-Type default via_briefTypeToFunnel), variant (derived from Content type + Slide position + Size via_creativeToVariant), campaign (auto-selected in the dropdown), landing page (fromCampaign.Landing page, forced for MY_RITUALS), notes (auto-stamped "Auto-created from creative <ID> · <filename>"). Original filename preserved as canonicalutm_contentwith the field locked so subsequent dropdown changes don't overwrite it. - Country fallback for Google Display. Google Display and Search Branded creatives don't differentiate by language, so marketeers store the country code (NL, BE, DE…) in the
Languagesfield instead ofCountries. Without a fallback, the auto-filled UTM showed an empty Country field. Added two-step fallback: (a)LANG_TO_COUNTRYmap for monocultural codes (NL→NL, DE→DE, FR→FR, IT→IT, ES→ES, PL→PL, BE-FR→BE — ENG stays empty since it's ambiguous), (b) ifLanguagescontains a recognized country code (NL, BE, DE, FR, UK, ES, IT, PL, EU) and country is still empty, lift it directly. Multi-value country handling now takes the first value as best guess instead of leaving the field blank.
Net effect: a UTM that used to take 30 seconds of manual data re-entry now opens fully populated. Click + Create UTM → review → save. The dashboard already knows the answer for every field — no reason to ask twice.
- Emoji-free UI. Removed decorative emoji icons from every label, dropdown, badge, button, tab, and toast (Planning sub-tabs, Brief Type pills, Moment type pills, UTM Channel pills, Library tabs, Home insight banners, Asset Library cards, Reschedule modal, "Generate UTMs", "Generate starter briefs", etc.). Visual identity now carried by color (sage / cranberry / ochre / plum / steel-blue) and typography (Cormorant headlines + DM Sans body). Typographic Unicode glyphs preserved where structural (⎘ copy, ⊞ QR, ✎ edit, ↻ regenerate, → arrows, · dot, — em-dash).
- "More" dropdown in the nav. Secondary pages — Library, Asset Library, UTMs, Documentation — collapsed into a single More ▾ menu next to Review. Primary work tabs (Home, Planning, Campaigns, Designer, Review) stay always-visible. Right-side controls (notifications, name chip, theme, status, MOA GPT, Admin) are now guaranteed to fit at any viewport width. Aggregate badge on More shows total unread count for Asset Library + UTMs combined.
- Brief count pill on moment cards. The big-number + small-caps layout (which looked floaty) replaced by a proper stat-pill: bordered chip, background tinted with the moment type color when count > 0, Cormorant 28px number + 8.5px caps label below, centered.
- Compact campaign briefing modal footer. Was 80–100px tall and wrapped to multiple rows on the 640px modal. Now single-line 40px footer with 3 groups: left (Close · ☆ Favorite · 🖨 Print as icon-only chips), center (Template · Reschedule · UTMs <count>), right (Regenerate · Edit · Duplicate). Modal widened to 720px so all 8 buttons fit on one row.
- Approval-bottleneck rows fully clickable. Tiny chips replaced by full-width clickable rows showing creative ID + filename + 📁 campaign + designer + age. Whole row opens the review modal — same affordance as creatives in Designer view. Pulsing dot signals urgency.
- Date-change approval — diff visible. Old "Current / Proposed" two-column layout replaced by an explicit What changes section: per-field cards that read "CHANGING" or "NO CHANGE", with old date
strikethrough red, new date bold, day-difference in warm color ("+14 days later" / "75 days earlier"). Reason gets its own labeled callout. - UTM page UI rebuild. Default status filter changed from Active to All (so the badge count matches what's on screen). New By campaign grouped view (cards with per-campaign active/draft/archived counts) + a Flat list alternative. Per-row ⎘ URL · ⊞ QR · ✎ Edit actions. Channel pills cleaner (no icon, color-only). Status pills with colored dot. Stat-row Top channel renamed without emoji.
- UTM editing flow. Click ✎ → opens the New UTM modal in edit mode (title becomes "Edit UTM code", primary button "✓ Save changes", footer gets a destructive 🗑 Delete on the left). Submit switches from POST to PATCH automatically when
_nuEditingIdis set. - Campaign-aware UTM creation. New UTM modal's "Campaign" field is now a dropdown listing all open campaigns sorted by start-date desc, with a "+ Type custom name" fallback. Selecting a campaign auto-fills country/language (if single-value) and shows a context hint with start date + countries count. Slug-aware matching: campaign "Test - Do not delete" matches UTM "Test_-_Do_not_delete".
- Per-campaign UTM count pill on the Campaigns table. Each campaign row shows
● N UTMswhen there are matched UTMs. Color dot signals state (sage = all active, ochre = drafts present). Clickable → jumps to UTMs page filtered by that campaign. - Toast visibility above modals. Bumped z-index 700 → 9500 so toasts always sit above modals (was hidden behind the briefing modal during bulk UTM generation). Toasts also got larger padding + bolder font for readability.
- Inline progress for bulk UTM generation. The UTMs button on the briefing modal now shows a spinner + "Creating UTMs… N/M" label during the bulk POST, in addition to the toast.
Net effect: the dashboard reads cleaner, fits better at all widths, and the right-side Admin button — which kept getting pushed off-screen — is now permanently anchored. Color and typography carry the visual identity; emojis stop competing for attention.
- 10 new tests. Modern features suite now 81 (was 71). Grand total 140 tests. New coverage: QR modal opens correctly · MY_RITUALS landing page routing · Search Branded parser handles country-only locale · uxConfirm/uxPrompt resolve via Promise · login greeting adapts to time of day · login password toggle · login overlay uses .show class · UTM CSV export safety · UTM filename lock on manual edit.
- Two new dedicated docs sections. 🔐 Login screen — atmospheric design, time-of-day greeting, glass card, behavior, dark mode, mobile, how to test. 💬 UX dialog system — Promise-based replacements for native confirm/alert/prompt, API table, visual design, behavior (Esc/Enter/focus trap), 20 user-facing sites migrated, code example.
- Pages & navigation refreshed. Now lists 9 main pages (was 8) — UTMs added as a top-level tab. Login overlay mentioned as the first-touch. Each card updated with new features (Moments band on Timeline, approval bottleneck on Review, asset preview lightbox on Asset Library, etc.).
- Test automation card refreshed. Per-suite descriptions updated to reflect actual coverage. Modern features card now has a 5-section breakdown (Core dashboard · Market Moments · F11–F15 workflow · Login + UX dialogs · UTM Codes).
- Sidebar links added for Login screen + UX dialog system.
- Naming convention encoded.
MO_[Channel]_[Platform]_[Funnel]_[Campaign]_[Variant]_[Country]_[Language]— slugify rules from the Excel (no spaces, no extensions, underscores as separators, strict country/language codes) implemented inbuildUTMFilename()+ reverse parserparseUTMFilename(). - New nav tab "UTMs" with table view, 5 filter chips (Channel · Country · Language · Status · Search), live stats row (total · active · top channel), and 3 toolbar actions (📄 Export CSV · ⎘ Copy URLs · + New UTM).
- + New UTM modal with cascading dropdowns: pick channel → platform options narrow per channel (Meta → facebook/instagram/ios/android · Google Search → branded/nonbranded · DV360 → banner/preroll/midroll · etc.). 10 channels supported. Variant + campaign + country + language → utm_content auto-builds. Live URL preview at bottom updates in real time.
- Briefing modal UTM panel. Auto-matches a creative's File name to
utm_contentvia slug comparison. Shows channel pill, full URL, copy/QR/detail buttons. If unmatched: "+ Create UTM" CTA pre-fills the New UTM modal from parsing the filename. - QR code generator. ⊞ button on any UTM row → modal with 240×240 PNG and SVG download (600×600) for print quality. Powered by api.qrserver.com — no dependencies. Use for flyers, backwalls, in-store displays.
- CSV export. 14-column dump of every UTM matching current filters (filename · all parts · all utm_* params · full URL). Filename auto-dated
mind-oasis-utms-YYYY-MM-DD.csv. - Forward-compatible. Page shows a clear setup hint (schema fields listed) when the Airtable table doesn't exist yet.
loadUTMs()catches multiple Airtable error variants (NOT_FOUND, MODEL_ID_NOT_FOUND, "Invalid permissions...") and returns[]gracefully. - 6 new tests. Modern suite now 70 (was 64). Grand total 129 tests. Tests cover canonical filename generation, URL emission with all 4 utm_* params, parser round-trip, page DOM presence, auto-match by filename, and exposure of uxConfirm/uxAlert/uxPrompt.
Net effect: what used to be a 4-tab Excel sheet with manual filename fills and copy-paste of generated URLs becomes a 5-click form. GoodKarma delivers an asset with the matching filename → dashboard auto-links it on next refresh. Ad platform manager copies the URL straight from the briefing modal. Marketing exports a CSV before each campaign launch. One source of truth in Airtable.
- Login screen. A full-screen welcome overlay on first visit (or after sign-out) — Mind Oasis brand gradient, Cormorant serif headline, email + password fields, "Remember me", "Forgot password?", and a prominent "Skip for now — auth integration coming soon" button. Sign-in and Skip both store
moa_logged_in=1in localStorage so the overlay never re-shows untilsignOut()is called. Dark mode aware. Onboarding tour fires after the overlay dismisses so first-time users get the guided walk-through right after login. - F20 · Moment PDF export. New 📄 Export PDF button on the moment drill-in panel. Opens a print-friendly page in a new window with: moment header (icon, type, status), description in a colored callout, reference links, 4-stat summary row (anchor date + countdown · lead-time-start · linked briefs + ship % · designer count), and a per-channel grouped brief table (name + channel + description excerpt · status · designer · due). A4-sized, no dashboard chrome, auto-triggers the print dialog. Stakeholder-ready one-pager.
- F18 · Asset preview lightbox. Clicking the thumbnail on an Asset Library card used to open the original URL in a new tab. Now it opens a slick in-app preview: dark backdrop with blur, image or video player (auto-detects MP4/WEBM), title in Cormorant, metadata strip (shoot date · photographer · models · LoB · buyout expiry), and two CTAs: "↗ Open original" and "✎ Full detail →". Hover overlay on the card shows "👁 Preview" affordance. Esc closes. Click outside the media closes.
- 3 new tests. Modern suite now 64 (was 61). Grand total 123 tests. Tests verify login overlay DOM + 5 handlers, exportMomentPDF safety on unknown IDs, asset preview lightbox renders name + metadata.
- F11 · Quick-duplicate brief. The "Duplicate" button on the briefing modal now routes by brief type: paid-media → existing full duplicate modal (variations + creative regen); standalone → quick-clone via prompt → 1 Airtable POST → instantly opens the new brief. Status reset to Planning, dates shifted forward keeping the same duration. Activity log entry per clone.
- F12 · Bulk operations expanded. The bulk toolbar on the Briefs page grew two more actions: Assign designer (Maike/Mendy/clear) and Link to moment (any moment from the cache + unlink). The moment dropdown auto-populates from
_momentsCachewhen the toolbar opens. All bulk actions go through the existing_bulkPatchpath so toasts + cache invalidation + activity log are consistent. - F13 · Approval-bottleneck panel on Review. A new panel between the stats row and the campaign-grouped list. Computes "aged in review" using activity-log entries that match set status to Ready for review, with createdTime fallback. Aged 3+ days surface as warning chips, 7+ days as error chips; click any → brief opens. Per-designer breakdown below shows total waiting, avg age, oldest item. Renders only when there's something to flag.
- F14 · Moment retrospective. New collapsed-by-default 📊 Past moments — what worked section at the bottom of the Moments tab. For each Completed-or-past moment: brief count, % completed (ship rate), lead-time-planned vs. used (with efficiency %), designer count. Summary stats in the section header (X moments · Y briefs · Z% shipped · efficiency%). The cards use slightly dimmed opacity to signal "historical."
- F15 · Library full-text search. A 340-px search input next to the Library tabs. Typing fires
_libGlobalSearch(q)which scans all 4 types in one pass — Hooks, Captions, USPs, Disclaimers — across concept, body text, angle, language, LoB, status. Results render grouped by type with highlighted match terms (Cormorant-warm amber) and "View all in tab →" jumps to that tab's filter. Empty query restores normal per-tab view. - 5 new tests. Modern suite now 61 (was 56). Grand total 120 tests. Each helper is verified callable + stable on empty/edge inputs.
- F9 · Moments band on Planning Timeline. Above the designer rows, a new Market Moments row paints horizontal bands per moment — band start = Lead-Time-Start, band end = Anchor Date, with a vertical pin marking the launch. Color-coded by Moment Type. At-risk moments (anchor close + 0 briefs / lead-time passed + 0 briefs) get a red accent + outer shadow. Click any band → drill-in. The row is omitted when no moments overlap the 6-week window — no empty placeholder.
- F10 · Moment context in the briefing modal. Opening a brief linked to a moment now shows a prominent band directly under the modal header: type icon, name in Cormorant serif, color-coded countdown ("in 5d", "today", "3d overdue"), anchor date, and lead-time-passed warning. Click the band → close brief → jump to moment drill-in. The previous tiny pills next to the title were nice-to-know; this is can't-miss.
- 2 new tests. Modern suite now 56 (was 54). Grand total 115 tests. Covers band rendering with injected moments, banner-slot presence in the modal markup.
Net effect: A planner scrolling the Timeline sees crunch periods at a glance (overlapping bands = busy). A designer opening a brief immediately sees the strategic why with a countdown. Moments stop being abstract — they take visual space proportional to their importance.
- F6 · Weekly digest sections.
buildWeeklyDigest()grew two new Markdown sections: 🗓 Moments needing attention (overdue/urgent/behind) and 🌱 Moments — next 30 days (forward calendar). Copy-paste-ready for Monday-morning team standup. - F7 · Moment-aware empty states. The Designer view + Briefs page empty states now append a small "By the way, these moments need briefs" chip-row when there's strategic risk.
emptyState(kind, opts)grew anextrafield; new helper_urgentMomentsExtra()builds the panel. If everything's calm, nothing renders. - F8 · Starter-brief generator. New ✨ Generate starter briefs button on the moment drill-in. Pick a moment → modal lets you check brief types + quantity → one Airtable POST per brief. All pre-linked to the moment, with sensible defaults (Start = lead-time-start, Due = anchor − 1d, Status = Planning). Activity log entry per brief. Re-opens the moment detail panel so you see the new briefs immediately. Goes from "5 clicks per brief × 5 channels = 25 clicks" to "5 checkboxes + 1 click."
- 3 new tests. Modern suite now 54 (was 51). Grand total 113 tests. Covers digest section presence with synthetic moments, empty-state extra wiring, starter-modal opening behavior, error handling for unknown moment IDs.
- Smart insight banner promotion. Moment-at-risk (overdue · urgent · behind) now outranks per-creative overdue in the insight priority list. Whole campaigns missing a moment is bigger than one creative being late.
- Moments-needing-attention chip row. New row below the mission-control grid: up to 6 at-risk moment chips with pulsing dots, anchor-date hints, and one-click drill-in. Only renders when there's risk to flag — Home stays calm otherwise.
- 1 new test.
test_home_moments_row_rendersinjects a synthetic urgent moment, callsrenderHome(), asserts both the row and the smart-insight surface the moment by name. Grand total 110 tests.
- F1 · Lead-time awareness. Per-moment health classifier:
overdue · urgent · behind · on-track · in-prod · future · done · undated. Moments needing attention (anchor close + no briefs, or lead-time passed + no briefs) now surface in a red banner at the top of Planning → 🗓 Moments — one chip per moment, one click → drill-in. Cards show a pulsing dot when urgent. - F2 · Moment context on Designer View. Campaign group headers and the Standalone briefs panel render the linked Moment as a colored pill next to the campaign / brief name. Designers see the why on every row at a glance.
- F3 · Coverage hint on New Brief. Pick a moment in + New brief → hint slot underneath the dropdown shows "in 5d · No briefs linked yet — yours will be the first" with lead-time-passed warning in amber. Also fires when entering the modal via "+ Brief for this moment" from a Moment drill-in.
- F4 · Onboarding step. Tour gets an 8th step explaining Brief Type × Moment as orthogonal axes (how vs. why). Selector targets the 🗓 Moments sub-tab;
onShownavigates to Planning + clicks the tab so users see the live UI while reading. - 4 new tests. Modern features suite now 50 (was 46). Grand total 109 tests. Covers health classifier output, coverage slot wiring, helper callability, onboarding step presence.
Net effect: Moments stop being "another tab to remember" and start being a first-class lane that the rest of the dashboard reflects back to you. If a moment is at risk, you'll see it on the Home/Planning page before you go looking.
Market Moments with 8 fields (Moment name, Moment Type, Anchor Date, Lead Time Start, Description, Reference Links, Status, Briefs reverse-link). Campaigns table gets one linked field Market Moment pointing to it. Any brief — paid or standalone — can optionally link to a moment.
- Brief Type × Moment are orthogonal. Brief Type = how we make it. Moment = why. A Moederdag campaign on Meta has Brief Type=
Campaign — Paid Media+ Moment=Moederdag 2026. - Create flow. New Brief modal grew a 🗓 Market Moment dropdown next to the LoB field — sorted by anchor date, completed-moments pushed last. Pick one or leave empty.
- Visual indicators. Linked moments show as colored pills next to the brief-type badge — in the briefing modal title and on every row of the Campaigns table. Moment-type colours: Holiday=red, Seasonal=ochre, Cultural=plum, Product Launch=sage, Brand=blue, Trend=amber, Always-on=stone.
- Campaigns page filter. New "🗓 Moment" dropdown alongside Brief Type. Filter by specific moment, or "— No moment" to find unanchored work.
- Planning → 🗓 Moments tab. 4th sub-tab. Calendar layout grouped by month showing each moment as a card: type-color left border, name, anchor date with "in 4d" urgency hint, count of linked briefs. Click any card → drill-in panel listing every linked brief grouped by Brief Type. "+ Brief for this moment" button on the drill-in pre-fills the dropdown.
- Stats banner. Top of Moments tab shows Total moments / Next 30 days / Total linked briefs as Cormorant-serif numbers.
- Forward-compatible. Dashboard works whether or not the Market Moments table exists yet —
loadMoments()returns[]on NOT_FOUND. First time you pick a moment in the New Brief modal, if the linked field is missing on Campaigns, Airtable returns "Unknown field name: Market Moment" and a toast surfaces. - Schema-drift coverage. EXPECTED_SCHEMA tracks Market Moments (all 8 fields) + the Campaigns.Market Moment linked field. The drift detector flags mismatches.
- 4 new tests. Modern features suite now 46 (was 42). Grand total 105 tests. Tests cover helpers, badge render, tab wiring, dropdown presence.
Airtable setup: Create Market Moments table with 8 fields, then add a Market Moment linked-record field on Campaigns pointing to it. The reciprocal Briefs field on Market Moments gets auto-created.
docs.html — but the actual file is named docs_Main file.html. Clicking it 404'd silently. Fixed in 4 places (the live anchor, an Admin helper text, plus 2 self-referential mentions in the docs themselves). Added an id="docs-page-link" attribute on the link element so it can be reliably tested. Two new automated tests added to prevent recurrence: test_docs_link_reachable HEAD-fetches the docs link and verifies it returns 200; test_no_dead_relative_links walks every <a href> on the page with a relative URL and HEAD-fetches each, listing any 404s. Suite total now 101 tests. Next time someone renames a file or copy-pastes a stale link, the suite will flag it before it ships to the team.Pending Date Change (long text): { newStart, newEnd, requestedBy, requestedAt, reason }. Until approved on the Review page, the campaign's actual Start Date / End Date stay untouched.
- How to request. Open any briefing → 📅 Reschedule button in the modal footer. Modal shows current dates locked, new-date pickers, optional reason field. Submit → toast "awaiting Review approval".
- Visual indicators. A pending change shows as a ⏳ pending dates amber pill: next to the campaign name on the Briefs table and inside the briefing modal title. Hover for the proposed dates.
- Review page → new tab. 📅 Date changes with a count badge. Lists every pending request as a card: campaign name, brief-type badge, requested-by + relative time, current vs proposed dates side-by-side, reason quote (if given), and Reject / ✓ Approve buttons. Tab is gated on the
approvepermission viadata-perm— Designers and Viewers don't see it. - Approve flow. Confirm dialog → atPatch sets
Start Date+End Datefrom the proposal, clearsPending Date Change. Activity Log records the approval. Re-renders Briefs + Timeline + Review. - Reject flow. Prompt for optional reason → atPatch clears the field. Activity Log records the rejection with the reason for the requester.
- Cancel-pending. The requester can re-open Reschedule and hit "Cancel pending" to retract their own request without waiting for a reviewer.
- Forward-compatible schema. If
Pending Date Changedoesn't exist yet on the Campaigns table, the first submit shows a clear toast: "Add a Pending Date Change Long-text field to the Campaigns table in Airtable to enable reschedule requests." No data loss — your input stays in the modal until you add the field.
Airtable setup: Add one Long-text field named exactly Pending Date Change to the Campaigns table. Schema-drift detector now expects it.
draggable="true" but the drop handler silently ignored two cases: (1) standalone briefs (no linked Creatives → targetCrs.length === 0 → early return) and (2) paid campaigns whose creatives hadn't been generated yet (same path). Fixed in _plRowDrop(): detects standalone via _isPaidBrief() + falls back to patching the Campaign record's own Designer field when no creatives exist. Confirm dialog adapted ("Move standalone brief / campaign ..."). Local cache updates the Campaign in place; workload chart on Project Summary re-renders so the new distribution shows immediately. Loading + success toasts replace the previous silent operation. logActivity wired with the correct designer-change summary. Now: drag any bar (paid or standalone, with or without creatives) → designer reassigned consistently.openRebalancePopover(evt, designer, weekIso) + _doRebalance(selectEl).allCampaigns / allCreatives / libData arrays (declared via top-level let) were not auto-mirrored to window in modern browsers. Several features added in the past sessions (iCal export, bulk operations, brief comments helpers, capacity warnings, hook performance lookups, designer workload chart) accessed them via window.allCampaigns and silently got undefined, falling back to empty arrays. Symptom in production: iCal "📅 .ics" button always reported "Nothing to export" even with rows visible on the Briefs page. Fix: new _syncDataToWindow() helper mirrors all three to window; called at boot AND after every assignment to those variables (loadDashboard, loadDesigner, loadReview, refreshBadges, delete-campaign cleanup). Belt + suspenders so any future feature can safely read window.allCampaigns regardless of which page it was last loaded from.- Role switcher moved out of the main nav. The Admin/Designer/Reviewer/Viewer chip was crowding the nav and pushed the real Admin page button off-screen on smaller laptop widths. The switcher now lives as its own card in Admin → Tools → 👥 Role switcher with a clear "for testing" subtitle. Same behavior — switch roles, see how the UI gates create/edit/delete/approve actions — just out of the production nav. Renders inline as a 2-column grid of role cards with current-role check + permission summary per role.
renderRoleSwitcher()handles the rendering, auto-called fromloadAdmin()andsetAdminTab('tools'). - Onboarding step 3 rewrote to lead with the buttons. Previously only mentioned the n c / n b shortcuts, which are great for power users but confusing for first-time visitors. New copy: "Two buttons on the right of the toolbar above — + New campaign (green) for paid-media, + New brief for standalone. Both can also be opened from the keyboard." Discovery-first, shortcut second.
- Admin nav button always visible. Mobile-responsive ruleset cleaned up: stale
#nav-role-chip-btnselectors removed,#nav-admin-btnkept readable at ≤460px (tightened padding + smaller font, no longer hidden). On desktop the nav now fits without horizontal crowding.
- 🎨 Branded empty states. Replaced plain "No briefs found" / "No creatives found" text with illustrated empty states. Six inline SVG illustrations in the Mind Oasis aesthetic (sage hairlines, geometric shapes, no stock-art): breath-circle for briefs, open-horizon for creatives, empty-page for library, lens for search-results, check-circle for approved, compass for generic. Single helper
emptyState(kind, opts)with title / sub / CTA. Used on Briefs (filter-aware: shows "Clear filters" CTA when filters yield nothing, else "+ New brief"), Designer View (per-designer copy), and ready for adoption elsewhere. - 🖨 Print stylesheet. Cmd+P from any open briefing modal now produces a clean A4 PDF: nav, toolbars, modals overlays, buttons, comments thread, and all chrome hidden. Active page renders full-width with white background regardless of theme (dark-mode auto-overrides to light for print). Cards keep borders, lose shadows. Links append URL inline. Animations / transitions / transforms all suppressed. Hidden via
@media print— zero impact on screen view. New 🖨 Print button in the briefing modal footer. - 💬 Comment @mentions. Type
@in any comment textarea (creative-level or brief-level) → autocomplete dropdown showing known users from Activity Log + Comments + currently-signed-in + Maike/Mendy. Arrow-key nav, Enter/Tab to insert, Esc to dismiss. Posted mentions render as sage pills; mentions of the current user get an accented background. Notification bell now counts @mentions for you regardless of which creative they're on — badge tinges amber when a fresh mention is among the unreads._mentionsForCurrentUser()+_highlightMentions()+_bindMentionAutocomplete()handle the pipeline. - 📊 Designer workload trend chart. New 8-week per-designer bar chart at the bottom of Planning → Project Summary. Each designer gets a row with 8 bars (past 7 weeks + this week); each bar's height = deliverables due that week (creatives' campaign End Dates + standalone briefs' Deliverable Count). Bars color-coded sage / amber / red against
PL_CAP_GREEN/PL_CAP_YELLOWthresholds. Current week subtly highlighted with a sage tint. Legend below explains the thresholds. Far more scannable than the per-row capacity pills on Timeline. - 📱 Mobile-responsive pass. Comprehensive
@media (max-width: 760px)+≤460pxruleset. Nav scrolls horizontally with custom scrollbar styling (no awkward wrap). Briefs table transforms to card-stack: each row becomes a 3-column grid (checkbox, name+type, badge) with metadata stacking below; Actions column hidden, replaced by tap-to-open briefing modal. Modals go full-screen on phones with internal scrolling. Filter toolbar swipes horizontally. Home grid collapses to single column. Bulk-action toolbar repositions to bottom-fixed (keyboard-aware). Planning timeline + heatmap get horizontal scrolling with 720px min-width to keep weeks readable. Toasts slide in from the top to clear the on-screen keyboard. Phone-only (<460px) hides Admin/Role labels keeping only icons. Touch interactions already covered by the luxury layer's(hover:none)guards.
- 🧠 Smart hook suggestions. New
suggestHooks(ctx, n)ranks Hook records from the Library by fit-score: Language match (30pts), State of Mind overlap (25+pts), Funnel match (15pts), Type match (12pts), Status active/approved gate (-40 if inactive), plus a performance bonus from_hookAvgCTR()that pulls in historical CTR per hook from Performance Daily. Renders as scored chips with click-to-copy. The longer the team uses it, the smarter the suggestions get — pure proprietary IP that grows with data. - 🌿 Mind Oasis brand-voice scorer.
scoreBrandVoice(text)returns a 0-100 score plus actionable signals. Encodes the editorial voice as heuristics: rewards calm/sensory/grounded vocabulary (breathe, pause, ritual, gentle…), penalizes urgent/salesy clichés (hurry, exclusive, game-changer, mind-blowing…), penalizes ALL-CAPS and stacked punctuation, prefers short breath-paced sentences (≤18 words), rewards "you/your" warmth. Animated SVG arc renders score live as you type. Forward-compatible: same interface plugs into a future Claude API call when we want richer feedback. Wired into the New Brief modal on the Description field. - 📰 Weekly auto-digest. New panel in Admin → Tools generates a Markdown report of the past 7 days: briefs created, creatives approved, comments posted, weekly spend + CTR + ROAS (when Performance Daily has data), active work by Brief Type, stuck/overdue lists, upcoming next-7 days, designer load with capacity icons. Copy to clipboard or download .md. Markdown renders natively in Slack, Notion, email — drop it in Monday stand-up ready.
- 📜 Status history timeline per brief. New "📜 History" section in every briefing modal. Reads from the Activity Log filtered by Campaign-link, sorts chronologically, renders as a vertical timeline with icons per event type (created 🟢, approved ✓, needs-changes ⚠, review ◎, status ↻, designer ✎, comment 💬, ad linked 🔗, deleted 🗑). Shows last 20 events with "+N earlier" rollup. Refreshes once when the background Activity-Log fetch completes.
- 👥 User roles. Client-side role gating with 4 tiers: Admin / Designer / Reviewer / Viewer. New role chip in the nav (next to dark-mode toggle) opens a picker with role-specific permission summaries. Permissions:
create,edit,delete,approve,configure. Buttons tagged withdata-perm="…"get hidden when the current role lacks the perm — applied byapplyRoleGating()after every render. Defaults to Admin so existing flows aren't restricted. Server-side enforcement still relies on Airtable's own permission model; this layer is for UX clarity. - 📋 Brief templates. New "Apply template…" dropdown on the Briefs toolbar + "📋 Save as template" button in every briefing modal. Templates store: Brief Type, Description, Channel Detail, Deliverable Count, Reference Links, LoB, Designer. Stored client-side in
localStorage.mind_oasis_brief_templates. Applying a template opens the New Brief modal pre-filled and re-fires the brand-voice + capacity hints. Includes 🗑 delete-by-number flow for cleanup. Save-as-template gated on thecreatepermission. - 🧭 Strategic roadmap docs section. New 🧭 Strategic roadmap in the documentation: what makes this tool proprietary today, what scalability risks loom, future IP directions (AI brief-assistant, Calm Calendar, cohort analysis, A/B detector, designer-fit recommender), and architectural decisions to make (backend / build process / Claude vs OpenAI / real-time collab). Plus a concrete next-30-days recommendation.
- Performance data layer. New
loadPerformance()fetches thePerformance Dailytable with a graceful fallback when the table doesn't exist yet (returns[]instead of throwing). Aggregators_perfForCreative(creativeId, days)and_perfForCampaign(campaignId, days)compute Spend / Impressions / Clicks / CTR / CPM / CPC / Conversions / Conversion Value / ROAS over a rolling window (default 14 days). Inline-SVG sparkline generator_perfSparkline(daily, metric, opts)renders trends without any chart library. EUR / percent / ratio / k/M formatters keep everything legible at a glance. Loaded once per session, cached, invalidated when atFetch's cache flushes. - Per-Creative performance in the briefing modal. A new 📈 Performance · last 14 days block sits between the Ad-tracking section and the Comments thread. 5 KPI tiles (Spend / Impressions / CTR / CPM / ROAS) — ROAS color-coded green ≥2× / amber ≥1× / red <1×. Spend-trend sparkline. "Synced X ago" timestamp from
Last Synced At. Empty state until performance rows arrive ("appears here once the Meta/Google sync writes data") — no fake numbers, no misleading zeroes. - Per-Brief performance summary in the campaign briefing. Aggregated across every Creative linked to the brief. Shown as a 5-cell row in the modal, between Copy & hooks and Creative progress for paid campaigns, or after References / before Discussion for standalones. Tells you immediately whether a brief is working without diving into each creative.
- Performance column on the Briefs table. New 📈 Perf column shows aggregate ROAS (color-coded) over 14d Spend per brief. Sortable by clicking the header (default order unchanged). Empty cell when no data yet.
- Admin → Performance sync health. New panel in Admin → Tools. Reads
Performance Daily+Sync Config. Shows per-service status cards (Meta / Google Ads) with last-sync time + row count; 7-day spend + all-time spend cards; unmatched-rows count (perf rows without a Creative link → almost always ads without theMO_recXXXnaming) + reverse-unmatched (Creatives with External Ad IDs but no perf yet). Friendly schema-missing warnings when the tables don't exist yet, so the panel is useful even before Phase 1 lands. - 🎓 Onboarding spotlight tour. 7-step guided introduction that auto-runs once per browser (localStorage
mind_oasis_onboarded). Spotlight is a transparent overlay with a hole punched around the highlighted element via the classic box-shadow trick — sage ring around the target, the rest of the viewport dimmed. Tooltip with step counter + dots progress, title in Cormorant serif, body in DM Sans, prev/next/skip buttons. Steps cover: welcome on the logo, Briefs nav tab, New campaign/brief buttons, Planning capacity, connection-status dot, dark-mode toggle, and a final farewell with the ⌘K and ? shortcuts. Steps that need a different page (Planning, Briefs) auto-navigate via anonShowhook before positioning. Esc skips at any time. Rerun + reset-for-next-visitor buttons live in Admin → Tools → Onboarding. Spotlight repositions on window resize. Respects reduced-motion via the global motion guards.
- Bulk operations on Briefs. Every row gets a checkbox (plus a select-all in the header). Selecting one or more reveals a sticky toolbar below the filter bar with: Change status → Apply, Change type → Apply, 📅 Export .ics, 🗑 Delete. Batch-PATCH and batch-DELETE in groups of 10 (Airtable's max per request). Each bulk action logs one Activity entry instead of one per record. Selection state lives in
window._selectedBriefsso inline-edit re-renders don't lose it. Selected rows get a subtle sage background. - Saved filter views. Briefs toolbar gained a View dropdown + ★ Save view button. Pick a filter combo (brief-type / status / LoB / platform / search), click Save view, name it ("Stores in review", "My PR briefs this week") → it's stored in
localStorage.mind_oasis_briefs_views. Switch between views from the dropdown; one-click resets all 5 filters at once. The dropdown also offers a 🗑 delete-by-number action for cleanup. - ★ Favorites + Recently viewed. A new ☆/★ Favorite button in the briefing modal footer toggles per-brief favorites (stored in localStorage). Every
openCampBriefingautomatically pushes the brief to a Recently-viewed list (max 6, dedupes). The Home page now shows both as side-by-side mini-lists underneath the Standalones-in-motion row: one click jumps straight back to a brief. Empty when nothing's starred yet — no UI noise. - 📅 iCal calendar export. Briefs toolbar gained a 📅 .ics button: downloads a
mind-oasis-briefs-YYYY-MM-DD.icsfile with one VEVENT per visible brief (respects current filters). The bulk-toolbar has the same export but scoped to selection. Subscribe to the file in your calendar (Google / Apple / Outlook) and brief deadlines appear next to your other events. Each event title prefixes the Brief Type icon, description carries type + designer + status + description. - Smart capacity warning. The New Brief modal now shows a soft warning when the chosen designer is already at or over the weekly capacity threshold (
PL_CAP_GREEN= 30 /PL_CAP_YELLOW= 50). Counts both the designer's paid-campaign creatives and standalone briefs' Deliverable Counts in the same week. Updates live as you change the designer or due-date. Informational only — never blocks save.
MO_recXXX, using the Creative's Airtable record-id) on your clipboard so you can paste it into Meta or Google Ads — that's how the future sync engine matches an ad back to a Creative (regex extracts the rec-id from the ad name). For ads that slip through without the naming convention, a + Link ad form lets you attach Meta or Google ad-IDs by hand; persisted to the new External Ad IDs field on Creatives. A dashed-sage placeholder under the form explicitly tells the user that metrics will populate when the sync ships (Phase 1+) — no silent empty state. Activity Log entries on every link/unlink so the team has an audit trail. Schema-drift detector now expects External Ad IDs on Creatives. Helpers shipped: _adNameForCreative, _parseAdName, _parseExternalAdId, _externalAdIds, _copyToClipboard, _addAdId, _removeAdId. Full architecture, schema, naming convention, and 5-phase rollout plan in the new 📊 Performance integration docs section.- 💬 Brief-level comments. Standalone briefs now have their own discussion thread inside the briefing modal — avatar, author, timestamp, message, post-new input. Comments are stored in the Comments table linked via the
Campaignfield (parallel toCreativefor paid campaigns). Forward-compatible: if theCampaignfield doesn't exist yet on the Comments table, the first post triggers a clear instruction toast explaining the one-time setup (Add field → Link to another record → Campaigns). All existing creative-level comments keep working unchanged. Activity log records brief comments via the existingcampaignIdpath; the notification bell will pick them up via the existing comment-counting infrastructure on next refresh. - 🌗 Dark mode. Editorial sage-dark variant — deep espresso (
#1c1917) bg, warm cream text, lifted sage accent for contrast. Toggle button (sun/moon) in the nav next to the connection dot. Persisted tolocalStorage.mind_oasis_theme; first paint honorsprefers-color-schemewhen no saved preference. Applied viahtml[data-theme="dark"]token overrides so every page, modal, and toast picks up the new palette without touching component CSS. Time-of-day Home gradients disabled in dark (they'd clash with the deep bg). Paper-grain overlay switches tomix-blend-mode: screenat 5% opacity for subtle dark-mode texture. - 🛎 Toast notifications. Unified bottom-right queue replaces ad-hoc statusbars where it matters. API:
toast(message, kind, opts)wherekindisok/err/info/load. Auto-dismiss (3.5s ok, 4s info, 7s err, sticky for load). Update-in-place viaopts.idso a loading toast can transition to a success/error of the same id. Toast colors map to status colors with a 3px left-border accent. Existing per-page statusbars keep working — toasts are additive. Wired into PAT save, new-brief creation, and the new inline-edit handlers. - ✏ Inline edit on Briefs table. Click the Status badge → dropdown to change. Click Start Date or Due Date → date picker. Click → optimistic update → Airtable PATCH → local cache refresh → re-render. Errors surface in a toast and the field rolls back. Escape cancels. Saves dozens of clicks per week — most status changes don't need the full briefing modal. Activity log records each inline change via the existing
logActivitypath. (Designer inline-edit deferred untilDesignerfield is confirmed on the Campaigns table — adds tomorrow with the rest of the Brief Types schema.)
- Foundation. New
Brief Typesingle-select field on the Campaigns table with 6 options (Campaign — Paid Media·Social — Stores·Social — Organic·PR·Internal·Other). 4 new optional fields for standalone briefs:Brief Description,Channel Detail,Deliverable Count,Reference Links. PlusDesigneron the Campaign record itself. Records without a Brief Type default to paid-media in code, so the dashboard keeps working before the field is added in Airtable. - Automation script. Add a one-line guard so the Creative generator skips non-paid briefs. Full snippet in the new Brief Types docs section.
- Briefs page (formerly "Campaigns" in the nav). New Brief-type column with colored badge, new Brief-type filter chip, new + New brief button (lightweight modal) next to + New campaign.
- New Brief modal. Compact form: name, type, description, channel, deliverable count, designer, dates, references, LoB. Writes to the Campaigns table but skips all paid-media fields. Field-not-found errors auto-retry without the unknown field, so creation succeeds even if you haven't added all 4 optional fields yet.
- Briefing modal conditional. Paid campaigns get the existing 4-section view (Campaign details / Targeting / Creative strategy / Copy & hooks). Standalone briefs get a focused 3-section view (Brief details / Description / References). Brief Type badge sits next to the title. Regenerate button is hidden for standalones (they don't go through the generator).
- Designer View. A new "Standalone briefs" group appears under the per-campaign creative groups, listing every non-paid brief assigned to the current designer with badge / channel / deliverable count / due date / status.
- Planning Timeline. Standalone briefs render as one-bar-per-brief on the designer's row. Brief icon precedes the name, deliverable count is the bar badge. Color is the brief-type color (status-mix makes no sense for a brief with no creatives). New "Color by → Brief type" option recolors all bars by category. New Brief-type filter dropdown.
- Planning Capacity. Weekly capacity pills count standalone briefs'
Deliverable Countalongside paid campaigns' creative counts. - Home page. New "Standalones in motion" mini-row underneath the mission-control grid, aggregating non-completed standalones by type into clickable chips that jump to the Briefs page pre-filtered.
- Admin → Data quality. All quality checks (platforms / languages / content type / hooks / caption / USP) now skip standalone briefs. They'd never have those fields by design.
- Schema drift detector.
Brief Type+ the 4 brief-only fields +Designeron Campaigns are part ofEXPECTED_SCHEMA, so the drift checker verifies they exist. - Keyboard shortcut. n b opens the New Brief modal (paired with the existing n c for New Campaign). Cheat-sheet updated.
- Command palette. Briefs surface alongside campaigns in ⌘K results, tagged with their Brief Type kind-badge.
- T2 — Connection status indicator. Small dot in the header (between user chip and MOA GPT) with four states: live (sage), degraded (amber), down (red), unknown (grey). Pulses while active. Click → floating panel with success/error counts, last-success time, last-error message, plus Reconnect / Configure token / Clear buttons. Every
atFetchandatPatchnow updates the tracker via_connOk()/_connErr(). - T3 — Real error banners. Every
load*()catch block now callsrenderLoadError(el, err, retryFn)instead of stamping"Error: ..."into the container. The helper detects 401/403/404/429/network errors and shows a human-readable reason, a Retry button bound to the load function, and (for 401/403) a Configure token button that opens the PAT wizard. Collapsible "Technical detail" carries the raw error for debugging. - T4 — PAT onboarding wizard.
TOKENis nowlet, sourced fromlocalStorage.mind_oasis_patwith the bundled token as fallback. Wizard modal (#pat-wizard) prompts for a PAT, hits the Airtable Meta API live to verify it covers the base, then saves to localStorage and reloads. Triggered from the connection panel or any 401/403 error banner. Auto-opens when a 401 surfaces. - T5 — Global search palette (⌘K). Frosted top-of-screen modal indexes everything currently in memory: campaigns, creatives, hooks, captions, USPs, disclaimers, assets, plus all 8 pages as fallback. Live filter, arrow-key nav, Enter to jump. Each result shows a kind-badge (Campaign / Hook / Page) and a meta line. Builds the index lazily on open — zero extra API calls.
- T6 — Keyboard shortcuts. Two-key navigation:
g h/p/c/d/r/l/a/mfor Home / Planning / Campaigns / Designer / Review / Library / Asset Library / Admin (1.2s timeout on thegprefix). Plusn cfor new campaign,⌘K/Ctrl Kfor search,?for the cheat-sheet modal,Esccloses any overlay. Disabled while typing in inputs/textareas/contentEditable. Discoverability: a footer chip on the Home page exposes⌘K searchand? shortcuts. - T10 — Maintainability pass. Top of the main
<script>tag now carries a complete table of contents with section names + their searchable▰▰▰marker. Searching for▰▰▰in the file jumps you between major sections (Airtable connection, conn-state, error helper, PAT wizard, keyboard shortcuts, command palette, schema drift, retention, etc.). Also documents conventions: linked-record bare-string format, lookup-vs-select pitfalls, never-reassignTOKEN. - T13 — Schema drift detector. New section in Admin → Tools. Fetches the live schema from
GET /v0/meta/bases/{base}/tables, compares against anEXPECTED_SCHEMAdict (every field the dashboard reads or writes across the 9 tables), and renders a per-table table with missing/extra fields highlighted. Catches Airtable renames before they break a page. Reuses the error banner on auth failure. - T14 — Activity Log retention. New section in Admin → Tools. Configurable cutoff (default 90 days), scans the full Activity Log via paginated reads, shows total entries / older than cutoff / date range stats, and offers a confirm-then-batch-delete (10 records per request — Airtable's max). Cleanup keeps the table fast indefinitely.
html { scroll-behavior: smooth } for buttery navigation. The page eyebrow is now a pill-shaped microscopic badge — sage-tinted background, sage hairline border, pulsing 5px dot prefix — replacing the old plain uppercase label. The page title scaled up via clamp(28–38px) with tighter tracking. The Home divider became an ornamental sage-gradient hairline with a centered diamond (◆) accent. The Home hero stat is now wrapped in a double-bezel nested architecture — outer sage-tinted shell (-8px inset) with its own border + concentric 30px radius, inner glass plate with linear-gradient highlight + multi-layer inset/ambient shadows + backdrop-blur. The hero greeting flows a vertical gradient from --text to a softer tone via background-clip, and its terminal period gets the sage brand color — a tiny luxury detail. The hero number now uses a vertical sage gradient (--green → --green2) via background-clip and scales up to 144px on wide screens. Home cards rebuilt: 16px concentric radius, layered gradient material, inset highlight, top-edge hairline shimmer (::before), AND a cursor-following sage glow (radial-gradient at --mx/--my, set live by a magnetic-tilt JS that rotates each card ~3° toward the pointer with perspective(800px) — restrained, never gimmicky, GPU-safe via transform/opacity only, skipped on touch + reduced-motion). The home-card-num is now italic serif for editorial mood; CTAs went uppercase tracked. Nav bar became softly frosted (backdrop-blur(14px) saturate(120%) on a 72% cream layer); active tab grew a 3px sage dot accent above its underline. Nav notification / Admin / MOA GPT pills picked up the inset highlight + cream gradient. Quick-action buttons got the same material treatment. Live activity dot grew a soft sage glow halo. Spinner refined (2px stroke, sage track). Everything still honors prefers-reduced-motion: reduce — texture, smooth-scroll, and tilt all disable cleanly.--ease-spring / --ease-entry / --ease-out) and ambient-elevation tokens (--elev-1/2/3) used across every interactive surface. Buttons now press with scale(0.97) haptic feedback and lift on hover with a soft green-tinted shadow. Inputs replaced the bland border-flip focus state with a soft 3px green ring (--ring-green) — tactile + an accessibility win. The nav tabs swapped binary underlines for a centered line that grows from the middle on hover/active with a cubic-bezier spring. Page transitions fade-up softly (360ms) instead of snapping. Dropdowns open with an origin-top fade+scale (220ms spring) instead of appearing instantly. Home cards lift 3px on hover with an ambient green halo + the CTA's chevron-gap grows from 6→10px. Home page hero, mission-control grid, and bottom row now fade-up on entry via an IntersectionObserver-driven [data-reveal] / [data-reveal-children] system (staggered children, 40–380ms cascade) — pure CSS, no scroll listeners, never animates layout properties. The four Home quick-action buttons grew a hover-triggered trailing arrow (pseudo-element, zero markup change). Every effect honors prefers-reduced-motion: reduce — animations and transitions all collapse to instant state changes for users who've opted out. Stays away from heavy luxury treatments that don't suit a data-dense tool — no OLED, no film grain, no slow 700ms+ motion. Restraint is the point.rel="noopener" for safety. Implemented as an <a> element (not an onclick handler) so middle-click + Cmd-click behave naturally.loadHome() (hero stat, trend, sub, focus / bottleneck cards, upcoming list, workload list, activity ticker), loadPlanning() (all three tab panels — Summary tiles & chart & lists, Timeline grid + bars, Capacity heatmap), and loadAdmin() (Health highlights, Stats, Campaign health, Performance, Data quality rows, Alerts, Audit checks, Activity log). Every page now uses the same .skeleton + sk-line / sk-circle / sk-bar shimmer system — refreshing any page visibly reloads instead of looking frozen.#page-builder) was carrying ~170 lines of HTML + ~145 lines of JS that no live workflow used. Deleted: the page HTML block, loadBuilderData() data fetcher + boot call, submitCampaign() / validate() / checkName() handlers, the ms() / linked() / updateLangs() / updateConds() / updatePreview() / showStatus() helpers, the boot-time chip + linked-record setup block, and the LANG_MAP / B / nameTimer globals. The duplicate-campaign name check (the only non-Builder consumer of B.existingNames) was rewritten to read directly from allCampaigns — same behavior, no shared state. Also took out the dead "Recent wins" CSS rules (the card was removed in an earlier sweep, the styles lingered). Net effect: the script is leaner, the HTML has no orphan pages, the documentation no longer carries a "Campaign Builder (hidden)" sidebar entry pretending a feature exists. The MOFO → MOFU typo that lived in the Builder funnel chips is moot now but was fixed before deletion for completeness.Project Summary tab (default): six KPI tiles (Active campaigns, Completed, Blocked = campaigns with "Needs changes", Overdue, Unassigned, My active), a 12-week SVG activity chart (campaigns created vs. campaigns started), a recent-comments activity feed, and two report lists — Designer Tasks Overdue + My Tasks Due This Week. Tiles are clickable and jump to the Timeline tab with the relevant filter pre-applied.
Timeline tab: Gantt-style 6-week rolling view with one bar per (designer × campaign) combo. Bars span from
Campaign starts → Assets due. Each bar shows: campaign name, total creative count, a thin green progress bar (% Approved), open-comment count (💬N), needs-changes count (⚠N), and a red ⚠ flag if the campaign is overdue. Today line is rendered as a vertical red rule. Each designer row has weekly count chips at the top (green ≤ 30, yellow ≤ 50, red > 50). Hover any bar → mini-popover with status mix, days remaining, % approved, and reassignment hint. Click a bar → opens the briefing modal scrolled to comments. Drag-reassign (feature #11): drag a bar from one designer's row to another → confirms then batch-PATCHes the Designer field on every creative in that campaign. Filters at the top: Designer (with "My week" using the signed-in identity), Platform, Status, Color-by (Status / Platform / LoB).Capacity tab: 8-week × designer heatmap with raw creative counts color-binned green/yellow/orange/red against the same 30/50 thresholds. Effort-estimation field was deliberately skipped — raw counts proved more honest than self-estimates per the designer's feedback.
Top summary banner: one-line health check above the tabs — N active creatives in next 30 days · % approved · overdue count · unassigned campaign count. Refreshes on every reload of the planning data.
No Airtable schema changes required — uses existing
Designer, Status, Campaign starts, Assets due fields. New test test_planning_page_wired (suite total 58 → 59). Dashboard page count in docs hero: 6 → 7.Status to Do not use. The new-campaign modal's dropdowns already filter to Active/Approved, so the item disappears from future campaign creation without breaking any existing references. If the status option doesn't exist yet in Airtable, the dashboard surfaces a clear alert with exact steps to add it. (2) ↻ Restore to Active — flips the row back. (3) 🗑 Delete — pre-flights by checking Campaigns for any linked-record references to this row (Hooks, Hooks Variation, Master Caption, USP); if any campaign is using it, the action is blocked with a list of the affected campaigns and a recommendation to use "Set to Do not use" instead. If unused, asks for a normal confirm. Disclaimers can't be auto-checked (the automation script picks them at generation time, not via direct link), so deleting one requires typing DELETE in a second prompt. Library cards now render with diagonal stripes + reduced opacity when their status is Do not use, so the state is visible at a glance. New test test_library_management_functions_exist (suite total 57 → 58). Airtable schema addition required: add a new Do not use option to the Status field on each of these tables — Hooks, Hooks Variations, Captions, USP Library, Disclaimer Library. Archive still works without it but surfaces the alert above until the option exists.Parent field in Airtable and the thread re-renders with the comment now indented under its new parent. Cycle prevention: the comment itself and all its descendants are filtered out of the candidate list, so you can never accidentally make a parent into a child of its own subtree. Useful for cleaning up comments that were posted as top-level by mistake (e.g. before the Parent field was correctly configured). (2) New admin test test_comments_parent_field_linked automatically detects when the Parent field is not configured as Linked record → Comments. Strategy: if any existing comment already has Parent populated → instant PASS (no side effects). Otherwise probe by POSTing a marker comment (Author schema-probe, Message __parent_field_probe__) with Parent set; if Airtable accepts → PASS and DELETE the probe row immediately; if Airtable rejects with a Parent-specific error → FAIL with exact fix instructions. Cleanup runs in a finally block so the probe row is always removed even on error. Suite total 56 → 57.height:30px with consistent padding so they line up cleanly. (2) Real creative labels in notifications — the panel was showing raw recXXX IDs when the user hadn't yet visited Designer or Review (because allCreatives wasn't loaded yet). Added window._creativeLabelCache persisted to localStorage + a batch _fetchMissingCreativeLabels() that uses OR(RECORD_ID()='rec1', RECORD_ID()='rec2', ...) to fetch up to 50 IDs per request. The panel renders recXXX initially then re-renders with real MO-04xxx labels when the fetch returns. (3) Click → jump straight to comments — openCreativeFromNotification now loads the Creatives table if it isn't loaded yet, bypasses the Designer-view filters (so the creative is always findable), opens the briefing modal, then auto-scrolls #cm-thread into view and focuses the comment input. No more scrolling past the technical-spec grid to find the conversation.+N pill if multiple unread on that row. Click a row → opens the briefing modal (which marks the creative as seen). Mark all as seen header button clears the entire panel in one click. Badge is driven from the same per-user localStorage last-seen map as the inline 💬 N pill, and is primed on page load (boot calls refreshAllComments()) so it's accurate immediately — no need to visit Designer/Review first. New helpers: updateNotificationBadge, renderNotificationsPanel, toggleNotifications, openCreativeFromNotification, markAllNotificationsSeen, markCreativeSeenAt, _unreadCommentsByCreative. New test test_notification_bell_present (suite total 55 → 56).Field "Parent" cannot accept the provided value because the Parent field on the Comments table was not created as Linked record → Comments. postNewComment now detects this specific error, auto-retries without Parent so the reply lands as a top-level comment, and surfaces a clear alert telling the user exactly how to fix the field type in Airtable. (2) The emoji reaction picker (+ 😊) opened underneath the briefing modal because the picker had z-index:50 while the modal overlay sits at 2000. Bumped picker to z-index:5000, larger emoji hit-targets (16→18px font, 4→6px padding), and slightly stronger shadow so it reads as floating above the modal.Message and (if present) Edited at, showing a small "edited" tag in the metadata row. (4) Hover-only action row — Resolve/Reopen/Reply/Edit collapse out of sight until the row is hovered or focused, making the default thread state much calmer. (5) ↩ Reply threading — the existing Parent field is now active; replies render indented under their parent with a left rule, recursively. (6) 😊 Emoji reactions — 👍 👀 ✅ ❤️ 🙏 🎉, stored as JSON in a new long-text Reactions field. (7) 📎 Attachments — Attach button, drag-drop onto the textarea, and paste-image (clipboard screenshots) all work; files upload to a new Attachments field on the comment record via the Airtable content endpoint after the row is created. Images render as 120-px thumbnails inline, other files as chip links. New test test_comments_phase3_functions_exist (suite total 54 → 55). Airtable schema additions required: add Attachments (Attachment), Reactions (Long text), and optionally Edited at (Date/time) to the Comments table — posting still works without them but the corresponding feature is a no-op until the field exists.loadCommentsForCreative used filterByFormula with FIND(creativeId, ARRAYJOIN({Creative})), but ARRAYJOIN on a linked-record field returns primary-field labels (e.g. MO-04589), not the underlying recXXX IDs — so the filter never matched. Switched to a cache-based approach: a single fetch of all comments populates window._allComments, and per-creative filtering happens client-side by walking each record's Creative array. refreshAllCommentCounts is now an alias for refreshAllComments so the cache stays in sync. submitNewComment/resolveComment/reopenComment now await refreshAllComments() before re-rendering the thread, so the new comment shows immediately.Comments.Author from Single select to Single line text so any name saves cleanly. Phase 2 features: unread tracking (per-creative last_seen in localStorage; badge turns red with a dot when there are unread comments created after your last view), comment-count 💬 N badge added to Review-page rows, briefing-open marks the creative as seen and triggers a count refresh so other views' badges clear in real time.Comments Airtable table (10th table in the base). Identity selector in the top nav (Sign in as Maike / Mendy / Marketing / Reviewer / Other; persisted to localStorage). Threaded panel on the creative briefing modal with author avatars color-coded by role, relative timestamps ("2h ago"), open/resolved status, "Show resolved" toggle, and reopen action. Designer-view cards now show a yellow 💬 N pill with the open-comment count, refreshed automatically when the view loads or a comment is posted/resolved. Existing Feedback field on Creatives stays as the system-message + structured "Needs changes" workflow — Comments are the human-conversation layer on top.test_type_values_valid updated to expect BRANDED/UGC (was the stale Branded/Promotional/Retargeting). Four new tests: test_edit_campaign_function_exists, test_regenerate_function_exists, test_designer_all_tab_present, test_slide_position_field_present. Counts in admin Tools tab and hero stat updated accordingly (Data integrity 10→11, Workflows 12→15)._setLinkedDropdown handles both bare-string ["recXXX"] and object [{id:"recXXX"}] shapes from Airtable's GET. (2) Existing Example attachment is now displayed as a thumbnail in the drop zone, with text "drop a new file to replace". (3) Close-confirmation popup translated from Dutch to English ("Are you sure?" / "You have unsaved changes…" / "Back" / "Close screen").▦ N/M pill on carousel rows (slide N of M). Briefing modal's spec grid splits the old single "Carousel #" cell into Carousel size and Slide position (e.g. "2 of 3"). Resolves the workflow blocker where multiple rows for the same campaign/platform/language/size were visually indistinguishable.Assets generated/Generation warning, then PATCHes Ready to generate false→true to fire a fresh automation run. Replaces the previously-manual delete + reset + re-tick workflow.Hooks, Hooks Variation, USP, Master Caption) now copied as bare-string ["recXXX"] arrays — moved out of the generic copyFields loop into a dedicated linkedFields normalization step that extracts plain record IDs. Variations override now writes a bare string instead of [{name}] array shape (single-select). Source Variations object form ({id,name,color}) is normalized to a plain string before POST.Slide position (Single select, options 1-5) added in Airtable. Script populates it with the slide's position within the carousel (1, 2, 3, …). The Carousel Asset # field now holds the total carousel size on every slide. Together they distinguish "slide 2 of a 3-slide carousel" cleanly.isSelectField helper. Wraps writes for Distribution Type, Funnel, State of Mind, Hook angle, Soundscape, Carousel Asset #, Lenght, Slide position — script now skips Lookup fields silently instead of throwing. Also: invalid platform × content type combinations are skipped with a warning instead of failing the whole generation. Empty-result guard added._ncEditingId state distinguishes edit from create."United Kingdom" to "UK" (matching the Airtable Countries option). Map now: UK→ENG, Netherlands→NL, Germany/Austria→DE, France→FR, Belgium→BE-FR, plus 16 countries → ENG.6s→06s; Carousel # assets trimmed to 2-5; Type chips fixed to BRANDED/UGC; Bullet next to phone chips fixed to 7-Day FREE TRAIL/START TODAY/€3,99; LoB filter Boutique→Boutiques; Variations writes use bare-string single-select shape; Languages writes filtered through VALID_CAMPAIGN_LANGUAGES whitelist; Assets generated: false sent explicitly on create.["recXXX"], NOT the documented [{id: "recXXX"}] object form (rejected as Value "[object Object]" is not a valid record ID). Dashboard's linkedRecordWrites updated accordingly. Scripting API still accepts both formats.PATCH /v0/{base}/{table}/{rec} with base64 data URL (rejected by Airtable) to the supported POST content.airtable.com/v0/{base}/{rec}/Example/uploadAttachment endpoint. Attachments now land correctly.Ready to generate from false → true via separate PATCH after the initial POST, so the Creative-input automation actually fires (it's an "On update" trigger, not "On create").Creative needs changes: {Creative ID}), Message body (uses {File name} and {Feedback team} tokens), recipient, condition, and run volume (5 / month). Flagged two bugs: (1) automation is named "Notify designer" but emails go to marketing@mindoas.com — the designer is never notified, (2) message body references a Feedback team field that is not in the documented Creatives schema (likely a rename or a missing field). Domain typo (mindoas.com) carries over from Automation A.marketing@mindoas.com, watched field (Status), condition (Status is Ready for review), and run volume (5 / month). Flagged the mindoas.com vs mindoasis.com domain inconsistency as a likely typo to verify.Asset File URL AND Asset File Upload), runs unconditionally, so clearing either field also flips Status to "Ready for review". Added the Asset File Upload attachment field to the Creatives schema. Flagged the unconditional behavior as a likely-unintended side-effect.Countries uses full country names (not codes), Languages options must include NL · DE · FR · BE-FR · UK · ENG.Hook Group, Caption Group, USP Group, Header / Sub header fields to library schemas. Expanded Creatives schema with all script-populated fields (File type, Max file size, Image phone, Soundscape, Hook text, Header, Subheader, USP's, etc.).