{
  "name": "Spawn recurring Notion tasks from a rules database on a schedule",
  "tags": [],
  "nodes": [
    {
      "id": "10a4ac70-7f7f-4dac-862e-4aa3be328de6",
      "name": "When Daily Schedule Fires",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        576,
        288
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 6
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "b269b54f-4e73-46b1-a12a-23be78a27378",
      "name": "Read Recurrence Rules",
      "type": "n8n-nodes-base.notion",
      "position": [
        832,
        288
      ],
      "parameters": {
        "options": {},
        "resource": "databasePage",
        "operation": "getAll",
        "returnAll": true,
        "databaseId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_NOTION_RULES_DATABASE_ID",
          "cachedResultName": "Recurrence rules"
        }
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "36f47629-0642-4218-a869-39b1023c9174",
      "name": "Select Rules Due Today",
      "type": "n8n-nodes-base.code",
      "position": [
        1072,
        288
      ],
      "parameters": {
        "jsCode": "const TIMEZONE = 'UTC';\n\nfunction todayIn(tz) {\n  const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' });\n  const parts = fmt.formatToParts(new Date());\n  const bag = {};\n  for (let i = 0; i < parts.length; i++) { bag[parts[i].type] = parts[i].value; }\n  const y = Number(bag.year);\n  const m = Number(bag.month);\n  const d = Number(bag.day);\n  return {\n    iso: bag.year + '-' + bag.month + '-' + bag.day,\n    year: y,\n    month: m,\n    day: d,\n    weekday: String(bag.weekday).slice(0, 3).toLowerCase(),\n    daysInMonth: new Date(Date.UTC(y, m, 0)).getUTCDate()\n  };\n}\n\nfunction toUtcDays(value) {\n  const bits = String(value == null ? '' : value).slice(0, 10).split('-');\n  if (bits.length !== 3) return null;\n  const stamp = Date.UTC(Number(bits[0]), Number(bits[1]) - 1, Number(bits[2]));\n  if (!isFinite(stamp)) return null;\n  return Math.round(stamp / 86400000);\n}\n\nfunction shortDays(raw) {\n  return String(raw == null ? '' : raw).toLowerCase().split(',').map(function (s) { return s.trim().slice(0, 3); }).filter(function (s) { return s.length === 3; });\n}\n\nconst today = todayIn(TIMEZONE);\nconst todayDays = toUtcDays(today.iso);\nconst nthThisMonth = Math.floor((today.day - 1) / 7) + 1;\nconst isLastOfKind = today.day + 7 > today.daysInMonth;\nconst rules = $input.all().map(function (i) { return i.json; });\nconst due = [];\n\nfor (let r = 0; r < rules.length; r++) {\n  const rule = rules[r];\n  const ruleId = String(rule.rule_id == null ? '' : rule.rule_id).trim();\n  const title = String(rule.task_title == null ? '' : rule.task_title).trim();\n  if (!ruleId || !title) continue;\n  if (rule.active === false) continue;\n\n  const freq = String(rule.frequency == null ? '' : rule.frequency).trim().toLowerCase();\n  let hit = false;\n\n  if (freq === 'every_n_days') {\n    const step = Math.max(1, Math.floor(Number(rule.interval_days) || 1));\n    const anchor = toUtcDays(rule.anchor_date);\n    if (anchor !== null && todayDays >= anchor) { hit = (todayDays - anchor) % step === 0; }\n  } else if (freq === 'weekly') {\n    hit = shortDays(rule.weekdays).indexOf(today.weekday) >= 0;\n  } else if (freq === 'monthly_day') {\n    const wanted = Math.floor(Number(rule.day_of_month) || 0);\n    if (wanted >= 1 && wanted <= 31) {\n      hit = today.day === wanted || (wanted > today.daysInMonth && today.day === today.daysInMonth);\n    }\n  } else if (freq === 'monthly_nth_weekday') {\n    const wantedDay = shortDays(rule.nth_weekday)[0];\n    const nth = Math.floor(Number(rule.nth) || 0);\n    if (wantedDay === today.weekday) { hit = nth === -1 ? isLastOfKind : nth === nthThisMonth; }\n  }\n\n  if (hit) {\n    due.push({ json: { rule_id: ruleId, task_title: title, frequency: freq, spawn_date: today.iso, timezone: TIMEZONE } });\n  }\n}\n\nreturn due;"
      },
      "typeVersion": 2
    },
    {
      "id": "54503278-7fde-4e45-aecf-a77d25b5b06f",
      "name": "Get Spawn Ledger",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        1376,
        288
      ],
      "parameters": {
        "filters": {
          "conditions": []
        },
        "operation": "get",
        "returnAll": true,
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "cadence_last_spawned"
        }
      },
      "executeOnce": true,
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "c8803cf1-f21f-4434-affc-b49c830f90a8",
      "name": "Filter Already Spawned",
      "type": "n8n-nodes-base.code",
      "position": [
        1632,
        288
      ],
      "parameters": {
        "jsCode": "const dueRules = $('Select Rules Due Today').all().map(function (i) { return i.json; });\nconst ledgerRows = $input.all().map(function (i) { return i.json; });\nconst stamped = {};\n\nfor (let i = 0; i < ledgerRows.length; i++) {\n  const row = ledgerRows[i];\n  const key = String(row.rule_id == null ? '' : row.rule_id).trim();\n  if (!key) continue;\n  stamped[key] = String(row.last_spawned_on == null ? '' : row.last_spawned_on).slice(0, 10);\n}\n\nconst fresh = [];\nfor (let r = 0; r < dueRules.length; r++) {\n  const rule = dueRules[r];\n  if (stamped[rule.rule_id] === rule.spawn_date) continue;\n  fresh.push({ json: rule });\n}\n\nreturn fresh;"
      },
      "typeVersion": 2
    },
    {
      "id": "ae826333-bbd1-4543-884d-73feb9c181ec",
      "name": "Create Notion Task Page",
      "type": "n8n-nodes-base.notion",
      "position": [
        1936,
        288
      ],
      "parameters": {
        "title": "={{ $json.task_title }}",
        "options": {},
        "resource": "databasePage",
        "databaseId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_NOTION_TASKS_DATABASE_ID",
          "cachedResultName": "Tasks"
        },
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "Due|date",
              "date": "={{ $json.spawn_date }}",
              "type": "date",
              "includeTime": false
            }
          ]
        }
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "dfa5a556-861d-4adf-8b21-5beb749a6800",
      "name": "Record Spawn In Ledger",
      "type": "n8n-nodes-base.dataTable",
      "position": [
        2192,
        288
      ],
      "parameters": {
        "columns": {
          "value": {
            "rule_id": "={{ $('Filter Already Spawned').item.json.rule_id }}",
            "rule_title": "={{ $('Filter Already Spawned').item.json.task_title }}",
            "last_spawned_on": "={{ $('Filter Already Spawned').item.json.spawn_date }}"
          },
          "schema": [
            {
              "id": "rule_id",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "rule_id",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "last_spawned_on",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "last_spawned_on",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "rule_title",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "rule_title",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "rule_id"
          ]
        },
        "filters": {
          "conditions": [
            {
              "keyName": "rule_id",
              "keyValue": "={{ $('Filter Already Spawned').item.json.rule_id }}"
            }
          ]
        },
        "options": {},
        "matchType": "allConditions",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "cadence_last_spawned"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "083252da-ecb1-4085-bd37-5b92b17aec8d",
      "name": "Post Spawn Summary",
      "type": "n8n-nodes-base.slack",
      "position": [
        2432,
        288
      ],
      "parameters": {
        "text": "=Spawned {{ $('Filter Already Spawned').all().length }} recurring task(s) for {{ $('Filter Already Spawned').first().json.spawn_date }}: {{ $('Filter Already Spawned').all().map(i => i.json.task_title).join(', ') }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "YOUR_SLACK_CHANNEL"
        },
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "executeOnce": true,
      "typeVersion": 2.5
    },
    {
      "id": "f0a3d0a7-3e51-4ce7-b4b1-9c797a8bdcf5",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -128,
        -48
      ],
      "parameters": {
        "width": 564,
        "height": 920,
        "content": "## Spawn recurring Notion tasks from a rules database on a schedule\n\n### How it works\n1. A daily Schedule Trigger reads a Notion rules database where every row describes one recurring to-do.\n2. A Code node tests each rule against today's date in a configurable timezone: every N days, weekly on chosen weekdays, monthly on day D, and the nth weekday of the month.\n3. A Data Table named `cadence_last_spawned` is read, and any rule already stamped with today's date is dropped so a repeat run creates nothing.\n4. Notion creates one page per due rule, with the due date set to today.\n5. The ledger is upserted on `rule_id` and Slack gets a one line summary of what was created.\n\n### Setup steps\n- [ ] Connect your Notion credential on `Read Recurrence Rules` and `Create Notion Task Page`.\n- [ ] Replace `YOUR_NOTION_RULES_DATABASE_ID` and `YOUR_NOTION_TASKS_DATABASE_ID` with your own database IDs.\n- [ ] Give the rules database the properties `rule_id`, `task_title`, `frequency`, `interval_days`, `anchor_date`, `weekdays`, `day_of_month`, `nth`, `nth_weekday`, and `active`.\n- [ ] Give the tasks database a date property named `Due`.\n- [ ] Create a Data Table called `cadence_last_spawned` with the columns `rule_id`, `last_spawned_on`, and `rule_title`.\n- [ ] Connect your Slack credential and pick the channel on `Post Spawn Summary`.\n\n### Customization\nSet `TIMEZONE` at the top of `Select Rules Due Today` to the zone your day boundary should follow, and add another branch in that node if you need a pattern the four built in ones cannot express."
      },
      "typeVersion": 1
    },
    {
      "id": "7c29a1c0-1679-4382-80dc-a6e67b98cdc9",
      "name": "Section Read Rules",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        512,
        192
      ],
      "parameters": {
        "color": 7,
        "width": 732,
        "height": 280,
        "content": "## Read rules and pick today"
      },
      "typeVersion": 1
    },
    {
      "id": "616243ff-c58d-4e24-806e-f2ead64bc30c",
      "name": "Section Skip Spawned",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1296,
        192
      ],
      "parameters": {
        "color": 7,
        "width": 512,
        "height": 280,
        "content": "## Skip what already spawned"
      },
      "typeVersion": 1
    },
    {
      "id": "38254fc2-6107-41e7-9dcc-0c8ea42fbe77",
      "name": "Section Create And Record",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1856,
        192
      ],
      "parameters": {
        "color": 7,
        "width": 780,
        "height": 280,
        "content": "## Create tasks and record them"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Get Spawn Ledger": {
      "main": [
        [
          {
            "node": "Filter Already Spawned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Recurrence Rules": {
      "main": [
        [
          {
            "node": "Select Rules Due Today",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Already Spawned": {
      "main": [
        [
          {
            "node": "Create Notion Task Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Spawn In Ledger": {
      "main": [
        [
          {
            "node": "Post Spawn Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Rules Due Today": {
      "main": [
        [
          {
            "node": "Get Spawn Ledger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Notion Task Page": {
      "main": [
        [
          {
            "node": "Record Spawn In Ledger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Daily Schedule Fires": {
      "main": [
        [
          {
            "node": "Read Recurrence Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}