This workflow corresponds to n8n.io template #14068 — we link there as the canonical source.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Automatically monitors daily student attendance from CSV files, identifies absent students, sends parent email alerts via SMTP, calculates risk scores, and generates an interactive HTML dashboard — all on a weekday schedule with no manual work needed. Schedule trigger — runs…
Source: https://n8n.io/workflows/14068/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
My workflow 6. Uses httpRequest, spreadsheetFile, emailSend, googleDrive. Scheduled trigger; 21 nodes.
Customer Service Call Workflow. Uses spreadsheetFile, twilio, googleSheets, emailSend. Scheduled trigger; 17 nodes.
Security teams, DevOps engineers, vulnerability analysts, and automation builders who want to eliminate repetitive Nessus scan parsing, AI-based risk triage, and manual reporting. Designed for orgs fo
This workflow fully automates the reconciliation process between your Local Database transactions and Payment Gateway transactions. It compares both data sources, identifies mismatches, categorizes di
This n8n workflow automatically finds apartments for rent in Germany, filters them by your city, rent budget, and number of rooms, and applies to them via email. Each application includes: A personali