Creative Performance OS
Documentation
Complete reference for the Mind Oasis campaign management dashboard — built on Airtable, deployed via GitHub Pages.
7Nav pages
10Airtable tables
129JS functions
54Automated tests
6Auto-fix scripts
5Airtable automations

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.

No backend required. All data is read from and written to Airtable directly. The dashboard uses a smart caching system to minimise API calls.

Architecture

The system consists of three layers that work together:

USERS DASHBOARD · index.html AIRTABLE REST API AIRTABLE DATABASE AUTOMATIONS Campaign Manager Creates · Reviews · Approves Designer Maike · Mendy Designs · Uploads Marketing Team Views · Cherry-picks assets Hosted on GitHub Pages index_Main file.html · docs_Main file.html Dashboard Campaigns Designer View Briefings · Upload Review Approve · Reject Library Hooks · Captions · USPs Asset Library Photos · Videos Admin Health · Tools · Tests WRITE READ Airtable REST API Bearer token · JSON · Smart cache · Fingerprint change detection Campaigns + Creatives Hooks + Variations Captions + USPs + Disclaimers Asset Library + Shoots A + B · Email notifications C · Auto status → ready for review D · Country → Language mapping

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.

🏠
Home Default
Editorial landing page. Time-of-day greeting, hero stat with live trend, smart insight banner, "what's changed" pill, 4 mission-control cards, live activity ticker, quick actions, Moments-at-risk row. See Home section.
📅
Planning & Workload
Project Summary tiles · Timeline (Gantt with drag-reassign + Moments band) · Capacity heatmap · Moments tab. Plus ISO-week column headers, "Jump to next campaign", per-row workload pills. See Planning section.
📋
Campaigns
All work in motion — paid-media campaigns and standalone briefs side by side. Create via + New campaign (full modal, with Auto-UTM checkbox) or + New brief (lightweight). Filter by Brief Type. Bulk toolbar with designer/moment-link actions. See Brief types.
🎨
Designer View
All / Maike / Mendy tabs. Creatives grouped by campaign — each group collapsible (state persisted to localStorage). Inline quick-assign dropdown per row. Bulk-assign buttons per campaign header. Moment pills on campaign groups. UTM panel in briefing modal. Drag & drop URL upload, lightbox preview.
Review
The ONLY place creatives can be approved. Tabs: All · Creatives · Library content · 📅 Date changes. Approval-bottleneck panel surfaces items aged 3+ days in review with per-designer breakdown. Red badge shows pending count.
📚
Library
Hooks, Hook Variations, Captions, USPs, Disclaimers. Global full-text search across all 5 types with highlighted matches. Tab-aware card pills. Per-item Archive / Restore / Delete with usage check. See Library item management.
🖼
Asset Library
Gallery + list view. Filter by type, LoB, status. Click thumbnail → in-app preview lightbox (image or video player) with metadata + download. Upload with shoot linking. Buyout expiry tracking.
🔗
UTMs May 2026
Auto-link tracking URLs to every paid asset. Table view with channel/country/language filters · live URL preview · QR codes · CSV export · cascading dropdown New UTM modal · auto-generation per campaign. See UTM Codes.
📄
Documentation
Links to this docs file. Hosted separately as docs_Main file.html.
🔧
Admin Separate
System health, data quality, alerts, exports, auto-fix, test automation. Visually separated in the top nav (rightmost button, not part of the main tab cluster).

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 localStorage as mind_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 + atPatch call.

🏠 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

  1. "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 localStorage as mind_oasis_home_last_visit.
  2. 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.
  3. 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. 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.
  5. 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-night with 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

Why we don't use story-point/hour estimates. Per the designer's feedback, asset complexity varies too much per row to estimate accurately. Raw creative count proved a more honest signal in practice. If counts ever feel misleading, a coarse 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 barRebalance 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:

FlowWhen
Click on workload-chart barYou see a red week and want to triage multiple briefs at once. Popover lists all, batch-reassign.
Drag a bar on TimelineQuick 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 Date to the proposed values, clears Pending 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

FieldTypeNotes
Pending Date ChangeLong textJSON-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):

