{"name":"BFCM ads workflow","nodes":[{"id":"bf400001-1111-4222-8333-444455556601","name":"Hourly BFCM Pulse","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-460,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":1}]}}},{"id":"bf400002-1111-4222-8333-444455556602","name":"BFCM Control Config","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[-200,300],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"ad_account_id","value":"act_ID","type":"string"},{"id":"a2","name":"day_budget_cap","value":"={{ 42000 }}","type":"number"},{"id":"a3","name":"target_roas","value":"={{ 2.4 }}","type":"number"},{"id":"a4","name":"max_step_up","value":"={{ 0.35 }}","type":"number"},{"id":"a5","name":"max_step_down","value":"={{ 0.30 }}","type":"number"},{"id":"a6","name":"stock_floor","value":"={{ 25 }}","type":"number"},{"id":"a7","name":"kill_spend_floor","value":"={{ 250 }}","type":"number"},{"id":"a8","name":"revenue_goal","value":"={{ 180000 }}","type":"number"}]},"options":{}}},{"id":"bf400003-1111-4222-8333-444455556603","name":"Read BFCM Promo Plan","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[60,300],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Promo Plan"},"options":{}}},{"id":"bf400004-1111-4222-8333-444455556604","name":"Fetch Hourly Ad Set Insights","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[320,300],"parameters":{"url":"https://graph.facebook.com/v21.0/{{ $('BFCM Control Config').first().json.ad_account_id }}/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"adset"},{"name":"fields","value":"campaign_name,adset_id,adset_name,spend,impressions,clicks,frequency,purchase_roas,actions,action_values,daily_budget"},{"name":"date_preset","value":"today"},{"name":"action_attribution_windows","value":"1d_click,1d_view"},{"name":"filtering","value":"[{\"field\":\"adset.effective_status\",\"operator\":\"IN\",\"value\":[\"ACTIVE\"]}]"},{"name":"limit","value":"250"}]},"options":{}}},{"id":"bf400005-1111-4222-8333-444455556605","name":"Fetch Shopify Stock Levels","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[320,560],"parameters":{"url":"https://brand.myshopify.com/admin/api/2024-10/variants.json","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,sku,title,inventory_quantity,inventory_policy,price"},{"name":"limit","value":"250"}]},"options":{}}},{"id":"bf400006-1111-4222-8333-444455556606","name":"Join Ads With Inventory","type":"n8n-nodes-base.merge","typeVersion":3,"position":[600,400],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"bf400007-1111-4222-8333-444455556607","name":"Pacing & Budget Shift Engine","type":"n8n-nodes-base.code","typeVersion":2,"position":[860,400],"parameters":{"jsCode":"// ── BFCM HOURLY CONTROL LOOP ──────────────────────────────────────────────\n// Input 0 = Meta ad-set insights for TODAY (hourly breakdown collapsed to\n// day-to-date totals). Input 1 = Shopify inventory levels per SKU.\n// The promo plan (hour-by-hour spend curve + revenue goal) is read from the\n// planning sheet upstream.\nconst rows      = $input.all().map(i => i.json);\nconst plan      = $('Read BFCM Promo Plan').all().map(i => i.json);\nconst cfg       = $('BFCM Control Config').first().json;\n\nconst DAY_BUDGET_CAP = Number(cfg.day_budget_cap);   // hard spend ceiling for the day\nconst TARGET_ROAS    = Number(cfg.target_roas);      // blended breakeven+margin target\nconst MAX_STEP_UP    = Number(cfg.max_step_up);      // never more than +X% per hour (Meta relearns)\nconst MAX_STEP_DOWN  = Number(cfg.max_step_down);\nconst MIN_ADSET_BUDGET = 50;\n\n// ── 1. Where should we be right now? ──────────────────────────────────────\n// The promo plan holds a cumulative spend share per hour, e.g. hour 20 = 0.78\n// because the 8pm-midnight block is where BFCM revenue actually lands.\nconst nowUtc  = new Date();\nconst hour    = nowUtc.getUTCHours();\nconst minute  = nowUtc.getUTCMinutes();\n\nconst planByHour = new Map(plan.map(p => [Number(p.hour), p]));\nconst curveAt = (h) => {\n  const row = planByHour.get(Math.max(0, Math.min(23, h)));\n  return row ? Number(row.cum_spend_share) : h / 24;   // fallback: flat curve\n};\n// interpolate inside the current hour so we don't step-function at :00\nconst expectedShare = curveAt(hour) + (curveAt(hour + 1) - curveAt(hour)) * (minute / 60);\nconst planRow = planByHour.get(hour) || {};\nconst hourLabel = String(planRow.block || 'unplanned block');\n\n// ── 2. Inventory index (stock-out guard) ──────────────────────────────────\n// A sold-out hero SKU on BFCM burns budget faster than anything else, so it\n// wins over every other rule below.\nconst stock = new Map();\nfor (const r of rows) {\n  if (r.sku == null && r.variant_sku == null) continue;\n  const sku = String(r.sku || r.variant_sku);\n  const qty = Number(r.inventory_quantity != null ? r.inventory_quantity : r.available || 0);\n  stock.set(sku, Math.max(0, qty) + (stock.get(sku) || 0));\n}\n\n// ── 3. Normalise Meta insights ────────────────────────────────────────────\nconst num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };\nconst pickAction = (arr, type) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find(a => a.action_type === type);\n  return hit ? num(hit.value) : 0;\n};\n\nconst adsets = [];\nlet totalSpend = 0, totalRevenue = 0;\n\nfor (const r of rows) {\n  if (!r.adset_id) continue;                       // inventory-only rows\n  const spend       = num(r.spend);\n  const impressions = num(r.impressions);\n  const clicks      = num(r.clicks);\n  const frequency   = num(r.frequency);\n  const roas        = Array.isArray(r.purchase_roas) ? num(r.purchase_roas[0] && r.purchase_roas[0].value) : num(r.purchase_roas);\n  const purchases   = pickAction(r.actions, 'purchase') || num(r.purchases);\n  const atc         = pickAction(r.actions, 'add_to_cart');\n  const revenue     = Array.isArray(r.action_values) ? pickAction(r.action_values, 'purchase') : roas * spend;\n  const budget      = num(r.daily_budget) / 100 || num(r.budget) || MIN_ADSET_BUDGET;  // Meta returns minor units\n\n  totalSpend   += spend;\n  totalRevenue += revenue;\n\n  adsets.push({\n    campaign_name: r.campaign_name || '',\n    adset_id: String(r.adset_id),\n    adset_name: r.adset_name || r.adset_id,\n    promo_sku: String(r.promo_sku || r.sku || ''),\n    spend, impressions, clicks, frequency, purchases, atc, revenue,\n    roas: spend > 0 ? revenue / spend : 0,\n    cpa: purchases > 0 ? spend / purchases : 0,\n    cvr: clicks > 0 ? purchases / clicks : 0,\n    ctr: impressions > 0 ? (clicks / impressions) * 100 : 0,\n    current_budget: budget\n  });\n}\n\n// ── 4. Pacing projection ──────────────────────────────────────────────────\n// Project end-of-day spend by dividing spend-to-date by the share of the day's\n// budget curve we have already consumed. pace_index > 1 = overspending.\nconst safeShare   = Math.max(expectedShare, 0.01);\nconst projected   = totalSpend / safeShare;\nconst paceIndex   = projected / DAY_BUDGET_CAP;\nconst blendedRoas = totalSpend > 0 ? totalRevenue / totalSpend : 0;\nconst budgetLeft  = Math.max(0, DAY_BUDGET_CAP - totalSpend);\n\n// Global throttle: if we are projected hot AND blended ROAS is under target we\n// pull the whole account back; if we are underpacing on a good ROAS we push.\nlet globalMultiplier = 1;\nif (paceIndex > 1.15 && blendedRoas < TARGET_ROAS) globalMultiplier = 0.80;\nelse if (paceIndex > 1.30)                          globalMultiplier = 0.90;\nelse if (paceIndex < 0.85 && blendedRoas >= TARGET_ROAS) globalMultiplier = 1.20;\nelse if (paceIndex < 0.70)                          globalMultiplier = 1.10;\n\n// ── 5. Score every ad set (0-100) ─────────────────────────────────────────\n// Efficiency 55 | volume proof 20 | funnel quality 15 | fatigue penalty 10\nconst scored = adsets.map(a => {\n  const roasScore = Math.max(0, Math.min(55, (a.roas / TARGET_ROAS) * 40));\n  const volScore  = Math.min(20, Math.log10(Math.max(a.purchases, 1)) * 14);\n  const funnel    = a.atc > 0 ? a.purchases / a.atc : 0;\n  const funnelSc  = Math.min(15, funnel * 45);\n  const fatigue   = a.frequency > 3.5 ? 10 : a.frequency > 2.5 ? 5 : 0;\n  const confidence = Math.min(1, a.spend / 150);   // don't trust a $12 ad set\n\n  const score = Math.round((roasScore + volScore + funnelSc - fatigue) * (0.55 + 0.45 * confidence));\n  return { ...a, score, fatigue_penalty: fatigue, confidence: Number(confidence.toFixed(2)) };\n});\n\nconst totalScore = scored.reduce((s, a) => s + Math.max(a.score, 1), 0) || 1;\n\n// ── 6. Decide per ad set ──────────────────────────────────────────────────\nconst out = [];\nfor (const a of scored) {\n  const qty = a.promo_sku ? stock.get(a.promo_sku) : undefined;\n  const soldOut = a.promo_sku !== '' && qty !== undefined && qty <= Number(cfg.stock_floor);\n\n  // Ideal budget = fair share of what is left in the day, weighted by score.\n  const fairShare = (Math.max(a.score, 1) / totalScore) * Math.max(budgetLeft, DAY_BUDGET_CAP * 0.1);\n  let target = fairShare * globalMultiplier;\n\n  // Clamp the hourly move so Meta's delivery does not reset into learning.\n  const upCap   = a.current_budget * (1 + MAX_STEP_UP);\n  const downCap = a.current_budget * (1 - MAX_STEP_DOWN);\n  target = Math.max(downCap, Math.min(upCap, target));\n  target = Math.max(MIN_ADSET_BUDGET, Math.round(target));\n\n  const deltaPct = a.current_budget > 0 ? (target - a.current_budget) / a.current_budget : 0;\n\n  let action, reason;\n  if (soldOut) {\n    action = 'pause_stockout';\n    target = 0;\n    reason = `SKU ${a.promo_sku} at ${qty} units (floor ${cfg.stock_floor}) — killing delivery before we sell air`;\n  } else if (a.spend >= Number(cfg.kill_spend_floor) && a.roas < TARGET_ROAS * 0.5) {\n    action = 'cut_hard';\n    target = MIN_ADSET_BUDGET;\n    reason = `ROAS ${a.roas.toFixed(2)} at $${a.spend.toFixed(0)} spent — under half of target ${TARGET_ROAS}`;\n  } else if (deltaPct >= 0.10) {\n    action = 'scale';\n    reason = `score ${a.score}, ROAS ${a.roas.toFixed(2)}, pace ${paceIndex.toFixed(2)} — shifting budget in`;\n  } else if (deltaPct <= -0.10) {\n    action = 'trim';\n    reason = `score ${a.score} below account average, pace ${paceIndex.toFixed(2)} — pulling budget out`;\n  } else {\n    action = 'hold';\n    target = a.current_budget;\n    reason = `within 10% of ideal budget — leave it alone`;\n  }\n\n  out.push({\n    ...a,\n    stock_qty: qty === undefined ? null : qty,\n    sold_out: soldOut,\n    action,\n    reason,\n    budget_change_needed: action !== 'hold',\n    new_budget: target,\n    new_budget_minor: Math.round(target * 100),\n    delta_pct: Number((deltaPct * 100).toFixed(1)),\n    // account-level context carried on every row for the digest + alerts\n    hour_utc: hour,\n    plan_block: hourLabel,\n    expected_share: Number(expectedShare.toFixed(3)),\n    actual_share: Number((totalSpend / DAY_BUDGET_CAP).toFixed(3)),\n    projected_day_spend: Math.round(projected),\n    pace_index: Number(paceIndex.toFixed(2)),\n    pace_state: paceIndex > 1.15 ? 'AHEAD' : paceIndex < 0.85 ? 'BEHIND' : 'ON PLAN',\n    account_spend: Math.round(totalSpend),\n    account_revenue: Math.round(totalRevenue),\n    blended_roas: Number(blendedRoas.toFixed(2)),\n    revenue_goal: Number(planRow.revenue_goal || cfg.revenue_goal),\n    goal_attainment: Number((totalRevenue / Number(planRow.revenue_goal || cfg.revenue_goal || 1)).toFixed(2)),\n    global_multiplier: globalMultiplier\n  });\n}\n\n// Biggest movers first — the war-room digest reads top-down.\nout.sort((a, b) => Math.abs(b.delta_pct) - Math.abs(a.delta_pct));\nreturn out.map(r => ({ json: r }));"}},{"id":"bf400008-1111-4222-8333-444455556608","name":"SKU Sold Out?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1120,400],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.sold_out }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"bf400009-1111-4222-8333-444455556609","name":"Pause Sold-Out Ad Sets","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1380,160],"parameters":{"method":"POST","url":"https://graph.facebook.com/v21.0/{{ $json.adset_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ status: \"PAUSED\" }) }}","options":{}}},{"id":"bf400010-1111-4222-8333-444455556610","name":"Alert Ops On Stock-Out","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1640,160],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#bfcm-war-room","mode":"name"},"text":"=🛑 STOCK-OUT GUARD — SKU {{ $json.promo_sku }} down to {{ $json.stock_qty }} units. Paused {{ $json.adset_name }} (was {{ $json.current_budget }}/day, ROAS {{ $json.roas }}). Restock or swap the creative to the backup hero.","otherOptions":{}}},{"id":"bf400011-1111-4222-8333-444455556611","name":"Budget Change Needed?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1380,400],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.budget_change_needed }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}},{"id":"c2","leftValue":"={{ Math.abs($json.delta_pct) }}","rightValue":10,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"bf400012-1111-4222-8333-444455556612","name":"Apply New Ad Set Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1640,400],"parameters":{"method":"POST","url":"https://graph.facebook.com/v21.0/{{ $json.adset_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: $json.new_budget_minor }) }}","options":{}}},{"id":"bf400013-1111-4222-8333-444455556613","name":"Hold Ad Set Steady","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[1640,660],"parameters":{"assignments":{"assignments":[{"id":"h1","name":"action","value":"hold","type":"string"},{"id":"h2","name":"applied","value":"={{ false }}","type":"boolean"},{"id":"h3","name":"note","value":"=Within 10% of ideal budget at pace index {{ $json.pace_index }} — no API call made","type":"string"}]},"includeOtherFields":true,"options":{}}},{"id":"bf400014-1111-4222-8333-444455556614","name":"Build War Room Digest","type":"n8n-nodes-base.code","typeVersion":2,"position":[1900,400],"parameters":{"jsCode":"// ── War-room digest ───────────────────────────────────────────────────────\n// One Slack message per run instead of one per ad set. Dedupes repeated\n// warnings so the channel stays readable during a 36-hour promo.\n// NOTE: we read the DECISION rows straight off the engine, not $input — the\n// Meta budget call upstream replaces each item with the API response, so\n// $input here is a mix of API acks and held rows.\nconst rows = $('Pacing & Budget Shift Engine').all().map(i => i.json);\nif (!rows.length) return [{ json: { text: 'BFCM loop ran, no ad sets returned.', skip: true } }];\n\nconst a = rows.find(r => r.pace_index !== undefined) || rows[0];\nconst money = (n) => '$' + Number(n || 0).toLocaleString('en-US');\nconst byAction = (k) => rows.filter(r => r.action === k);\n\nconst scaled  = byAction('scale');\nconst trimmed = byAction('trim');\nconst cut     = byAction('cut_hard');\nconst paused  = byAction('pause_stockout');\n\nconst moved = scaled.reduce((s, r) => s + (r.new_budget - r.current_budget), 0);\nconst freed = [...trimmed, ...cut, ...paused].reduce((s, r) => s + (r.current_budget - r.new_budget), 0);\n\nconst line = (r) => `• ${r.adset_name} — ROAS ${Number(r.roas || 0).toFixed(2)} | ${money(r.current_budget)} → ${money(r.new_budget)} (${r.delta_pct > 0 ? '+' : ''}${r.delta_pct}%)`;\n\n// dedupe stock warnings by SKU (several ad sets can share one hero product)\nconst stockSeen = new Set();\nconst stockLines = paused.filter(r => {\n  if (stockSeen.has(r.promo_sku)) return false;\n  stockSeen.add(r.promo_sku); return true;\n}).map(r => `• ${r.promo_sku} — ${r.stock_qty} units left, ${r.adset_name} paused`);\n\nconst emoji = a.pace_state === 'AHEAD' ? '🔥' : a.pace_state === 'BEHIND' ? '🐢' : '✅';\n\nconst parts = [\n  `${emoji} *BFCM ${String(a.hour_utc).padStart(2, '0')}:00 UTC — ${a.plan_block}*`,\n  `Spend ${money(a.account_spend)} · Revenue ${money(a.account_revenue)} · Blended ROAS *${a.blended_roas}*`,\n  `Pacing *${a.pace_state}* (index ${a.pace_index}, projected ${money(a.projected_day_spend)}) · goal attainment ${Math.round(a.goal_attainment * 100)}%`,\n  `Budget moved: +${money(moved)} into winners, ${money(freed)} pulled out.`\n];\nif (scaled.length)     parts.push('*Scaling*\\n' + scaled.slice(0, 5).map(line).join('\\n'));\nif (trimmed.length || cut.length) parts.push('*Trimming*\\n' + [...cut, ...trimmed].slice(0, 5).map(line).join('\\n'));\nif (stockLines.length) parts.push('*Stock-out guard*\\n' + stockLines.join('\\n'));\n\nreturn [{ json: {\n  text: parts.join('\\n\\n'),\n  hour_utc: a.hour_utc,\n  pace_state: a.pace_state,\n  pace_index: a.pace_index,\n  blended_roas: a.blended_roas,\n  account_spend: a.account_spend,\n  account_revenue: a.account_revenue,\n  scaled: scaled.length,\n  trimmed: trimmed.length + cut.length,\n  paused_stockout: paused.length,\n  budget_in: Math.round(moved),\n  budget_out: Math.round(freed),\n  skip: false\n} }];"}},{"id":"bf400015-1111-4222-8333-444455556615","name":"Post BFCM War Room Update","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2160,400],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#bfcm-war-room","mode":"name"},"text":"={{ $json.text }}","otherOptions":{}}},{"id":"bf400016-1111-4222-8333-444455556616","name":"Log Hourly Decisions","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2420,400],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"BFCM Decision Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bf400017-1111-4222-8333-444455556617","name":"Note Intake","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-500,60],"parameters":{"content":"## 1. INTAKE — plan, spend, stock\nEvery hour: read the hour-by-hour promo plan (cumulative spend share + revenue goal per block), pull today's ad-set insights from Meta, and pull live Shopify variant stock. All three feed the engine.","height":320,"width":460,"color":4}},{"id":"bf400018-1111-4222-8333-444455556618","name":"Note Engine","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[820,60],"parameters":{"content":"## 2. ENGINE — pace, score, reallocate\nProjects end-of-day spend from the plan curve (pace_index), scores every ad set on ROAS / volume / funnel minus frequency fatigue, then reallocates the remaining day budget by score with a ±35%/30% hourly clamp so Meta never re-enters learning.","height":320,"width":460,"color":5}},{"id":"bf400019-1111-4222-8333-444455556619","name":"Note Actions","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1360,-140],"parameters":{"content":"## 3. ACTIONS — guard, shift, alert\nStock-out guard wins first: any ad set on a SKU at or under the stock floor gets PAUSED and alerted. Everything else either gets a new daily_budget pushed to Meta or is left alone, then one deduped war-room digest goes to Slack and every decision is logged.","height":300,"width":480,"color":3}}],"connections":{"Hourly BFCM Pulse":{"main":[[{"node":"BFCM Control Config","type":"main","index":0}]]},"BFCM Control Config":{"main":[[{"node":"Read BFCM Promo Plan","type":"main","index":0},{"node":"Fetch Shopify Stock Levels","type":"main","index":0}]]},"Read BFCM Promo Plan":{"main":[[{"node":"Fetch Hourly Ad Set Insights","type":"main","index":0}]]},"Fetch Hourly Ad Set Insights":{"main":[[{"node":"Join Ads With Inventory","type":"main","index":0}]]},"Fetch Shopify Stock Levels":{"main":[[{"node":"Join Ads With Inventory","type":"main","index":1}]]},"Join Ads With Inventory":{"main":[[{"node":"Pacing & Budget Shift Engine","type":"main","index":0}]]},"Pacing & Budget Shift Engine":{"main":[[{"node":"SKU Sold Out?","type":"main","index":0}]]},"SKU Sold Out?":{"main":[[{"node":"Pause Sold-Out Ad Sets","type":"main","index":0}],[{"node":"Budget Change Needed?","type":"main","index":0}]]},"Pause Sold-Out Ad Sets":{"main":[[{"node":"Alert Ops On Stock-Out","type":"main","index":0}]]},"Alert Ops On Stock-Out":{"main":[[{"node":"Log Hourly Decisions","type":"main","index":0}]]},"Budget Change Needed?":{"main":[[{"node":"Apply New Ad Set Budget","type":"main","index":0}],[{"node":"Hold Ad Set Steady","type":"main","index":0}]]},"Apply New Ad Set Budget":{"main":[[{"node":"Build War Room Digest","type":"main","index":0}]]},"Hold Ad Set Steady":{"main":[[{"node":"Build War Room Digest","type":"main","index":0}]]},"Build War Room Digest":{"main":[[{"node":"Post BFCM War Room Update","type":"main","index":0}]]},"Post BFCM War Room Update":{"main":[[{"node":"Log Hourly Decisions","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}