{
  "name": "Log finished tennis matches to Google Sheets every day",
  "nodes": [
    {
      "parameters": {
        "content": "## Log finished tennis matches to Google Sheets\n\n### How it works\nEvery morning the workflow asks Live Tennis API for the completed matches that changed since its last run. It writes one row per match to a Google Sheet, with date, tour, level, tournament, round, surface, players, winner, set scores and outcome. Rows are keyed on `match_id`. A late result arrives as a change, and a corrected result replaces the old row. A result withdrawn after it was logged, for example when two records of one match are merged, stays in the sheet. The sheet becomes a results log for a model or a newsletter.\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. Completed matches need the BASIC plan or above.\n2. Add a **Google Sheets OAuth2** credential.\n3. Create a sheet with a tab named `Results`. Its first row holds these column names: match_id, date, tour, level, tournament, round, surface, draw, player1, player2, winner, score, outcome, result_version, updated_at\n4. In **Config**, set `spreadsheetId` (the long id in the sheet's URL). Leave `tour` empty for every tour, or set one such as `atp` or `itf`.\n5. Run it once, check the rows, then activate it. The first run, and every manual run, starts two days back.\n\n### Customization\nAdd columns by adding fields in the Code node and the sheet header.\n\nExpect three to eight requests a day, depending on the slate.\n\nTested on n8n 2.44.6.",
        "height": 730,
        "width": 520
      },
      "id": "5e1f15c2-4c79-5256-a7ee-74bd86f147f6",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -700,
        80
      ]
    },
    {
      "parameters": {
        "content": "## Fetch and write\nRead what changed since the last run, flatten it, upsert by match_id, then remember the watermark.",
        "height": 300,
        "width": 1500,
        "color": 7
      },
      "id": "ed418b02-b5e3-5531-90fb-68d3aaf76f0c",
      "name": "Fetch and write",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -140,
        160
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 6
            }
          ]
        }
      },
      "id": "a90d194c-36cf-55d7-80ab-0546f8deb251",
      "name": "Every day at 6 UTC",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -100,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "50bbd374-8e13-5677-840a-17f6d8d06a0f",
              "name": "spreadsheetId",
              "value": "YOUR_GOOGLE_SHEET_ID",
              "type": "string"
            },
            {
              "id": "59637725-d62f-5d9c-b399-96cbd83d99dd",
              "name": "sheetName",
              "value": "Results",
              "type": "string"
            },
            {
              "id": "4bcdedf6-3b3a-5317-88e6-2ed87871b4ff",
              "name": "tour",
              "value": "",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "9d724e66-8fb2-56b4-890e-9724bb9ea3b6",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        120,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Where the last successful run got to. Kept between runs only when the workflow is active,\n// so the first run and every manual test start two days back.\nconst state = $getWorkflowStaticData('global');\nconst back = state.watermark ? Date.parse(state.watermark) - 60 * 1000\n  : Date.now() - 2 * 24 * 3600 * 1000;\n// A read the page cap cut short goes on from its cursor. An empty cursor means none.\nreturn [{ json: { since: new Date(back).toISOString().replace(/\\.\\d{3}Z$/, 'Z'),\n  cursor: state.cursor || '' } }];"
      },
      "id": "5e7c11ae-7aea-5fc1-800f-28c7c911010f",
      "name": "Where to start",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        300
      ]
    },
    {
      "parameters": {
        "url": "https://api.livetennisapi.com/api/public/v1/matches",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "status",
              "value": "completed"
            },
            {
              "name": "updated_since",
              "value": "={{ $('Where to start').first().json.since }}"
            },
            {
              "name": "tour",
              "value": "={{ String($('Config').first().json.tour || '').trim().toLowerCase() }}"
            },
            {
              "name": "cursor",
              "value": "={{ $('Where to start').first().json.cursor }}"
            },
            {
              "name": "limit",
              "value": "200"
            }
          ]
        },
        "options": {
          "pagination": {
            "pagination": {
              "parameters": {
                "parameters": [
                  {
                    "name": "cursor",
                    "value": "={{ $response.body.meta.next_cursor }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ !$response.body.meta || !$response.body.meta.next_cursor }}",
              "limitPagesFetched": true,
              "maxRequests": 20,
              "requestInterval": 2500
            }
          }
        }
      },
      "id": "61698773-7fa3-5fb3-9280-350194df4557",
      "name": "Get changed results",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        560,
        300
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const records = $input.all()\n  .flatMap(i => Array.isArray(i.json.data) ? i.json.data : [i.json])\n  .filter(r => r && r.id);\nfunction setScores(score) {\n  const g = score && score.games;\n  if (!Array.isArray(g) || g.length !== 2 || !Array.isArray(g[0])) return [];\n  return g[0].map((x, i) => `${x}-${g[1][i]}`);\n}\n\nfunction finalLine(m, winnerFirst) {\n  const sets = setScores(m.score)\n    .map(x => winnerFirst && m.winner === 2 ? x.split('-').reverse().join('-') : x);\n  const last = sets[sets.length - 1];\n  if (m.outcome === 'completed' && sets.length >= 3 && (last === '1-0' || last === '0-1')) {\n    sets[sets.length - 1] = `${last} (match tiebreak)`;\n  }\n  return sets.join(' ');\n}\n\nfunction saveNextStart(pages) {\n  if (!pages.length) return;\n  const state = $getWorkflowStaticData('global');\n  const meta = pages[pages.length - 1].json.meta || {};\n  if (meta.next_cursor) { state.cursor = meta.next_cursor; return; }\n  delete state.cursor;\n  if (meta.watermark) state.watermark = meta.watermark;\n}\n\nconst cfg = $('Config').first().json;\nconst tour = String(cfg.tour || '').trim().toLowerCase();\n// The change feed can return a match twice. Keep its newest copy, one row per match.\nconst byId = new Map();\nfor (const m of records) {\n  if (m.status !== 'completed') continue;\n  if (tour && !String(m.tour || '').startsWith(tour)) continue;\n  byId.set(m.id, m);\n}\nif (!byId.size) {\n  // Nothing to write, so nothing can fail: move the start point on here.\n  saveNextStart($input.all());\n  return [];\n}\nreturn [...byId.values()]\n  .map(m => {\n    const p1 = (m.players && m.players.p1) || {}, p2 = (m.players && m.players.p2) || {};\n    return { json: {\n      match_id: m.id,\n      date: String(m.scheduled_time || '').slice(0, 10),\n      tour: m.tour || '',\n      level: m.tier || '',\n      tournament: m.tournament || '',\n      round: m.round || '',\n      surface: m.surface || '',\n      draw: m.draw || '',\n      player1: p1.name || '',\n      player2: p2.name || '',\n      winner: m.winner === 1 ? p1.name : m.winner === 2 ? p2.name : '',\n      score: finalLine(m),\n      outcome: m.outcome || '',\n      result_version: m.result_version || 1,\n      updated_at: m.updated_at || '',\n    } };\n  });"
      },
      "id": "fa59affe-e05c-5b0d-80ff-dc1ab625288b",
      "name": "Turn matches into rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        300
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Config').first().json.spreadsheetId }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Config').first().json.sheetName }}"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "match_id"
          ],
          "schema": [
            {
              "id": "match_id",
              "displayName": "match_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "date",
              "displayName": "date",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "tour",
              "displayName": "tour",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "level",
              "displayName": "level",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "tournament",
              "displayName": "tournament",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "round",
              "displayName": "round",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "surface",
              "displayName": "surface",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "draw",
              "displayName": "draw",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "player1",
              "displayName": "player1",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "player2",
              "displayName": "player2",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "winner",
              "displayName": "winner",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "score",
              "displayName": "score",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "outcome",
              "displayName": "outcome",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "result_version",
              "displayName": "result_version",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "updated_at",
              "displayName": "updated_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "6da00c26-aae8-5758-b6b6-59f6590a886d",
      "name": "Add or update rows",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1000,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "function saveNextStart(pages) {\n  if (!pages.length) return;\n  const state = $getWorkflowStaticData('global');\n  const meta = pages[pages.length - 1].json.meta || {};\n  if (meta.next_cursor) { state.cursor = meta.next_cursor; return; }\n  delete state.cursor;\n  if (meta.watermark) state.watermark = meta.watermark;\n}\n\n// The rows are in the sheet, so the next run can start from where this one stopped.\nsaveNextStart($('Get changed results').all());\nreturn [];"
      },
      "id": "9b16e48e-2f46-5d16-b548-d1fd4aa43e1a",
      "name": "Remember where to start next",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1220,
        300
      ]
    }
  ],
  "connections": {
    "Every day at 6 UTC": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Where to start",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Where to start": {
      "main": [
        [
          {
            "node": "Get changed results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get changed results": {
      "main": [
        [
          {
            "node": "Turn matches into rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Turn matches into rows": {
      "main": [
        [
          {
            "node": "Add or update rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add or update rows": {
      "main": [
        [
          {
            "node": "Remember where to start next",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1",
    "timezone": "UTC"
  },
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