Campaign details
Name (duplicate check) · Start/End date · Landing page (App Stores / App PDP / At Home PDP / Experience PDP / TBD) · Call to action (checkbox) · Example asset (drag & drop, uploaded via Airtable's content.airtable.com/uploadAttachment endpoint)
Targeting
LoB · Distribution Type (Paid / Organic / Owned) · Platforms · Funnel (TOFU / MOFU / BOFU) · Countries (full names except UK) → Languages preview computed via COUNTRY_LANG_MAP and written directly on create
Creative strategy
State of Mind · Content type (Still / Video / Carousel) · Type (BRANDED / 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)
Copy & hooks
Master Caption · Hooks · Hooks Variation · USP — searchable dropdowns from Airtable. Filtered to ENG-only options regardless of which countries are selected (see callout below).
Save flow. The dashboard does 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.
Lenght — intentional typo, matches the exact Airtable field name on both Campaigns and Creatives.

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 PATCH instead of POST, using the same retry/option-stripping logic as create.
  • If a new Example file is dropped, it replaces the existing attachment.
  • The internal _ncEditingId state distinguishes edit from create. openNewCampaignModal() resets it back to null when called fresh.
What edits propagate to existing Creatives. Lookup fields on Creatives (Distribution Type, Funnel, State of Mind, Hook angle, Variation, Caption Text) auto-update when their source Campaign field changes. Script-written Creative fields (platforms, Languages, Type, Content type, Size, etc.) stay fixed at their generated values — they only refresh on full regeneration.

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:

  1. Confirms with the user (lists the destructive consequences — feedback, asset URLs and review state on existing creatives are lost).
  2. Fetches every Creative row linked to this campaign via FIND(campId, ARRAYJOIN({campaign name})).
  3. Deletes them in batches of 10 (Airtable's DELETE-many limit).
  4. PATCHes the Campaign with Assets generated = false, Ready to generate = false, and clears Generation warning — clearing the script's pre-flight guards.
  5. Brief 500 ms pause, then a second PATCH with Ready to generate = true — this is the value-change that fires the Creative-input automation.
  6. Cache invalidated, dashboard reloads.
Destructive — designer work is lost. Asset File URLs, Asset File Uploads, Feedback, and Status changes on the existing Creatives are all wiped when those rows are deleted. The fresh creatives the script generates start from a clean slate. Use this when you've meaningfully edited a campaign (added a platform, changed Hooks/USP, etc.) and need the Creatives to reflect the new config — not for cosmetic edits where the Lookup auto-fill is enough.

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

Inline quick-assign per row
Pill-style dropdown directly on each creative row. Click → pick Maike / Mendy / + Assign → instant PATCH + activity-log entry. No briefing-modal round-trip. Width fixed at 128px so all rows align vertically.
Bulk-assign per campaign
Each campaign group header has a 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.
Collapse / expand campaigns
Click any campaign header to toggle. Chevron rotates. Collapsed state persists across reloads via localStorage (mind_oasis_designer_collapsed). Toolbar has Collapse all / Expand all shortcuts.
🔎
Campaign filter dropdown
Top toolbar has a Campaign select listing all unique campaign names for the current designer scope. Works alongside Status, Platform, and the free-text search input.

Original features

📎
Drag & drop URL
Asset file and Work file zones accept pasted URLs or dragged URLs from the browser address bar. Saves to Airtable and auto-sets status to Ready for review.
Copy URL
Each creative card shows a ⎘ URL button when an Asset File URL is set. One click copies to clipboard.
🔍
Lightbox preview
Click the example asset image in the briefing to open a fullscreen lightbox. Press Escape or click × to close. z-index 9000 so it sits above the briefing modal.
Carousel slide indicator
Carousel rows show a green ▦ 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.

Workflow rule: "Approved" is hidden from the Status dropdown. When the briefing is opened from anywhere other than the Review page (Designer view, notification bell, Planning timeline, Home), the Status dropdown shows To do / In progress / Ready for review / Needs changes — but NOT Approved. Approvals must happen from the Review page. The only exception: if a creative is already Approved when you open it, the option stays in the list so the dropdown reflects current state. This enforces a separation of duties: designers ship, reviewers approve.

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

Sign in (top of nav)
Type your name in the user chip in the top nav (Tim, Maike, Mendy, anyone). Persists in localStorage as mind_oasis_user — survives reloads. Each comment is tagged with whoever's name is set when posting.
Post a comment
Open any creative briefing, scroll to the Comments section, type and hit Post. Author + timestamp are auto-set.
Resolve a thread
Click ✓ 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.
Spot pending discussions
Designer view and Review page rows show a 💬 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.

localStorage identity, not auth. This is an internal-team feature for ~5–10 known users. Anyone can type any name. When proper auth (Netlify Identity, Auth0) lands, swap the implementation of getCurrentUser() — the rest of the system doesn't change.

Phase 3 — reply threading, edit, reactions, attachments, keyboard

↩ Reply (threading)
Hover any comment → click ↩ 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 own comments
Hover your own comment → click ✎ 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.
😊 Emoji reactions
Hover a comment → click the dashed + 😊 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.
📎 Attachments
Add files three ways: 📎 Attach button, drag onto the textarea, or paste a screenshot directly (⌘/Ctrl+V). Files stack as chips below the input and upload after the comment record is created via the Airtable content endpoint. Images render as 120-px thumbnails on the rendered comment; other files render as a chip link.
⌘/Ctrl + Enter to post
Both the main composer and any inline reply box support ⌘/Ctrl+Enter. The "Commenting as Name" hint above the textarea shows you who you'll post as before you hit send.
Hover-only action row
Resolve / Reopen / Reply / Edit only appear when the row is hovered or focused. The default state of the thread is calm — no row shouting for attention.

↳ 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:

  1. Fast path — if any existing comment in the cache has Parent populated, that's proof the field works. Instant PASS, no side effects.
  2. 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 a Parent-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.

Schema heads-up — set Author to Single line text. The Comments table's 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

  • HeaderNotifications · 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), +N pill 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

ActionType fieldCreativeCampaign
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 savedaction
Feedback left (preview of text in summary)action
Campaign createdaction
Campaign duplicatedaction
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 Activeaction
Library item permanently deletedaction

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

Airtable Activity Log table — required schema. Must exist as a table named exactly 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 text
  • By — single line text
  • Creative — linked record → Creatives
  • Campaign — linked record → Campaigns
  • Created — created time (auto)
Type options are case-sensitive. The Personal Access Token (PAT) used by the dashboard must have 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 DELETE in 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

Add 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().

Manage Shoots modal
Lists every shoot sorted by date desc. Each row shows shoot name, date, location, photographer, agency. Add new shoots inline.
Quick-create from asset
When uploading an asset, the + 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.
Talent on the asset
Each asset can also store Models (comma-separated names) and Influencer (name / @handle), independent of the shoot.
Buyout rights
Per-asset: 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.
Implementation note. openQuickNewShoot() is currently defined twice in the source (the second definition overrides the first). Worth deduplicating in the next clean-up pass.


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.

Skeleton loader (May 2026). 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

Reports & exports
CSV campaign overview · PDF briefing per campaign · Weekly report PDF (approved this week, open for review, expiring within 14 days)
Auto-fix scripts
Fix missing languages · Clear generation errors · Set missing status · Approve draft library · Recalculate estimated creatives · Reset generated flag

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.

🔌 API & Airtable (12 tests)
Token validity, all 9 tables reachable, full CRUD cycle (write/read/delete test record), field name schema validation (now incl. UTM Codes + Market Moments)
🖥 UI & Modals (16 tests)
All pages in DOM, nav tabs clickable, new campaign modal opens and has all fields, language filter, linked record dropdowns, lightbox, copy URL
🗃 Data integrity (11 tests)
Required fields on all campaigns, creatives linked, no orphans, no duplicate names, COUNTRY_LANG_MAP complete, valid content/type/distribution values, Slide position present on carousel creatives
⚡ Workflows (20 tests)
Dashboard loads, designer filter + All tab present, review badges, all 6 auto-fix functions, atFetch live call, all 3 export functions defined, Edit campaign + Regenerate creatives features wired, comments thread, notification bell, library management
✨ Modern features (81 tests) May 2026
Core dashboard chrome: Brief Types · Performance integration loaders · Smart hooks + brand-voice scorer · Connection state · PAT wizard · Error banner · Toast system · Theme toggle · Empty states · Command palette · Cheat-sheet · Onboarding 8-step · Role switcher · @-mention · Bulk operations · Saved views · Templates · Favorites + Recent · iCal · Inline edits · Capacity warning · Rebalance popover · Designer workload chart · Reschedule modal + approval · Pending-dates parser · Review dates tab · Weekly digest · Schema drift · Activity retention · Brief-level comments

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.

#FieldTypeOptions / Notes
1campaign nameTextPrimary field. Duplicate check on creation.
2campaign name officalFormula⚠ Typo in field name (offical). Slug = {LoB}_{platforms}_{State of Mind}_{Content type}_{Distribution Type}, optionally suffixed with _{Variations}. Excludes language/country.
3ExampleAttachmentSingle attachment, uploaded via drag & drop in the new-campaign modal.
4platformsMultiple selectLowercase. Auto-generated: Meta · Google App · Google Display · Youtube · DV360 Display · DV360 Video · Apple Search Ads. Manual-only (script skips): PR · Stores · Google Search.
5LoBMultiple selectBrand · At Home · App · Boutiques
6Distribution TypeSingle selectPaid · Organic · Owned
7State of MindMultiple selectGeneric · Focus · Recharge · Relax · Sleep
8Content typeMultiple selectStill · Video · Carousel
9VariationsSingle selectOptions: 110. Used by campaign name offical formula.
10LenghtSingle select⚠ Intentional typo in field name. Options: 06s, 15s, 20s, 30s, 60s. Same options on Creatives. Only used when Content type includes Video.
11Carousel # assetsSingle select25. Matches the Carousel Asset # options on Creatives. Script does Number(name) to convert.
12CountriesMultiple selectVerified 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.
13LanguagesMultiple selectAuto-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.
14TypeMultiple selectOptions: BRANDED (uppercase) · UGC. Case-sensitive.
15FunnelMultiple selectTOFU · MOFU · BOFU
16HooksLinked → HooksMulti. Script iterates.
17Hook angle hiddenLookupPulls Hook angle from linked Hooks.
18Hooks VariationLinked → Hooks VariationsSingular field name (not "Variations"). Script tries variations before base hooks.
19USPLinked → USP LibraryMulti. Script iterates.
20USP Text hiddenLookupPulls USP Text from linked USP.
21Call to actionCheckboxWhen checked, the script populates Creatives' Call to Action URL.
22Landing pageSingle selectApp Stores · App PDP · At Home PDP · Experience PDP · TBD
23CTA URL hiddenFormulaLikely computes the URL from Landing page + Call to action. ⚠ Generation script also has its own URL_MAP for this — two sources of truth. Reconcile.
24App Store Logo's hiddenFormula⚠ 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.
25Bullet next to phoneSingle selectOptions: 7-Day FREE TRAIL, START TODAY, €3,99. ⚠ "TRAIL" → should be "TRIAL". European decimal.
26Master CaptionLinked → CaptionsSingle record. Script reads [0].
27Caption Label hiddenLookupPulls from linked Master Caption. Not currently read by the dashboard.
28Caption ID hiddenLookupPulls from linked Master Caption.
29Soundscape Index hiddenNumberManaged by generation script. Cycles 1 → 2 → 3 → 1. Reset to 1 at start of generation.
30Start DateDate"Assets due" hint in the new-campaign modal.
31End DateDateDrives the "Campaigns expiring soon" health card (≤ 7 days out).
32StatusSingle selectPlanning · Live · Paused · Completed. Distinct from the Creatives Status (review state).
33Ready to generateCheckboxTriggers Automation 1 (Creative input).
34Assets generatedCheckboxSet to true on success. Script refuses to regenerate when true.
35Creatives numbersLinked → CreativesReverse link to all generated Creatives for this Campaign.
36Estimated creativesNumberCalculated and written by the script.
37Generation warningLong textErrors and success messages from the script (Dutch + ✅/⚠️/❌ prefix).
38Hooks copy hiddenLinked recordLikely an archival snapshot of the Hooks selection. Not read by the dashboard.
39USP Library copy hidden duplicateLinked record⚠ Two fields with this same name exist on the table. Code addressing by name resolves to one unpredictably. Delete one.
40USP Library copy hidden duplicateLinked record⚠ Duplicate of #39 — same name. Airtable distinguishes them by internal ID only.
Schema clean-up to consider.
  • Duplicate USP Library copy field. 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 to App Store Logos if you ever do a sweep.
  • Two CTA-URL sources. Airtable has a CTA URL formula on Campaigns; the generation script computes its own via URL_MAP and writes Call to Action URL on 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.
Dashboard ↔ schema alignment fixed (May 2026). Several previously-flagged dashboard bugs were resolved during the May 2026 verification session:
  • Variations writes now use bare-string single-select shape (was array [{name}]).
  • Type chips now BRANDED / UGC (was the wrong Branded / Promotional / Retargeting set).
  • Bullet next to phone chips on the new-campaign modal now 7-Day FREE TRAIL / START TODAY / €3,99 (was Yes / No).
  • Carousel # assets trimmed to 25 (was 210) to match Airtable.
  • Lenght dropdown uses 06s (was 6s) to match Airtable.
  • LoB filter uses Boutiques (was Boutique).
  • Linked records to Campaigns sent as bare-string arrays — see "REST API quirks" below.
  • Disclaimer Library writes for Bullet next to phone kept as 3,99 (no €) — that table has its own option set, distinct from Campaigns.
REST API quirk on this base — linked-record writes. When the dashboard writes a linked-record field to Campaigns via Airtable's REST API, this base only accepts the bare-string format: "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.

FieldTypeNotes
Creative IDFormula"MO-" + zero-padded {ID} → e.g. MO-04589. Auto-computed.
IDAutonumberDrives the Creative ID formula. Auto-incremented per row.
campaign nameLinked → CampaignsParent campaign. Script writes [{id: recordId}] via Scripting API.
File nameFormulaSlug = {Creative ID}_{campaign name}_{platforms}_{Languages}_{Size}_{Distribution Type}[_{Variation}]. Auto-computed.
Work File nameFormulaSlug = WF-{campaign name}_{platforms}_{Content type}_{Size}_{Languages|ALLLANG}. Auto-computed.
StatusSingle selectTo do · In progress · Ready for review · Needs changes · Approved. Watched by Automation A (Notify campaign manager) and B (Notify designer).
DesignerSingle/Multi selectMaike · Mendy. Used by Designer view filter.
Platform & format — set by generation script (Single Select unless noted)
platformsSingle selectMeta, 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.)
LanguagesSingle selectENG, NL, BE-FR, DE, FR. Same options as the Campaigns Languages field.
TypeSingle selectBRANDED, UGC.
Content typeSingle selectStill, Video, Carousel.
SizeSingle select9: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 typeSingle selectPNG, JPG, MP4, MP4 - max 10-20 MB, HTML. Script writes JPG/MP4/HTML/PNG.
Max file sizeSingle line textPlatform-specific cap (e.g. "30 MB", "150 KB"). Plain string from getMaxFileSize.
LenghtSingle select⚠ Typo. Options: 06s, 10s, 15s, 20s, 30s, 60s. Only written when Content type includes Video.
Carousel Asset #Single selectOptions: 2, 3, 4, 5 (matches Campaigns' Carousel # assets). Script writes the total carousel size on every slide of a carousel.
Slide positionSingle selectOptions: 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 phoneSingle selectOptions: 4 mental states - English, 4 mental states - Dutch, 4 mental states - German, 4 mental states - French. Mapped per language.
SoundscapeSingle select12 options: Focus 1–3, Recharge 1–3, Relax 1–3, Sleep 1–3. Cycles via Campaign's Soundscape Index.
Call to Action URLSingle line textFrom Landing page → URL_MAP, only when Call to action is checked.
Lookups — auto-fill from links, script does NOT write
Distribution Type lookupLookup → CampaignsAuto-fills from the linked Campaign's Distribution Type. Script's isSelectField guard skips writing.
Funnel lookupLookup → CampaignsAuto-fills from linked Campaign.
State of Mind lookupLookup → CampaignsAuto-fills from linked Campaign.
Variation lookupLookup → CampaignsAuto-fills from linked Campaign's Variations field. Used in the File name formula.
Hook angle lookupLookup → linked HookAuto-fills from the Hook record this Creative is linked to.
Caption Text lookupLookup → linked CaptionAuto-fills from the Caption record this Creative is linked to.
Copy resolved from libraries — set by script
CaptionLinked → CaptionsResolved by Caption Group + Languages + Approved (ENG fallback). Script writes via Scripting API [{id: ...}].
USPLinked → USP LibraryResolved by USP Group + Language + Approved (ENG fallback).
USP'sLong textPlain-text copy of the resolved USP's text, written alongside the link.
HookLinked → HooksSet when no variation was found — uses Hook Group + Language + Approved.
Hook VariationsLinked → Hooks VariationsPreferred over Hook when an Approved variation is linked on the Campaign.
Hook textLong textPlain-text body of the resolved hook.
HeaderLong textFrom the resolved hook.
SubheaderLong textFrom the resolved hook.
Designer / reviewer fields
Asset File URLURLSet by designer. Watched by the Auto-update Creative Status automation → flips Status to "Ready for review" on any change (including clearing).
Asset File UploadAttachmentAlternative to Asset File URL — direct file upload. Also watched by the Auto-update Creative Status automation.
Asset Work File URLURLSource file (Figma, AE, etc.).
FeedbackLong textReviewer 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.

FieldTypeNotes
HookTextPrimary field — the actual hook copy
Hook ConceptTextShort label / theme — used as the search key in dashboard
Hook GroupTextRequired by generation script. Joins Hooks across languages — script matches by Group + Language + Status="Approved"
LanguageSingle selectUsed for language-fallback resolution. ENG is the universal fallback.
HeaderTextCopied verbatim onto generated Creatives
Sub headerTextCopied verbatim onto generated Creatives
Hook angleSingle selectEditorial angle classification — copied to Creatives
Hook typeSingle selecte.g. Question, Statement, Stat
FunnelMultiple selectTOFU · MOFU · BOFU
StatusSingle selectDraft · 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.

FieldTypeNotes
HookTextPrimary field — variation copy
Hook ConceptTextInherited / matched to parent Hook
VariationTextVariation label
HeaderTextCopied to generated Creatives
Sub headerTextCopied to generated Creatives
Hook angleSingle selectSame vocabulary as Hooks
StatusSingle selectDraft · 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.

FieldTypeNotes
CaptionLong textPrimary field — the caption body
Caption ConceptTextShort label — used as search key in dashboard
Caption IDFormula / TextStable identifier
Caption GroupTextRequired by generation script. Joins Captions across languages — script matches by Group + Languages + Status="Approved", with ENG fallback
LanguagesMultiple selectDrives language filter on dashboard dropdowns AND drives script matching
State of MindMultiple selectGeneric · Focus · Recharge · Relax · Sleep
StatusSingle selectDraft · 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.

FieldTypeNotes
USP TextLong textPrimary field — the USP copy
USP ConceptTextShort label — search key in dashboard
USP GroupTextRequired by generation script. Joins USPs across languages — script matches by Group + Language + Status="Approved", with ENG fallback
LanguageSingle selectUsed by both dashboard dropdown filter and script matching
LoBSingle/Multi selectBrand · At Home · App · Boutiques. Drives LoB filter.
StatusSingle selectDraft · Approved. Only Approved USPs match in generation.

Disclaimer Library table

Legal/compliance disclaimers. Driven by LIB_TABLES.disclaimers.

FieldTypeNotes
Disclaimer TextLong textPrimary field — the disclaimer body
Disclaimer ConceptTextShort label — search key
Disclaimer labelTextDisplay label rendered on the creative
LanguageSingle selectPer-language disclaimer text
CurrencySingle selectPrice disclaimers use this for formatting
StatusSingle selectDraft · Active

Asset Library table

Stock and shoot assets reusable across campaigns. Linked to Shoots.

