{
  "id": "h6lHBu07HwpxuhAK",
  "meta": {
    "builderVariant": "mcp",
    "aiBuilderAssisted": true
  },
  "name": "Convert UTC timestamps to MENA local time, work week and Ramadan-aware send windows",
  "tags": [],
  "nodes": [
    {
      "id": "a80d7e80-4b11-4f2a-8ac8-dea3ec087aad",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        480
      ],
      "parameters": {
        "width": 480,
        "height": 1184,
        "content": "## Convert UTC timestamps to MENA local time, work week and Ramadan-aware send windows\n\nEvery n8n workspace runs on UTC, and almost every scheduling template assumes a Mon-Fri week. Both are wrong for the Arabic-speaking world: the work week is Sun-Thu, the consumer peak is 19:00-22:00 local, and Ramadan moves that peak past midnight.\n\nCall this sub-workflow from any automation with a timestamp and a country. It returns one typed object telling you what time it really is there.\n\n### How it works\n\nThree Code nodes. No packages, no credentials, no API calls.\n\n1. **Local time and work week** - converts to the target zone, emits an explicit +03:00 offset, and maps the weekday against a per-country weekend table. Also returns `local_day_start_utc` and `local_day_end_utc`, the values your daily rollups should use instead of `CURRENT_DATE`. That is the fix for numbers that appear to reset at 03:00.\n2. **Hijri and season** - Umm al-Qura date from Node's built-in ICU, plus Ramadan, Eid al-Fitr, Eid al-Adha and Hajj flags.\n3. **Daypart and send window** - returns `is_sendable`, and when false a `block_reason` and a `send_after` timestamp you can queue on. Friday 11:00-13:00 is suppressed for everyone.\n\n### Setup\n\nNothing to configure to try it. Open **Initiate Self Test** and execute: 20 assertions run and print a pass/fail table.\n\nTo use it, add an Execute Sub-workflow node in your own workflow and pass `ts_utc`, `tz`, `country` (iq sa ae kw qa bh om jo eg) and `audience` (b2c or b2b).\n\n### Customization\n\nEvery country defaults to a Fri-Sat weekend except `ae`, which is Sat-Sun for the public sector since 2022. Edit the `WEEKEND` map in the first and third Code nodes if your market differs.\n\nPass `holiday_overrides` as `[{ date: \"YYYY-MM-DD\", country: \"iq\", name: \"Eid\" }]` to correct dates. Use `\"*\"` for all countries. This matters: Umm al-Qura is astronomical, while real Eid dates follow local moon sighting and can differ by a day between neighbouring countries."
      },
      "typeVersion": 1
    },
    {
      "id": "4017752e-4eda-4526-9d35-573f2dccce1f",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2224,
        480
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 368,
        "content": "## Production entry path\n\nA calling workflow passes ts_utc, tz, country, audience and optional holiday_overrides. Anything missing falls back to Baghdad, Iraq, b2c and now."
      },
      "typeVersion": 1
    },
    {
      "id": "e060ba6a-e228-41cb-8832-65ae486a986a",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2224,
        880
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 336,
        "content": "## Self-test entry path\n\nBuilds 20 assertion cases and feeds them through the same resolver the production path uses. Run it manually after import."
      },
      "typeVersion": 1
    },
    {
      "id": "fac2bfec-81b3-414e-b55b-ed271b7cb59a",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2672,
        608
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 336,
        "content": "## Resolve time context\n\nThree pure-JS stages shared by both entry paths: local time and Sun-Thu work week, then Umm al-Qura Hijri and season, then daypart and send window."
      },
      "typeVersion": 1
    },
    {
      "id": "cfd5fd69-4e77-453f-9d9b-572bbf5cd807",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3344,
        480
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 560,
        "content": "## Route the output\n\nSelf-test runs get a pass/fail table. Real callers get the typed contract, where every timestamp carries an explicit UTC offset, never a naive local time."
      },
      "typeVersion": 1
    },
    {
      "id": "75fd20ed-46ed-4379-9b51-5252dc070404",
      "name": "Workflow Invocation Trigger",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "position": [
        2272,
        688
      ],
      "parameters": {
        "workflowInputs": {
          "values": [
            {
              "name": "ts_utc"
            },
            {
              "name": "tz"
            },
            {
              "name": "country"
            },
            {
              "name": "audience"
            },
            {
              "name": "holiday_overrides",
              "type": "array"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "3645514b-5787-4e41-bad0-e82bc3d96ab4",
      "name": "Set Default Parameters",
      "type": "n8n-nodes-base.set",
      "position": [
        2496,
        688
      ],
      "parameters": {
        "options": {
          "ignoreConversionErrors": true
        },
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "tz",
              "type": "string",
              "value": "={{ $json.tz || \"Asia/Baghdad\" }}"
            },
            {
              "id": "a2",
              "name": "country",
              "type": "string",
              "value": "={{ $json.country || \"iq\" }}"
            },
            {
              "id": "a3",
              "name": "audience",
              "type": "string",
              "value": "={{ $json.audience || \"b2c\" }}"
            },
            {
              "id": "a4",
              "name": "ts_utc",
              "type": "string",
              "value": "={{ $json.ts_utc || $now.toUTC().toISO() }}"
            },
            {
              "id": "a5",
              "name": "holiday_overrides",
              "type": "array",
              "value": "={{ $json.holiday_overrides || [] }}"
            },
            {
              "id": "a6",
              "name": "is_self_test",
              "type": "boolean",
              "value": false
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.5
    },
    {
      "id": "a4d8fb5e-7c72-45ff-bffa-ef1854e9ad74",
      "name": "Calculate Local Time & Work Week",
      "type": "n8n-nodes-base.code",
      "position": [
        2720,
        784
      ],
      "parameters": {
        "jsCode": "\nconst WD = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };\n\nconst WEEKEND = {\n  iq: [5, 6], sa: [5, 6], kw: [5, 6], qa: [5, 6], bh: [5, 6],\n  om: [5, 6], jo: [5, 6], eg: [5, 6],\n  ae: [6, 0]\n};\n\nfunction tzParts(date, tz) {\n  const dtf = new Intl.DateTimeFormat('en-US', {\n    timeZone: tz, hour12: false, weekday: 'short',\n    year: 'numeric', month: '2-digit', day: '2-digit',\n    hour: '2-digit', minute: '2-digit', second: '2-digit'\n  });\n  const o = {};\n  for (const p of dtf.formatToParts(date)) o[p.type] = p.value;\n  if (o.hour === '24') o.hour = '00';\n  return o;\n}\n\nfunction offsetMinutes(date, tz) {\n  const p = tzParts(date, tz);\n  const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);\n  return Math.round((asUTC - date.getTime()) / 60000);\n}\n\nfunction offsetString(mins) {\n  const sign = mins < 0 ? '-' : '+';\n  const a = Math.abs(mins);\n  return sign + String(Math.floor(a / 60)).padStart(2, '0') + ':' + String(a % 60).padStart(2, '0');\n}\n\nfunction utcFromWall(y, mo, d, h, mi, s, tz) {\n  const target = Date.UTC(y, mo - 1, d, h, mi, s);\n  let guess = target;\n  for (let i = 0; i < 4; i++) {\n    const off = offsetMinutes(new Date(guess), tz);\n    const next = target - off * 60000;\n    if (next === guess) break;\n    guess = next;\n  }\n  return new Date(guess);\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json;\n  const tz = j.tz || 'Asia/Baghdad';\n  const country = String(j.country || 'iq').toLowerCase();\n  const audience = j.audience === 'b2b' ? 'b2b' : 'b2c';\n\n  const base = j.ts_utc ? new Date(j.ts_utc) : new Date();\n  if (isNaN(base.getTime())) {\n    throw new Error('MENA Clock: invalid ts_utc \"' + j.ts_utc + '\" (expected ISO 8601)');\n  }\n\n  const p = tzParts(base, tz);\n  const offMin = offsetMinutes(base, tz);\n  const offStr = offsetString(offMin);\n  const wdIdx = WD[p.weekday];\n  const weekend = WEEKEND[country] || [5, 6];\n  const isWeekend = weekend.indexOf(wdIdx) !== -1;\n\n  const y = +p.year, mo = +p.month, d = +p.day;\n  const dayStart = utcFromWall(y, mo, d, 0, 0, 0, tz);\n  const probe = tzParts(new Date(dayStart.getTime() + 26 * 3600000), tz);\n  const dayEnd = utcFromWall(+probe.year, +probe.month, +probe.day, 0, 0, 0, tz);\n\n  let nextWork = null;\n  for (let k = 0; k <= 8; k++) {\n    const cand = new Date(dayStart.getTime() + k * 90000000);\n    const cp = tzParts(cand, tz);\n    if (weekend.indexOf(WD[cp.weekday]) !== -1) continue;\n    const start = utcFromWall(+cp.year, +cp.month, +cp.day, 9, 0, 0, tz);\n    if (start.getTime() > base.getTime()) { nextWork = start; break; }\n  }\n\n  out.push({ json: Object.assign({}, j, {\n    tz: tz,\n    country: country,\n    audience: audience,\n    ts_utc: base.toISOString(),\n    local_iso: p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute + ':' + p.second + offStr,\n    local_date: p.year + '-' + p.month + '-' + p.day,\n    local_hour: +p.hour,\n    local_minute: +p.minute,\n    utc_offset: offStr,\n    utc_offset_minutes: offMin,\n    weekday_local: p.weekday,\n    weekday_index: wdIdx,\n    is_weekend: isWeekend,\n    is_workday: !isWeekend,\n    next_workday_start: nextWork ? nextWork.toISOString() : null,\n    local_day_start_utc: dayStart.toISOString(),\n    local_day_end_utc: dayEnd.toISOString()\n  }) });\n}\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "d00e0e9e-9777-4542-9042-513df9c0a5d4",
      "name": "Determine Hijri Date & Season",
      "type": "n8n-nodes-base.code",
      "position": [
        2944,
        784
      ],
      "parameters": {
        "jsCode": "\nconst HIJRI_MONTHS = [\n  'Muharram', 'Safar', 'Rabi al-Awwal', 'Rabi al-Thani', 'Jumada al-Ula', 'Jumada al-Akhirah',\n  'Rajab', 'Shaban', 'Ramadan', 'Shawwal', 'Dhu al-Qadah', 'Dhu al-Hijjah'\n];\n\nfunction hijriOn(date, tz) {\n  const dtf = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', {\n    timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric'\n  });\n  const o = {};\n  for (const p of dtf.formatToParts(date)) o[p.type] = p.value;\n  return { y: parseInt(o.year, 10), m: parseInt(o.month, 10), d: parseInt(o.day, 10) };\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json;\n  const tz = j.tz;\n  const base = new Date(j.ts_utc);\n  const h = hijriOn(base, tz);\n\n  const isRamadan = h.m === 9;\n  const isEidFitr = h.m === 10 && h.d >= 1 && h.d <= 3;\n  const isEidAdha = h.m === 12 && h.d >= 10 && h.d <= 13;\n  const isHajj = h.m === 12 && h.d >= 8 && h.d <= 13;\n\n  let daysToRamadan = 0;\n  if (!isRamadan) {\n    daysToRamadan = null;\n    for (let k = 1; k <= 380; k++) {\n      const probe = hijriOn(new Date(base.getTime() + k * 86400000), tz);\n      if (probe.m === 9) { daysToRamadan = k; break; }\n    }\n  }\n\n  const overrides = Array.isArray(j.holiday_overrides) ? j.holiday_overrides : [];\n  let holiday = null;\n  for (const o of overrides) {\n    if (!o || !o.date) continue;\n    const scope = String(o.country || '*').toLowerCase();\n    if (scope !== '*' && scope !== j.country) continue;\n    if (String(o.date) === j.local_date) { holiday = String(o.name || 'Holiday'); break; }\n  }\n\n  out.push({ json: Object.assign({}, j, {\n    hijri_year: h.y,\n    hijri_month: h.m,\n    hijri_day: h.d,\n    hijri_month_name: HIJRI_MONTHS[h.m - 1] || String(h.m),\n    is_ramadan: isRamadan,\n    ramadan_day: isRamadan ? h.d : null,\n    is_eid_fitr: isEidFitr,\n    is_eid_adha: isEidAdha,\n    is_hajj_window: isHajj,\n    days_to_ramadan: daysToRamadan,\n    is_holiday: holiday !== null,\n    holiday_name: holiday\n  }) });\n}\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "8f2c6886-20d3-4102-9a35-9a009bdb27f5",
      "name": "Identify Daypart & Send Window",
      "type": "n8n-nodes-base.code",
      "position": [
        3168,
        784
      ],
      "parameters": {
        "jsCode": "\nconst WD = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };\nconst WEEKEND = {\n  iq: [5, 6], sa: [5, 6], kw: [5, 6], qa: [5, 6], bh: [5, 6],\n  om: [5, 6], jo: [5, 6], eg: [5, 6],\n  ae: [6, 0]\n};\n\nfunction tzParts(date, tz) {\n  const dtf = new Intl.DateTimeFormat('en-US', {\n    timeZone: tz, hour12: false, weekday: 'short',\n    year: 'numeric', month: '2-digit', day: '2-digit',\n    hour: '2-digit', minute: '2-digit', second: '2-digit'\n  });\n  const o = {};\n  for (const p of dtf.formatToParts(date)) o[p.type] = p.value;\n  if (o.hour === '24') o.hour = '00';\n  return o;\n}\nfunction offsetMinutes(date, tz) {\n  const p = tzParts(date, tz);\n  return Math.round((Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime()) / 60000);\n}\nfunction utcFromWall(y, mo, d, h, mi, s, tz) {\n  const target = Date.UTC(y, mo - 1, d, h, mi, s);\n  let guess = target;\n  for (let i = 0; i < 4; i++) {\n    const off = offsetMinutes(new Date(guess), tz);\n    const next = target - off * 60000;\n    if (next === guess) break;\n    guess = next;\n  }\n  return new Date(guess);\n}\n\nconst SEND_START = 9;\nconst PRAYER_START = 11;\nconst PRAYER_END = 13;\nconst RAMADAN_LATE_END = 2;\n\nfunction daypartFor(h, audience, isRamadan) {\n  if (audience === 'b2b') {\n    if (h >= 9 && h < 14) return 'business_peak';\n    if (h >= 6 && h < 9) return 'morning';\n    if (h >= 14 && h < 18) return 'afternoon';\n    if (h >= 18 && h < 23) return 'evening';\n    return 'late_night';\n  }\n  if (isRamadan) {\n    if (h >= 22 || h < 2) return 'evening_peak';\n    if (h >= 2 && h < 6) return 'late_night';\n    if (h >= 6 && h < 12) return 'morning';\n    if (h >= 12 && h < 17) return 'afternoon';\n    return 'evening';\n  }\n  if (h >= 19 && h < 22) return 'evening_peak';\n  if (h >= 6 && h < 12) return 'morning';\n  if (h >= 12 && h < 17) return 'afternoon';\n  if (h >= 17 && h < 19) return 'evening';\n  if (h >= 22) return 'evening';\n  return 'late_night';\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const j = item.json;\n  const tz = j.tz;\n  const audience = j.audience;\n  const isB2B = audience === 'b2b';\n  const weekend = WEEKEND[j.country] || [5, 6];\n  const base = new Date(j.ts_utc);\n  const h = j.local_hour;\n\n  const isRamadanB2C = !isB2B && j.is_ramadan;\n  const sendEnd = isB2B ? 17 : (isRamadanB2C ? 24 : 21);\n  const lateWindowEnd = isRamadanB2C ? RAMADAN_LATE_END : 0;\n\n  const daypart = daypartFor(h, audience, j.is_ramadan);\n\n  const inWindow = (h >= SEND_START && h < sendEnd) || (lateWindowEnd > 0 && h < lateWindowEnd);\n\n  let blockReason = null;\n  if (isB2B && j.is_weekend) blockReason = 'weekend';\n  else if (isB2B && j.is_holiday) blockReason = 'holiday';\n  else if (j.weekday_index === 5 && h >= PRAYER_START && h < PRAYER_END) blockReason = 'friday_prayer';\n  else if (!inWindow) blockReason = h < SEND_START ? 'before_window' : 'after_window';\n\n  const isSendable = blockReason === null;\n\n  let sendAfter = null;\n  if (!isSendable) {\n    if (blockReason === 'friday_prayer') {\n      const p = tzParts(base, tz);\n      sendAfter = utcFromWall(+p.year, +p.month, +p.day, PRAYER_END, 0, 0, tz);\n    } else {\n      const dayStart = new Date(j.local_day_start_utc);\n      for (let k = 0; k <= 9; k++) {\n        const cp = tzParts(new Date(dayStart.getTime() + k * 90000000), tz);\n        if (isB2B && weekend.indexOf(WD[cp.weekday]) !== -1) continue;\n        const cand = utcFromWall(+cp.year, +cp.month, +cp.day, SEND_START, 0, 0, tz);\n        if (cand.getTime() > base.getTime()) { sendAfter = cand; break; }\n      }\n    }\n  }\n\n  out.push({ json: Object.assign({}, j, {\n    daypart: daypart,\n    is_evening_peak: daypart === 'evening_peak',\n    is_business_peak: daypart === 'business_peak',\n    send_window_start_hour: SEND_START,\n    send_window_end_hour: sendEnd,\n    is_sendable: isSendable,\n    block_reason: blockReason,\n    send_after: sendAfter ? sendAfter.toISOString() : null\n  }) });\n}\nreturn out;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "185fe995-5743-4446-ad20-06c17955c982",
      "name": "Check Self Test Condition",
      "type": "n8n-nodes-base.if",
      "position": [
        3392,
        784
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.is_self_test }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "b3df067d-9473-4a3e-915c-a62e2de8e83f",
      "name": "Perform Assertions & Reporting",
      "type": "n8n-nodes-base.code",
      "position": [
        3616,
        880
      ],
      "parameters": {
        "jsCode": "\nconst rows = [];\nlet passed = 0, failed = 0;\n\nfor (const item of $input.all()) {\n  const j = item.json;\n  const expect = j.expect || {};\n  const misses = [];\n  for (const key of Object.keys(expect)) {\n    const want = expect[key];\n    const got = j[key];\n    if (JSON.stringify(want) !== JSON.stringify(got)) {\n      misses.push(key + ': expected ' + JSON.stringify(want) + ', got ' + JSON.stringify(got));\n    }\n  }\n  const ok = misses.length === 0;\n  if (ok) passed++; else failed++;\n  rows.push({\n    status: ok ? 'PASS' : 'FAIL',\n    case: j.case_name,\n    local_iso: j.local_iso,\n    failures: misses\n  });\n}\n\nreturn [{ json: {\n  suite: 'MENA Clock self-test',\n  total: rows.length,\n  passed: passed,\n  failed: failed,\n  all_passed: failed === 0,\n  results: rows\n} }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "79a008aa-da07-45ea-b69c-73b0ad30cbec",
      "name": "Return MENA Clock Contract",
      "type": "n8n-nodes-base.set",
      "position": [
        3616,
        688
      ],
      "parameters": {
        "options": {
          "ignoreConversionErrors": true
        },
        "assignments": {
          "assignments": [
            {
              "id": "o1",
              "name": "local_iso",
              "type": "string",
              "value": "={{ $json.local_iso }}"
            },
            {
              "id": "o2",
              "name": "local_date",
              "type": "string",
              "value": "={{ $json.local_date }}"
            },
            {
              "id": "o3",
              "name": "utc_offset",
              "type": "string",
              "value": "={{ $json.utc_offset }}"
            },
            {
              "id": "o4",
              "name": "weekday_local",
              "type": "string",
              "value": "={{ $json.weekday_local }}"
            },
            {
              "id": "o5",
              "name": "is_workday",
              "type": "boolean",
              "value": "={{ $json.is_workday }}"
            },
            {
              "id": "o6",
              "name": "is_weekend",
              "type": "boolean",
              "value": "={{ $json.is_weekend }}"
            },
            {
              "id": "o7",
              "name": "next_workday_start",
              "type": "string",
              "value": "={{ $json.next_workday_start }}"
            },
            {
              "id": "o8",
              "name": "local_day_start_utc",
              "type": "string",
              "value": "={{ $json.local_day_start_utc }}"
            },
            {
              "id": "o9",
              "name": "local_day_end_utc",
              "type": "string",
              "value": "={{ $json.local_day_end_utc }}"
            },
            {
              "id": "o10",
              "name": "hijri_year",
              "type": "number",
              "value": "={{ $json.hijri_year }}"
            },
            {
              "id": "o11",
              "name": "hijri_month_name",
              "type": "string",
              "value": "={{ $json.hijri_month_name }}"
            },
            {
              "id": "o12",
              "name": "hijri_day",
              "type": "number",
              "value": "={{ $json.hijri_day }}"
            },
            {
              "id": "o13",
              "name": "is_ramadan",
              "type": "boolean",
              "value": "={{ $json.is_ramadan }}"
            },
            {
              "id": "o14",
              "name": "ramadan_day",
              "type": "number",
              "value": "={{ $json.ramadan_day }}"
            },
            {
              "id": "o15",
              "name": "is_eid_fitr",
              "type": "boolean",
              "value": "={{ $json.is_eid_fitr }}"
            },
            {
              "id": "o16",
              "name": "is_eid_adha",
              "type": "boolean",
              "value": "={{ $json.is_eid_adha }}"
            },
            {
              "id": "o17",
              "name": "is_hajj_window",
              "type": "boolean",
              "value": "={{ $json.is_hajj_window }}"
            },
            {
              "id": "o18",
              "name": "days_to_ramadan",
              "type": "number",
              "value": "={{ $json.days_to_ramadan }}"
            },
            {
              "id": "o19",
              "name": "is_holiday",
              "type": "boolean",
              "value": "={{ $json.is_holiday }}"
            },
            {
              "id": "o20",
              "name": "holiday_name",
              "type": "string",
              "value": "={{ $json.holiday_name }}"
            },
            {
              "id": "o21",
              "name": "daypart",
              "type": "string",
              "value": "={{ $json.daypart }}"
            },
            {
              "id": "o22",
              "name": "is_evening_peak",
              "type": "boolean",
              "value": "={{ $json.is_evening_peak }}"
            },
            {
              "id": "o23",
              "name": "is_sendable",
              "type": "boolean",
              "value": "={{ $json.is_sendable }}"
            },
            {
              "id": "o24",
              "name": "block_reason",
              "type": "string",
              "value": "={{ $json.block_reason }}"
            },
            {
              "id": "o25",
              "name": "send_after",
              "type": "string",
              "value": "={{ $json.send_after }}"
            }
          ]
        }
      },
      "typeVersion": 3.5
    },
    {
      "id": "82f8e648-92bb-4553-903c-2ea97e5f479b",
      "name": "Initiate Self Test",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        2272,
        1056
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "f08bd719-3ad1-4f91-a622-b5326bfe827c",
      "name": "Build Assertion Cases",
      "type": "n8n-nodes-base.code",
      "position": [
        2496,
        1056
      ],
      "parameters": {
        "jsCode": "\nconst cases = [\n  { name: 'Baghdad Friday is a weekend day', ts_utc: '2026-08-07T09:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    expect: { weekday_local: 'Fri', is_weekend: true, is_workday: false, is_sendable: false, block_reason: 'weekend', utc_offset: '+03:00' } },\n  { name: 'Baghdad Sunday is a workday', ts_utc: '2026-08-09T07:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    expect: { weekday_local: 'Sun', is_weekend: false, is_workday: true, is_sendable: true, block_reason: null } },\n  { name: 'UAE Sunday is a WEEKEND day (Sat-Sun since 2022)', ts_utc: '2026-08-09T07:00:00Z', tz: 'Asia/Dubai', country: 'ae', audience: 'b2b',\n    expect: { is_weekend: true, is_workday: false, utc_offset: '+04:00' } },\n  { name: 'Baghdad 22:00 local resolves the correct local day start', ts_utc: '2026-08-09T19:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { local_hour: 22, local_date: '2026-08-09', local_day_start_utc: '2026-08-08T21:00:00.000Z' } },\n  { name: 'Baghdad 00:30 local belongs to the NEXT local day, not the UTC day', ts_utc: '2026-08-09T21:30:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { local_date: '2026-08-10', local_hour: 0, is_sendable: false, block_reason: 'before_window' } },\n  { name: 'b2c evening peak is 19:00-22:00 outside Ramadan', ts_utc: '2026-08-09T17:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { local_hour: 20, daypart: 'evening_peak', is_sendable: true } },\n  { name: 'Friday midday prayer window is blocked', ts_utc: '2026-08-07T09:30:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { local_hour: 12, weekday_local: 'Fri', is_sendable: false, block_reason: 'friday_prayer' } },\n  { name: 'A non-Ramadan date reports is_ramadan false', ts_utc: '2026-08-09T12:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { is_ramadan: false, ramadan_day: null } },\n  { name: 'Civil override marks a holiday and blocks b2b', ts_utc: '2026-08-10T08:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    holiday_overrides: [{ date: '2026-08-10', country: 'iq', name: 'Test Holiday' }],\n    expect: { is_holiday: true, holiday_name: 'Test Holiday', is_sendable: false, block_reason: 'holiday' } },\n  { name: 'Override scoped to another country does not apply', ts_utc: '2026-08-10T08:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    holiday_overrides: [{ date: '2026-08-10', country: 'sa', name: 'Saudi Only' }],\n    expect: { is_holiday: false, holiday_name: null } },\n  { name: 'Wildcard override applies to every country', ts_utc: '2026-08-10T08:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    holiday_overrides: [{ date: '2026-08-10', country: '*', name: 'Global Day' }],\n    expect: { is_holiday: true, holiday_name: 'Global Day' } },\n  { name: 'Cairo resolves its own local weekday', ts_utc: '2026-08-09T10:00:00Z', tz: 'Africa/Cairo', country: 'eg', audience: 'b2c',\n    expect: { weekday_local: 'Sun' } },\n  { name: 'Before the window queues to 09:00 the same day', ts_utc: '2026-08-09T04:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { local_hour: 7, is_sendable: false, block_reason: 'before_window', send_after: '2026-08-09T06:00:00.000Z' } },\n  { name: 'After the b2c window queues to 09:00 the next day', ts_utc: '2026-08-09T19:30:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { local_hour: 22, is_sendable: false, block_reason: 'after_window', send_after: '2026-08-10T06:00:00.000Z' } },\n  { name: 'b2b Thursday evening queues to Sunday 09:00, skipping Fri-Sat', ts_utc: '2026-08-06T15:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    expect: { weekday_local: 'Thu', is_sendable: false, send_after: '2026-08-09T06:00:00.000Z' } },\n  { name: 'next_workday_start skips the Fri-Sat weekend', ts_utc: '2026-08-07T09:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    expect: { next_workday_start: '2026-08-09T06:00:00.000Z' } },\n  { name: 'Ramadan 22:30 b2c is the peak AND is sendable, not cut off at 21:00', ts_utc: '2026-02-25T19:30:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { is_ramadan: true, ramadan_day: 8, local_hour: 22, daypart: 'evening_peak', is_sendable: true, block_reason: null } },\n  { name: 'Ramadan 01:30 b2c is still inside the late window, past midnight', ts_utc: '2026-02-25T22:30:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { is_ramadan: true, local_hour: 1, is_sendable: true, block_reason: null } },\n  { name: 'Ramadan 03:00 b2c is past the late window and blocked', ts_utc: '2026-02-26T00:00:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2c',\n    expect: { is_ramadan: true, local_hour: 3, is_sendable: false, block_reason: 'before_window' } },\n  { name: 'Ramadan does NOT extend the b2b window', ts_utc: '2026-02-25T19:30:00Z', tz: 'Asia/Baghdad', country: 'iq', audience: 'b2b',\n    expect: { is_ramadan: true, local_hour: 22, is_sendable: false, block_reason: 'after_window' } }\n];\n\nreturn cases.map(function (c) {\n  return { json: {\n    is_self_test: true,\n    case_name: c.name,\n    expect: c.expect,\n    ts_utc: c.ts_utc,\n    tz: c.tz,\n    country: c.country,\n    audience: c.audience,\n    holiday_overrides: c.holiday_overrides || []\n  } };\n});\n"
      },
      "typeVersion": 2
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": true,
    "executionOrder": "v1"
  },
  "versionId": "37868551-daa4-4a8f-8e08-79eca82c1bfa",
  "nodeGroups": [],
  "connections": {
    "Initiate Self Test": {
      "main": [
        [
          {
            "node": "Build Assertion Cases",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Assertion Cases": {
      "main": [
        [
          {
            "node": "Calculate Local Time & Work Week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Default Parameters": {
      "main": [
        [
          {
            "node": "Calculate Local Time & Work Week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Self Test Condition": {
      "main": [
        [
          {
            "node": "Perform Assertions & Reporting",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Return MENA Clock Contract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Workflow Invocation Trigger": {
      "main": [
        [
          {
            "node": "Set Default Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Determine Hijri Date & Season": {
      "main": [
        [
          {
            "node": "Identify Daypart & Send Window",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Identify Daypart & Send Window": {
      "main": [
        [
          {
            "node": "Check Self Test Condition",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Local Time & Work Week": {
      "main": [
        [
          {
            "node": "Determine Hijri Date & Season",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}