{
  "id": "XsVaYkX86o40Zztx",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Track student attendance from CSV, alert parents by email, and generate an HTML dashboard",
  "tags": [],
  "nodes": [
    {
      "id": "c933a392-8c2b-48b3-8757-ba850ab7537d",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -384,
        -288
      ],
      "parameters": {
        "width": 500,
        "height": 620,
        "content": "## How it works\n\n1. Runs every weekday at 17:30 (Mon\u2013Fri)\n2. Reads `student_attendance.csv` and filters to today's records only\n3. Splits students into Absent / Present branches\n4. Fetches parent contacts from `student_contacts.csv` and merges the data\n5. Calculates risk level, attendance %, consecutive streak, and trend\n6. Sends a colour-coded HTML email alert to parents of absent students via SMTP\n7. Builds and saves an interactive `dashboard.html` with 5 tabs: Today, Weekly, Monthly, Full History, At-Risk\n8. Appends today's records to `attendance_report.csv` for historical tracking\n\n## Setup steps\n\n1. Upload `student_attendance.csv` to the n8n files folder\n2. Upload `student_contacts.csv` to the n8n files folder\n3. Create an empty `attendance_report.csv` in the n8n files folder\n4. Add `smtp_user` in n8n \u2192 Settings \u2192 Variables\n5. Configure SMTP credentials in the **Send Email** node\n6. Adjust the cron schedule if needed (default: 17:30 Mon\u2013Fri)"
      },
      "typeVersion": 1
    },
    {
      "id": "88bd7eff-365a-4891-be99-43f6c9235d0b",
      "name": "Group Trigger Ingestion",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        144,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 860,
        "height": 420,
        "content": "## Trigger & data ingestion\n\nSchedule trigger reads attendance CSV and filters to today's records only."
      },
      "typeVersion": 1
    },
    {
      "id": "254b824c-29fa-422d-82bf-d8288543cbd6",
      "name": "Group Routing Merge",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1040,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 798,
        "height": 424,
        "content": "## Absence routing & contact merge\n\nSplits absent vs present students, fetches parent contacts, and merges records."
      },
      "typeVersion": 1
    },
    {
      "id": "9e76a543-d8ac-4906-aea1-1b4023a783f4",
      "name": "Group Alert Logic",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1856,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 244,
        "height": 416,
        "content": "## Alert logic\n\nCalculates risk level, attendance %, streak, and trend per student."
      },
      "typeVersion": 1
    },
    {
      "id": "9dd75633-cd8e-4f97-bb65-6b27c32c937f",
      "name": "Group Notifications",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2144,
        -304
      ],
      "parameters": {
        "color": 7,
        "width": 494,
        "height": 272,
        "content": "## Parent notifications\n\nGenerates and sends a colour-coded HTML email alert to parents via SMTP."
      },
      "typeVersion": 1
    },
    {
      "id": "66e18686-7781-48d3-8485-2f0e87fc11a6",
      "name": "Group No Absence",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2144,
        336
      ],
      "parameters": {
        "color": 7,
        "width": 276,
        "height": 274,
        "content": "## No absences branch\n\nSkips email and dashboard when all students are present today."
      },
      "typeVersion": 1
    },
    {
      "id": "0d8e0e33-ad19-4222-967e-54b92efa97fc",
      "name": "No Absences Today",
      "type": "n8n-nodes-base.code",
      "position": [
        2224,
        480
      ],
      "parameters": {
        "jsCode": "// False branch: all students present today\nconst now = new Date(new Date().toLocaleString('en-US', {timeZone:'Asia/Kolkata'}));\nconst dd = String(now.getDate()).padStart(2,'0');\nconst mm = String(now.getMonth()+1).padStart(2,'0');\nconst yyyy = now.getFullYear();\nconst today = `${dd}-${mm}-${yyyy}`;\nconst nowStr = now.toLocaleString('en-IN', {timeZone:'Asia/Kolkata'});\nconst headers = 'Date,StudentID,Name,Class,Subject,Teacher,Status,AbsencesLast30Days,ConsecutiveStreak,AttendancePct,Trend,AlertLevel,AlertReason,ParentName,EmailSent,WhatsAppSent';\nreturn [{json:{todayRows:[], headers, nowStr, today, allAbsent:[], noAbsencesToday:true}}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "7b23422e-e7d0-4c55-8eac-429ddc56ad26",
      "name": "Recurring Time Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        192,
        32
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "30 17 * * 1-5"
            }
          ]
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "68e30462-a058-4c61-874b-530275978305",
      "name": "Student Attendance Data",
      "type": "n8n-nodes-base.readWriteFile",
      "position": [
        416,
        32
      ],
      "parameters": {
        "options": {},
        "fileSelector": "/home/node/.n8n-files/File_name.csv"
      },
      "typeVersion": 1
    },
    {
      "id": "6f5f6070-20b2-4632-8943-80c8fd93b25f",
      "name": "Extract Attendance CSV",
      "type": "n8n-nodes-base.spreadsheetFile",
      "position": [
        640,
        32
      ],
      "parameters": {
        "options": {
          "rawData": true,
          "headerRow": true
        }
      },
      "typeVersion": 2
    },
    {
      "id": "cb8c1959-cfa0-4d31-8439-2241bb9beedb",
      "name": "IF: Is Absent?",
      "type": "n8n-nodes-base.if",
      "position": [
        1072,
        32
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-absent",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.Status }}",
              "rightValue": "Absent"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "f83c1d55-22ad-48d1-b3cd-40fc2ed447e7",
      "name": "Students Contacts",
      "type": "n8n-nodes-base.readWriteFile",
      "position": [
        1264,
        -96
      ],
      "parameters": {
        "options": {},
        "fileSelector": "/home/node/.n8n-files/File_name.csv"
      },
      "executeOnce": true,
      "typeVersion": 1
    },
    {
      "id": "e8d3f1a6-94c3-49ac-b127-20c964159826",
      "name": "Extract Contacts CSV",
      "type": "n8n-nodes-base.spreadsheetFile",
      "position": [
        1488,
        -96
      ],
      "parameters": {
        "options": {
          "headerRow": true
        }
      },
      "executeOnce": true,
      "typeVersion": 2
    },
    {
      "id": "06f4be2e-c00e-4314-a136-3364515a3340",
      "name": "Merge Absent + Contacts",
      "type": "n8n-nodes-base.merge",
      "position": [
        1696,
        32
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "mergeByFields": {
          "values": [
            {
              "field1": "StudentID",
              "field2": "StudentID"
            }
          ]
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "8bc30b58-a119-4bba-9683-24807367b66e",
      "name": "Alert Logic Block",
      "type": "n8n-nodes-base.code",
      "position": [
        1920,
        32
      ],
      "parameters": {
        "jsCode": "function parseDate(str) {\n  const p = String(str||'').trim().split('-');\n  if (p.length===3) return new Date(+p[2],+p[1]-1,+p[0]);\n  return new Date(0);\n}\n\n// Always use Asia/Kolkata timezone for consistent \"today\" regardless of server TZ\nconst nowIST = new Date(new Date().toLocaleString('en-US', {timeZone:'Asia/Kolkata'}));\nconst todayStr = String(nowIST.getDate()).padStart(2,'0')+'-'+String(nowIST.getMonth()+1).padStart(2,'0')+'-'+nowIST.getFullYear();\nconst todayDate = parseDate(todayStr);\nconst thirtyAgo = new Date(todayDate); thirtyAgo.setDate(thirtyAgo.getDate()-30);\n\n// Normalize a raw date string \u2014 handles DD-MM-YYYY, YYYY-MM-DD, D/M/YYYY etc.\nfunction normalizeDate(raw) {\n  const s = String(raw||'').trim();\n  // Already DD-MM-YYYY\n  if (/^\\d{2}-\\d{2}-\\d{4}$/.test(s)) return s;\n  // YYYY-MM-DD (ISO)\n  if (/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) {\n    const [y,m,d] = s.split('-');\n    return `${d}-${m}-${y}`;\n  }\n  // D/M/YYYY or DD/MM/YYYY\n  if (/^\\d{1,2}\\/\\d{1,2}\\/\\d{4}$/.test(s)) {\n    const [d,m,y] = s.split('/');\n    return String(d).padStart(2,'0')+'-'+String(m).padStart(2,'0')+'-'+y;\n  }\n  // Excel serial number (number only) \u2014 convert to date\n  if (/^\\d{5}$/.test(s)) {\n    const serial = parseInt(s);\n    const excelEpoch = new Date(1899,11,30);\n    const date = new Date(excelEpoch.getTime() + serial * 86400000);\n    return String(date.getDate()).padStart(2,'0')+'-'+String(date.getMonth()+1).padStart(2,'0')+'-'+date.getFullYear();\n  }\n  return s;\n}\n\nconst byStudent = {};\nfor (const item of $input.all()) {\n  const sid = String(item.json.StudentID||'').trim();\n  if (!sid) continue;\n  // Normalize the date on every row as it comes in\n  const normalizedDate = normalizeDate(item.json.Date);\n  const enriched = {...item.json, Date: normalizedDate};\n  if (!byStudent[sid]) byStudent[sid]=[];\n  byStudent[sid].push(enriched);\n}\n\nconst output = [];\nfor (const [sid,rows] of Object.entries(byStudent)) {\n  const contact = rows[0];\n  const last30 = rows.filter(r=>{const d=parseDate(r.Date);return d>=thirtyAgo&&d<=todayDate;}).sort((a,b)=>parseDate(a.Date)-parseDate(b.Date));\n  const absent30=last30.filter(r=>String(r.Status).toLowerCase()==='absent').length;\n  const total30=last30.length;\n  const attendancePct=total30>0?Math.round(((total30-absent30)/total30)*100):100;\n  let streak=0;\n  for(let i=last30.length-1;i>=0;i--){if(String(last30[i].Status).toLowerCase()==='absent')streak++;else break;}\n  const l7=last30.slice(-7),p7=last30.slice(-14,-7);\n  const ar=a=>a.length?a.filter(r=>String(r.Status).toLowerCase()==='absent').length/a.length:0;\n  const trend=ar(l7)>ar(p7)+0.1?'WORSENING':ar(l7)<ar(p7)-0.1?'IMPROVING':'STABLE';\n  let alertLevel='LOW',alertReason=`${absent30} absence(s) in 30 days`;\n  if(absent30>=5||streak>=3){alertLevel='HIGH';alertReason=streak>=3?`${streak} consecutive absences`:`${absent30} absences in 30 days`;}\n  else if(absent30>=3){alertLevel='MEDIUM';alertReason=`${absent30} absences in 30 days`;}\n  const todayRow=rows.find(r=>String(r.Date).trim()===todayStr)||rows[rows.length-1];\n  output.push({json:{studentId:sid,name:contact.Name||'Student',parentName:contact.ParentName||'Parent',email:contact.Email||'',phone:contact.Phone||'',class:todayRow.Class||contact.Class||'',subject:todayRow.Subject||'',teacher:todayRow.Teacher||'',date:todayStr,absencesLast30Days:absent30,consecutiveStreak:streak,attendancePct,trend,alertLevel,alertReason}});\n}\nreturn output;\n"
      },
      "typeVersion": 2
    },
    {
      "id": "bc63d2c7-c79c-4bd7-8484-d52a3e6d4a66",
      "name": "Generate Absence Email",
      "type": "n8n-nodes-base.code",
      "position": [
        2192,
        -176
      ],
      "parameters": {
        "jsCode": "const item = $input.item.json;\nconst cfg = { HIGH:{color:'#dc2626',bg:'#fef2f2',border:'#fecaca',label:'Critical Alert',icon:'\ud83d\udd34'}, MEDIUM:{color:'#d97706',bg:'#fffbeb',border:'#fde68a',label:'Warning',icon:'\ud83d\udfe1'}, LOW:{color:'#16a34a',bg:'#f0fdf4',border:'#bbf7d0',label:'Low Risk',icon:'\ud83d\udfe2'} }[item.alertLevel]||{color:'#16a34a',bg:'#f0fdf4',border:'#bbf7d0',label:'Low Risk',icon:'\ud83d\udfe2'};\nconst trendIcon={WORSENING:'\ud83d\udcc8 Worsening',STABLE:'\u27a1\ufe0f Stable',IMPROVING:'\ud83d\udcc9 Improving'};\nconst subject=`${cfg.icon} Absence: ${item.name} \u2014 ${item.date} [${item.alertLevel}]`;\nconst body=`<html><body style=\"font-family:Arial,sans-serif;max-width:600px;margin:auto\"><div style=\"background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:24px\"><h2>Absence Notification</h2><p style=\"color:#6b7280;font-size:13px\">${item.date}</p><div style=\"background:${cfg.bg};border-left:4px solid ${cfg.color};padding:12px;border-radius:4px;margin:16px 0\"><strong style=\"color:${cfg.color}\">${cfg.label}: ${item.alertReason}</strong></div><table style=\"width:100%;font-size:14px;border-collapse:collapse\"><tr><td style=\"padding:8px;color:#6b7280\">Student</td><td><b>${item.name}</b></td></tr><tr style=\"background:#f9f9f9\"><td style=\"padding:8px;color:#6b7280\">Class</td><td>${item.class}</td></tr><tr><td style=\"padding:8px;color:#6b7280\">Subject/Teacher</td><td>${item.subject} / ${item.teacher}</td></tr><tr style=\"background:#f9f9f9\"><td style=\"padding:8px;color:#6b7280\">Absences 30d</td><td><b>${item.absencesLast30Days}</b></td></tr><tr><td style=\"padding:8px;color:#6b7280\">Streak</td><td>${item.consecutiveStreak} day(s)</td></tr><tr style=\"background:#f9f9f9\"><td style=\"padding:8px;color:#6b7280\">Attendance</td><td><b>${item.attendancePct}%</b></td></tr><tr><td style=\"padding:8px;color:#6b7280\">Trend</td><td>${trendIcon[item.trend]||item.trend}</td></tr></table><p style=\"margin-top:16px;font-size:13px;color:#4b5563\">Dear ${item.parentName}, please contact the school if you have questions.</p></div></body></html>`;\nreturn [{json:{...item,emailSubject:subject,emailBody:body}}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "30e57f21-7296-4684-ba90-0ac805470287",
      "name": "Build Dashboard and Report",
      "type": "n8n-nodes-base.code",
      "position": [
        2192,
        112
      ],
      "parameters": {
        "jsCode": "const allAbsent=$input.all().map(i=>i.json).filter(i=>i.studentId); // filter out empty items\nconst now=new Date(new Date().toLocaleString('en-US', {timeZone:'Asia/Kolkata'}));\nconst dd=String(now.getDate()).padStart(2,'0'),mm=String(now.getMonth()+1).padStart(2,'0'),yyyy=now.getFullYear();\nconst today=`${dd}-${mm}-${yyyy}`;\nconst nowStr=now.toLocaleString('en-IN',{timeZone:'Asia/Kolkata'});\nconst headers='Date,StudentID,Name,Class,Subject,Teacher,Status,AbsencesLast30Days,ConsecutiveStreak,AttendancePct,Trend,AlertLevel,AlertReason,ParentName,EmailSent,WhatsAppSent';\n\n// If no absences today (false branch), todayRows will be empty \u2014 dashboard shows \"No absences\"\nconst todayRows=allAbsent.map(s=>[today,s.studentId,s.name,s.class,s.subject,s.teacher,'Absent',s.absencesLast30Days,s.consecutiveStreak,s.attendancePct,s.trend,s.alertLevel,`\"${s.alertReason}\"`,s.parentName,s.email?'YES':'NO',s.phone?'YES':'NO'].join(','));\nreturn [{json:{todayRows,headers,nowStr,today,allAbsent,noAbsencesToday:allAbsent.length===0}}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "4f2aa786-1404-4dc3-8687-02efe7edcc59",
      "name": "Send Email",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        2416,
        -176
      ],
      "parameters": {
        "options": {},
        "subject": "={{ $json.emailSubject }}",
        "toEmail": "={{ $json.email }}",
        "fromEmail": "={{ $vars.smtp_user }}"
      },
      "typeVersion": 2.1
    },
    {
      "id": "5c53845e-9b24-464e-abb2-5eb14c97fb4c",
      "name": "Existing Report History",
      "type": "n8n-nodes-base.readWriteFile",
      "position": [
        2544,
        112
      ],
      "parameters": {
        "options": {},
        "fileSelector": "/home/node/.n8n-files/File_name.csv"
      },
      "typeVersion": 1
    },
    {
      "id": "3b86af26-3c7e-446f-b57f-e44bf6f6a34e",
      "name": "Extract History CSV",
      "type": "n8n-nodes-base.spreadsheetFile",
      "position": [
        2752,
        112
      ],
      "parameters": {
        "options": {
          "headerRow": true
        }
      },
      "typeVersion": 2
    },
    {
      "id": "c9b04d9b-6bbd-48d1-84a8-204f9bd50571",
      "name": "Combine History + Build Dashboard",
      "type": "n8n-nodes-base.code",
      "position": [
        2976,
        112
      ],
      "parameters": {
        "jsCode": "// Handle both branches: true (Build Dashboard ran) or false (No Absences Today ran)\nlet built;\ntry { built = $('Build Dashboard and Report').first().json; } catch(e) {\n  try { built = $('No Absences Today').first().json; } catch(e2) {\n    const now2 = new Date(new Date().toLocaleString('en-US',{timeZone:'Asia/Kolkata'}));\n    const dd2=String(now2.getDate()).padStart(2,'0'), mm2=String(now2.getMonth()+1).padStart(2,'0'), yyyy2=now2.getFullYear();\n    built={todayRows:[],headers:'Date,StudentID,Name,Class,Subject,Teacher,Status,AbsencesLast30Days,ConsecutiveStreak,AttendancePct,Trend,AlertLevel,AlertReason,ParentName,EmailSent,WhatsAppSent',nowStr:now2.toLocaleString('en-IN',{timeZone:'Asia/Kolkata'}),today:`${dd2}-${mm2}-${yyyy2}`,allAbsent:[],noAbsencesToday:true};\n  }\n}\nconst {todayRows,headers,nowStr,today}=built;\n\n// History comes as plain JSON rows from Extract History CSV3 (spreadsheetFile node)\nconst historyRows=$input.all().map(i=>i.json).filter(r=>r.Date&&r.StudentID&&r.AlertLevel);\nconst historical=historyRows.filter(r=>String(r.Date).trim()!==today);\n\n// Parse today CSV lines into objects\nfunction parseCSVLine(line){\n  const res=[];let cur='',inQ=false;\n  for(const c of line){if(c==='\"'){inQ=!inQ;}else if(c===','&&!inQ){res.push(cur);cur='';}else cur+=c;}\n  res.push(cur);return res.map(v=>v.trim().replace(/^\"|\"$/g,''));\n}\nconst hdrArr=parseCSVLine(headers);\n// If no absences today, show a clean \"All Present\" message in Today tab\nconst noAbsencesToday = built.noAbsencesToday === true;\nconst todayObjs=todayRows.map(line=>{const v=parseCSVLine(line);const o={};hdrArr.forEach((h,i)=>o[h]=v[i]||'');return o;});\n\nconst allData=[...historical,...todayObjs];\n\nfunction toLine(r){\n  const reason=(r.AlertReason||'').includes(',')?`\"${r.AlertReason}\"`:r.AlertReason||'';\n  return [r.Date,r.StudentID,r.Name,r.Class,r.Subject,r.Teacher,r.Status,r.AbsencesLast30Days,r.ConsecutiveStreak,r.AttendancePct,r.Trend,r.AlertLevel,reason,r.ParentName,r.EmailSent,r.WhatsAppSent].join(',');\n}\nconst fullCsv=[headers,...allData.map(toLine)].join('\\n');\n\nfunction parseDDMMYYYY(s){const p=String(s).split('-');return p.length===3?new Date(+p[2],+p[1]-1,+p[0]):new Date(0);}\nfunction fmtMonth(s){const d=parseDDMMYYYY(s);return d.toLocaleString('en-IN',{month:'short',year:'numeric'});}\nfunction getWeekKey(s){const d=parseDDMMYYYY(s);const day=d.getDay()||7;d.setDate(d.getDate()-day+1);return String(d.getDate()).padStart(2,'0')+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+d.getFullYear();}\n\nconst allDates=[...new Set(allData.map(r=>r.Date))].sort((a,b)=>parseDDMMYYYY(a)-parseDDMMYYYY(b));\nconst allMonths=[...new Set(allDates.map(fmtMonth))];\nconst allWeeks=[...new Set(allDates.map(getWeekKey))].sort((a,b)=>parseDDMMYYYY(a)-parseDDMMYYYY(b));\nconst classes=[...new Set(allData.map(r=>r.Class))].filter(Boolean).sort();\nconst subjects=[...new Set(allData.map(r=>r.Subject))].filter(Boolean).sort();\nconst students=[...new Set(allData.map(r=>r.StudentID))].filter(Boolean);\n\n// \u2500\u2500 Precompute per-student total days present/absent across all history \u2500\u2500\nconst studentStats={};\nfor(const sid of students){\n  const rows=allData.filter(r=>r.StudentID===sid);\n  const absent=rows.filter(r=>r.Status==='Absent').length;\n  const name=rows[0].Name||sid;\n  const cls=rows[0].Class||'';\n  const lastAlert=rows.sort((a,b)=>parseDDMMYYYY(b.Date)-parseDDMMYYYY(a.Date))[0].AlertLevel||'LOW';\n  const lastTrend=rows[0].Trend||'STABLE';\n  const lastAbs30=parseInt(rows[0].AbsencesLast30Days)||0;\n  const lastStreak=parseInt(rows[0].ConsecutiveStreak)||0;\n  const lastPct=parseFloat(rows[0].AttendancePct)||100;\n  studentStats[sid]={name,cls,absent,total:rows.length,lastAlert,lastTrend,lastAbs30,lastStreak,lastPct};\n}\n\nconst dateOpts=[...allDates].reverse().map(d=>`<option value=\"${d}\">${d}</option>`).join('');\nconst classOpts=classes.map(c=>`<option>${c}</option>`).join('');\nconst monthOpts=allMonths.map(m=>`<option value=\"${m}\">${m}</option>`).join('');\nconst weekOpts=allWeeks.map(w=>`<option value=\"${w}\">Week of ${w}</option>`).join('');\n\nconst dashHtml=`<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Student Attendance Dashboard</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js\"><\\/script>\n<style>\n*{box-sizing:border-box;margin:0;padding:0}\nbody{font-family:system-ui,sans-serif;background:#f0f2f5;color:#1f2937}\n.header{background:#1e293b;color:#fff;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;box-shadow:0 2px 8px rgba(0,0,0,.2)}\n.header h1{font-size:17px;font-weight:600;letter-spacing:.01em}\n.header .sub{font-size:11px;opacity:.5;margin-top:2px}\n.updated{font-size:11px;opacity:.45;text-align:right;line-height:1.6}\n.main{padding:16px 20px;max-width:1500px;margin:auto}\n.topbar{display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;align-items:center;background:#fff;padding:10px 16px;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06)}\n.topbar label{font-size:11px;color:#6b7280;font-weight:500}\n.topbar select{padding:5px 10px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;color:#1f2937;font-size:12px;cursor:pointer}\n.tabs{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap}\n.tab{padding:7px 18px;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid #e5e7eb;background:#fff;color:#64748b;font-weight:500;transition:all .15s}\n.tab:hover{background:#f8fafc}\n.tab.active{background:#1e293b;color:#fff;border-color:transparent}\n.section{display:none}.section.active{display:block}\n.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:14px}\n@media(max-width:1000px){.kpis{grid-template-columns:repeat(3,1fr)}.g2,.g3{grid-template-columns:1fr}}\n.kpi{background:#fff;border-radius:10px;padding:14px 16px;box-shadow:0 1px 4px rgba(0,0,0,.06)}\n.kpi .val{font-size:28px;font-weight:600;line-height:1}\n.kpi .lbl{font-size:10px;color:#9ca3af;margin-top:5px;text-transform:uppercase;letter-spacing:.06em}\n.kpi .sub{font-size:11px;margin-top:3px}\n.g2{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}\n.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:12px}\n.g13{display:grid;grid-template-columns:1fr 3fr;gap:12px;margin-bottom:12px}\n.card{background:#fff;border-radius:10px;padding:16px;box-shadow:0 1px 4px rgba(0,0,0,.06)}\n.card h3{font-size:10px;font-weight:600;color:#94a3b8;margin-bottom:14px;text-transform:uppercase;letter-spacing:.07em}\n.tbl{width:100%;border-collapse:collapse;font-size:12px}\n.tbl th{text-align:left;padding:8px 10px;color:#94a3b8;font-weight:600;border-bottom:2px solid #f1f5f9;font-size:10px;text-transform:uppercase;letter-spacing:.05em;position:sticky;top:0;background:#fff;z-index:1}\n.tbl td{padding:8px 10px;border-bottom:1px solid #f8fafc;color:#374151;vertical-align:middle}\n.tbl tr:last-child td{border-bottom:none}\n.tbl tr:hover td{background:#f8fafc}\n.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:700;letter-spacing:.02em}\n.b-high{background:#fee2e2;color:#dc2626}.b-med{background:#fef3c7;color:#d97706}.b-low{background:#dcfce7;color:#16a34a}\n.b-w{background:#fee2e2;color:#dc2626}.b-s{background:#f1f5f9;color:#64748b}.b-i{background:#dcfce7;color:#16a34a}\n.scroll{overflow-x:auto;max-height:400px;overflow-y:auto}\n.empty{text-align:center;padding:40px;color:#cbd5e1;font-size:13px}\n.bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px}\n.bar-row .bn{font-size:12px;color:#374151;width:90px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.bar-wrap{flex:1;background:#f1f5f9;border-radius:4px;height:13px;overflow:hidden}\n.bar-fill{height:100%;border-radius:4px;transition:width .4s}\n.bar-row .bv{font-size:11px;color:#94a3b8;width:28px;text-align:right;flex-shrink:0}\n.month-card{background:#fff;border-radius:10px;padding:14px;box-shadow:0 1px 4px rgba(0,0,0,.06);border-left:4px solid #6366f1}\n.month-card h4{font-size:13px;font-weight:600;color:#1e293b;margin-bottom:8px}\n.month-stat{display:flex;justify-content:space-between;font-size:12px;color:#64748b;padding:3px 0;border-bottom:1px solid #f8fafc}\n.month-stat:last-child{border:none}\n.month-stat span:last-child{font-weight:600;color:#1e293b}\n.risk-badge{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}\n.week-label{font-size:10px;color:#94a3b8;margin-bottom:6px;font-weight:600}\n<\\/style>\n<\\/head>\n<body>\n<div class=\"header\">\n  <div><h1>\ud83d\udcca Student Attendance Dashboard<\\/h1><div class=\"sub\">School Attendance Tracking System<\\/div><\\/div>\n  <div class=\"updated\">Last updated: ${nowStr}<br>Auto-updates daily at 17:30 <\\/div>\n<\\/div>\n<div class=\"main\">\n  <div class=\"topbar\">\n    <label>Class<\\/label><select id=\"fClass\" onchange=\"applyFilters()\"><option value=\"all\">All Classes<\\/option>${classOpts}<\\/select>\n    <label>Alert<\\/label><select id=\"fAlert\" onchange=\"applyFilters()\"><option value=\"all\">All Levels<\\/option><option>HIGH<\\/option><option>MEDIUM<\\/option><option>LOW<\\/option><\\/select>\n    <label>Trend<\\/label><select id=\"fTrend\" onchange=\"applyFilters()\"><option value=\"all\">All Trends<\\/option><option>WORSENING<\\/option><option>STABLE<\\/option><option>IMPROVING<\\/option><\\/select>\n  <\\/div>\n  <div class=\"tabs\">\n    <div class=\"tab active\"  onclick=\"switchTab('today',this)\">\ud83d\udcc5 Today<\\/div>\n    <div class=\"tab\" onclick=\"switchTab('weekly',this)\">\ud83d\udcc6 Weekly<\\/div>\n    <div class=\"tab\" onclick=\"switchTab('monthly',this)\">\ud83d\uddd3 Monthly<\\/div>\n    <div class=\"tab\" onclick=\"switchTab('history',this)\">\ud83d\udcda Full History<\\/div>\n    <div class=\"tab\" onclick=\"switchTab('risk',this)\">\ud83d\udea8 At-Risk<\\/div>\n  <\\/div>\n\n  <!-- TODAY TAB -->\n  <div id=\"today\" class=\"section active\">\n    <div class=\"kpis\" id=\"todayKpis\"><\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>Absent by Class \u2014 Today<\\/h3><div id=\"todayClassBar\"><\\/div><\\/div>\n      <div class=\"card\"><h3>Absent by Subject \u2014 Today<\\/h3><div id=\"todaySubjectBar\"><\\/div><\\/div>\n    <\\/div>\n    <div class=\"card\"><h3>Today's Absence Records<\\/h3><div class=\"scroll\"><table class=\"tbl\" id=\"todayTbl\"><\\/table><\\/div><\\/div>\n  <\\/div>\n\n  <!-- WEEKLY TAB -->\n  <div id=\"weekly\" class=\"section\">\n    <div class=\"topbar\" style=\"margin-bottom:14px\">\n      <label>Week<\\/label><select id=\"fWeek\" onchange=\"renderWeekly()\"><option value=\"all\">All Weeks<\\/option>${weekOpts}<\\/select>\n    <\\/div>\n    <div class=\"kpis\" id=\"weeklyKpis\"><\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>Daily Absences This Week<\\/h3><canvas id=\"weeklyDailyChart\" height=\"160\"><\\/canvas><\\/div>\n      <div class=\"card\"><h3>Absent by Subject This Week<\\/h3><div id=\"weeklySubjectBar\"><\\/div><\\/div>\n    <\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>Top Absent Students This Week<\\/h3><div class=\"scroll\"><table class=\"tbl\" id=\"weeklyTopTbl\"><\\/table><\\/div><\\/div>\n      <div class=\"card\"><h3>Alert Level Split This Week<\\/h3><canvas id=\"weeklyAlertDonut\" height=\"200\"><\\/canvas><\\/div>\n    <\\/div>\n  <\\/div>\n\n  <!-- MONTHLY TAB -->\n  <div id=\"monthly\" class=\"section\">\n    <div class=\"topbar\" style=\"margin-bottom:14px\">\n      <label>Month<\\/label><select id=\"fMonth\" onchange=\"renderMonthly()\"><option value=\"all\">All Months<\\/option>${monthOpts}<\\/select>\n    <\\/div>\n    <div class=\"kpis\" id=\"monthlyKpis\"><\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>Monthly Absence Trend<\\/h3><canvas id=\"monthlyTrendChart\" height=\"160\"><\\/canvas><\\/div>\n      <div class=\"card\"><h3>Subject-wise Absences This Month<\\/h3><div id=\"monthlySubjectBar\"><\\/div><\\/div>\n    <\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>Top Absent Students This Month<\\/h3><div class=\"scroll\"><table class=\"tbl\" id=\"monthlyTopTbl\"><\\/table><\\/div><\\/div>\n      <div class=\"card\"><h3>Class Attendance % This Month<\\/h3><canvas id=\"monthlyClassChart\" height=\"200\"><\\/canvas><\\/div>\n    <\\/div>\n    <div class=\"card\" style=\"margin-bottom:12px\"><h3>Month-by-Month Summary<\\/h3><div id=\"monthSummaryGrid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;padding-top:4px\"><\\/div><\\/div>\n  <\\/div>\n\n  <!-- FULL HISTORY TAB -->\n  <div id=\"history\" class=\"section\">\n    <div class=\"topbar\" style=\"margin-bottom:14px\">\n      <label>Date<\\/label><select id=\"fDate\" onchange=\"renderHistory()\"><option value=\"all\">All Dates<\\/option>${dateOpts}<\\/select>\n    <\\/div>\n    <div class=\"kpis\" id=\"histKpis\"><\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>Daily Absence Count Over Time<\\/h3><canvas id=\"histDailyChart\" height=\"160\"><\\/canvas><\\/div>\n      <div class=\"card\"><h3>Attendance Rate by Class Over Time<\\/h3><canvas id=\"histClassChart\" height=\"160\"><\\/canvas><\\/div>\n    <\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>Subject-wise Total Absences<\\/h3><div id=\"histSubjectBar\"><\\/div><\\/div>\n      <div class=\"card\"><h3>Alert Level Over Time<\\/h3><canvas id=\"histAlertChart\" height=\"200\"><\\/canvas><\\/div>\n    <\\/div>\n    <div class=\"card\"><h3>All Records<\\/h3><div class=\"scroll\"><table class=\"tbl\" id=\"histTbl\"><\\/table><\\/div><\\/div>\n  <\\/div>\n\n  <!-- AT-RISK TAB -->\n  <div id=\"risk\" class=\"section\">\n    <div class=\"kpis\" id=\"riskKpis\"><\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>\ud83d\udd34 High Risk Students<\\/h3><div class=\"scroll\"><table class=\"tbl\" id=\"highRiskTbl\"><\\/table><\\/div><\\/div>\n      <div class=\"card\"><h3>\ud83d\udfe1 Medium Risk Students<\\/h3><div class=\"scroll\"><table class=\"tbl\" id=\"medRiskTbl\"><\\/table><\\/div><\\/div>\n    <\\/div>\n    <div class=\"g2\">\n      <div class=\"card\"><h3>\ud83d\udcc8 Worsening Trend Students<\\/h3><div class=\"scroll\"><table class=\"tbl\" id=\"worsenTbl\"><\\/table><\\/div><\\/div>\n      <div class=\"card\"><h3>Top 10 Most Absent (All Time)<\\/h3><div id=\"topAbsentBar\"><\\/div><\\/div>\n    <\\/div>\n    <div class=\"card\"><h3>Subject-wise At-Risk Distribution<\\/h3><canvas id=\"riskSubjectChart\" height=\"120\"><\\/canvas><\\/div>\n  <\\/div>\n<\\/div>\n\n<script>\nconst ALL_DATA=${JSON.stringify(allData)};\nconst ALL_NO_ABSENCES_TODAY=${JSON.stringify(noAbsencesToday)};\nconst ALL_DATES=${JSON.stringify(allDates)};\nconst ALL_MONTHS=${JSON.stringify(allMonths)};\nconst ALL_WEEKS=${JSON.stringify(allWeeks)};\nconst ALL_CLASSES=${JSON.stringify(classes)};\nconst ALL_SUBJECTS=${JSON.stringify(subjects)};\nconst STUDENT_STATS=${JSON.stringify(studentStats)};\nconst TODAY='${today}';\n\nlet charts={};\nfunction parseDDMMYYYY(s){const p=String(s).split('-');return p.length===3?new Date(+p[2],+p[1]-1,+p[0]):new Date(0);}\nfunction fmtMonth(s){return parseDDMMYYYY(s).toLocaleString('en-IN',{month:'short',year:'numeric'});}\nfunction getWeekKey(s){const d=parseDDMMYYYY(s);const day=d.getDay()||7;d.setDate(d.getDate()-day+1);return String(d.getDate()).padStart(2,'0')+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+d.getFullYear();}\nfunction dc(id){if(charts[id]){charts[id].destroy();delete charts[id];}}\nfunction pct(a,t){return t===0?0:Math.round(a/t*100);}\n\n// Global filters\nfunction getFilters(){\n  return {\n    cls:document.getElementById('fClass').value,\n    alert:document.getElementById('fAlert').value,\n    trend:document.getElementById('fTrend').value\n  };\n}\nfunction applyGlobalFilter(rows){\n  const f=getFilters();\n  return rows.filter(r=>(f.cls==='all'||r.Class===f.cls)&&(f.alert==='all'||r.AlertLevel===f.alert)&&(f.trend==='all'||r.Trend===f.trend));\n}\n\nfunction makeBar(id,items,cf,showPct){\n  const el=document.getElementById(id);if(!el)return;\n  if(!items.length){el.innerHTML='<div class=\"empty\">No data<\\/div>';return;}\n  const mx=Math.max(...items.map(i=>i.val),1);\n  el.innerHTML=items.slice(0,10).map(i=>'<div class=\"bar-row\"><span class=\"bn\" title=\"'+i.label+'\">'+i.label+'<\\/span><div class=\"bar-wrap\"><div class=\"bar-fill\" style=\"width:'+pct(i.val,mx)+'%;background:'+cf(i)+'\"><\\/div><\\/div><span class=\"bv\">'+(showPct?i.val+'%':i.val)+'<\\/span><\\/div>').join('');\n}\nfunction donut(id,labels,vals,colors){\n  dc(id);const ctx=document.getElementById(id);if(!ctx)return;\n  charts[id]=new Chart(ctx,{type:'doughnut',data:{labels,datasets:[{data:vals,backgroundColor:colors,borderWidth:0,hoverOffset:4}]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{font:{size:11},padding:8}}},cutout:'62%'}});\n}\nfunction lineChart(id,labels,datasets,opts){\n  dc(id);const ctx=document.getElementById(id);if(!ctx)return;\n  charts[id]=new Chart(ctx,{type:'line',data:{labels,datasets},options:{responsive:true,interaction:{mode:'index',intersect:false},scales:{x:{grid:{color:'#f1f5f9'},ticks:{font:{size:10},maxTicksLimit:12}},y:{beginAtZero:true,grid:{color:'#f1f5f9'},ticks:{font:{size:10}}},...(opts||{})},...(opts||{}),plugins:{legend:{labels:{font:{size:11}}}}}});\n}\nfunction barChart(id,labels,datasets){\n  dc(id);const ctx=document.getElementById(id);if(!ctx)return;\n  charts[id]=new Chart(ctx,{type:'bar',data:{labels,datasets},options:{responsive:true,scales:{x:{grid:{display:false},ticks:{font:{size:10}}},y:{beginAtZero:true,grid:{color:'#f1f5f9'},ticks:{font:{size:10}}}},plugins:{legend:{labels:{font:{size:11}}}}}});\n}\n\nfunction tblHTML(cols,rows){\n  if(!rows.length)return '<tr><td class=\"empty\" colspan=\"'+cols.length+'\">No data<\\/td><\\/tr>';\n  return '<thead><tr>'+cols.map(c=>'<th>'+c+'<\\/th>').join('')+'<\\/tr><\\/thead><tbody>'+rows.join('')+'<\\/tbody>';\n}\nfunction alertBadge(l){return '<span class=\"badge '+(l==='HIGH'?'b-high':l==='MEDIUM'?'b-med':'b-low')+'\">'+l+'<\\/span>';}\nfunction trendBadge(t){return '<span class=\"badge '+(t==='WORSENING'?'b-w':t==='IMPROVING'?'b-i':'b-s')+'\">'+t+'<\\/span>';}\nfunction pctColor(p){return +p>=85?'#16a34a':+p>=70?'#d97706':'#dc2626';}\n\n// \u2500\u2500 TODAY \u2500\u2500\nfunction renderToday(){\n  if(ALL_NO_ABSENCES_TODAY){\n    document.getElementById('todayKpis').innerHTML='<div class=\"kpi\" style=\"grid-column:1/-1;text-align:center;background:linear-gradient(135deg,#f0fdf4,#dcfce7);border:1px solid #bbf7d0\"><div class=\"val\" style=\"color:#16a34a;font-size:48px\">\u2705</div><div class=\"lbl\" style=\"font-size:16px;color:#16a34a;margin-top:8px\">No Absences Today</div><div class=\"sub\" style=\"color:#16a34a\">All students were present on '+TODAY+'</div></div>';\n    document.getElementById('todayClassBar').innerHTML='';\n    document.getElementById('todaySubjectBar').innerHTML='';\n    const tbl=document.getElementById('todayTbl');if(tbl)tbl.innerHTML='<tr><td class=\"empty\" colspan=\"10\" style=\"padding:40px;color:#16a34a;font-size:14px\">\u2705 All students were present today \u2014 no absence records.</td></tr>';\n    return;\n  }\n  const raw=ALL_DATA.filter(r=>r.Date===TODAY);\n  const data=applyGlobalFilter(raw);\n  const tot=data.length,high=data.filter(r=>r.AlertLevel==='HIGH').length,med=data.filter(r=>r.AlertLevel==='MEDIUM').length;\n  const wors=data.filter(r=>r.Trend==='WORSENING').length,sent=data.filter(r=>r.EmailSent==='YES').length;\n  const avgP=tot>0?Math.round(data.reduce((s,r)=>s+parseFloat(r.AttendancePct||0),0)/tot):0;\n  document.getElementById('todayKpis').innerHTML=kpiHTML([\n    {val:tot,lbl:'Absent Today',sub:TODAY,subc:'#64748b'},\n    {val:high,lbl:'High Risk',sub:med+' medium',subc:'#d97706',vc:'#dc2626'},\n    {val:wors,lbl:'Worsening',sub:data.filter(r=>r.Trend==='IMPROVING').length+' improving',subc:'#16a34a',vc:'#d97706'},\n    {val:avgP+'%',lbl:'Avg Attendance',sub:avgP>=85?'On target':'Below 85%',subc:avgP>=85?'#16a34a':'#dc2626'},\n    {val:sent,lbl:'Notified',sub:data.filter(r=>r.WhatsAppSent==='YES').length+' WhatsApp',subc:'#64748b',vc:'#16a34a'}\n  ]);\n  makeBar('todayClassBar',ALL_CLASSES.map(c=>({label:c,val:data.filter(r=>r.Class===c).length})),i=>i.val>5?'#ef4444':i.val>2?'#f59e0b':'#22c55e');\n  makeBar('todaySubjectBar',ALL_SUBJECTS.map(s=>({label:s,val:data.filter(r=>r.Subject===s).length})).sort((a,b)=>b.val-a.val),()=>'#6366f1');\n  const tbl=document.getElementById('todayTbl');\n  if(tbl)tbl.innerHTML=tblHTML(['Student','Name','Class','Subject','Teacher','Abs 30d','Streak','Rate','Trend','Alert'],\n    data.map(r=>'<tr><td>'+r.StudentID+'<\\/td><td>'+(r.Name||'\u2014')+'<\\/td><td>'+r.Class+'<\\/td><td>'+(r.Subject||'\u2014')+'<\\/td><td>'+(r.Teacher||'\u2014')+'<\\/td><td>'+r.AbsencesLast30Days+'<\\/td><td>'+(+r.ConsecutiveStreak>0?'<b style=\"color:#dc2626\">'+r.ConsecutiveStreak+'d<\\/b>':'\u2014')+'<\\/td><td><span style=\"color:'+pctColor(r.AttendancePct)+'\">'+parseFloat(r.AttendancePct).toFixed(1)+'%<\\/span><\\/td><td>'+trendBadge(r.Trend)+'<\\/td><td>'+alertBadge(r.AlertLevel)+'<\\/td><\\/tr>'));\n}\n\n// \u2500\u2500 WEEKLY \u2500\u2500\nfunction renderWeekly(){\n  const selWeek=document.getElementById('fWeek')&&document.getElementById('fWeek').value;\n  const weekDates=selWeek&&selWeek!=='all'?ALL_DATES.filter(d=>getWeekKey(d)===selWeek):ALL_DATES.slice(-7);\n  const raw=applyGlobalFilter(ALL_DATA.filter(r=>weekDates.includes(r.Date)));\n  const tot=raw.length,high=raw.filter(r=>r.AlertLevel==='HIGH').length,med=raw.filter(r=>r.AlertLevel==='MEDIUM').length;\n  const wors=raw.filter(r=>r.Trend==='WORSENING').length;\n  const days=[...new Set(raw.map(r=>r.Date))].sort((a,b)=>parseDDMMYYYY(a)-parseDDMMYYYY(b));\n  document.getElementById('weeklyKpis').innerHTML=kpiHTML([\n    {val:tot,lbl:'Total Absences',sub:days.length+' school day(s)',subc:'#64748b'},\n    {val:high,lbl:'High Risk',sub:med+' medium',subc:'#d97706',vc:'#dc2626'},\n    {val:wors,lbl:'Worsening',sub:'trend',subc:'#64748b',vc:'#d97706'},\n    {val:tot>0?Math.round(tot/Math.max(days.length,1)):0,lbl:'Avg/Day',sub:'absences',subc:'#64748b'},\n    {val:[...new Set(raw.map(r=>r.StudentID))].length,lbl:'Unique Students',sub:'absent this week',subc:'#64748b'}\n  ]);\n  lineChart('weeklyDailyChart',days.map(d=>d.slice(0,5)),[\n    {label:'Absences',data:days.map(d=>raw.filter(r=>r.Date===d).length),borderColor:'#6366f1',backgroundColor:'rgba(99,102,241,0.1)',tension:.3,fill:true,pointRadius:4}\n  ]);\n  makeBar('weeklySubjectBar',ALL_SUBJECTS.map(s=>({label:s,val:raw.filter(r=>r.Subject===s).length})).sort((a,b)=>b.val-a.val),()=>'#6366f1');\n  // Top students this week by absence count\n  const sidCounts={};raw.forEach(r=>{sidCounts[r.StudentID]=(sidCounts[r.StudentID]||0)+1;});\n  const topRows=Object.entries(sidCounts).sort((a,b)=>b[1]-a[1]).slice(0,10);\n  const wTbl=document.getElementById('weeklyTopTbl');\n  if(wTbl)wTbl.innerHTML=tblHTML(['Student','Name','Class','Absences','Alert'],\n    topRows.map(([sid,cnt])=>{const st=STUDENT_STATS[sid]||{};return '<tr><td>'+sid+'<\\/td><td>'+(st.name||'\u2014')+'<\\/td><td>'+(st.cls||'\u2014')+'<\\/td><td><b>'+cnt+'<\\/b><\\/td><td>'+alertBadge(st.lastAlert||'LOW')+'<\\/td><\\/tr>';}));\n  donut('weeklyAlertDonut',['High','Medium','Low'],[high,med,raw.filter(r=>r.AlertLevel==='LOW').length],['#ef4444','#f59e0b','#22c55e']);\n}\n\n// \u2500\u2500 MONTHLY \u2500\u2500\nfunction renderMonthly(){\n  const selMonth=document.getElementById('fMonth')&&document.getElementById('fMonth').value;\n  const curMonth=selMonth&&selMonth!=='all'?selMonth:(ALL_MONTHS[ALL_MONTHS.length-1]||'');\n  const monthData=applyGlobalFilter(ALL_DATA.filter(r=>fmtMonth(r.Date)===curMonth));\n  const tot=monthData.length,high=monthData.filter(r=>r.AlertLevel==='HIGH').length,med=monthData.filter(r=>r.AlertLevel==='MEDIUM').length;\n  const wors=monthData.filter(r=>r.Trend==='WORSENING').length;\n  const mDays=[...new Set(monthData.map(r=>r.Date))].sort((a,b)=>parseDDMMYYYY(a)-parseDDMMYYYY(b));\n  const avgP=tot>0?Math.round(monthData.reduce((s,r)=>s+parseFloat(r.AttendancePct||0),0)/tot):0;\n  document.getElementById('monthlyKpis').innerHTML=kpiHTML([\n    {val:tot,lbl:'Total Absences',sub:curMonth,subc:'#64748b'},\n    {val:high,lbl:'High Risk',sub:med+' medium',subc:'#d97706',vc:'#dc2626'},\n    {val:wors,lbl:'Worsening',sub:'trend this month',subc:'#64748b',vc:'#d97706'},\n    {val:avgP+'%',lbl:'Avg Attendance',sub:avgP>=85?'On target':'Below 85%',subc:avgP>=85?'#16a34a':'#dc2626'},\n    {val:mDays.length,lbl:'School Days',sub:'recorded this month',subc:'#64748b'}\n  ]);\n  // Monthly trend over all months\n  lineChart('monthlyTrendChart',ALL_MONTHS,[\n    {label:'Total Absences',data:ALL_MONTHS.map(m=>ALL_DATA.filter(r=>fmtMonth(r.Date)===m).length),borderColor:'#6366f1',backgroundColor:'rgba(99,102,241,0.08)',tension:.3,fill:true,pointRadius:4,pointBackgroundColor:'#6366f1'},\n    {label:'High Risk',data:ALL_MONTHS.map(m=>ALL_DATA.filter(r=>fmtMonth(r.Date)===m&&r.AlertLevel==='HIGH').length),borderColor:'#ef4444',backgroundColor:'transparent',tension:.3,pointRadius:3}\n  ]);\n  makeBar('monthlySubjectBar',ALL_SUBJECTS.map(s=>({label:s,val:monthData.filter(r=>r.Subject===s).length})).sort((a,b)=>b.val-a.val),()=>'#6366f1');\n  // Top students this month\n  const mSid={};monthData.forEach(r=>{mSid[r.StudentID]=(mSid[r.StudentID]||0)+1;});\n  const mTop=Object.entries(mSid).sort((a,b)=>b[1]-a[1]).slice(0,10);\n  const mTbl=document.getElementById('monthlyTopTbl');\n  if(mTbl)mTbl.innerHTML=tblHTML(['Student','Name','Class','Absences','Alert'],\n    mTop.map(([sid,cnt])=>{const st=STUDENT_STATS[sid]||{};return '<tr><td>'+sid+'<\\/td><td>'+(st.name||'\u2014')+'<\\/td><td>'+(st.cls||'\u2014')+'<\\/td><td><b>'+cnt+'<\\/b><\\/td><td>'+alertBadge(st.lastAlert||'LOW')+'<\\/td><\\/tr>';}));\n  // Class attendance % bar chart this month\n  barChart('monthlyClassChart',ALL_CLASSES,[\n    {label:'Avg Attendance %',data:ALL_CLASSES.map(c=>{const rows=monthData.filter(r=>r.Class===c);return rows.length?Math.round(rows.reduce((s,r)=>s+parseFloat(r.AttendancePct||0),0)/rows.length):0;}),backgroundColor:['#6366f1','#8b5cf6','#06b6d4','#10b981','#f59e0b'],borderRadius:6}\n  ]);\n  // Month summary cards\n  const grid=document.getElementById('monthSummaryGrid');\n  if(grid)grid.innerHTML=ALL_MONTHS.map(m=>{\n    const rows=ALL_DATA.filter(r=>fmtMonth(r.Date)===m);\n    const abs=rows.length,h=rows.filter(r=>r.AlertLevel==='HIGH').length,w=rows.filter(r=>r.Trend==='WORSENING').length;\n    const ap=rows.length?Math.round(rows.reduce((s,r)=>s+parseFloat(r.AttendancePct||0),0)/rows.length):0;\n    return '<div class=\"month-card\"><h4>'+m+'<\\/h4><div class=\"month-stat\"><span>Total Absences<\\/span><span>'+abs+'<\\/span><\\/div><div class=\"month-stat\"><span>High Risk<\\/span><span style=\"color:#dc2626\">'+h+'<\\/span><\\/div><div class=\"month-stat\"><span>Worsening<\\/span><span style=\"color:#d97706\">'+w+'<\\/span><\\/div><div class=\"month-stat\"><span>Avg Attendance<\\/span><span style=\"color:'+pctColor(ap)+'\">'+ap+'%<\\/span><\\/div><\\/div>';\n  }).join('');\n}\n\n// \u2500\u2500 FULL HISTORY \u2500\u2500\nfunction renderHistory(){\n  const fDate=document.getElementById('fDate')&&document.getElementById('fDate').value;\n  const raw=fDate&&fDate!=='all'?ALL_DATA.filter(r=>r.Date===fDate):ALL_DATA;\n  const data=applyGlobalFilter(raw);\n  const tot=data.length,high=data.filter(r=>r.AlertLevel==='HIGH').length;\n  const wors=data.filter(r=>r.Trend==='WORSENING').length;\n  const avgP=tot>0?Math.round(data.reduce((s,r)=>s+parseFloat(r.AttendancePct||0),0)/tot):0;\n  document.getElementById('histKpis').innerHTML=kpiHTML([\n    {val:tot,lbl:'Total Records',sub:[...new Set(data.map(r=>r.Date))].length+' day(s)',subc:'#64748b'},\n    {val:high,lbl:'High Risk',sub:data.filter(r=>r.AlertLevel==='MEDIUM').length+' medium',subc:'#d97706',vc:'#dc2626'},\n    {val:wors,lbl:'Worsening',sub:data.filter(r=>r.Trend==='IMPROVING').length+' improving',subc:'#16a34a',vc:'#d97706'},\n    {val:avgP+'%',lbl:'Avg Attendance',sub:avgP>=85?'On target':'Below 85%',subc:avgP>=85?'#16a34a':'#dc2626'},\n    {val:ALL_MONTHS.length,lbl:'Months Tracked',sub:ALL_DATES.length+' total days',subc:'#64748b'}\n  ]);\n  lineChart('histDailyChart',ALL_DATES.map(d=>d.slice(0,5)),[\n    {label:'High',data:ALL_DATES.map(d=>ALL_DATA.filter(r=>r.Date===d&&r.AlertLevel==='HIGH').length),borderColor:'#ef4444',backgroundColor:'rgba(239,68,68,0.08)',tension:.3,fill:true,pointRadius:2},\n    {label:'Medium',data:ALL_DATES.map(d=>ALL_DATA.filter(r=>r.Date===d&&r.AlertLevel==='MEDIUM').length),borderColor:'#f59e0b',backgroundColor:'rgba(245,158,11,0.08)',tension:.3,fill:true,pointRadius:2},\n    {label:'Low',data:ALL_DATES.map(d=>ALL_DATA.filter(r=>r.Date===d&&r.AlertLevel==='LOW').length),borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,0.06)',tension:.3,fill:true,pointRadius:2}\n  ]);\n  lineChart('histClassChart',ALL_DATES.map(d=>d.slice(0,5)),ALL_CLASSES.map((c,i)=>({\n    label:c,\n    data:ALL_DATES.map(d=>{const rows=ALL_DATA.filter(r=>r.Date===d&&r.Class===c);return rows.length?Math.round(rows.reduce((s,r)=>s+parseFloat(r.AttendancePct||0),0)/rows.length):null;}),\n    borderColor:['#6366f1','#8b5cf6','#06b6d4','#10b981','#f59e0b'][i%5],\n    backgroundColor:'transparent',tension:.3,pointRadius:1,spanGaps:true\n  })));\n  makeBar('histSubjectBar',ALL_SUBJECTS.map(s=>({label:s,val:data.filter(r=>r.Subject===s).length})).sort((a,b)=>b.val-a.val),()=>'#6366f1');\n  barChart('histAlertChart',ALL_MONTHS,[\n    {label:'High',data:ALL_MONTHS.map(m=>ALL_DATA.filter(r=>fmtMonth(r.Date)===m&&r.AlertLevel==='HIGH').length),backgroundColor:'#ef4444',borderRadius:4},\n    {label:'Medium',data:ALL_MONTHS.map(m=>ALL_DATA.filter(r=>fmtMonth(r.Date)===m&&r.AlertLevel==='MEDIUM').length),backgroundColor:'#f59e0b',borderRadius:4},\n    {label:'Low',data:ALL_MONTHS.map(m=>ALL_DATA.filter(r=>fmtMonth(r.Date)===m&&r.AlertLevel==='LOW').length),backgroundColor:'#22c55e',borderRadius:4}\n  ]);\n  const hTbl=document.getElementById('histTbl');\n  if(hTbl)hTbl.innerHTML=tblHTML(['Date','Student','Name','Class','Subject','Abs 30d','Rate','Trend','Alert'],\n    data.sort((a,b)=>parseDDMMYYYY(b.Date)-parseDDMMYYYY(a.Date)).map(r=>'<tr><td>'+r.Date+'<\\/td><td>'+r.StudentID+'<\\/td><td>'+(r.Name||'\u2014')+'<\\/td><td>'+r.Class+'<\\/td><td>'+(r.Subject||'\u2014')+'<\\/td><td>'+r.AbsencesLast30Days+'<\\/td><td><span style=\"color:'+pctColor(r.AttendancePct)+'\">'+parseFloat(r.AttendancePct).toFixed(1)+'%<\\/span><\\/td><td>'+trendBadge(r.Trend)+'<\\/td><td>'+alertBadge(r.AlertLevel)+'<\\/td><\\/tr>'));\n}\n\n// \u2500\u2500 AT-RISK \u2500\u2500\nfunction renderRisk(){\n  const data=applyGlobalFilter(ALL_DATA);\n  // Use latest record per student\n  const latestBySid={};\n  data.forEach(r=>{if(!latestBySid[r.StudentID]||parseDDMMYYYY(r.Date)>parseDDMMYYYY(latestBySid[r.StudentID].Date))latestBySid[r.StudentID]=r;});\n  const latest=Object.values(latestBySid);\n  const high=latest.filter(r=>r.AlertLevel==='HIGH'),med=latest.filter(r=>r.AlertLevel==='MEDIUM');\n  const wors=latest.filter(r=>r.Trend==='WORSENING');\n  document.getElementById('riskKpis').innerHTML=kpiHTML([\n    {val:high.length,lbl:'High Risk Students',sub:'5+ abs or 3 consecutive',subc:'#64748b',vc:'#dc2626'},\n    {val:med.length,lbl:'Medium Risk',sub:'3\u20134 absences in 30d',subc:'#64748b',vc:'#d97706'},\n    {val:wors.length,lbl:'Worsening Trend',sub:'recent pattern up',subc:'#64748b',vc:'#d97706'},\n    {val:latest.filter(r=>+r.ConsecutiveStreak>=3).length,lbl:'3+ Day Streak',sub:'consecutive absences',subc:'#64748b',vc:'#dc2626'},\n    {val:latest.filter(r=>parseFloat(r.AttendancePct)<75).length,lbl:'Below 75%',sub:'attendance rate',subc:'#64748b',vc:'#dc2626'}\n  ]);\n  const rCols=['Student','Name','Class','Last Absent','Abs 30d','Streak','Rate','Trend'];\n  const rRow=r=>'<tr><td>'+r.StudentID+'<\\/td><td>'+(r.Name||'\u2014')+'<\\/td><td>'+r.Class+'<\\/td><td>'+r.Date+'<\\/td><td><b style=\"color:#dc2626\">'+r.AbsencesLast30Days+'<\\/b><\\/td><td>'+(+r.ConsecutiveStreak>0?'<b style=\"color:#dc2626\">'+r.ConsecutiveStreak+'d<\\/b>':'\u2014')+'<\\/td><td><span style=\"color:'+pctColor(r.AttendancePct)+'\">'+parseFloat(r.AttendancePct).toFixed(1)+'%<\\/span><\\/td><td>'+trendBadge(r.Trend)+'<\\/td><\\/tr>';\n  const hTbl=document.getElementById('highRiskTbl');if(hTbl)hTbl.innerHTML=tblHTML(rCols,high.sort((a,b)=>+b.AbsencesLast30Days-+a.AbsencesLast30Days).map(rRow));\n  const mTbl=document.getElementById('medRiskTbl');if(mTbl)mTbl.innerHTML=tblHTML(rCols,med.sort((a,b)=>+b.AbsencesLast30Days-+a.AbsencesLast30Days).map(rRow));\n  const wTbl=document.getElementById('worsenTbl');if(wTbl)wTbl.innerHTML=tblHTML(rCols,wors.sort((a,b)=>+b.AbsencesLast30Days-+a.AbsencesLast30Days).map(rRow));\n  // Top 10 most absent all time\n  const topAbs=Object.entries(STUDENT_STATS).sort((a,b)=>b[1].absent-a[1].absent).slice(0,10);\n  makeBar('topAbsentBar',topAbs.map(([sid,s])=>({label:s.name||sid,val:s.absent})),i=>{const r=i.val/Math.max(...topAbs.map(x=>x[1].absent),1);return r>0.7?'#ef4444':r>0.4?'#f59e0b':'#6366f1';});\n  // Subject risk chart\n  barChart('riskSubjectChart',ALL_SUBJECTS,[\n    {label:'High Risk',data:ALL_SUBJECTS.map(s=>high.filter(r=>r.Subject===s).length),backgroundColor:'#ef4444',borderRadius:4},\n    {label:'Medium Risk',data:ALL_SUBJECTS.map(s=>med.filter(r=>r.Subject===s).length),backgroundColor:'#f59e0b',borderRadius:4}\n  ]);\n}\n\nfunction kpiHTML(items){\n  return items.map(({val,lbl,sub,subc,vc})=>'<div class=\"kpi\"><div class=\"val\"'+(vc?' style=\"color:'+vc+'\"':'')+'>'+(val??0)+'<\\/div><div class=\"lbl\">'+lbl+'<\\/div>'+(sub?'<div class=\"sub\" style=\"color:'+(subc||'#64748b')+'\">'+sub+'<\\/div>':'')+'<\\/div>').join('');\n}\n\nfunction applyFilters(){\n  const active=document.querySelector('.tab.active');\n  if(active)active.click();\n}\n\nfunction switchTab(id,el){\n  document.querySelectorAll('.section').forEach(s=>s.classList.remove('active'));\n  document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));\n  document.getElementById(id).classList.add('active');\n  el.classList.add('active');\n  setTimeout(()=>{\n    if(id==='today')renderToday();\n    else if(id==='weekly')renderWeekly();\n    else if(id==='monthly')renderMonthly();\n    else if(id==='history')renderHistory();\n    else if(id==='risk')renderRisk();\n  },30);\n}\n\n// Initial render\nrenderToday();\n<\\/script><\\/body><\\/html>`;\n\nreturn [\n  {json:{type:'csv', textContent:fullCsv,   fileName:'/home/node/.n8n-files/attendance_report.csv'}},\n  {json:{type:'html',textContent:dashHtml,  fileName:'/home/node/.n8n-files/dashboard.html'}}\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "1f31fd3e-db8e-42be-8611-da4c07a1d242",
      "name": "Convert Data",
      "type": "n8n-nodes-base.convertToFile",
      "position": [
        3200,
        112
      ],
      "parameters": {
        "options": {
          "fileName": "={{ $json.fileName }}"
        },
        "operation": "toText",
        "sourceProperty": "textContent"
      },
      "typeVersion": 1
    },
    {
      "id": "54ee6850-a6eb-47d4-bcc5-fa44250fc0a9",
      "name": "Build Visual Report and Update Attendance File",
      "type": "n8n-nodes-base.readWriteFile",
      "position": [
        3456,
        112
      ],
      "parameters": {
        "options": {},
        "fileName": "=/home/node/.n8n-files/{{ $binary.data.fileName }}",
        "operation": "write"
      },
      "typeVersion": 1
    },
    {
      "id": "56a26d48-b722-4c8a-bfa7-4d0825be0489",
      "name": "Date Filter",
      "type": "n8n-nodes-base.filter",
      "position": [
        848,
        32
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "a3daa56b-90fa-4921-8310-e203ba356284",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.Date }}",
              "rightValue": "={{ $now.toFormat('dd-MM-yyyy') }}"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "3913a364-a32c-4ed9-ac13-2095138266fc",
      "name": "Group Dashboard Report",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2144,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 1506,
        "height": 324,
        "content": "## Dashboard & report update\n\nBuilds interactive HTML dashboard and appends today's records to attendance history CSV."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "8a9da86f-cf2b-4ffb-9b6e-f272d451e57e",
  "connections": {
    "Date Filter": {
      "main": [
        [
          {
            "node": "IF: Is Absent?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Convert Data": {
      "main": [
        [
          {
            "node": "Build Visual Report and Update Attendance File",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF: Is Absent?": {
      "main": [
        [
          {
            "node": "Students Contacts",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge Absent + Contacts",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Absences Today",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Alert Logic Block": {
      "main": [
        [
          {
            "node": "Generate Absence Email",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Dashboard and Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "No Absences Today": {
      "main": [
        [
          {
            "node": "Existing Report History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Students Contacts": {
      "main": [
        [
          {
            "node": "Extract Contacts CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract History CSV": {
      "main": [
        [
          {
            "node": "Combine History + Build Dashboard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Contacts CSV": {
      "main": [
        [
          {
            "node": "Merge Absent + Contacts",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Extract Attendance CSV": {
      "main": [
        [
          {
            "node": "Date Filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Absence Email": {
      "main": [
        [
          {
            "node": "Send Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Recurring Time Trigger": {
      "main": [
        [
          {
            "node": "Student Attendance Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Existing Report History": {
      "main": [
        [
          {
            "node": "Extract History CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Absent + Contacts": {
      "main": [
        [
          {
            "node": "Alert Logic Block",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Student Attendance Data": {
      "main": [
        [
          {
            "node": "Extract Attendance CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Dashboard and Report": {
      "main": [
        [
          {
            "node": "Existing Report History",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine History + Build Dashboard": {
      "main": [
        [
          {
            "node": "Convert Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}