FieldTypeNotes
Asset nameTextPrimary field
Asset typeSingle selectPhoto · Video · Raw · Other
Asset URLURLExternal link (Drive / Dropbox)
Thumbnail previewAttachmentJPG/PNG, max 5MB. Drag & drop upload.
ShootLinked → ShootsParent shoot record (optional)
ModelsTextComma-separated names
InfluencerTextName or @handle
LoBMultiple selectBrand · At Home · App · Boutiques
State of MindMultiple selectGeneric · Focus · Recharge · Relax · Sleep
TagsTextComma-separated keywords for search
Buyout expiryDateSurfaced in admin alerts ≤ 30 days out
Buyout channelMultiple selectSocial · Web · Stores
Buyout countriesMultiple selectGlobal, Europe, NL, BE, DE, FR, UK, ES
NotesLong textFree-form notes
StatusSingle selectDraft · Approved · etc.

Shoots table

Photo/video sessions that group Asset Library records.

FieldTypeNotes
Shoot nameTextPrimary field. Required when creating from the asset modal.
Shoot dateDateUsed for sort-desc in the shoots list
LocationTexte.g. Amsterdam
PhotographerTextName
Model agencyTextAgency name
NotesLong textFree-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.

FieldTypeNotes
Comment IDAutonumberPrimary field. Stable identifier (display as CM-00042 if you add a formula).
CreativeLinked → CreativesThe creative this comment belongs to. Dashboard always writes a single-element bare-string array.
AuthorSingle 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.
MessageLong textThe comment body. Plain text in v1; rich text later.
CreatedCreated timeAuto-set by Airtable.
StatusSingle selectOpen (default) · Resolved. Resolved threads collapse by default.
Resolved bySingle selectSame options as Author. Set when a user clicks ✓ Resolve.
Resolved atDate/timeSet when Status flips to Resolved.
ParentLinked → CommentsThe comment this one is a reply to. When set, the dashboard renders the comment indented under its parent, building a tree.
AttachmentsAttachmentFiles 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.
ReactionsLong textStores 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 atDate/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).
Schema additions for Phase 3 (May 2026). Add three fields to the Comments table to enable the full feature set: 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.

FieldTypeNotes
Action IDAutonumberPrimary field.
TypeSingle selectOptions (case-sensitive): designer · status · regen · comment · action · etc.
SummaryLong textHuman-readable phrase, e.g. "assigned MO-04711 to Maike".
BySingle line textSigned-in user's name from the top-nav identity chip.
CreativeLinked → CreativesSet when the action concerns a specific creative. Bare-string array format.
CampaignLinked → CampaignsSet when the action concerns a campaign (or a creative whose campaign can be resolved). Bare-string array format.
CreatedCreated timeAuto-set by Airtable.
PAT scope. The dashboard's Personal Access Token must have access to this table. PATs created before the Activity Log table existed need to be reconfigured — set their base scope to "All current and future tables in this base" or explicitly add Activity Log to the allowed table list. Without this, writes silently fail (logged to browser console as [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

NameTriggerActionWatches
Automation Campaign → Creative inputRecord updated on CampaignsRun script (with If Ready to generate is ✓ condition)Field: Ready to generate
Automation Campaign → Country to LanguagesRecord updated on CampaignsRun scriptField: Countries
Auto-update Creative Status to "Ready for review"Record updated on CreativesUpdate 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 CreativesSend an email → marketing@mindoas.comField: Status. Condition: If Status is Ready for review
Automation B: "Notify designer — feedback ontvangen"Record updated on CreativesSend an email → marketing@mindoas.com ⚠️ not the designerField: Status. Condition: If Status is Needs changes
Naming. The system is partially Dutch-localized — automation B uses "ontvangen" (received), and the generation script's error messages and timestamps are also Dutch (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 generated is already true on this campaign
  • Any of platforms, Languages, Type, Content type, State of Mind are empty
  • Creatives already exist linked to this campaign (must be deleted first)
  • Campaign uses an invalid platform × content-type combination (see matrix below)
  • Lenght is set without selecting Video as content type, or vice-versa
  • Carousel # assets set 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

PlatformForbidden content types
DV360 DisplayCarousel, Video
DV360 VideoStill, Carousel
Google DisplayVideo, Carousel
Google SearchAll — no creatives generated
YoutubeStill, Carousel
Apple Search AdsCarousel

Size matrix (per platform × content type)

PlatformVideoStillCarousel
Meta9:16, 4:5, 1:14:5, 1:14:5, 1:1
Google App9:16, 16:9, 1:11:1, 4:5, 16:9
Google Display300×250, 728×90, 160×600, 300×600, 320×50, 970×250
Youtube16:9, 9:16
DV360 Display300×250, 728×90, 160×600, 300×600, 320×50, 970×250
DV360 Video16:9
Apple Search AdsApp preview video 1920×180pxiPhone screenshot (5x)

File type & max-size mapping

Platform / contextFile typeMax file size
DV360 Display · Google DisplayHTML150 KB
Any platform · VideoMP4see platform
Any platform · Still / CarouselJPGsee platform
Meta4 GB (MP4) · 30 MB (image)
Google App1 GB (MP4) · 5 MB (image)
Youtube · DV360 Video1 GB
Apple Search Ads500 MB (MP4) · 1 MB (PNG)
Default fallback4 MB

Landing page → CTA URL map

Landing pageCTA URL
App Storeshttps://mindoasis.com/downloads
App PDPhttps://mindoasis.com/app
At Home PDPhttps://mindoasis.com/athome
Experience PDPhttps://mindoasis.com/experiences
TBD(empty)

Only populated when both Landing page is set and Call to action is checked.

Image phone (per language)

LanguageImage phone option
NL4 mental states - Dutch
DE4 mental states - German
FR · BE-FR4 mental states - French
Anything else4 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 / 3
  • Recharge → Recharge - 1 / 2 / 3
  • Relax → Relax - 1 / 2 / 3
  • Sleep → Sleep - 1 / 2 / 3
  • Generic → 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
Take master Caption's 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
Same logic with USP Group, matched by Language + Status="Approved". ENG fallback. The USP text is also copied into the Creative's USP's text field.
Hook
Tries a linked Hooks Variation first (must be Approved). Otherwise resolves by Hook Group + Language + Approved, then ENG fallback. Always populates Hook text, Header, Subheader, Hook angle.
No hook linked
Generation still runs, but Hook text / Header / Subheader stay empty and a warning is appended to Generation warning.
Hidden schema requirement. The fallback logic depends on 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
NetherlandsNL
GermanyDE
AustriaDE
FranceFR
BelgiumBE-FR
UKENG
Ireland · Sweden · Denmark · FinlandENG
Poland · Romania · Hungary · BulgariaENG
Croatia · Estonia · Latvia · LithuaniaENG
Luxembourg · Portugal · Italy · SloveniaENG
Anything elseENG (with run-log warning)

Resulting Languages set is deduplicated. Picking Netherlands + Belgium + Germany produces {NL, BE-FR, DE}. Picking UK + Ireland + Italy produces {ENG}.

Note about Languages field options. The Airtable 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 StatusReady for review. The action runs Always — there is no condition checking whether the URL/upload is non-empty.

Side-effect to know about. Because the action runs unconditionally on any modification of either watched field, clearing the Asset File URL or removing the Asset File Upload will also flip the Creative's Status to "Ready for review". If a designer accidentally empties one of these fields, the creative re-enters review with no asset attached. Add an 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.

Recipient
marketing@mindoas.com
Run volume
5 runs this month · last test 3 days ago, successful
Email-domain typo to verify. The recipient address is marketing@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.

Recipient
marketing@mindoas.com
Subject
Creative needs changes: {Creative ID}
Message body
{File name} Needs changes. Open the dashboard to review the feedback. {Feedback team}
Run volume
5 runs this month · last test 3 days ago, successful
Bug — automation name vs. recipient mismatch. The automation is called "Notify designer — feedback ontvangen" but the recipient is 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.
Schema reference to verify. The message body references a 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.
Domain typo carries over. Same mindoas.com vs. mindoasis.com issue as Automation A — see the warn-box on Automation A.

Operational notes from the Airtable Automations panel

Run volume
Generation script: 451 runs / month (current month, per the Airtable panel)
Last test status
⚠️ The most recent test run of the generation script (3 days ago) errored — worth investigating before the next live run.
Before running generation: set platforms, languages, content type, type, and state of mind on the campaign. Use the Data quality tab to check, and Auto-fix to correct missing languages. Remember: the script will refuse if creatives already exist for the campaign or if the estimate exceeds 300.

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
Data is returned instantly from memory. Zero API calls. Shown in console as [cache] HIT
Cache miss
Fingerprint changed → full fetch. Cache is updated. Shown as [cache] STALE or [cache] FETCH
Auto-invalidation
Every atPatch write automatically calls invalidateCache(tbl) for that table, forcing a fresh fetch next time
Max age
Cache expires after 30 seconds regardless. "↺ Refresh" on Admin clears all caches immediately via invalidateAllCache()

Performance & limits

Airtable rate limit
5 requests/second per base. The smart cache prevents hitting this in normal use. Admin page loads 8 tables simultaneously — monitor for 429 errors at scale.
Record limits
Plus plan: 50,000 records per base. At 20 campaigns with ~90 creatives each = 1,800 records. Well within limit for the foreseeable future.
File size
index.html is ~436KB. Browsers load this in <1s on modern connections. No external dependencies except Google Fonts and jsPDF (loaded on demand).
Scaling concern
At 5,000+ creatives, Designer View and Review will slow down (loads all records). Pagination should be added at that point.

📐 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

TypeWhat it's forHooks / USP / captions?Automation triggers?
Campaign — Paid MediaMeta / Google / DV360 paid-media campaigns. The current pre-existing flow.✓ required✓ yes
Social — StoresSocial posts tied to a physical store. e.g. Instagram for Amsterdam Zuid.✗ not used✗ skipped
Social — OrganicOwned-channel content: organic IG/TikTok/LinkedIn from the master account.✗ not used✗ skipped
PRPress releases, embargoed press assets, media kits.✗ not used✗ skipped
InternalInternal communications, decks, slack-banners.✗ not used✗ skipped
OtherCatch-all for one-offs that don't fit elsewhere.✗ not used✗ skipped

How to create each type

+ New campaign
Full modal — opens via n c or the green button on the Briefs page. Includes platforms, languages, content type, state of mind, hooks, USPs, captions, automation trigger.
+ New brief
Lightweight modal — opens via n b or the secondary button. Just name, type, description, channel/location, deliverable count, designer, dates, references, LoB. No hooks, no USPs, no automation.

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 Count alongside 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 of EXPECTED_SCHEMA so the detector verifies they exist.

Required Airtable schema changes

To enable brief types, add these fields to the Campaigns table in Airtable:

FieldTypeOptions / Notes
Brief TypeSingle selectOptions (exactly as written, including em-dash):
Campaign — Paid Media · Social — Stores · Social — Organic · PR · Internal · Other
Default existing records to Campaign — Paid Media so the automation keeps running for them.
Brief DescriptionLong textFree-form description for standalone briefs. Optional.
Channel DetailSingle line texte.g. "Filiaal Amsterdam Zuid", "@mindoasis Instagram". Optional.
Deliverable CountNumber (integer)How many assets the brief expects. Used by Planning's capacity calc when no Creatives are linked. Optional, default 1.
Reference LinksLong textOne URL per line. Renders as clickable links in the briefing modal. Optional.
DesignerSingle selectIf not already present on Campaigns, add it. Options should match the Creatives.Designer field exactly (currently Maike, Mendy).
Backwards-compat. Records without a 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:

FieldTypeNotes
CampaignLink to another record → CampaignsParallel 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)

TypeIconColor
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_recXXX naming convention is dead-simple but bulletproof. No fragile webhooks, no broken mappings.

Scalability — what breaks as the team grows

