{
  "name": "Family Calendar",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [{"field": "cronExpression", "expression": "0 7 * * *"}]
        }
      },
      "id": "schedule-daily",
      "name": "Daily 7:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [0, 0]
    },
    {
      "parameters": {
        "rule": {
          "interval": [{"field": "minutes", "minutesInterval": 15}]
        }
      },
      "id": "schedule-reminder",
      "name": "Every 15 Min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [0, 300]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "DEINE_ICAL_URL",
        "options": {
          "response": {"response": {"fullResponse": false, "responseFormat": "text"}}
        }
      },
      "id": "get-ics-daily",
      "name": "Get ICS Daily",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [220, 0]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "DEINE_ICAL_URL",
        "options": {
          "response": {"response": {"fullResponse": false, "responseFormat": "text"}}
        }
      },
      "id": "get-ics-reminder",
      "name": "Get ICS Reminder",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [220, 300]
    },
    {
      "parameters": {
        "jsCode": "/*\n * Parse ICS - Daily Overview v1.2\n * Filtert Termine fuer heute mit korrekter Zeitzonenbehandlung\n */\n\nconst icsData = $input.first().json.data || $input.first().json;\nconst icsStr = typeof icsData === 'string' ? icsData : '';\n\nif (!icsStr) {\n  return [{ json: { events: [], eventCount: 0, hasEvents: false, message: 'Keine Kalenderdaten', type: 'daily' } }];\n}\n\nconst veventMatches = icsStr.match(/BEGIN:VEVENT[\\s\\S]*?END:VEVENT/g) || [];\n\nconst events = [];\nconst seen = new Set();\n\n// Aktuelle Zeit in deutscher Zeitzone\nconst nowUTC = new Date();\nconst germanFormatter = new Intl.DateTimeFormat('de-DE', {\n  timeZone: 'Europe/Berlin',\n  year: 'numeric', month: '2-digit', day: '2-digit',\n  hour: '2-digit', minute: '2-digit', hour12: false\n});\nconst parts = germanFormatter.formatToParts(nowUTC);\nconst getPart = (type) => parseInt(parts.find(p => p.type === type)?.value || '0');\nconst now = new Date(getPart('year'), getPart('month') - 1, getPart('day'), getPart('hour'), getPart('minute'));\n\nconst todayYear = now.getFullYear();\nconst todayMonth = now.getMonth();\nconst todayDate = now.getDate();\nconst todayStart = new Date(todayYear, todayMonth, todayDate, 0, 0, 0);\n\nconst parseICalDate = (dt) => {\n  if (!dt) return null;\n  let cleanDt = dt.replace(/[^0-9TZ]/g, '');\n  \n  if (cleanDt.length === 8) {\n    const year = parseInt(cleanDt.substr(0, 4));\n    const month = parseInt(cleanDt.substr(4, 2)) - 1;\n    const day = parseInt(cleanDt.substr(6, 2));\n    return { year, month, day, allDay: true, dateObj: new Date(year, month, day) };\n  }\n  \n  const year = parseInt(cleanDt.substr(0, 4));\n  const month = parseInt(cleanDt.substr(4, 2)) - 1;\n  const day = parseInt(cleanDt.substr(6, 2));\n  const hour = parseInt(cleanDt.substr(9, 2)) || 0;\n  const min = parseInt(cleanDt.substr(11, 2)) || 0;\n  \n  let dateObj;\n  let localHour = hour;\n  let localMin = min;\n  \n  if (cleanDt.endsWith('Z')) {\n    const utcDate = new Date(Date.UTC(year, month, day, hour, min));\n    const germanParts = germanFormatter.formatToParts(utcDate);\n    const getP = (type) => parseInt(germanParts.find(p => p.type === type)?.value || '0');\n    dateObj = new Date(getP('year'), getP('month') - 1, getP('day'), getP('hour'), getP('minute'));\n    localHour = getP('hour');\n    localMin = getP('minute');\n  } else {\n    dateObj = new Date(year, month, day, hour, min);\n  }\n  \n  return {\n    year: dateObj.getFullYear(), month: dateObj.getMonth(), day: dateObj.getDate(),\n    hour: localHour, min: localMin, allDay: false, dateObj\n  };\n};\n\nfor (const vevent of veventMatches) {\n  const unfoldedEvent = vevent.replace(/\\r?\\n[ \\t]/g, '');\n  \n  const getField = (name) => {\n    const regex = new RegExp(`^${name}[^:]*:(.+)$`, 'm');\n    const match = unfoldedEvent.match(regex);\n    return match ? match[1].replace(/\\\\n/g, ' ').replace(/\\\\,/g, ',').replace(/\\\\;/g, ';').trim() : null;\n  };\n  \n  const summary = getField('SUMMARY') || 'Ohne Titel';\n  const location = getField('LOCATION');\n  const dtstart = getField('DTSTART');\n  const dtend = getField('DTEND');\n  const rrule = getField('RRULE');\n  \n  if (!dtstart) continue;\n  \n  const start = parseICalDate(dtstart);\n  const end = parseICalDate(dtend);\n  if (!start) continue;\n  \n  let isToday = (start.year === todayYear && start.month === todayMonth && start.day === todayDate);\n  \n  // Wiederkehrender Termin\n  if (rrule && !isToday) {\n    const untilMatch = rrule.match(/UNTIL=([0-9TZ]+)/);\n    if (untilMatch) {\n      const until = parseICalDate(untilMatch[1]);\n      if (until && until.dateObj < todayStart) continue;\n    }\n    \n    if (rrule.includes('FREQ=YEARLY')) {\n      isToday = (start.month === todayMonth && start.day === todayDate);\n    } else if (rrule.includes('FREQ=MONTHLY')) {\n      isToday = (start.day === todayDate);\n    } else if (rrule.includes('FREQ=WEEKLY')) {\n      const startDow = start.dateObj.getDay();\n      const todayDow = now.getDay();\n      const bydayMatch = rrule.match(/BYDAY=([A-Z,]+)/);\n      if (bydayMatch) {\n        const days = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };\n        const allowedDays = bydayMatch[1].split(',').map(d => days[d]);\n        isToday = allowedDays.includes(todayDow);\n      } else {\n        isToday = (startDow === todayDow);\n      }\n    } else if (rrule.includes('FREQ=DAILY')) {\n      isToday = true;\n    }\n  }\n  \n  if (!isToday) continue;\n  \n  const formatTime = (h, m) => `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;\n  const startTimeStr = start.allDay ? 'allday' : formatTime(start.hour, start.min);\n  const dedupKey = `${summary}|${startTimeStr}`;\n  \n  if (seen.has(dedupKey)) continue;\n  seen.add(dedupKey);\n  \n  events.push({\n    summary, location,\n    allDay: start.allDay,\n    startTime: start.allDay ? null : startTimeStr,\n    endTime: (end && !end.allDay) ? formatTime(end.hour, end.min) : null,\n    sortKey: start.allDay ? '00:00' : startTimeStr\n  });\n}\n\nevents.sort((a, b) => {\n  if (a.allDay && !b.allDay) return -1;\n  if (!a.allDay && b.allDay) return 1;\n  return (a.sortKey || '').localeCompare(b.sortKey || '');\n});\n\nconst weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];\nconst months = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];\nconst dateStr = `${weekdays[now.getDay()]}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()}`;\n\nlet message = `☀️ *Guten Morgen!*\\n\\nEure heutigen Termine:\\n_${dateStr}_\\n\\n`;\n\nif (events.length === 0) {\n  message += '✨ Keine Termine heute - geniesst den freien Tag!';\n} else {\n  for (const ev of events) {\n    if (ev.allDay) {\n      message += `🌅 *${ev.summary}* (ganztaegig)`;\n    } else {\n      message += `🕐 ${ev.startTime}${ev.endTime ? ' - ' + ev.endTime : ''} Uhr\\n*${ev.summary}*`;\n    }\n    if (ev.location) message += `\\n📍 ${ev.location}`;\n    message += '\\n\\n';\n  }\n}\n\nmessage += '_n8n.sgit.space | Family Calendar_';\n\nreturn [{\n  json: { events, eventCount: events.length, hasEvents: events.length > 0, message, type: 'daily' }\n}];"
      },
      "id": "parse-daily",
      "name": "Parse Daily Events",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [440, 0]
    },
    {
      "parameters": {
        "jsCode": "/*\n * Parse ICS - Reminder v1.2\n * Prueft ob ein Termin in ca. 1 Stunde beginnt\n */\n\nconst staticData = $getWorkflowStaticData('global');\nconst sentReminders = staticData.sentReminders || {};\n\nconst icsData = $input.first().json.data || $input.first().json;\nconst icsStr = typeof icsData === 'string' ? icsData : '';\n\nif (!icsStr) {\n  return [{ json: { reminders: [], reminderCount: 0, hasReminders: false, message: '', type: 'reminder' } }];\n}\n\nconst veventMatches = icsStr.match(/BEGIN:VEVENT[\\s\\S]*?END:VEVENT/g) || [];\n\nconst reminders = [];\nconst seen = new Set();\n\nconst nowUTC = new Date();\nconst germanFormatter = new Intl.DateTimeFormat('de-DE', {\n  timeZone: 'Europe/Berlin',\n  year: 'numeric', month: '2-digit', day: '2-digit',\n  hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false\n});\nconst parts = germanFormatter.formatToParts(nowUTC);\nconst getPart = (type) => parseInt(parts.find(p => p.type === type)?.value || '0');\nconst nowGerman = new Date(getPart('year'), getPart('month') - 1, getPart('day'), getPart('hour'), getPart('minute'), getPart('second'));\n\nconst todayYear = nowGerman.getFullYear();\nconst todayMonth = nowGerman.getMonth();\nconst todayDate = nowGerman.getDate();\nconst todayStart = new Date(todayYear, todayMonth, todayDate, 0, 0, 0);\nconst nowMinutes = nowGerman.getHours() * 60 + nowGerman.getMinutes();\n\nconst parseICalDate = (dt) => {\n  if (!dt) return null;\n  let cleanDt = dt.replace(/[^0-9TZ]/g, '');\n  if (cleanDt.length === 8) return { allDay: true, dateObj: new Date(parseInt(cleanDt.substr(0,4)), parseInt(cleanDt.substr(4,2))-1, parseInt(cleanDt.substr(6,2))) };\n  const year = parseInt(cleanDt.substr(0, 4));\n  const month = parseInt(cleanDt.substr(4, 2)) - 1;\n  const day = parseInt(cleanDt.substr(6, 2));\n  const hour = parseInt(cleanDt.substr(9, 2)) || 0;\n  const min = parseInt(cleanDt.substr(11, 2)) || 0;\n  \n  let dateObj, localHour = hour, localMin = min;\n  \n  if (cleanDt.endsWith('Z')) {\n    const utcDate = new Date(Date.UTC(year, month, day, hour, min));\n    const germanParts = germanFormatter.formatToParts(utcDate);\n    const getP = (type) => parseInt(germanParts.find(p => p.type === type)?.value || '0');\n    dateObj = new Date(getP('year'), getP('month') - 1, getP('day'), getP('hour'), getP('minute'));\n    localHour = getP('hour');\n    localMin = getP('minute');\n  } else {\n    dateObj = new Date(year, month, day, hour, min);\n  }\n  \n  return { allDay: false, dateObj, hour: localHour, min: localMin, year: dateObj.getFullYear(), month: dateObj.getMonth(), day: dateObj.getDate() };\n};\n\nfor (const vevent of veventMatches) {\n  const unfoldedEvent = vevent.replace(/\\r?\\n[ \\t]/g, '');\n  \n  const getField = (name) => {\n    const regex = new RegExp(`^${name}[^:]*:(.+)$`, 'm');\n    const match = unfoldedEvent.match(regex);\n    return match ? match[1].replace(/\\\\n/g, ' ').replace(/\\\\,/g, ',').trim() : null;\n  };\n  \n  const uid = getField('UID') || Math.random().toString();\n  const summary = getField('SUMMARY') || 'Termin';\n  const location = getField('LOCATION');\n  const dtstart = getField('DTSTART');\n  const rrule = getField('RRULE');\n  \n  if (!dtstart) continue;\n  \n  const start = parseICalDate(dtstart);\n  if (!start || start.allDay) continue;\n  \n  let checkDate = start.dateObj;\n  let isValidRecurrence = true;\n  \n  if (rrule) {\n    const untilMatch = rrule.match(/UNTIL=([0-9TZ]+)/);\n    if (untilMatch) {\n      const until = parseICalDate(untilMatch[1]);\n      if (until && until.dateObj < todayStart) isValidRecurrence = false;\n    }\n    \n    if (!isValidRecurrence) continue;\n    \n    const eventDow = start.dateObj.getDay();\n    const todayDow = nowGerman.getDay();\n    let shouldAdjust = false;\n    \n    if (rrule.includes('FREQ=DAILY')) shouldAdjust = true;\n    else if (rrule.includes('FREQ=WEEKLY')) {\n      const bydayMatch = rrule.match(/BYDAY=([A-Z,]+)/);\n      if (bydayMatch) {\n        const days = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };\n        const allowedDays = bydayMatch[1].split(',').map(d => days[d]);\n        shouldAdjust = allowedDays.includes(todayDow);\n      } else {\n        shouldAdjust = (eventDow === todayDow);\n      }\n    } else if (rrule.includes('FREQ=MONTHLY')) {\n      shouldAdjust = (start.dateObj.getDate() === todayDate);\n    } else if (rrule.includes('FREQ=YEARLY')) {\n      shouldAdjust = (start.dateObj.getMonth() === todayMonth && start.dateObj.getDate() === todayDate);\n    }\n    \n    if (shouldAdjust) {\n      checkDate = new Date(todayYear, todayMonth, todayDate, start.hour, start.min);\n    }\n  }\n  \n  if (checkDate.getFullYear() !== todayYear || checkDate.getMonth() !== todayMonth || checkDate.getDate() !== todayDate) continue;\n  \n  const eventMinutes = checkDate.getHours() * 60 + checkDate.getMinutes();\n  const diffMin = eventMinutes - nowMinutes;\n  \n  // Reminder 45-75 Minuten vorher\n  if (diffMin >= 45 && diffMin <= 75) {\n    const formatTime = (d) => `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;\n    const startTimeStr = formatTime(checkDate);\n    const dedupKey = `${summary}|${startTimeStr}`;\n    \n    if (seen.has(dedupKey)) continue;\n    seen.add(dedupKey);\n    \n    const todayKey = `${todayYear}${String(todayMonth+1).padStart(2,'0')}${String(todayDate).padStart(2,'0')}-${dedupKey}`;\n    \n    if (!sentReminders[todayKey]) {\n      reminders.push({ uid, summary, location, startTime: startTimeStr, minutesUntil: Math.round(diffMin), todayKey });\n    }\n  }\n}\n\nfor (const r of reminders) { sentReminders[r.todayKey] = true; }\n\nconst twoDaysAgo = new Date();\ntwoDaysAgo.setDate(twoDaysAgo.getDate() - 2);\nconst cutoff = `${twoDaysAgo.getFullYear()}${String(twoDaysAgo.getMonth()+1).padStart(2,'0')}${String(twoDaysAgo.getDate()).padStart(2,'0')}`;\nfor (const key of Object.keys(sentReminders)) {\n  if (key.substr(0,8) < cutoff) delete sentReminders[key];\n}\nstaticData.sentReminders = sentReminders;\n\nlet message = '';\nif (reminders.length > 0) {\n  message = `⏰ *Erinnerung*\\n\\n`;\n  for (const r of reminders) {\n    message += `In ca. *${r.minutesUntil} Minuten*:\\n`;\n    message += `📌 *${r.summary}*\\n`;\n    message += `🕐 ${r.startTime} Uhr`;\n    if (r.location) message += `\\n📍 ${r.location}`;\n    message += '\\n\\n';\n  }\n  message += '_n8n.sgit.space | Family Calendar_';\n}\n\nreturn [{ json: { reminders, reminderCount: reminders.length, hasReminders: reminders.length > 0, message, type: 'reminder' } }];"
      },
      "id": "parse-reminder",
      "name": "Parse Reminder Events",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [440, 300]
    },
    {
      "parameters": {
        "chatId": "DEINE_CHAT_ID",
        "text": "={{ $json.message }}",
        "additionalFields": {"parse_mode": "Markdown", "appendAttribution": false}
      },
      "id": "telegram-daily",
      "name": "Telegram Daily",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [660, 0],
      "credentials": {"telegramApi": {"id": "", "name": "Dein Telegram Bot"}}
    },
    {
      "parameters": {
        "conditions": {
          "options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict"},
          "conditions": [{"id": "has-reminders", "leftValue": "={{ $json.hasReminders }}", "rightValue": true, "operator": {"type": "boolean", "operation": "equals"}}],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-reminders",
      "name": "Has Reminders?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [660, 300]
    },
    {
      "parameters": {
        "chatId": "DEINE_CHAT_ID",
        "text": "={{ $json.message }}",
        "additionalFields": {"parse_mode": "Markdown", "appendAttribution": false}
      },
      "id": "telegram-reminder",
      "name": "Telegram Reminder",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [880, 200],
      "credentials": {"telegramApi": {"id": "", "name": "Dein Telegram Bot"}}
    },
    {
      "parameters": {},
      "id": "noop-no-reminder",
      "name": "No Reminder",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [880, 400]
    },
    {
      "parameters": {
        "content": "## 📅 Family Calendar\n\n**Morgen-Uebersicht (oben):**\nTaeglich 7:00 → ICS abrufen → Heute filtern\n→ Telegram mit allen Terminen\n\n**Erinnerungen (unten):**\nAlle 15 Min → ICS pruefen\n→ Termin in ~1h? → Telegram Reminder\n\n**Anpassen:**\n- DEINE_ICAL_URL: Google/iCloud/Nextcloud iCal URL\n- DEINE_CHAT_ID: Telegram Chat oder Gruppe",
        "height": 260,
        "width": 340
      },
      "id": "sticky-note",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-120, -180]
    }
  ],
  "connections": {
    "Daily 7:00": {"main": [[{"node": "Get ICS Daily", "type": "main", "index": 0}]]},
    "Every 15 Min": {"main": [[{"node": "Get ICS Reminder", "type": "main", "index": 0}]]},
    "Get ICS Daily": {"main": [[{"node": "Parse Daily Events", "type": "main", "index": 0}]]},
    "Get ICS Reminder": {"main": [[{"node": "Parse Reminder Events", "type": "main", "index": 0}]]},
    "Parse Daily Events": {"main": [[{"node": "Telegram Daily", "type": "main", "index": 0}]]},
    "Parse Reminder Events": {"main": [[{"node": "Has Reminders?", "type": "main", "index": 0}]]},
    "Has Reminders?": {
      "main": [
        [{"node": "Telegram Reminder", "type": "main", "index": 0}],
        [{"node": "No Reminder", "type": "main", "index": 0}]
      ]
    }
  },
  "settings": {"executionOrder": "v1"},
  "staticData": null,
  "tags": [],
  "triggerCount": 2,
  "pinData": {}
}
