{
  "name": "Post your tennis players' daily order of play to Discord",
  "nodes": [
    {
      "parameters": {
        "content": "## Daily tennis order of play for your players, posted to Discord\n\n### How it works\nEvery morning the workflow reads the upcoming fixtures from Live Tennis API and keeps the matches of the players you follow. It posts one Discord message with their start times in your time zone. Fixtures match on player id where the schedule has one, and on surname where it has only a name. That way qualifying matches still show up. A long list is split into several messages to stay under Discord's length limit.\n\n### Setup\n1. Install the verified community node `n8n-nodes-livetennisapi` (tested with 0.2.1). Search the nodes panel for Live Tennis API, or use Settings > Community nodes when you self-host. n8n's [install guide](https://docs.n8n.io/integrations/community-nodes/installation/) has the steps. Create a **Live Tennis API** credential with your API key and select it on every Live Tennis API node.\n2. In your Discord server, create a webhook (Server settings > Integrations > Webhooks) and add a **Discord Webhook** credential with its URL.\n3. In **Config**, set `followedPlayerIds`, `followedNames` (surnames, comma-separated) and your `timezone`.\n4. Run it once to check the message, then activate it.\n\n### Customization\nChange `hoursAhead` to look further ahead. Set `postWhenEmpty` to true to get a message on days with no matches. Swap the Discord node for the chat app you use.\n\nIt uses about four requests a day, which suits the free plan.\n\nTested on n8n 2.44.6.",
        "height": 610,
        "width": 520
      },
      "id": "75ead819-ef61-5093-a6f9-6545dbbfb7a6",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -700,
        80
      ]
    },
    {
      "parameters": {
        "content": "## Fetch and filter\nRead the coming fixtures, keep your players, format start times in your zone.",
        "height": 300,
        "width": 1040,
        "color": 7
      },
      "id": "ee194f58-b024-519d-9035-64f4ed5e716b",
      "name": "Fetch and filter",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        160
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 7
            }
          ]
        }
      },
      "id": "96257832-5fec-5160-9bdb-2802a9bfbd93",
      "name": "Every morning at 7",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -100,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "5928caf2-39ae-5a3f-ba38-dee3a012d27d",
              "name": "followedPlayerIds",
              "value": "34, 13, 55, 889",
              "type": "string"
            },
            {
              "id": "05ed6a0e-9f6e-5040-916f-a0caedf7f9f3",
              "name": "followedNames",
              "value": "Sinner, Alcaraz, Sabalenka, Swiatek",
              "type": "string"
            },
            {
              "id": "aca918f9-fa9b-542d-8024-9da4283a723e",
              "name": "timezone",
              "value": "Europe/London",
              "type": "string"
            },
            {
              "id": "a36b0f25-d77b-555b-a980-e4745f3e3722",
              "name": "hoursAhead",
              "value": 24,
              "type": "number"
            },
            {
              "id": "21b9010f-11e3-5b93-8121-d551bf4fc287",
              "name": "postWhenEmpty",
              "value": false,
              "type": "boolean"
            }
          ]
        },
        "options": {}
      },
      "id": "81a7a14c-9843-53d2-9c32-5c9c0ccea021",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        120,
        300
      ]
    },
    {
      "parameters": {
        "resource": "fixture",
        "operation": "getAll",
        "returnAll": true,
        "filters": {}
      },
      "id": "21440087-ddd1-5492-8c79-825773ec0d5c",
      "name": "Get fixtures",
      "type": "n8n-nodes-livetennisapi.liveTennisApi",
      "typeVersion": 1,
      "position": [
        340,
        300
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst followed = new Set(String(cfg.followedPlayerIds || '').split(',')\n  .map(s => Number(s.trim())).filter(n => n > 0));\nconst records = $input.all()\n  .flatMap(i => Array.isArray(i.json.data) ? i.json.data : [i.json])\n  .filter(r => r && r.id);\n\nconst names = String(cfg.followedNames || '').split(',')\n  .map(s => s.trim().toLowerCase()).filter(Boolean);\nconst tz = cfg.timezone || 'UTC';\nconst hours = Number(cfg.hoursAhead) || 24;\nconst start = Date.now() - 30 * 60 * 1000, end = Date.now() + hours * 3600 * 1000;\n// Fixtures can carry ids or only names, so match on either.\nconst named = n => names.some(x => String(n || '').toLowerCase().includes(x));\n// A fixture with no start time yet still has its day: place it at the start of that UTC day.\nconst at = f => f.start_time ? Date.parse(f.start_time)\n  : f.event_date ? Date.parse(f.event_date + 'T00:00:00Z') : NaN;\nconst picked = records\n  .filter(f => !f.status || f.status === 'scheduled')\n  .filter(f => { const t = at(f);\n    return f.start_time ? t >= start && t <= end : t >= start - 86400000 && t <= end; })\n  .filter(f => followed.has(f.player1_id) || followed.has(f.player2_id) ||\n    named(f.player1_name) || named(f.player2_name))\n  .sort((a, b) => at(a) - at(b));\nconst when = new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short',\n  hour: '2-digit', minute: '2-digit' });\nconst day = new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', weekday: 'short' });\nconst lines = picked.map(f => '- ' +\n  (f.start_time ? when.format(new Date(f.start_time)) : `${day.format(new Date(at(f)))}, time to be set`) +\n  `  ${f.player1_name} v ${f.player2_name}, ${f.tournament}` + (f.round ? ` (${f.round})` : ''));\nif (!lines.length) {\n  if (!cfg.postWhenEmpty) return [];\n  lines.push(`None of your players is scheduled in the next ${hours} hours.`);\n}\n// Discord takes 2,000 characters a message, so long lists go out in parts.\nconst head = `Order of play, next ${hours} hours (${tz} time). Start times can move.`;\nconst messages = [];\nlet text = head;\nfor (const l of lines) {\n  if ((text + '\\n' + l).length > 1900) { messages.push(text); text = l; }\n  else text += '\\n' + l;\n}\nmessages.push(text);\nreturn messages.map(t => ({ json: { text: t } }));"
      },
      "id": "2b6efb26-68d6-5f21-8987-dd5f1aacb3a9",
      "name": "Pick your players' matches",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        300
      ]
    },
    {
      "parameters": {
        "authentication": "webhook",
        "content": "={{ $json.text }}",
        "options": {}
      },
      "id": "e43cb094-03bf-5f30-8dc5-c0300c10daa2",
      "name": "Post to Discord",
      "type": "n8n-nodes-base.discord",
      "typeVersion": 2,
      "position": [
        780,
        300
      ]
    }
  ],
  "connections": {
    "Every morning at 7": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Get fixtures",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get fixtures": {
      "main": [
        [
          {
            "node": "Pick your players' matches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick your players' matches": {
      "main": [
        [
          {
            "node": "Post to Discord",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