RiskWhenMitigation
atFetch(table, '', []) loads every record~5k records per tableAdd server-side filterByFormula + pagination on Briefs and Designer-View
16k-line single HTML file2+ devs in parallelBuild process (Vite/esbuild) + split into ES modules. ~1d setup, big maintenance dividend.
No real auth — token in localStorage10+ team membersCloudflare Worker proxy: keeps token server-side, uses Worker JWT for per-user auth
Performance Daily row count1M+ rowsRetention pattern already in place — extend to Performance with 90d hot / aggregate-only after that
Mobile responsivenessDesigners on the goAudit + breakpoint pass (1d). The luxury layer already disables tilt on touch devices.
Single-platform sync (Meta only initially)Mind Oasis expands to TikTok / LinkedIn / PinterestSync 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 layerWhat it would beInvestment
AI brief-assistantClaude 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 CalendarEditorial 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 analysisSlice 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 detectorAuto-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 extensionToday: 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

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. Week 1: Add the Airtable schema (Brief Type, Performance Daily, Sync Config, Comments.Campaign). Pilot the MO_recXXX naming convention on 5 active Meta ads.
  2. 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.
  3. Week 3: Submit Google Ads API access request (3-7d wait). Mobile-responsive audit. Decide on backend (Cloudflare Worker pilot for the AI assistant).
  4. Week 4: If Google approved → Phase 3 sync. Otherwise: build the AI brief-assistant on top of Airtable Automation + Anthropic API.
Status (21 May 2026): the section above is the historical strategic outlook. The Pre-Launch Performance-laag below supersedes the "Future IP directions" and "Concrete next 30 days" lists as the definitive Q3-Q4 2026 roadmap.

✦ 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.

The honesty rule (non-negotiable). The tool never shows a bare "87/100 — this wins". Every feedback point is (a) actionable, (b) sourced — own data vs. best practice, (c) honest about uncertainty where data is missing. Sample size 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:

  1. Designer creates a creative and uploads it in Designer View (exists)
  2. Coach analyses the asset → channel-aware scorecard + readiness summary (new, Fase 1)
  3. Review approves / rejects (exists) — Coach score travels along as context
  4. UTM links the launched asset to its performance data (exists)
  5. Results (CPA/ROAS per channel) flow back into Performance_Results (new, Fase 2)
  6. 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

ModuleWhere in the OSWhy therePhase
CoachDesigner View + Review (no new menu item)That's where the designer already is when needing feedbackFase 1
Score / readinessFills existing "No score" (Library) + "PERF" column (Campaigns)Hooks already exist in the UI — no new designFase 1-2
Lens (patterns)New tab under existing section, or Home widgetAnalysis tool — doesn't need to be prominent in daily workflowFase 2
Spyder (competitors)More → new "Competitors" itemResearch, separate from own workflowFase 3
DiscoveryWithin Competitors or as Library filterExtension of existing LibraryFase 3
Swipe FilesMore → "Swipe" or inside CompetitorsInspiration archive, low daily frequencyFase 3
Fatigue alertsHome (bottleneck-style) + PlanningWhere the team already looks at running workFase 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

🚫 Do NOT touch without explicit ask
  • 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
✓ Always do
  • Additive: new code in new modules/files, plugged into existing extension points
  • New Airtable fields get a perf_ or coach_ 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
⚖️ Privacy & legal (hard constraints)
  • 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)

TableCore fieldsFeeds
Creative_Featureslink→asset · channel · hook_type · format · talent_type · ugc_vs_studio · cta_type · text_density · contrast · hook_start_sec · captionedCoach + Lens
Coach_Reviewslink→asset · readiness · per-element status+action · source (own/best) · confident (bool) · overrule_reason · versionLoop + Lens
Performance_Resultslink→asset · channel · date · impressions · CTR · CPA · ROAS · hold_rate · fatigue_flagLens + PERF column
Competitor_Adsbrand · channel · creative_url · format · first_seen · days_running · angle_tag (no PII)Spyder + Coach context
Winning_Patternschannel · attribute · avg CPA/ROAS · n (sample size) · confidenceCoach 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

PhaseWhatOutputHonest 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 LibraryDesigners 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 detectionCoach 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

ComponentStatusWhy
Coach UI + scorecardNowInterface + heuristics, well-scoped
Specs / ratio / text densityNowOCR + file checks work for ~70-80% of cases; expect 20% needing manual re-check
Swipe FilesNowVisual layer on Airtable
Spyder (own selection)Fase 3Official API, rate limits, ongoing maintenance
Lens / patternsAfter dataNeeds filled Performance_Results. Realistic: 6-12 months of data accumulation with 3 active campaigns before statistical signal emerges.
CPA/ROAS predictionAfter dataOnly honest with enough own history
Scene-level video analysisLaterComputer vision — separate sub-project
Competitor conversion predictionNeverData isn't public. Would be pseudo-confidence.

Sharpening — points I'd push back on in the plan

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

Don't start Fase 1 until these three are done:
  1. Deployment live (Netlify or internal host) so the team can access the OS without running a local server.
  2. 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.
  3. 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 creativeFase 2 — Performance_Results + Lens (scoped to start with Meta CSV)
Q3 — Hook performance scoringFase 2 — Lens patterns (broader: every creative attribute, not just hooks)
Q4 — Winning-hook recommendations, A/B variant suggestionsFase 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

Brief Type
HOW we make it — paid campaign, social stores, organic, PR, internal, other. Determines whether automation runs.
Market Moment
WHY we make it — Moederdag, Black Friday, product launch, etc. Connects related briefs across channels.

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

FieldTypeNotes
Moment namePrimary (text)"Moederdag 2026", "Black Friday 2026"
Moment TypeSingle selectHoliday · Seasonal · Cultural · Product Launch · Brand · Trend · Always-on
Anchor DateDateThe day the moment lands (e.g. 11 May 2026 for Moederdag)
Lead Time StartDateWhen briefs should start being created — for planning ahead
DescriptionLong textTone-of-voice notes, context, what makes this moment important
Reference LinksLong textInspiration URLs, previous editions, mood-boards. One URL per line.
StatusSingle selectPlanned · In production · Live · Completed
BriefsLink → 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)

TypeIconColor
Holiday🎉 Cranberry
Seasonal🍂 Warm ochre
Cultural🏳️‍🌈 Plum
Product Launch🚀 Sage
Brand Steel blue
Trend Amber
Always-on Warm stone
Forward-compatible. The dashboard works fine before the table exists — Moments-related controls render empty placeholders. First time you submit a brief with a Moment selected, if the linked field on Campaigns isn't there yet, Airtable surfaces a clear "Unknown field name" toast.

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.

