Files
brightwheel-exporter/tools/templates/attendance_showcase_template.html
T
haopengzhan 0e23532113
CI Verification / Lint & Extension Integrity Check (push) Successful in 2m58s
feat: add python analysis scripts and HTML reporting templates for nap and attendance data
2026-08-23 22:28:01 -07:00

1205 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🏫 Daycare Attendance & Daily Schedule Analysis</title>
<!-- Modern Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<style>
:root {
--bg-base: #060B14;
--bg-surface: rgba(14, 22, 38, 0.75);
--bg-surface-hover: rgba(22, 34, 58, 0.85);
--bg-card-subtle: rgba(255, 255, 255, 0.03);
--border-color: rgba(255, 255, 255, 0.08);
--border-highlight: rgba(56, 189, 248, 0.3);
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--cyan-glow: rgba(56, 189, 248, 0.25);
--cyan-primary: #38BDF8;
--indigo-primary: #6366F1;
--violet-primary: #818CF8;
--emerald-primary: #34D399;
--amber-primary: #FBBF24;
--rose-primary: #FB7185;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-full: 9999px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--bg-base);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
overflow-x: hidden;
background-image:
radial-gradient(circle at 15% 15%, rgba(56, 189, 248, 0.12) 0%, transparent 40%),
radial-gradient(circle at 85% 20%, rgba(99, 102, 241, 0.1) 0%, transparent 45%),
radial-gradient(circle at 50% 85%, rgba(52, 211, 153, 0.06) 0%, transparent 50%);
background-attachment: fixed;
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 2.5rem 1.5rem 5rem;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2.5rem;
flex-wrap: wrap;
gap: 1.5rem;
}
.brand-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.85rem;
background: rgba(56, 189, 248, 0.15);
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
color: var(--cyan-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.brand-badge span.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--cyan-primary);
box-shadow: 0 0 8px var(--cyan-primary);
}
h1.title {
font-family: 'Outfit', sans-serif;
font-size: clamp(2rem, 3.5vw, 2.75rem);
font-weight: 800;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #FFFFFF 30%, #BAE6FD 70%, #38BDF8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
line-height: 1.15;
}
p.subtitle {
color: var(--text-secondary);
font-size: 1.05rem;
margin-top: 0.4rem;
}
.header-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.15rem;
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
border: none;
}
.btn-secondary {
background: var(--bg-surface);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-surface-hover);
border-color: var(--border-highlight);
transform: translateY(-1px);
}
.btn-primary {
background: linear-gradient(135deg, #0284C7, #0369A1);
color: #FFFFFF;
box-shadow: 0 4px 15px var(--cyan-glow);
}
.btn-primary:hover {
opacity: 0.95;
transform: translateY(-1px);
}
/* Glass Cards */
.glass-card {
background: var(--bg-surface);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.glass-card:hover {
border-color: rgba(255, 255, 255, 0.14);
}
/* KPI Grid */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.25rem;
margin-bottom: 2.5rem;
}
.kpi-card {
padding: 1.4rem;
position: relative;
overflow: hidden;
}
.kpi-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent-start, var(--cyan-primary)), var(--accent-end, var(--indigo-primary)));
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
}
.kpi-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.kpi-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.kpi-icon {
font-size: 1.25rem;
}
.kpi-value {
font-family: 'Outfit', sans-serif;
font-size: 2.15rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 0.35rem;
}
.kpi-sub {
font-size: 0.82rem;
color: var(--text-muted);
}
/* Section Styling */
.section-title-wrap {
margin-bottom: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.section-title {
font-family: 'Outfit', sans-serif;
font-size: 1.45rem;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.6rem;
}
.section-desc {
color: var(--text-secondary);
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
/* Insights Narrative Grid */
.insights-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 1.5rem;
margin-bottom: 2.5rem;
}
.insight-card {
display: flex;
flex-direction: column;
height: 100%;
}
.insight-tag {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0.65rem;
border-radius: var(--radius-sm);
margin-bottom: 0.85rem;
}
.tag-rhythm { background: rgba(56, 189, 248, 0.15); color: var(--cyan-primary); }
.tag-family { background: rgba(129, 140, 248, 0.15); color: var(--violet-primary); }
.tag-streaks { background: rgba(52, 211, 153, 0.15); color: var(--emerald-primary); }
.tag-outliers { background: rgba(251, 191, 36, 0.15); color: var(--amber-primary); }
.insight-title {
font-family: 'Outfit', sans-serif;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.6rem;
}
.insight-body {
color: var(--text-secondary);
font-size: 0.92rem;
line-height: 1.6;
flex-grow: 1;
}
.insight-stat-highlight {
margin-top: 1rem;
padding: 0.85rem;
background: var(--bg-card-subtle);
border-left: 3px solid var(--cyan-primary);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
font-size: 0.88rem;
color: #E2E8F0;
}
/* Charts Container */
.chart-box {
margin-bottom: 2.5rem;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
flex-wrap: wrap;
gap: 0.75rem;
}
.canvas-container {
position: relative;
width: 100%;
height: 340px;
}
/* Dual Chart Grid */
.dual-chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2.5rem;
}
@media (max-width: 900px) {
.dual-chart-grid {
grid-template-columns: 1fr;
}
}
/* Calendar Heatmap */
.calendar-card {
margin-bottom: 2.5rem;
overflow-x: auto;
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 1rem;
justify-content: flex-end;
}
.legend-box {
width: 12px;
height: 12px;
border-radius: 3px;
}
.c-none { background: rgba(255, 255, 255, 0.04); }
.c-half { background: #FBBF24; }
.c-std { background: #38BDF8; }
.c-full { background: #34D399; }
.c-ext { background: #818CF8; }
.heatmap-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, 14px);
grid-auto-columns: 14px;
gap: 4px;
padding: 0.5rem 0;
min-width: 780px;
}
.heat-cell {
width: 14px;
height: 14px;
border-radius: 3px;
position: relative;
cursor: pointer;
transition: transform 0.1s ease;
}
.heat-cell:hover {
transform: scale(1.3);
z-index: 10;
outline: 1px solid #FFFFFF;
}
/* Interactive Data Table */
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-input {
background: var(--bg-card-subtle);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.55rem 1rem;
border-radius: var(--radius-md);
font-size: 0.9rem;
min-width: 260px;
outline: none;
}
.search-input:focus {
border-color: var(--cyan-primary);
box-shadow: 0 0 10px var(--cyan-glow);
}
.filter-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.4rem 0.8rem;
border-radius: var(--radius-sm);
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-btn.active, .filter-btn:hover {
background: rgba(56, 189, 248, 0.2);
border-color: var(--cyan-primary);
color: var(--text-primary);
}
.table-wrap {
overflow-x: auto;
max-height: 480px;
overflow-y: auto;
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
text-align: left;
}
table.data-table th {
background: #0F172A;
color: var(--text-secondary);
font-weight: 600;
padding: 0.85rem 1rem;
position: sticky;
top: 0;
z-index: 5;
border-bottom: 1px solid var(--border-color);
}
table.data-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: var(--text-primary);
}
table.data-table tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
.duration-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
}
.dur-full { background: rgba(52, 211, 153, 0.15); color: var(--emerald-primary); }
.dur-std { background: rgba(56, 189, 248, 0.15); color: var(--cyan-primary); }
.dur-half { background: rgba(251, 191, 36, 0.15); color: var(--amber-primary); }
/* Footer */
.footer {
text-align: center;
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.85rem;
}
/* Print styling */
@media print {
body {
background: #FFFFFF !important;
color: #000000 !important;
}
.glass-card {
border: 1px solid #E2E8F0 !important;
box-shadow: none !important;
background: #FFFFFF !important;
}
.header-actions, .table-controls {
display: none !important;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Top Header -->
<header class="header">
<div>
<div class="brand-badge">
<span class="dot"></span>
<span>Preschool & Daycare Attendance Analytics</span>
</div>
<h1 class="title">🏫 Attendance & Daily Schedule Analysis</h1>
<p class="subtitle" id="childHeaderSubtitle">Loading attendance insights...</p>
</div>
<div class="header-actions">
<button class="btn btn-secondary" onclick="window.print()">🖨️ Print / Save PDF</button>
<button class="btn btn-primary" onclick="exportAttendanceCSV()">📥 Export Clean CSV</button>
</div>
</header>
<!-- Executive KPI Grid -->
<section class="kpi-grid">
<div class="glass-card kpi-card" style="--accent-start: #38BDF8; --accent-end: #0284C7;">
<div class="kpi-header">
<span class="kpi-title">Total School Time</span>
<span class="kpi-icon">🏫</span>
</div>
<div class="kpi-value" id="kpiTotalHours">-- hrs</div>
<div class="kpi-sub"><span id="kpiTotalDays">--</span> paired school days</div>
</div>
<div class="glass-card kpi-card" style="--accent-start: #34D399; --accent-end: #10B981;">
<div class="kpi-header">
<span class="kpi-title">Average Daily Stay</span>
<span class="kpi-icon">⏱️</span>
</div>
<div class="kpi-value" id="kpiMeanStay">-- hrs</div>
<div class="kpi-sub">Median: <strong id="kpiMedianStay">--h</strong> (IQR: <span id="kpiIQR">--</span>)</div>
</div>
<div class="glass-card kpi-card" style="--accent-start: #FBBF24; --accent-end: #F59E0B;">
<div class="kpi-header">
<span class="kpi-title">Avg Drop-Off Time</span>
<span class="kpi-icon">🌅</span>
</div>
<div class="kpi-value" id="kpiAvgDropoff">--:-- AM</div>
<div class="kpi-sub">Morning Arrival Anchor</div>
</div>
<div class="glass-card kpi-card" style="--accent-start: #818CF8; --accent-end: #6366F1;">
<div class="kpi-header">
<span class="kpi-title">Avg Pick-Up Time</span>
<span class="kpi-icon">🌇</span>
</div>
<div class="kpi-value" id="kpiAvgPickup">--:-- PM</div>
<div class="kpi-sub">Afternoon Departure Anchor</div>
</div>
</section>
<!-- Deep-Dive Insights Narrative Cards -->
<div class="section-title-wrap">
<h2 class="section-title">🔬 Data-Backed Daily Schedule Insights</h2>
</div>
<section class="insights-grid">
<!-- Insight 1 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-rhythm">⏰ Daily Schedule Rhythm</span>
<h3 class="insight-title">The 7.6-Hour Core Workday Anchor</h3>
<p class="insight-body" id="insightBody1">
<span class="dyn-child-name">Child</span> logged an average of <strong>7.62 hours</strong> (~7h 37m) per day at daycare, arriving at <strong><span id="dynDropoff">9:43 AM</span></strong> and departing at <strong><span id="dynPickup">5:21 PM</span></strong>. Over <strong>88%</strong> of all days strictly followed this standard full-day schedule.
</p>
<div class="insight-stat-highlight" id="insightStat1">
<strong>Daily Std Dev:</strong> Only &plusmn;0.79 hours &bull; <strong>Core Window:</strong> 9:30 AM – 5:25 PM
</div>
</div>
<!-- Insight 2 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-family">👨‍👩‍👧 Family Partnership</span>
<h3 class="insight-title">Caregiver Drop-off & Pick-up Split</h3>
<p class="insight-body" id="insightBody2">
Handoff records reflect an active shared parenting team:
<strong><span id="dynTopDropoff">Parent 1</span></strong> handled <span id="dynDropoffPct">77%</span> of morning drop-offs, while pick-ups were actively shared (<span id="dynPickupSplit">66% vs 32%</span>).
</p>
<div class="insight-stat-highlight" id="insightStat2">
<strong>Parent Coverage:</strong> 100% of check-ins/outs signed by primary caregivers.
</div>
</div>
<!-- Insight 3 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-streaks">📅 Consistency & Streaks</span>
<h3 class="insight-title">Longest Attendance Streaks</h3>
<p class="insight-body">
Across the 13 months, <span class="dyn-child-name">Child</span> achieved a peak unbroken attendance streak of <strong><span id="dynStreak">24</span> consecutive school days</strong>, with reliable Monday-to-Friday attendance through seasonal transitions.
</p>
<div class="insight-stat-highlight" id="insightStat3">
<strong>Total School Days:</strong> <span id="dynTotalDaysAttended">195</span> full days &bull; <strong>Total Active Days:</strong> <span id="dynTotalActiveDays">236</span>
</div>
</div>
<!-- Insight 4 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-outliers">🏆 Schedule Extremes & Half-Days</span>
<h3 class="insight-title">Milestones & Special Schedules</h3>
<p class="insight-body">
The longest school day recorded was <strong><span id="dynLongestDay">8.63 hours</span></strong>, while the shortest half-day (doctor appointment / early pickup) was <strong><span id="dynShortestDay">1.52 hours</span></strong>.
</p>
<div class="insight-stat-highlight" id="insightStat4">
<strong>Earliest Arrival:</strong> <span id="dynEarliest">9:02 AM</span> &bull; <strong>Latest Departure:</strong> <span id="dynLatest">5:46 PM</span>
</div>
</div>
</section>
<!-- Interactive Daily Duration Timeline Chart -->
<section class="glass-card chart-box">
<div class="chart-header">
<div>
<h2 class="section-title">📈 Daily Stay Duration Timeline & Trendline</h2>
<p class="section-desc" style="margin-bottom: 0;">Hours spent at school each day. Cyan curve shows the 14-day rolling average.</p>
</div>
</div>
<div class="canvas-container">
<canvas id="timelineChart"></canvas>
</div>
</section>
<!-- Dual Charts: Arrival/Departure Distribution & Parent Split -->
<section class="dual-chart-grid">
<!-- Scatter Plot Chart -->
<div class="glass-card chart-box" style="margin-bottom: 0;">
<div class="chart-header">
<div>
<h2 class="section-title" style="font-size: 1.2rem;">🌅 Drop-off vs. 🌇 Pick-up Times</h2>
<p class="section-desc" style="margin-bottom: 0;">Morning arrival vs afternoon pick-up distribution.</p>
</div>
</div>
<div class="canvas-container" style="height: 280px;">
<canvas id="scatterChart"></canvas>
</div>
</div>
<!-- Parent Signer Split Chart -->
<div class="glass-card chart-box" style="margin-bottom: 0;">
<div class="chart-header">
<div>
<h2 class="section-title" style="font-size: 1.2rem;">👨‍👩‍👧 Drop-off vs. Pick-up Share</h2>
<p class="section-desc" style="margin-bottom: 0;">Signer distribution across the school year.</p>
</div>
</div>
<div class="canvas-container" style="height: 280px;">
<canvas id="parentSplitChart"></canvas>
</div>
</div>
</section>
<!-- Monthly Attendance Hours Bar Chart -->
<section class="glass-card chart-box" style="margin-top: 2rem;">
<div class="chart-header">
<div>
<h2 class="section-title">📊 Monthly School Days & Hours Logged</h2>
<p class="section-desc" style="margin-bottom: 0;">Total daycare hours attended across all 13 months.</p>
</div>
</div>
<div class="canvas-container" style="height: 300px;">
<canvas id="monthlyChart"></canvas>
</div>
</section>
<!-- Calendar Attendance Heatmap -->
<section class="glass-card calendar-card" style="margin-top: 2rem;">
<div class="section-title-wrap">
<div>
<h2 class="section-title">📅 13-Month Daycare Attendance Calendar Heatmap</h2>
<p class="section-desc" style="margin-bottom: 0;">Complete school year attendance map. Hover over any block to see drop-off & pick-up details.</p>
</div>
</div>
<div class="heatmap-grid" id="calendarHeatmap">
<!-- Generated via JS -->
</div>
<div class="heatmap-legend">
<span>No School</span>
<div class="legend-box c-none" title="Weekend / Holiday"></div>
<div class="legend-box c-half" title="Half Day (<6.0h)"></div>
<div class="legend-box c-std" title="Standard (6.0–7.49h)"></div>
<div class="legend-box c-full" title="Full Day (7.5–8.2h)"></div>
<div class="legend-box c-ext" title="Extended Day (>8.2h)"></div>
<span>Full / Extended Day</span>
</div>
</section>
<!-- Interactive Data Explorer Table -->
<section class="glass-card" style="margin-top: 2rem;">
<div class="section-title-wrap">
<div>
<h2 class="section-title">📋 Attendance Session Explorer</h2>
<p class="section-desc" style="margin-bottom: 0;">Browse, search, and filter all paired attendance days.</p>
</div>
</div>
<div class="table-controls">
<input type="text" id="searchInput" class="search-input" placeholder="🔍 Search by date, parent name, notes..." oninput="filterTable()">
<div class="filter-tags">
<button class="filter-btn active" onclick="setDurationFilter('all', this)">All Days</button>
<button class="filter-btn" onclick="setDurationFilter('full', this)">Full Day (7.5h+)</button>
<button class="filter-btn" onclick="setDurationFilter('std', this)">Standard (6.0–7.49h)</button>
<button class="filter-btn" onclick="setDurationFilter('half', this)">Half Day (&lt;6.0h)</button>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Date</th>
<th>Day</th>
<th>Drop-Off</th>
<th>Pick-Up</th>
<th>Stay Duration</th>
<th>Rolling 14d Avg</th>
<th>Drop-Off By</th>
<th>Pick-Up By</th>
</tr>
</thead>
<tbody id="tableBody">
<!-- Populated via JS -->
</tbody>
</table>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<p>Data extracted & analyzed via <strong>Brightwheel Memories & Reports Exporter</strong> &bull; Generated <span id="footerDate"></span></p>
</footer>
</div>
<script>
// Embedded Data Payload
const DATA = __ATTENDANCE_DATA_JSON__;
let activeFilter = 'all';
document.addEventListener('DOMContentLoaded', () => {
initDashboard();
});
function initDashboard() {
const stats = DATA.statistics || {};
const sessions = DATA.sessions || [];
const studentName = DATA.student_name || "Child";
// Header info
document.getElementById('childHeaderSubtitle').textContent =
`${studentName}'s 13-Month Daycare Attendance Profile (${sessions.length} paired days tracked from ${sessions[0]?.date || ''} to ${sessions[sessions.length - 1]?.date || ''})`;
document.getElementById('footerDate').textContent = DATA.generated_at || new Date().toLocaleDateString();
// Child name replacements
document.querySelectorAll('.dyn-child-name').forEach(el => el.textContent = studentName);
// KPIs
document.getElementById('kpiTotalHours').textContent = `${stats.total_school_hours || 0} hrs`;
document.getElementById('kpiTotalDays').textContent = stats.total_paired_days || sessions.length;
document.getElementById('kpiMeanStay').textContent = `${stats.mean_stay_hours || 0} hrs`;
document.getElementById('kpiMedianStay').textContent = `${stats.median_stay_hours || 0}h`;
document.getElementById('kpiIQR').textContent = `${stats.p25_hours || 0}h–${stats.p75_hours || 0}h`;
document.getElementById('kpiAvgDropoff').textContent = stats.avg_dropoff_time || '--';
document.getElementById('kpiAvgPickup').textContent = stats.avg_pickup_time || '--';
// Dynamic insight numbers
const longest = stats.longest_day || {};
const shortest = stats.shortest_day || {};
const earliest = stats.earliest_dropoff || {};
const latest = stats.latest_pickup || {};
if (document.getElementById('dynDropoff')) document.getElementById('dynDropoff').textContent = stats.avg_dropoff_time || '9:43 AM';
if (document.getElementById('dynPickup')) document.getElementById('dynPickup').textContent = stats.avg_pickup_time || '5:21 PM';
const dropSplit = stats.dropoff_split || [];
const pickSplit = stats.pickup_split || [];
if (dropSplit.length) {
if (document.getElementById('dynTopDropoff')) document.getElementById('dynTopDropoff').textContent = dropSplit[0].name;
if (document.getElementById('dynDropoffPct')) document.getElementById('dynDropoffPct').textContent = `${dropSplit[0].pct}%`;
}
if (pickSplit.length >= 2) {
if (document.getElementById('dynPickupSplit')) document.getElementById('dynPickupSplit').textContent = `${pickSplit[0].name} (${pickSplit[0].pct}%) vs ${pickSplit[1].name} (${pickSplit[1].pct}%)`;
}
if (document.getElementById('dynStreak')) document.getElementById('dynStreak').textContent = stats.longest_streak_days || 24;
if (document.getElementById('dynTotalDaysAttended')) document.getElementById('dynTotalDaysAttended').textContent = stats.total_paired_days || sessions.length;
if (document.getElementById('dynTotalActiveDays')) document.getElementById('dynTotalActiveDays').textContent = stats.total_activity_days || stats.total_paired_days;
if (document.getElementById('dynLongestDay')) document.getElementById('dynLongestDay').textContent = `${longest.stay_duration_hours || 8.63} hours`;
if (document.getElementById('dynShortestDay')) document.getElementById('dynShortestDay').textContent = `${shortest.stay_duration_hours || 1.52} hours`;
if (document.getElementById('dynEarliest')) document.getElementById('dynEarliest').textContent = earliest.dropoff_time_str || '9:02 AM';
if (document.getElementById('dynLatest')) document.getElementById('dynLatest').textContent = latest.pickup_time_str || '5:46 PM';
// Render Visualizations
renderTimelineChart(sessions, stats);
renderScatterChart(sessions);
renderParentSplitChart(stats);
renderMonthlyChart(stats.monthly_breakdown || []);
renderCalendarHeatmap(sessions);
renderTable(sessions);
}
function renderTimelineChart(sessions, stats) {
const ctx = document.getElementById('timelineChart').getContext('2d');
const labels = sessions.map(s => s.date);
const durations = sessions.map(s => s.stay_duration_hours);
const rollingAvg = sessions.map(s => s.rolling_avg_14d_hours);
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Daily Stay Duration (Hours)',
data: durations,
borderColor: 'rgba(56, 189, 248, 0.35)',
backgroundColor: 'rgba(56, 189, 248, 0.7)',
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: '#38BDF8',
showLine: false,
order: 2
},
{
label: '14-Day Rolling Trendline',
data: rollingAvg,
borderColor: '#34D399',
borderWidth: 3,
tension: 0.35,
pointRadius: 0,
fill: false,
order: 1
},
{
label: '7.6h Average Baseline',
data: Array(sessions.length).fill(stats.mean_stay_hours || 7.62),
borderColor: 'rgba(251, 191, 36, 0.4)',
borderWidth: 1.5,
borderDash: [5, 5],
pointRadius: 0,
fill: false,
order: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#94A3B8' } },
tooltip: {
backgroundColor: '#0F172A',
padding: 12,
callbacks: {
afterLabel: function(context) {
if (context.datasetIndex === 0) {
const s = sessions[context.dataIndex];
return `Dropoff: ${s.dropoff_time_str} (${s.dropoff_by})\nPickup: ${s.pickup_time_str} (${s.pickup_by})`;
}
}
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.03)' },
ticks: { color: '#64748B', maxTicksLimit: 14 }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#64748B', callback: val => val + ' hrs' },
min: 0,
max: 10
}
}
}
});
}
function renderScatterChart(sessions) {
const ctx = document.getElementById('scatterChart').getContext('2d');
const points = sessions.map(s => ({
x: s.dropoff_dec_hour,
y: s.pickup_dec_hour,
raw: s
}));
new Chart(ctx, {
type: 'scatter',
data: {
datasets: [{
label: 'Day Sessions',
data: points,
backgroundColor: '#38BDF8',
pointRadius: 4.5,
pointHoverRadius: 7
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0F172A',
callbacks: {
label: ctx => {
const s = ctx.raw.raw;
return `${s.date}: In at ${s.dropoff_time_str} ➔ Out at ${s.pickup_time_str} (${s.stay_duration_hours} hrs)`;
}
}
}
},
scales: {
x: {
title: { display: true, text: 'Drop-Off Time (Morning)', color: '#94A3B8' },
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: {
color: '#64748B',
callback: val => {
const h = Math.floor(val);
const m = Math.round((val - h) * 60);
return `${h}:${m < 10 ? '0' : ''}${m} AM`;
}
},
min: 8.5,
max: 11.5
},
y: {
title: { display: true, text: 'Pick-Up Time (Afternoon)', color: '#94A3B8' },
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: {
color: '#64748B',
callback: val => {
const h = Math.floor(val) - 12;
const m = Math.round((val - Math.floor(val)) * 60);
return `${h}:${m < 10 ? '0' : ''}${m} PM`;
}
},
min: 15.0,
max: 18.5
}
}
}
});
}
function renderParentSplitChart(stats) {
const ctx = document.getElementById('parentSplitChart').getContext('2d');
const dropSplit = stats.dropoff_split || [];
const pickSplit = stats.pickup_split || [];
// Combine unique parent names
const names = Array.from(new Set([...dropSplit.map(x => x.name), ...pickSplit.map(x => x.name)])).slice(0, 3);
const dropCounts = names.map(n => dropSplit.find(x => x.name === n)?.count || 0);
const pickCounts = names.map(n => pickSplit.find(x => x.name === n)?.count || 0);
new Chart(ctx, {
type: 'bar',
data: {
labels: names,
datasets: [
{
label: 'Morning Drop-offs',
data: dropCounts,
backgroundColor: '#38BDF8',
borderRadius: 6
},
{
label: 'Afternoon Pick-ups',
data: pickCounts,
backgroundColor: '#818CF8',
borderRadius: 6
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94A3B8' } },
tooltip: { backgroundColor: '#0F172A' }
},
scales: {
x: { grid: { display: false }, ticks: { color: '#64748B' } },
y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#64748B' } }
}
}
});
}
function renderMonthlyChart(monthlyData) {
const ctx = document.getElementById('monthlyChart').getContext('2d');
const labels = monthlyData.map(m => m.month);
const hours = monthlyData.map(m => m.total_hours);
const days = monthlyData.map(m => m.days_attended);
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Total Hours at Daycare',
data: hours,
backgroundColor: '#38BDF8',
borderRadius: 6,
yAxisID: 'y'
},
{
label: 'Days Attended',
data: days,
type: 'line',
borderColor: '#FBBF24',
backgroundColor: '#FBBF24',
borderWidth: 2,
pointRadius: 4,
yAxisID: 'y1'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94A3B8' } },
tooltip: { backgroundColor: '#0F172A' }
},
scales: {
x: { grid: { display: false }, ticks: { color: '#64748B' } },
y: {
type: 'linear',
position: 'left',
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: { color: '#64748B', callback: val => val + 'h' },
title: { display: true, text: 'Total Hours', color: '#94A3B8' }
},
y1: {
type: 'linear',
position: 'right',
grid: { display: false },
ticks: { color: '#FBBF24' },
title: { display: true, text: 'Days', color: '#FBBF24' },
min: 0,
max: 25
}
}
}
});
}
function renderCalendarHeatmap(sessions) {
const grid = document.getElementById('calendarHeatmap');
grid.innerHTML = '';
if (!sessions || sessions.length === 0) return;
const dateMap = {};
sessions.forEach(s => {
dateMap[s.date] = s;
});
const firstDateStr = sessions[0].date;
const lastDateStr = sessions[sessions.length - 1].date;
const startDate = new Date(firstDateStr + 'T00:00:00');
const endDate = new Date(lastDateStr + 'T00:00:00');
const curr = new Date(startDate);
while (curr <= endDate) {
const dStr = curr.toISOString().split('T')[0];
const session = dateMap[dStr];
const cell = document.createElement('div');
cell.className = 'heat-cell';
if (session) {
const dur = session.stay_duration_hours;
if (dur >= 8.2) cell.classList.add('c-ext');
else if (dur >= 7.5) cell.classList.add('c-full');
else if (dur >= 6.0) cell.classList.add('c-std');
else cell.classList.add('c-half');
cell.title = `${dStr} (${session.weekday_short})\nStay: ${dur} hrs (${session.dropoff_time_str} – ${session.pickup_time_str})\nIn: ${session.dropoff_by} | Out: ${session.pickup_by}`;
} else {
cell.classList.add('c-none');
cell.title = `${dStr} (No School / Weekend)`;
}
grid.appendChild(cell);
curr.setDate(curr.getDate() + 1);
}
}
function renderTable(sessions) {
const tbody = document.getElementById('tableBody');
tbody.innerHTML = '';
const query = (document.getElementById('searchInput').value || '').toLowerCase().trim();
const filtered = sessions.filter(s => {
// Duration filter
if (activeFilter === 'full' && s.stay_duration_hours < 7.5) return false;
if (activeFilter === 'std' && (s.stay_duration_hours < 6.0 || s.stay_duration_hours >= 7.5)) return false;
if (activeFilter === 'half' && s.stay_duration_hours >= 6.0) return false;
// Search query
if (query) {
const matchStr = `${s.date} ${s.weekday} ${s.dropoff_by} ${s.pickup_by} ${s.dropoff_time_str} ${s.pickup_time_str}`.toLowerCase();
if (!matchStr.includes(query)) return false;
}
return true;
});
filtered.forEach(s => {
const tr = document.createElement('tr');
let badgeClass = 'dur-std';
if (s.stay_duration_hours >= 7.5) badgeClass = 'dur-full';
else if (s.stay_duration_hours < 6.0) badgeClass = 'dur-half';
tr.innerHTML = `
<td><strong>${s.date}</strong></td>
<td style="color: var(--text-secondary);">${s.weekday}</td>
<td>${s.dropoff_time_str}</td>
<td>${s.pickup_time_str}</td>
<td><span class="duration-badge ${badgeClass}">${s.stay_duration_hours} hrs</span></td>
<td style="color: var(--text-muted);">${s.rolling_avg_14d_hours || '--'}h</td>
<td style="color: var(--text-secondary);">${s.dropoff_by}</td>
<td style="color: var(--text-secondary);">${s.pickup_by}</td>
`;
tbody.appendChild(tr);
});
}
function filterTable() {
renderTable(DATA.sessions || []);
}
function setDurationFilter(filter, el) {
activeFilter = filter;
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
if (el) el.classList.add('active');
filterTable();
}
function exportAttendanceCSV() {
const sessions = DATA.sessions || [];
if (!sessions.length) return;
let csv = 'Date,Month,Day,Dropoff Time,Pickup Time,Stay Hours,Stay Mins,Rolling 14d Avg,Dropoff By,Pickup By\n';
sessions.forEach(s => {
csv += `"${s.date}","${s.month}","${s.weekday}","${s.dropoff_time_str}","${s.pickup_time_str}",${s.stay_duration_hours},${s.stay_duration_minutes},${s.rolling_avg_14d_hours || ''},"${s.dropoff_by}","${s.pickup_by}"\n`;
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `attendance_sessions_${DATA.student_name || 'child'}.csv`;
a.click();
URL.revokeObjectURL(url);
}
</script>
</body>
</html>