{
  "name": "Email a weekly summary of tennis ranking changes for your players",
  "nodes": [
    {
      "parameters": {
        "content": "## Weekly tennis ranking changes by email\n\n### How it works\nEvery Monday the workflow reads the current ranking and points of each player you follow from Live Tennis API. It compares them with the previous week and emails one summary, biggest moves first. Each line shows places gained or lost and the change in points. Players who entered or left the ranking are named too. It suits anyone who tracks a short list of players.\n\n### Setup\n1. Create a **Header Auth** credential. Its Name is `X-API-Key` and its Value is your Live Tennis API key. Select it on every HTTP Request node.\n2. Add an **SMTP** credential for the address the email comes from.\n3. In **Config**, set `followedPlayerIds` (find ids with `GET /players?search=`), `emailTo` and `emailFrom`.\n4. Activate the workflow. The first run records a baseline. Ranks are kept only between active runs, not manual tests.\n\n### Customization\nSet `onlyWhenChanged` to true to skip weeks with no movement. Swap the email node for Slack or Discord.\n\nOne request per player per week fits the free plan. The HTTP version spaces requests 2.5 seconds apart to stay under the per-minute limit.\n\nTested on n8n 2.44.6.",
        "height": 650,
        "width": 520
      },
      "id": "ce6ba82b-afe3-5f7a-93cf-e0977c085a17",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -700,
        80
      ]
    },
    {
      "parameters": {
        "content": "## Read each player\nOne request per followed player, spaced out to respect the rate limit.",
        "height": 300,
        "width": 440,
        "color": 7
      },
      "id": "4f40f171-314d-5a52-9225-3ba7cb3da746",
      "name": "Read each player",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        300,
        160
      ]
    },
    {
      "parameters": {
        "content": "## Compare and send\nCompare with the stored ranks and email the summary.",
        "height": 300,
        "width": 440,
        "color": 7
      },
      "id": "8dfabd7b-b08c-579e-814b-14eef123f782",
      "name": "Compare and send",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        740,
        160
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 9
            }
          ]
        }
      },
      "id": "8f2ec4e1-0330-5b34-99d1-0174e8034fc2",
      "name": "Every Monday at 9",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -100,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "3c5d54fd-0267-5829-95f1-f62377558040",
              "name": "followedPlayerIds",
              "value": "34, 13, 55, 889",
              "type": "string"
            },
            {
              "id": "01ba2375-30ba-5a15-80c9-f761752577d7",
              "name": "emailTo",
              "value": "you@example.com",
              "type": "string"
            },
            {
              "id": "22850e63-d584-53cb-a2ac-f3414438571f",
              "name": "emailFrom",
              "value": "alerts@example.com",
              "type": "string"
            },
            {
              "id": "bf2df053-75d2-51db-9e5c-786dfa703303",
              "name": "onlyWhenChanged",
              "value": false,
              "type": "boolean"
            }
          ]
        },
        "options": {}
      },
      "id": "8a587662-50ce-5c0c-85e4-13b3e9061468",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        120,
        300
      ]
    },
    {
      "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));\nreturn [...followed].map(playerId => ({ json: { playerId } }));"
      },
      "id": "c7bb69e6-81df-52fe-9de9-c7cc6b881de9",
      "name": "One item per player",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        300
      ]
    },
    {
      "parameters": {
        "url": "=https://api.livetennisapi.com/api/public/v1/players/{{ $json.playerId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 2500
            }
          }
        }
      },
      "id": "55a7dac3-2852-5577-b582-bea49adca563",
      "name": "Get player ranking",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        560,
        300
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\n// Last week's ranks. Kept between runs only when the workflow is active.\nconst state = $getWorkflowStaticData('global');\nconst last = state.ranks || {};\nconst next = {};\nconst fmt = n => Number(n).toLocaleString('en-GB');\nconst rows = [];\nfor (const item of $input.all()) {\n  const p = item.json;\n  if (!p || !p.id) continue;\n  // An unranked player can still carry the last rank we held, so trust the status. With no\n  // rank and no \"unranked\" verdict, the rank is unknown this week: say so and keep last week's.\n  // \"unverified\" can still carry an old rank, so only \"ranked\" (or no status) is a rank.\n  const status = p.ranking_status;\n  const rank = status === 'unranked' || status === 'unverified' ? null : (p.ranking || null);\n  const unknown = status === 'unverified' || (!rank && status !== 'unranked');\n  const was = last[p.id];\n  // Points held for an unranked player are old too, so they are neither shown nor kept.\n  const points = rank ? p.ranking_points : null;\n  if (!unknown) next[p.id] = { ranking: rank, points };\n  const now = rank ? `No. ${rank}` : unknown ? 'rank not confirmed this week' : 'unranked';\n  const pts = points != null ? `, ${fmt(points)} points` : '';\n  let change = unknown ? 'no comparison' : 'first check';\n  let move = 0, pointsMoved = false;\n  if (was && !unknown) {\n    if (was.ranking && rank && was.ranking !== rank) {\n      move = was.ranking - rank;\n      change = `${move > 0 ? 'up' : 'down'} ${Math.abs(move)} from No. ${was.ranking}`;\n    } else if (!was.ranking && rank) { change = 'back in the ranking'; move = 1000; }\n    else if (was.ranking && !rank) { change = 'dropped out of the ranking'; move = -1000; }\n    else change = 'no change';\n    if (was.points != null && points != null && was.points !== points) {\n      const d = points - was.points;\n      change += `, ${d > 0 ? '+' : ''}${fmt(d)} points`;\n      pointsMoved = true;\n    }\n  }\n  rows.push({ move, pointsMoved, line: `${p.name} (${String(p.tour || '').toUpperCase()}): ${now}${pts} (${change})` });\n}\nstate.ranks = { ...last, ...next };\nconst moved = rows.filter(r => r.move !== 0 || r.pointsMoved);\nif (cfg.onlyWhenChanged && Object.keys(last).length && !moved.length) return [];\nrows.sort((a, b) => Math.abs(b.move) - Math.abs(a.move));\nconst first = !Object.keys(last).length;\nconst intro = first ? 'First check. Changes will show from next week.'\n  : 'Ranking changes since the last check:';\nreturn [{ json: {\n  subject: first ? `Tennis ranking baseline for ${rows.length} players`\n    : `Tennis ranking check: ${moved.length} of ${rows.length} players moved`,\n  text: [intro, '', ...rows.map(r => '- ' + r.line), '',\n    'Rankings from Live Tennis API.'].join('\\n') } }];"
      },
      "id": "03812904-1bd7-5140-ba85-0fa79fbd97ab",
      "name": "Compare with last week",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        300
      ]
    },
    {
      "parameters": {
        "fromEmail": "={{ $('Config').first().json.emailFrom }}",
        "toEmail": "={{ $('Config').first().json.emailTo }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "text",
        "text": "={{ $json.text }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "1fbd024f-23f3-516d-8133-d4313de97455",
      "name": "Send the email",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        1000,
        300
      ],
      "webhookId": "0ef1fccb-ca70-5038-acb2-0bdb75796af5"
    }
  ],
  "connections": {
    "Every Monday at 9": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "One item per player",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One item per player": {
      "main": [
        [
          {
            "node": "Get player ranking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get player ranking": {
      "main": [
        [
          {
            "node": "Compare with last week",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare with last week": {
      "main": [
        [
          {
            "node": "Send the email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