What changed in v3 (May 2026 correction sheet):
  • New AppActivation campaign 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_campaign URL parameter default = App_July_2026 for all paid-app channels (was mindoasis_app_intro). Matches the value Ramona uses in the "UTM -setup" sheet column H. Owned-media defaults unchanged (mindoasis_app_launch for Email/PR, mindoasis_myrituals_25pct for 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 like UGC_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 / AlwaysOnUGC_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 familyPatternExample
Meta TOFUMO_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 GenericMO_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 AppMO_Google_App_[iOS|Android]_AppActivation_[Prospecting|Retargeting]_[Country]_[Language]MO_Google_App_iOS_AppActivation_Prospecting_NL_ENG
Google SearchMO_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 segmentMO_Meta_iOS_TOFU_UGC_Relax_Parent_AlwaysOn_Video_1_NL_ENG

Theme vocabulary (v3)

ThemeWhen to useSub-segments
ComingSoonPre-launch teasers (Meta TOFU only)
AppIntroApp introduction / awareness (Meta TOFU only)
UGCUser-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
GenericNon-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.

ContextWhen it appliesHow attribution worksOS auto-detect
📊 web_via_smart_script default~95% of touchpoints — Google App, Apple Search, DV360, YouTube, Display, Email, In-Store QR, Website, App Rituals, OrganicUser clicks UTM URL → lands on mindoasis.com → AppsFlyer Smart Script reads UTM params → converts to OneLink → user is redirected to app-install with attribution intactAll channels except META and PR
📱 meta_sdkMeta 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 purposesChannel = META
🌐 web_onlyPR press releases, info pages without app componentNo app-install funnel — UTM tracks web sessions only in GA4Channel = PR
How the OS uses Attribution Context:
  • 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.
Airtable schema — add this Single Select field: The OS works without it (graceful no-op, badge inferred from channel). To enable persistent storage + editing, add field Attribution Context (Single select) to the UTM Codes table with options:
  • web_via_smart_script — color: sage
  • meta_sdk — color: Meta blue
  • web_only — color: amber
Once added, bulk-gen + New UTM modal will write the field automatically. Existing UTM records without the field still display the badge correctly.
⚠ Smart Script validation checklist — high-level (NOT per-UTM):

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 pageUsed byValidationOwner
mindoasis.com/downloadMost 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 preservedMr Analytics + dev
mindoasis.com/myritualsEmail + In-Store Flyer + In-Store Backwall + App Rituals + MY_RITUALS + Website RitualsSame as aboveMr Analytics + dev
mindoasis.com/appMeta Organic — Posts/Influencers (Mind Oasis page)Same as aboveMr Analytics + dev
mindoasis.com/ (homepage)PR — Press ReleaseTest session attribution — no app install expected, so just verify utm params land in GA4Mr 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 preservedMr Analytics + Meta-AppsFlyer dev integration
Heads-up — v2 vs v3 differences in this section:
  • The v2 utm_campaign defaults below show mindoasis_app_intro. These are now App_July_2026 in the code — the table below has been kept as historical reference but the per-channel campaign property on each UTM_CHANNELS entry has been updated to App_July_2026 for paid-app channels. Owned-media defaults are unchanged.
  • The "Naming convention" table further down shows v2-era patterns (Meta extended with App_intro instead of AppActivation_ComingSoon). The v3 patterns are the new authoritative versions — see the "Filename anatomy" table immediately above.
What changed in v2 (May 2026) — historical:
  • Landing-page domain unified to mindoasis.com (was mindoasis.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 olv instead of video (matches Ramona's GA4 setup).
  • utm_source per channel: Meta = meta (unified — IG/FB not split), Email = rituals_newsletter (was klaviyo), 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.

Note on Meta: The dashboard uses a single unified 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 keyLabelmediumsourcecampaign defaultLanding
Paid media
METAMeta — Feed/Stories/Reels (incl. UGC)cpcmetamindoasis_app_intro · _ugc when Campaign starts with UGC_/download
GOOGLE_PLAYSTOREGoogle — Play Storecpcgooglemindoasis_app_intro/download
GOOGLE_DISPLAYGoogle — Displaydisplaygooglemindoasis_app_intro/download
YOUTUBEGoogle — YouTubeolvyoutubemindoasis_app_intro/download
GOOGLE_SEARCHGoogle — Searchcpcgooglemindoasis_app_intro/download
GOOGLE_DISCOVERYGoogle — Discoverydiscoverygooglemindoasis_app_intro/download
GOOGLE_APPGoogle — App Campaignscpcgoogle-appmindoasis_app_intro/download
DV360_DISPLAYDV360 — Display Bannerdisplaydv360mindoasis_app_intro/download
DV360_VIDEODV360 — Display Videoolvdv360mindoasis_app_intro/download
APPLE_SEARCHApple Search Adscpcapplemindoasis_app_intro/download
Owned media
EMAILEmail + Layersemailrituals_newslettermindoasis_app_launch/myrituals
INSTOREIn-Store — Flyer (QR)qrinstore_flyermindoasis_myrituals_25pct/myrituals
INSTORE_BACKWALLIn-Store — Backwallqrinstore_backwallmindoasis_myrituals_25pct/myrituals
ORGANIC_MOMeta Organic — Mind Oasissocialig_organicmindoasis_app_intro/app
WEBSITE_RITUALSWebsite Ritualswebsiterituals_websitemindoasis_app_intro/myrituals
APP_RITUALSApp Rituals — Two Tilewebsiterituals_pdpmindoasis_myrituals_25pct/myrituals
PRPR — Press Releaseprpressmindoasis_app_launch/
MY_RITUALSMy Rituals (loyalty)crmmyritualsmindoasis_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 familyPatternExample
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 UGCmindoasis-app-ugc-{audience}-{theme}-{year} (kebab-case)mindoasis-app-ugc-parents-focus-2026
Google Search BrandedMO_Google_Search_Branded_{Country} (no language — text-free creatives)MO_Google_Search_Branded_NL
Google Search Non-BrandedMO_Google_Search_NonBranded_{Country}MO_Google_Search_NonBranded_UK
Google App CampaignsMO_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}_GOOGLEMO_AppIntroSleep_Static1_4_5_FR_GOOGLE
DV360 Banner (HTML5)MO_AppIntro{Theme}_{Size}_{Language}MO_AppIntroFocus_320_480_FR
DV360 VideoMO_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:

FieldTypeNotes
utm_contentSingle line textPrimary field = filename
ChannelSingle select18 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).
PlatformSingle line textOptional — channel-specific (ios, android, branded, flyer, etc.)
FunnelSingle selectTOFU, MOFU, BOFU, Branded, NonBranded, Prospecting, Retargeting, AlwaysOn
Campaign NameSingle line textApp_intro, Coming_soon, UGC, etc.
VariantSingle line textVideo_1, Static_2, Carousel_1, 300_250 — distinguishes assets within one campaign
CountrySingle selectNL, BE, DE, FR, UK (+ legacy: ES, IT, PL, EU)
LanguageSingle selectENG, NL, DE, FR (+ legacy: BE-FR, IT, ES, PL)
Landing PageURLhttps://www.mindoasis.com/download · /myrituals · /app · /
utm_source / utm_medium / utm_campaignSingle line textAuto-derived per channel — see Channel reference table
StatusSingle selectDraft · Active · Archived
NotesLong textOptional

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 name to a UTM record's utm_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) or UTMs [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 nameUTM Codes.utm_content as the implicit join key. When you use the Link to creative picker, an explicit Creative linked-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.

FieldQuestion it answersExample valuesAlways present?
Channel"Where will the ad show?" — the advertising surface / utm_source categoryMETA, 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 channelios, android (app campaigns), or UGC audience (parents, menopausal, all) for Meta UGCOnly for app-install campaigns (META + GOOGLE_APP). Empty for everyone else.
Naming consistency note: the underlying Airtable field is still called 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.
When you see Platform empty in the UTM detail modal: that's correct behavior for visual ad channels like Google Display, YouTube, DV360, Apple Search Ads, Email, In-Store, PR, etc. These channels don't include a platform segment in their filename pattern per Ramona's Excel spec. The New UTM modal hides the Platform field entirely for these channels; the Detail modal also omits it from the field grid when not applicable. If you're looking at an older UTM that has Platform set to a non-canonical value, you can clear it via Edit.

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.

#SourceWhen it firesExample
1Creative.CountriesCreative has an explicit country (single or multi — first value wins)Creative has ['DE'] → Country = DE
2Campaign.Countriessingle valueCampaign explicitly targets ONE country — most specific campaign-level signal, beats the generic Language defaultCampaign Countries ['Belgium'] + Language ENG → Country BE (NOT UK, because campaign explicitly says BE)
3Language → 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 overrideLanguage NL → Country NL · Language ENG → Country UK (Mind Oasis default)
4Languages field stores a country codeGoogle Display / Search Branded pattern: country code lives in LanguagesLanguages ['NL'] on Google Display creative → Country NL
5aCampaign.Countries — multi + Language=ENGCampaign has multiple countries AND Language is ENG → prefer UK (Excel default)Campaign [NL, DE, UK] + Lang ENG → Country UK
5bCampaign.Countries — multi fallbackCampaign has multiple countries, no UK or non-ENG languageCampaign [FR, ES] + Lang ENG → Country FR (first)
6EmptyNone of the above produces a valueUser picks manually
Why ENG → UK is the Mind Oasis default: per Ramona's Excel, UK is the primary English market. Phase 1 NL/BE campaigns also use ENG content but those are handled by Step 2 (single-country campaign overrides the default). So you only get UK from the Language default when there's no explicit campaign signal pointing elsewhere.
Why this matters: Most YouTube and Google Display creatives have Language set on the record but no Country (the localized version is implicit). Without this fallback chain, every UTM linked from such a creative would land with Country empty — useless for GA4 geo-attribution. The algorithm walks from most-specific (explicit creative field) to most-inferred (campaign-level default with ENG heuristic) so the field is correctly populated in 95%+ of real-world cases. Same logic runs in both _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:

ChannelGranularityHow to create
Meta — Feed/Stories/Reels, FB, IG, UGCPer asset (Video_1, V_2, Static_1, S_2, Carousel × language × country)Bulk-gen from briefing modal
Google DisplayPer banner (theme × format × language)Bulk-gen
YouTubePer video (theme × variant × language)Bulk-gen
DV360 Banner / VideoPer asset (theme × size × language)Bulk-gen
Google App CampaignsPer OS × Targeting × Country × Language (~30 per campaign)New UTM modal — manual
Google Search Branded / NonBrandedPer country only (creatives are text-free, no language differentiation) — 5 UTMs (NL/BE/DE/FR/UK)New UTM modal — manual
Apple Search AdsPer country (one per market) — 5 UTMs maxNew UTM modal — manual
Email + LayersPer send (1 UTM per email campaign)New UTM modal — manual
In-Store Flyer / BackwallPer touchpoint (1 UTM per QR location)New UTM modal — manual
PR / Organic / Website Rituals / App RitualsPer touchpoint (1 UTM per placement)New UTM modal — manual
Rule of thumb: if the channel has visual creatives that vary, use bulk-gen (1 UTM per Creative). If the channel is text-based or single-touchpoint (Search, Apple Ads, Email, PR, In-Store, Organic), use the + New UTM modal directly — that produces 1 UTM per country/segment, not per creative.
Tip — avoid over-generation: if your campaign's Platforms field includes Apple Search Ads or Google Search alongside visual platforms (Meta, YouTube, etc.), the bulk generator will still create one UTM per creative for ALL of them, including the text-driven channels. The extra UTMs are harmless but unused. Either (a) accept the over-generation, or (b) create separate Campaign records: one for visual channels (bulk-gen), one for text-driven channels (manual New UTM × country).

Troubleshooting bulk generation

Error / behaviorCauseFix
"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 ~30Bulk 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.

Why this matters: a single paid-media campaign typically generates 30–100 creatives (Languages × Hooks × Variations). Manually creating 100 UTMs by hand is 5 minutes of tedious copy-paste. With this flow, you tick a box → walk away → the table is auto-populated by the time the creatives are ready.
Forward-compatible. The dashboard works without the 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:

  1. Marketing / Mind Oasis creates the UTM in the dashboard (5 clicks, all field validation).
  2. The dashboard auto-generates the canonical filename + URL.
  3. GoodKarma delivers the asset with the matching filename — the briefing modal auto-links it.
  4. 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.type between 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:

FunctionReturnsOptions
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 to false (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 message field 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 upload
MO_rec123abc456def78_v2         ← re-upload of same creative
MO_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.

Fallback for non-conformant ads. Some ads will inevitably ship without the naming convention (legacy ads, agency uploads, etc.). The briefing modal also offers a + Link ad form to attach an ad ID by hand — pick platform, paste numeric ID, link. Stored on the Creative as the new External Ad IDs field. The sync respects both sources.

📐 Required Airtable schema additions

1. New field on the Creatives table

FieldTypeNotes
External Ad IDsLong textOne 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.

FieldTypeNotes
Composite KeyFormula (primary){Creative} & "_" & {Date} & "_" & {Platform} — used by the sync for idempotent upsert.
CreativeLink → CreativesThe Creative this measurement is for.
DateDateThe day the metrics were recorded for.
PlatformSingle selectMeta · Google Ads
External Ad IDTextSource-of-truth id (for debug + redundancy with the Creative's External Ad IDs field).
SpendCurrency (EUR)Money spent that day on this ad.
ImpressionsNumber
ClicksNumber
CTRPercent (formula){Clicks}/{Impressions}
CPMCurrency (formula){Spend}/{Impressions}*1000
CPCCurrency (formula){Spend}/{Clicks}
ConversionsNumberFrom pixel / conversion event. Optional but core for ROAS.
Conversion ValueCurrencyRevenue attributed to conversions.
ROASNumber (formula){Conversion Value}/{Spend}
Last Synced AtDateTimeStamped 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 fieldFrom → FieldFunction
Total SpendPerformance Daily → SpendSUM
Total ImpressionsPerformance Daily → ImpressionsSUM
Total ClicksPerformance Daily → ClicksSUM
Avg CTRPerformance Daily → CTRAVERAGE
Avg CPMPerformance Daily → CPMAVERAGE
Total ConversionsPerformance Daily → ConversionsSUM
ROAS to dateFormulaSUM({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.

FieldTypeNotes
ServiceSingle selectMeta · Google Ads
Account IDTextact_123456 (Meta) or 1234567890 (Google).
Account LabelTextHuman-readable name for filters in the UI.
Access TokenLong text (encrypted)Long-lived (60d for Meta). Refreshed monthly by an automation.
Refresh TokenLong textGoogle Ads only — required for the OAuth refresh flow.
Last Sync AtDateTimeFor incremental sync (only fetch from this timestamp forward).
ActiveCheckboxPause/resume per account without deleting credentials.

🗓 Rollout phases

PhaseDeliverableMy effortYour effortStatus
0 — FoundationAd-tracking section in briefing modal · "Copy ad name" button · Manual "Link ad" form · External Ad IDs persisted to Creatives · Schema-drift coverage0.5d (shipped)0.5d (add fields + tables in Airtable)✓ Done in code · awaiting your schema work
1 — Meta syncAirtable Automation Sync Meta Performance runs every 4h: pulls Marketing API /insights, regex-matches rec-id from ad names, upserts Performance Daily1.5d0.5d (Meta Business App + system user + token)Pending Phase 0 schema
2 — Dashboard performance views UI shippedPer-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 flowing3-4d✓ UI ready, awaiting Performance Daily rows
3 — Google Ads syncSame pattern as Meta. OAuth one-time setup. Reports.search via Google Ads REST. Match logic identical.1.5d1-2d (Google Ads API approval — 3-7d wait)Plan in parallel with Phase 1
4 — Smart insightsWinners leaderboard · Hook-concept performance rollup · A/B-test detector · Home insight banner ("Hook X performs 3.2x baseline") · CSV/PDF performance reports2-3dBlocked on Phase 2

Total effort: ~9-12 days of dev work · ~3 days of Airtable / Meta / Google admin · 2-3 weeks doorlooptijd.

⚠ Risks & mitigations

RiskMitigation
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 platformsComposite Key includes Platform → Meta:1234 and Google:1234 are separate rows.
Privacy / GDPROnly 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_recXXX on 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 IDs on 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 IDs in EXPECTED_SCHEMA — first drift-check after you add the field will confirm it's wired.

Your day-1 setup checklist

  1. Add External Ad IDs (Long text) to the Creatives table
  2. Create the Performance Daily table with the schema above
  3. Add the 7 rollup fields to Creatives
  4. Create the Sync Config table — leave empty for now; we'll fill in Phase 1
  5. Pilot the naming convention: pick 5 active Meta ads, rename them to MO_recXXX using the Copy ad name button. Sanity-check the format.
  6. (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.

KeysGoes to
g hHome
g pPlanning
g cCampaigns
g dDesigner View
g rReview
g lLibrary
g aAsset Library
g mAdmin

Actions

KeysWhat it does
K / Ctrl KOpen global search palette — filters across campaigns, creatives, hooks, captions, USPs, disclaimers, assets, and pages. Built from in-memory caches, no extra API calls.
n cNew campaign — jumps to Campaigns and opens the create modal.
?Show the keyboard cheat-sheet modal.
EscClose any open modal (PAT wizard / cheat-sheet / palette / lightbox / conn panel).
Discoverability. A footer chip on the Home page exposes K search and ? shortcuts as clickable buttons — so first-time users see the shortcuts even without trying them.

Changelog

1 Jun 2026
UTM-pagina volledig op het OS-design-system gezet — voelt nu als één geheel met Campaigns. De UTM-pagina gebruikte nog eigen, losstaande componenten waardoor hij visueel "anders" aanvoelde dan de rest. Nu hergebruikt hij exact dezelfde bouwstenen als elke andere tabel-pagina:
  • 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 = groen b-live, Draft = amber b-paused, Archived = grijs b-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 globale th/td-styling en de .tbl-wrap/.tbl-header-schaal. Channel-chips hergebruiken de .badge-vorm, getint per kanaalkleur. Geen bespoke inline-tabel meer.
Puur visueel/structureel — geen wijziging in data, filters of UTM-logica.
30 May 2026 (avond)
Login-pagina polling + 4 admin-test fixes + QR Studio prototype hookup. Vier kleine fixes en één nieuwe feature die het dashboard verder klaar maakt voor team-rollout.
  • Login-pagina auto-redirect (poll-mechanisme): nadat de magic-link is verzonden start de login-pagina automatisch met poll-checks op /api/auth/me elke 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_valid herschreven: detecteert proxy-mode i.p.v. lokale TOKEN.length-check, geeft heldere 401/500 foutmeldingen
    • test_airtable_write_read_delete: defensieve safeJson() parser, geen "Unexpected token T" crash meer bij HTML-responses
    • Hardcoded docs_Main%20file.html verwijzingen (3 plekken) → /docs route. 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_table errors op voor o.a. Campaigns - table stores advertising campaigns
  • QR Studio prototype-integratie (Beta): qr-studio-prototype.html van Stijn live op /qr route (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.name uit session (read-only). Avatar krijgt 2-letter initialen op groene cirkel. Hover toont email + rol als tooltip.
30 May 2026 (eind van de dag)
🚀 Productie-deploy — OS draait nu op Vercel met magic-link auth. De grootste mijlpaal sinds de v3 UTM-revisie: het dashboard leeft niet meer op Tims laptop maar op een veilige team-URL.
  • 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_PAT wordt 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_CAPS in lib/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_ROSTER is 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.
30 May 2026 (laat)
Meta schema cleanup — 10 lege/dubbele velden weg, link-backfills draaiende. Fill-rate audit langs alle 5 Meta-tabellen identificeerde redundante velden. Schema is nu ~25% slanker.
  • 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)
  • 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: knownMissingFields Sets 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-pull kreeg expliciete "DELETED FIELDS" lijst in de prompt, zodat de daily-cron weet wat hij NIET moet schrijven.
  • Open punt: Het Campaign link-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".
30 May 2026
OS Upgrade Pack — 10 features bundled in één release. Eén samenhangende module aan het eind van de hoofd-script-block (regel 25714+) die admin-grade helpers, governance en automation toevoegt zonder bestaande flows te raken.
  • #2 Meta daily-pull cron: Scheduled task mind-oasis-meta-daily-pull draait elke ochtend 08:00 lokaal. Vult Performance Daily/Demographics/Placement met yesterday-data via Meta MCP en stempelt Last 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) met ROLE_CAPS map. _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 draait generateUTMsForBrief automatisch. 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 Score Number-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.
Admin panel: Nieuwe sectie "Upgrade Pack · 30 May 2026" auto-injected op #page-admin met 8 clickable stat-cards die alle helpers één-klik triggeren + role-toggle + notification-test.
29 May 2026 (laat)
UTM-pagina UI re-aligned met Campaigns/Briefings rhythm. Drie gerichte fixes zodat de UTM-pagina visueel als één geheel voelt met de rest van de OS:
  • Primary CTA harmonized: + New UTM button switched van btn btn-primary met inline padding-overrides naar tf-btn tf-btn-primary — identiek pattern als + New campaign en + New brief op 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 ? Naming tf-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 door class="loading-state" met solid 1px border — matcht nu Assets/Library/Review empty states.
Geen functionele wijzigingen — puur visuele afstemming. UTM-pagina was ~85% aligned; deze drie fixes brengen het naar 100%. Stats-grid, filter-toolbar, View-toggle en search-input pattern waren al consistent.
29 May 2026 (avond)
Meta data enrichment compleet — 3090 records across 5 tables met Coach-ready training data. Tim spec'te 3 nieuwe Airtable-tabellen + uitbreidingen op History voor video drop-off curves. Volle fetch + import in dezelfde dag.
  • 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 zoals an_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_strategy retourneert 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 én Insufficient permissions to create new select option errors. 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.

29 May 2026
Meta Ads MCP working + 1675 records imported across Meta Ads History + Meta Performance Daily. Tim shared a briefing-claude-code.md document with the key insight we'd been missing: "er is geen connect-stap — je werkt door het ad_account_id mee te geven in elke tool-call". Earlier OAuth-reconnect frustration was wasted effort. Account ID 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) and Meta Performance Daily (one row per ad × day with composite key Date + 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.
    Both have forward-compatible error handling: detect 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_creatives tool is in "gradual rollout" — NOT yet enabled for account 592918140273491. 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).

28 May 2026
Belgium bilingual + Attribution Context field added in Airtable + 3 backfills executed. Closed off three loose ends: (a) updated the 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 via uxAlert; 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 Context Single 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.

21 May 2026
Country veld — Optie C: auto-fill van Language + required validation voor Paid channels. Tim flagde dat een handmatig aangemaakte Apple Search Ads UTM Country leeg had. Reden bleek: de auto-fill 5-staps chain firede alleen bij bulk-gen + linked-creative flow, niet bij vrij typen. Tim's vraag was: "kunnen we Country dan niet weghalen?" — antwoord: nee, want Phase 1 launcht in ENG naar NL/BE/UK gelijktijdig (drie aparte markten, één taal), dus zonder Country valt die scheiding weg in Ads Manager + GA4. In plaats van weghalen: Optie C — slimmer maken zodat het veld altijd correct wordt ingevuld.
  • Language → Country auto-fill (reverse direction) voor monoculturele markten via nieuwe _NU_LANGUAGE_TO_COUNTRY map: NL→NL · DE→DE · FR→FR · IT→IT · ES→ES · PL→PL · BE-FR→BE. ENG bewust niet gemapped (ambigu: NL/BE/UK in Phase 1). Mirror van de bestaande Country→Language pattern, met dezelfde lock-/unlock-logica: _nuCountryUserSet flag 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 language hint 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.
  • 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.
    Live geverifieerd: 30/30 UTM tests passen in een echte browser.

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.

21 May 2026
Attribution Context — dual-attribution model (UTM ↔ OneLink) built into the OS. Tim flagged that Mr Analytics introduced OneLink (AppsFlyer mobile-attribution) alongside our existing UTM system. Standard UTMs work for ~95% of touchpoints (AppsFlyer Smart Script auto-translates them to OneLink at app-install on the landing pages). The exception is Meta paid app-install — there OneLink doesn't work and attribution goes via the Meta-AppsFlyer SDK integration. Built three additions to give marketers visibility into which attribution path each UTM uses.
  • New constant UTM_ATTRIBUTION_CONTEXTS with 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: METAmeta_sdk · PRweb_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 Context to 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 Context as 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.

21 May 2026
UTM spec v3 — corrected GoodKarma deliverables encoded into the OS. Tim shared "Mind Oasis App x GK - Deliverables (1).xlsx" with a new "UTM instructions GK (correction)" sheet listing every Phase 1 + Phase 2 filename the team will actually use, with explicit fixes for spacing / parentheses / iOS-vs-Android labelling. Parsed all 394 rows, identified the structural changes from v2, and implemented them additively (every old filename still parses; new spec output is now the builder default).
  • New AppActivation campaign 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 — extract AppActivation as 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_THEMES constant 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) and Android (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_CASE lookup.
  • utm_campaign URL parameter default = App_July_2026 for paid-app channels (was mindoasis_app_intro). Matches column H in the "UTM -setup (Ramona)" sheet. UGC auto-default became App_July_2026_ugc. Owned-media defaults unchanged. Applied across UTM_CHANNELS entries, buildUTMUrl fallback chain, and bulkGenerateUTMsForCampaign field 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 like UGC_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 to utm_campaign=App_July_2026, the buildUTMUrl fallback emits it correctly, and UTM_THEMES uses spec canonical names (no legacy Coming_soon / App_intro).
  • 2 existing tests updated: test_utm_builder_filename_canonical now expects iOS PascalCase in output; test_utm_meta_unified now expects utm_campaign=App_July_2026_ugc (was mindoasis_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.

21 May 2026
Pre-Launch Performance-laag — Q3-Q4 2026 roadmap locked in. Tim shared "Bouwplan - Pre-Launch Performance-laag.docx" for review. Read in full, evaluated against the existing OS architecture and the open items on our plate (Meta MCP attempt this week, deployment still pending, team adoption not yet started). Plan is strong — restrained navigation (one new menu item under More), correct mental model (the Loop: Designer → Coach → Review → UTM → Performance → Lens → smarter Coach), and explicit honesty rule (never bare scores, always sourced + uncertainty-aware). New Pre-Launch Performance-laag section added between Strategic Roadmap and Market Moments.
  • What's now formalised: 5 new additive Airtable tables (Creative_Features · Coach_Reviews · Performance_Results · Competitor_Ads · Winning_Patterns) with mandatory n + 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_Results proven 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."

20 May 2026
UTM page UI alignment — design language now consistent with Campaigns / Designer / Review. Tim flagged the UTM page didn't feel like part of the same product as the other pages. Audited the visual rhythm side-by-side and ported the dominant patterns:
  • Stats grid — 3 cards → 4 cards (.grid4 class). Was: a custom grid-template-columns:repeat(3,1fr) inline grid with Total/Active/Draft. Now: standard .grid4 with 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-btn chips 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:transparent with 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 to 2px 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.

18 May 2026
End-to-end test verification — all 24 UTM tests + 87 other modern tests pass in a real browser. Tim asked the right question ("have you actually run the tests?") — turned out I had only verified JS parses, never actually executed the suite. Ran every test in the live preview via the Claude Preview MCP tools. Two real bugs surfaced, both fixed:
  • Bug 1: CARROUSEL (sic — Excel typo with double-R) not matched by the variant regex. Filenames like MO_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: extended parseUTMFilename's variant regex from /^(Video|Static|Carousel|...)\d*$/i to /^(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 ran TEST_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.

18 May 2026
Creative-picker robustness — 6 fixes for YouTube-style creatives without explicit Country. Tim flagged that linking a YouTube creative (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) Languages field as country code (Google Display pattern), (4) Campaign.Countries single-value, (5a) Campaign.Countries multi-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) and GOOGLE_APP (iOS/Android) actually use a Platform segment in their filenames. Cleared platforms: [] for the other 16 channels. _nuChannelChange now hides the whole Platform field row when platforms is 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 uxConfirm dialog 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_resolution covers 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) explicit Creative.Countries wins 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.

18 May 2026
UTM flow polish — 6 fixes after live bulk-gen testing. After Tim ran the first bulk-gen on a real campaign (120 UTMs for "Test - Do not delete"), 6 friction points surfaced. Fixed in one pass — UTM flow is now production-ready.
  • 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 from bulkGenerateUTMsForCampaign after the final POST batch. Button now correctly resets to UTMs [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 Name was 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 Creative linked-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.

18 May 2026
Country → Language auto-fill in the New UTM modal. Small UX polish based on team feedback: when you pick a country in the New UTM modal, the language field auto-fills to that country's primary language. Editable — pick a different language and the auto-fill backs off and won't overwrite your choice.
  • 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 _nuLanguageUserSet tracks 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_autofill exercises 6 scenarios: NL→NL, DE→DE, UK→ENG, user override locks, clearing unlocks, FR→FR after unlock. Suite total: 147 tests.
18 May 2026
Meta unified — reverted IG/FB/UGC channel split per team decision. Same-day revision after Tim flagged that the Excel's 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_UGC taken out of UTM_CHANNELS. Channel count back from 21 → 18.
  • META.source reverted from 'ig''meta'. New UTMs on the META channel now emit utm_source=meta in 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) emits utm_campaign=mindoasis_app_intro_ugc (new auto-default in buildUTMUrl) and (b) generates the kebab-case filename pattern mindoasis-app-ugc-{audience}-{theme}-{year} in buildUTMFilename. Audience (parents / menopausal / all) lives in the Platform field; theme follows the UGC_ prefix; year defaults to the current year.
  • _platformToUTMChannel simplified: all Meta variants (meta, facebook, instagram, ugc) collapse to META. No more 3-branch case for IG vs FB vs UGC.
  • Parser still detects kebab-case UGC filenames — just routes them to channel:'META' (with campaign:'UGC_Focus') instead of a separate META_UGC channel. Existing kebab-case filenames in the Excel parse cleanly.
  • Tests updated: test_utm_meta_source_is_ig → renamed to test_utm_meta_unified (asserts source='meta', no IG/FB/UGC channels exist, UGC utm_campaign auto-defaults). test_utm_parser_ugc_kebab_case now expects channel='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.

18 May 2026
UTM parser + builder — 5 fixes to match Ramona's Excel exactly. After auditing the dashboard against every filename pattern in "Mind Oasis App x GK - Deliverables.xlsx" (v2), I found 5 places where parsing/building diverged from the Excel. All 5 fixed in one pass — UTM is now leading.
  • Fix 1: META.source was 'meta', Excel uses 'ig'. Critical bug — the Excel UTM -setup sheet has zero rows with utm_source=meta. Every Meta UTM uses either ig (Instagram, primary) or fb (Facebook). Old behavior was emitting utm_source=meta into GA4 which doesn't match Ramona's channel-grouping setup. Now META defaults to 'ig' (most common placement) and the channel label was renamed "Meta (legacy — prefer IG/FB)" so the team is nudged toward explicit META_IG or META_FB for 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. Because GOOGLE_PLAYSTORE comes before GOOGLE_SEARCH in the channel list, MO_Google_Search_Branded_NL mis-routed to GOOGLE_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-set funnel when implicit — Google_Search_Branded presets funnel:'Branded'.
  • Fix 3: Channel-aware tail parsing for country vs language. Was: parser always tried language first (because NL is in both UTM_LANGUAGES and UTM_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 no Meta prefix in the filename. Now: any filename whose first token matches /^AppIntro[A-Z]/ auto-resolves to META. If the LAST token is _GOOGLE, it routes to GOOGLE_DISPLAY instead (Google Display banner variant per spec). The variant regex was also extended from /^(Video|Static|Carousel|...)$/ to /^(Video|Static|Carousel|...)\d*$/ so it matches Video1 / 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 standard MO_..._..._... pattern. parseUTMFilename now matches this with a dedicated regex at the top, returning channel:'META_UGC', platform:audience (parents/menopausal/all), campaign:'UGC_'+Theme, variant:year. buildUTMFilename handles the inverse — when channel is META_UGC, it produces kebab-case output instead of underscore-separated.
  • Bonus: bulkGenerateUTMsForCampaign now prefers creative.platforms over 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 _channelKeyToFilenameToken helper. Maps channel keys to Excel filename tokens: META → "Meta", GOOGLE_SEARCH → "Google_Search", DV360_VIDEO → "DV360_Video", etc. buildUTMFilename uses this so the output matches what Ramona's Excel produces exactly (no more GoogleSearch jammed-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. _GOOGLE suffix 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 existing test_utm_parser_search_branded_country_only was 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.

18 May 2026
Bulk UTM generator hardening + granularity guidance. Same-day follow-up after the v2 channel migration surfaced edge cases when generating UTMs for campaigns whose creatives haven't been generated yet.
  • Bug: "Insufficient permissions to create new select option 'MO-04711'". Root cause: when a Creative had no File name yet (Airtable automation hadn't run), the bulk generator fell back to Creative ID (e.g. MO-04711) and tried to write that as the Channel — which Airtable rejected because MO-04711 isn't a known Single Select option. Three fixes:
    • Skip Creative-ID-only filenames. Pattern /^[A-Z]{1,4}[-_]\d+$/i detects values like MO-04711, MO_12345 and 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.
  • 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 reads Creative.Countries and Creative.Languages directly (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.

18 May 2026
UTM spec v2 — 21 channels, mindoasis.com domain, per-channel defaults. Processed Ramona's updated "Mind Oasis App x GK - Deliverables.xlsx" (v2, May 2026) into the dashboard. The marketing team now picks a channel and the medium / source / campaign / landing page auto-fill exactly as the GA4 setup expects — no more reverse-engineering the Excel.
  • 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.appmindoasis.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_PRESETS updated, plus the landing-page resolver in _createUTMFromCreative rewritten to match every path the spec uses. Existing records with the old mindoasis.app URL still work (the explicit Landing Page field always wins).
  • utm_medium normalized: Video channels (YouTube + DV360 Video) now use olv (Online Video) instead of video — matches the actual GA4 channel grouping Ramona set up.
  • utm_source split per platform: Meta IG = ig, Meta FB = fb (was both meta). Email = rituals_newsletter (was klaviyo). In-Store Flyer = instore_flyer, Backwall = instore_backwall (was both instore). 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 when Campaign Name is 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 _GOOGLE suffix; 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-empty landing / medium / source / campaign / group, that YouTube + DV360_VIDEO use olv, and that every landing is on mindoasis.com. The existing test_utm_my_rituals_landing_page got 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.

18 May 2026
Two friction fixes: More dropdown + "Create UTM" auto-fill from briefing. Same-day follow-up after team feedback on the UI polish pass.
  • More dropdown wasn't opening. The new More ▾ menu in the header was clipped invisible because the parent <nav> had overflow:hidden — the dropdown rendered, then got immediately cropped to zero height. Fixed by splitting the rule to overflow-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 (from Creative.platforms via _platformToUTMChannel), country (from Creative.CountriesCampaign.Countries → filename), language (from Creative.Languages), funnel (from Creative.FunnelCampaign.Funnel → Brief-Type default via _briefTypeToFunnel), variant (derived from Content type + Slide position + Size via _creativeToVariant), campaign (auto-selected in the dropdown), landing page (from Campaign.Landing page, forced for MY_RITUALS), notes (auto-stamped "Auto-created from creative <ID> · <filename>"). Original filename preserved as canonical utm_content with 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 Languages field instead of Countries. Without a fallback, the auto-filled UTM showed an empty Country field. Added two-step fallback: (a) LANG_TO_COUNTRY map 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) if Languages contains 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.

18 May 2026
UI polish pass — emoji-free, compact, scalable. Across-the-board cleanup based on team feedback. Result: cleaner editorial look, better information density, and a header structure that doesn't break at any viewport width.
  • 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 _nuEditingId is 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 UTMs when 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.

17 May 2026
📚 Documentation + test coverage refresh. Audit pass to bring docs and the test suite up to date with everything shipped this week.
  • 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.
17 May 2026
🔗 UTM Codes — auto-link tracking URLs to every asset. Translated the convention from "Mind Oasis App x GK - Deliverables.xlsx" (Ramona) into a first-class dashboard feature. No more spreadsheet round-trips between Mind Oasis, GoodKarma, and the ad platform manager.
  • 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 in buildUTMFilename() + reverse parser parseUTMFilename().
  • 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_content via 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.

17 May 2026
🔐 Login screen + 📄 Moment PDF export + 👁 Asset preview lightbox. Three additions touching the first-touch experience, stakeholder reporting, and creative asset browsing.
  • 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=1 in localStorage so the overlay never re-shows until signOut() 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.
17 May 2026
⚙ Five workflow accelerators: clone, bulk ops, bottlenecks, retrospectives, search. Five separate but complementary improvements covering the daily flow.
  • 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 _momentsCache when the toolbar opens. All bulk actions go through the existing _bulkPatch path 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.
17 May 2026
📅 Moments visualized: Timeline band + briefing-modal context. Two more places where moments become visually obvious instead of implicit.
  • 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.

17 May 2026
📨 Moments now propagate to digest, empty states, and a starter-brief generator. Three more lanes where the Moments concept earns its keep — the dashboard now reflects strategic risk in every workflow surface, not just the Moments tab.
  • 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 an extra field; 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.
17 May 2026
🏠 Moments now surface on the Home page. Until now, you had to navigate to Planning → 🗓 Moments to see if anything was at risk. The team lands on Home every morning — that's where strategic risk should hit you first. Two additions:
  • 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_renders injects a synthetic urgent moment, calls renderHome(), asserts both the row and the smart-insight surface the moment by name. Grand total 110 tests.
17 May 2026
🛠 Market Moments workflow polish — 4 friction points removed. An honest audit of the Moments workflow surfaced four spots where the dashboard knew the answer but didn't show it. Fixed in a single pass:
  • 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; onShow navigates 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.

17 May 2026
🗓 Market Moments — cross-channel anchors as their own concept. A social asset isn't always part of a campaign — sometimes it's about Moederdag, Black Friday, or a product launch that pulls together paid + social + PR + email. Modeled as a new Airtable table 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.

17 May 2026
🐛 Broken Documentation link fixed + dead-link guard added. The ↗ Open documentation button on the Documentation page linked to 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.
17 May 2026
📅 Reschedule with approval flow. Campaigns and standalone briefs can now be rescheduled to a new Start / End — but the change is proposed, not applied directly. Stored on the Campaign record as a single JSON-encoded field 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 approve permission via data-perm — Designers and Viewers don't see it.
  • Approve flow. Confirm dialog → atPatch sets Start Date + End Date from the proposal, clears Pending 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 Change doesn'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.

17 May 2026
🐛 Drag-reassign fix on Planning Timeline. Bars on the Timeline were marked 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.
17 May 2026
⚖ One-click capacity rebalance. When a designer is over the weekly threshold, clicking the amber or red bar in the workload chart on Planning → Project Summary now opens a rebalance panel: lists every creative + standalone brief that designer has due that week, with a Move to → dropdown per row showing other designers + their current week load. One click → atPatch → in-memory cache updated → row fades out → chart re-renders without leaving the page. Each reassignment is logged to the Activity Log as a designer change. Includes a top-of-chart hint banner that surfaces overload counts ("2 over-capacity + 1 approaching") with explicit "Click any amber or red bar below to rebalance" instructions so the affordance is discoverable. Standalone briefs are gated to the Campaigns table; creatives to the Creatives table — both reassign via the same flow. Capacity legend on the chart unchanged: ≤30/wk sage · 31–50 amber · over 50 red. New helpers: openRebalancePopover(evt, designer, weekIso) + _doRebalance(selectEl).
17 May 2026
↩ Page renamed back to "Campaigns" (was briefly "Briefs"). Nav tab, page title, stats label, empty-states, table column header, command-palette entry, and the onboarding tour step all updated. "Briefs" stays as a search-alias in K so old muscle memory still works. The dual create flow stays as is — + New campaign for paid-media + + New brief for standalone — those names describe the workflow modes, not the page identity. Brief Types (the discriminator field) keeps its name in the schema since it's the established Airtable contract.
17 May 2026
🔧 Critical fix — window-mirror for in-memory data caches. The script-scoped 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.
17 May 2026
🧹 Nav cleanup + onboarding clarity. Three small but real fixes after first-time-user feedback:
  • 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 from loadAdmin() and setAdminTab('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-btn selectors removed, #nav-admin-btn kept readable at ≤460px (tightened padding + smaller font, no longer hidden). On desktop the nav now fits without horizontal crowding.
17 May 2026
✨ Polish tier — five finishing touches.
  • 🎨 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_YELLOW thresholds. 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) + ≤460px ruleset. 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.
17 May 2026
🧠 Intelligence + Scale bundle — six features that turn the dashboard into a proprietary tool.
  • 🧠 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 with data-perm="…" get hidden when the current role lacks the perm — applied by applyRoleGating() 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 the create permission.
  • 🧭 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.
17 May 2026
📈 Performance Phase 2 (UI) + 🎓 Onboarding tour.
  • Performance data layer. New loadPerformance() fetches the Performance Daily table 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 the MO_recXXX naming) + 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 an onShow hook 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.
17 May 2026
⚡ Productivity bundle — five day-1 wins.
  • 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._selectedBriefs so 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 openCampBriefing automatically 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.ics file 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.
17 May 2026
📊 Performance integration — Phase 0 foundation shipped. First step toward a Billy-Grace-style creative performance overlay (Meta + Google Ads). Every Creative briefing modal now has an 📊 Ad tracking section between metadata and comments. Includes a ⎘ Copy ad name button that puts the canonical name (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.
17 May 2026
🛎 Four quality-of-life features.
  • 💬 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 Campaign field (parallel to Creative for paid campaigns). Forward-compatible: if the Campaign field 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 existing campaignId path; 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 to localStorage.mind_oasis_theme; first paint honors prefers-color-scheme when no saved preference. Applied via html[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 to mix-blend-mode: screen at 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) where kind is ok / err / info / load. Auto-dismiss (3.5s ok, 4s info, 7s err, sticky for load). Update-in-place via opts.id so 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 logActivity path. (Designer inline-edit deferred until Designer field is confirmed on the Campaigns table — adds tomorrow with the rest of the Brief Types schema.)
11 May 2026
📐 Brief Types — dashboard now manages standalone briefs alongside paid-media campaigns.
  • Foundation. New Brief Type single-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. Plus Designer on 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 Count alongside 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 + Designer on Campaigns are part of EXPECTED_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.
11 May 2026
🛠 Production-hardening sweep — 8 topics shipped in one pass.
  • 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 atFetch and atPatch now updates the tracker via _connOk() / _connErr().
  • T3 — Real error banners. Every load*() catch block now calls renderLoadError(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. TOKEN is now let, sourced from localStorage.mind_oasis_pat with 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/m for Home / Planning / Campaigns / Designer / Review / Library / Asset Library / Admin (1.2s timeout on the g prefix). Plus n c for new campaign, ⌘K / Ctrl K for search, ? for the cheat-sheet modal, Esc closes any overlay. Disabled while typing in inputs/textareas/contentEditable. Discoverability: a footer chip on the Home page exposes ⌘K search and ? 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-reassign TOKEN.
  • T13 — Schema drift detector. New section in Admin → Tools. Fetches the live schema from GET /v0/meta/bases/{base}/tables, compares against an EXPECTED_SCHEMA dict (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.
11 May 2026
💎 Luxury layer — magazine-grade editorial treatment on top of the polish layer. Pushed the dashboard further into the "Editorial Luxury" archetype. Whisper of paper-grain texture (~3.5% opacity, fixed pointer-events-none SVG noise) lifts the cream backdrop from flat to printed. Body gained a triple radial-gradient ambient backdrop — warmer toward upper-left, cooling toward bottom — so the cream has depth instead of looking like a solid fill. Custom thin sage scrollbar (10px, rounded thumb, brand-tinted) replaces the OS default; 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.
11 May 2026
✨ Premium polish layer — spring motion, haptic press, fluid reveals. Layered a focused "premium polish" CSS block on top of the existing system (no class renames, no markup churn) to lift the dashboard from "clean utility" to agency-tier feel. Introduced easing tokens (--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.
11 May 2026
💬 Added MOA GPT launcher to the top nav. New button placed between the user-identity chip and the Admin button, separated by the same vertical divider as Admin so it reads as an external tool launcher (not an internal page). Renders as a green-on-hover pill labelled "MOA GPT" with a chat-bubble icon + a small ↗ external-link glyph. Opens https://knowledge-base-one-delta.vercel.app/ in a new tab with rel="noopener" for safety. Implemented as an <a> element (not an onclick handler) so middle-click + Cmd-click behave naturally.
11 May 2026
⏳ Every page now has a full skeleton loader on refresh. Previously only Designer / Campaigns / Library / Assets / Review reset their content to skeleton shimmer when their loader ran — Home, Planning, and Admin kept their stale data on screen the entire time, which made the app feel frozen on refresh. Fixed by adding explicit reset blocks to 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.
11 May 2026
🧹 Dead-code cleanup — Campaign Builder fully removed. The hidden multi-step Campaign Builder wizard (#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 MOFOMOFU typo that lived in the Builder funnel chips is moot now but was fixed before deletion for completeness.
11 May 2026
📚 Comprehensive documentation sweep — back in sync with the dashboard. Brought the docs from "changelog-only" back to "complete reference." Added new sections: 🏠 Home page (editorial hero, mission-control cards, time-of-day gradient, smart insight banner, milestone celebrations, what's-changed-since-last-visit pill, live ticker, quick actions), 📅 Planning & Workload (three sub-tabs in detail — Project Summary tiles, Timeline with drag-reassign and ISO week numbers, Capacity heatmap with the soft-cap formula explained), 🔔 Notifications bell (popover panel, unread tracking, creative-label resolution), 📜 Activity Log (full action→field mapping table, dual local+Airtable write path, PAT scope requirement), Library item management (Archive/Restore/Delete with usage check, Disclaimer special case). Updated existing sections: Pages & navigation grid 7→8 with new order (Home · Planning · Campaigns · Designer · Review · | · Library · Asset Library · Documentation) and Dashboard renamed to Campaigns, Designer view (inline quick-assign, bulk-assign per campaign header, collapse/expand campaigns, Campaign filter dropdown, "Approved only from Review" workflow rule callout), Campaigns (ENG-only linked-record filter explanation with the why/safe/fallback rationale), Airtable schema (10→11 tables, new Activity Log table schema with required Type options). Sidebar nav updated to include the new section anchors. After this sweep, every feature in the live dashboard has a dedicated documentation page — not just a changelog entry.
11 May 2026
Planning page polish — designer assignment + timeline navigation. Two fixes after live testing: (1) Added a Designer dropdown to the creative briefing modal (sits next to the Status dropdown). Pick Maike / Mendy / Unassigned (or any name already in use on the table) → PATCHes the Creatives.Designer field and refreshes every view in sync (Designer view, Review, Planning). If Airtable rejects the value (because Designer is a single-select without that option), the dashboard surfaces a clear alert with exact steps to fix the field type. This was the missing "assign designer to a creative" workflow. (2) Added navigation controls to both the Timeline and Capacity tabs: ◀◀ / ◀ / Today / ▶ / ▶▶ buttons plus a ⇥ Jump to next campaign button that scans every campaign's Campaign starts / Assets due dates and snaps the window to the earliest upcoming one. Each tab has its own persistent anchor, so navigating Timeline doesn't affect Capacity and vice versa. When a window has no campaigns (common when test campaigns sit far in the future like 2028), an empty-state card now tells you to use Jump-to-next-campaign instead of leaving you staring at empty cells. Date range label on the right shows the current visible window at a glance.
11 May 2026
📅 Planning page shipped — Project Summary + Timeline + Capacity. New nav tab between Review and Library opens a three-panel planning workspace.

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 startsAssets 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.
11 May 2026
Library item management — Archive (Do not use) + usage-aware Delete. Three new actions in the library-item detail modal footer: (1) 🚫 Set to "Do not use" — PATCHes the row's 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.
11 May 2026
Retroactive re-parenting + schema-drift diagnostic. Two related additions: (1) New ↳ Make reply of… hover action on every top-level (no-Parent) comment opens a modal picker listing all other comments on the same creative as candidate parents. Picking one PATCHes the 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.
11 May 2026
Notification bell — polish pass. Three fixes after live testing: (1) Nav alignment — bell, user chip, and Admin button all now share a fixed 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 commentsopenCreativeFromNotification 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.
11 May 2026
🔔 Notification bell added to top nav. A bell icon in the nav (next to the user chip) shows a red badge with the global unread-comment count for the current user. Click → popover panel lists every creative with unread comments, sorted by most recent. Each row: latest author avatar, creative primary ID, message snippet, relative time, and a +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).
11 May 2026
Comments Phase-3 hotfixes — replies + reaction picker. Two bugs found in live testing: (1) replying produced 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.
10 May 2026
Comments — Phase 3 shipped. Six features in one pass: (1) ⌘/Ctrl + Enter posts the comment (and submits inline replies + edits). (2) "Commenting as Name" hint above the textarea so the author is obvious before posting. (3) ✎ Edit own comments — pencil icon on hover, only on your own rows; PATCHes 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.
10 May 2026
Comments visibility bug fixed. Newly-posted comments weren't appearing in the thread until the modal was reopened. Root cause: 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.
10 May 2026
Comments — Phase 2 + identity simplification. Replaced the dropdown identity picker with a single inline name input (works with any name — Sophie/Tim/etc., not just the 5 predefined roles). Avatar colors are now derived deterministically via a small hash of the name. Recommended Airtable schema change: set 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.
10 May 2026
Comments & threads on creatives — Phase 1 shipped. New 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.
10 May 2026
Test automation suite expanded 50 → 54. 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).
10 May 2026
Edit-campaign polish. Three fixes: (1) Linked-record dropdowns (Hooks, Hooks Variation, USP, Master Caption) now pre-fill correctly when opening edit — _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").
10 May 2026
Designer view "All" tab. Added a third tab next to Maike/Mendy that shows every creative including unassigned ones. New default. Without this, freshly-generated creatives (which have no Designer assigned) were invisible until someone manually assigned each row. Both the main filter and the briefing-modal filter were updated to bypass the Designer match when "All" is active.
10 May 2026
Slide position visible to designers. Designer view's creative cards now show a green ▦ 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.
10 May 2026
Regenerate creatives feature shipped. New "↻ Regenerate creatives" button in the briefing-modal footer (only shown for already-generated campaigns). One click: confirms, deletes existing Creative rows in batches of 10, resets 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.
10 May 2026
Duplicate-campaign flow aligned with REST API quirks. Linked records (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.
10 May 2026
End-to-end generation working. Test campaign created via dashboard → Languages auto-filled → automation triggered → all Creative records generated successfully → Designer view receives them. Path proven start-to-finish.
10 May 2026
New Creatives field — 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.
10 May 2026
Generation script — Lookup-safe. Added 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.
10 May 2026
Edit-campaign feature shipped. New "Edit campaign" button in briefing modal opens the new-campaign modal in edit mode, pre-fills every field, and PATCHes the existing record on save. _ncEditingId state distinguishes edit from create.
10 May 2026
Country → Languages script updated. User changed UK → ENG in the Languages field options, and updated the script's map key from "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.
10 May 2026
Dashboard alignment with verified schema. Lenght dropdown 6s06s; 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 BoutiqueBoutiques; Variations writes use bare-string single-select shape; Languages writes filtered through VALID_CAMPAIGN_LANGUAGES whitelist; Assets generated: false sent explicitly on create.
10 May 2026
REST API quirk verified. Linked-record writes to this base via REST API only accept bare-string format ["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.
10 May 2026
Example asset upload fixed. Switched from broken 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.
10 May 2026
Post-create automation triggers — dashboard now flips 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").
9 May 2026
Creatives field schema verified end-to-end. Distribution Type, Funnel, State of Mind, Hook angle, Variation, Caption Text confirmed as Lookup fields (not writable). Other 22 fields confirmed as Single Select / Linked record / Long text / Formula / etc. Documented Lookup vs Select distinction in schema table.
May 2026
Automation B (Notify designer): documented Subject (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.
May 2026
Automation A (Notify campaign manager): documented recipient 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.
May 2026
Auto-update Creative Status: corrected — watches two fields (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.
May 2026
Country → Languages script: documented the full 22-country mapping, added Schema discrepancy warning (Airtable uses full country names; the hidden Builder page uses 2-letter codes that won't match), flagged Spain as unmapped. Corrected the Campaigns schema: Countries uses full country names (not codes), Languages options must include NL · DE · FR · BE-FR · UK · ENG.
May 2026
Automations rewrite: real names of all 5 automations from the Airtable panel. Full generation-script reference — pre-flight guards, 300-creative hard cap, manual-only platforms (PR, Stores, Google Search), invalid platform/content combinations, SIZE_MAP, file-type & max-size mapping, Landing page → CTA URL map, Image phone per language, Soundscape cycling, Hook/Caption/USP resolution + ENG fallback. Added required 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.).
May 2026
Documentation pass: added schemas for the 8 previously-undocumented Airtable tables (Creatives, Hooks, Hooks Variations, Captions, USP Library, Disclaimer Library, Asset Library, Shoots), Campaign Builder hidden-page section, Shoots & talent section, Global search section. Clarified that Test automation lives inside the Tools admin tab.
May 2026
Admin page fully redesigned with 5 tabs. Test automation engine (50 tests). Smart caching with fingerprint detection. PDF/CSV/week report exports. Auto-fix scripts. Drag & drop file upload. Lightbox preview. Copy URL button. Custom linked-record dropdowns with language filter. Admin separated in nav.
Apr 2026
Initial build: Dashboard, Designer View, Review, Library, Asset Library, Documentation. New campaign modal. Airtable integration. Automation script integration. System diagnostics.