Files
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

1194 lines
39 KiB
HTML
Raw Permalink 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 Nap & Sleep Architecture 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: #080C15;
--bg-surface: rgba(16, 23, 38, 0.75);
--bg-surface-hover: rgba(26, 36, 56, 0.85);
--bg-card-subtle: rgba(255, 255, 255, 0.03);
--border-color: rgba(255, 255, 255, 0.08);
--border-highlight: rgba(99, 102, 241, 0.3);
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--indigo-glow: rgba(99, 102, 241, 0.25);
--violet-primary: #818CF8;
--indigo-primary: #6366F1;
--cyan-primary: #38BDF8;
--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(99, 102, 241, 0.12) 0%, transparent 40%),
radial-gradient(circle at 85% 25%, rgba(56, 189, 248, 0.08) 0%, transparent 45%),
radial-gradient(circle at 50% 85%, rgba(129, 140, 248, 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(99, 102, 241, 0.15);
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
color: var(--violet-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(--emerald-primary);
box-shadow: 0 0 8px var(--emerald-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%, #CBD5E1 70%, #818CF8 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, var(--indigo-primary), #4F46E5);
color: #FFFFFF;
box-shadow: 0 4px 15px var(--indigo-glow);
}
.btn-primary:hover {
opacity: 0.95;
transform: translateY(-1px);
}
/* Glassmorphism 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(--indigo-primary)), var(--accent-end, var(--cyan-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);
}
.kpi-badge {
display: inline-block;
padding: 0.15rem 0.45rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.25rem;
}
.kpi-badge.success {
background: rgba(52, 211, 153, 0.15);
color: var(--emerald-primary);
}
/* 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-maturation { background: rgba(129, 140, 248, 0.15); color: var(--violet-primary); }
.tag-ceiling { background: rgba(251, 191, 36, 0.15); color: var(--amber-primary); }
.tag-rhythm { background: rgba(52, 211, 153, 0.15); color: var(--emerald-primary); }
.tag-records { background: rgba(251, 113, 133, 0.15); color: var(--rose-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(--violet-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;
}
.chart-controls {
display: flex;
gap: 0.5rem;
}
.pill-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.35rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.pill-btn.active, .pill-btn:hover {
background: var(--indigo-primary);
border-color: var(--indigo-primary);
color: #FFFFFF;
}
.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-short { background: #FB7185; }
.c-med { background: #6366F1; }
.c-opt { background: #38BDF8; }
.c-deep { background: #34D399; }
.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(--violet-primary);
box-shadow: 0 0 10px var(--indigo-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(99, 102, 241, 0.2);
border-color: var(--violet-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-deep { background: rgba(52, 211, 153, 0.15); color: var(--emerald-primary); }
.dur-opt { background: rgba(56, 189, 248, 0.15); color: var(--cyan-primary); }
.dur-med { background: rgba(129, 140, 248, 0.15); color: var(--violet-primary); }
.dur-short { background: rgba(251, 113, 133, 0.15); color: var(--rose-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;
}
.footer a {
color: var(--violet-primary);
text-decoration: none;
}
/* 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>Childhood Circadian & Sleep Architecture</span>
</div>
<h1 class="title">🌙 Daycare Nap & Sleep Analysis</h1>
<p class="subtitle" id="childHeaderSubtitle">Loading statistical 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="exportDataCSV()">📥 Export Clean CSV</button>
</div>
</header>
<!-- Executive KPI Grid -->
<section class="kpi-grid">
<div class="glass-card kpi-card" style="--accent-start: #6366F1; --accent-end: #818CF8;">
<div class="kpi-header">
<span class="kpi-title">Total Rest Tracked</span>
<span class="kpi-icon">🛌</span>
</div>
<div class="kpi-value" id="kpiTotalHours">-- hrs</div>
<div class="kpi-sub"><span id="kpiTotalNaps">--</span> completed nap sessions</div>
</div>
<div class="glass-card kpi-card" style="--accent-start: #38BDF8; --accent-end: #34D399;">
<div class="kpi-header">
<span class="kpi-title">Average Nap Duration</span>
<span class="kpi-icon">⏱️</span>
</div>
<div class="kpi-value" id="kpiMeanDuration">-- min</div>
<div class="kpi-sub">Median: <strong id="kpiMedianDuration">--m</strong> (IQR: <span id="kpiIQR">--</span>)</div>
</div>
<div class="glass-card kpi-card" style="--accent-start: #34D399; --accent-end: #FBBF24;">
<div class="kpi-header">
<span class="kpi-title">Routine Consistency</span>
<span class="kpi-icon">🎯</span>
</div>
<div class="kpi-value" id="kpiConsistency">--%</div>
<div class="kpi-sub">Within 1 std deviation of mean</div>
</div>
<div class="glass-card kpi-card" style="--accent-start: #FBBF24; --accent-end: #FB7185;">
<div class="kpi-header">
<span class="kpi-title">Nap Window Ceiling</span>
<span class="kpi-icon">⏰</span>
</div>
<div class="kpi-value" id="kpiWakeWindow">3:09 PM</div>
<div class="kpi-sub">Avg Start: <strong id="kpiAvgStart">1:39 PM</strong> (<span id="kpiCorrelation">r = -0.78</span>)</div>
</div>
</section>
<!-- Deep-Dive Insights Narrative Cards -->
<div class="section-title-wrap">
<h2 class="section-title">🔬 Data-Backed Deep Dive Findings</h2>
</div>
<section class="insights-grid">
<!-- Insight 1 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-maturation">📉 Developmental Maturation</span>
<h3 class="insight-title">The 13-Month Sleep Transition Curve</h3>
<p class="insight-body" id="insightBody1">
In early Fall 2025 (Aug–Oct), <span class="dyn-child-name">Child</span> averaged <strong>~105–117 minutes</strong> (~2.0 hrs) of deep midday sleep.
By Winter/Spring 2026, nap length naturally consolidated down to a steady <strong>~75–85 minutes</strong> (~1.35 hrs).
This reflects classic preschool stamina maturation without losing circadian stability.
</p>
<div class="insight-stat-highlight" id="insightStat1">
<strong>Peak Month:</strong> <span id="dynPeakMonth">Aug 2025</span> &bull; <strong>Consolidated Baseline:</strong> <span id="dynBaseMonth">78–84m avg</span>
</div>
</div>
<!-- Insight 2 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-ceiling">⏰ The Hard-Stop Ceiling Effect</span>
<h3 class="insight-title">Fixed 3:00 PM Afternoon Wake Boundary</h3>
<p class="insight-body" id="insightBody2">
We found a strong negative correlation (<strong>r = <span id="dynCorr">--</span></strong>) between nap start time and total duration.
Because daycare afternoon snack and circle time occur strictly around <strong>3:00 PM – 3:15 PM</strong>, putting <span class="dyn-child-name">the child</span> down later directly cuts sleep duration.
</p>
<div class="insight-stat-highlight" id="insightStat2">
<strong>Elasticity Slope:</strong> Every 15-minute delay in start time reduces total nap duration by ~<span id="dynSlopeMins">14.2</span> minutes.
</div>
</div>
<!-- Insight 3 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-rhythm">⚖️ Circadian Regularity</span>
<h3 class="insight-title">Monday-to-Friday Steady Rhythm</h3>
<p class="insight-body">
There is virtually zero weekday fatigue variance across the entire school year.
Average nap length stays within a razor-thin margin across all 5 weekdays (Mon–Fri), demonstrating exceptional daycare routine consistency.
</p>
<div class="insight-stat-highlight" id="insightStat3">
<strong>Weekday Variance:</strong> &lt; 2.5% delta across Monday through Friday school days.
</div>
</div>
<!-- Insight 4 -->
<div class="glass-card insight-card">
<span class="insight-tag tag-records">🏆 Records & Outlier Events</span>
<h3 class="insight-title">Deep Rest Milestones & Power Naps</h3>
<p class="insight-body" id="insightBody4">
Out of <span id="dynSessionCount">208</span> tracked sessions, <span class="dyn-child-name">Child</span>'s all-time deep rest record was <strong id="dynLongestRecord">172 minutes (2.87 hrs)</strong>, while shortest recorded power nap was <strong id="dynShortestRecord">29 minutes</strong>.
</p>
<div class="insight-stat-highlight" id="insightStat4">
<strong>Optimal Rest Rate:</strong> <span id="dynOptRate">91.4%</span> of all sessions within optimal 60–120 minute window.
</div>
</div>
</section>
<!-- Interactive 13-Month Timeline Chart -->
<section class="glass-card chart-box">
<div class="chart-header">
<div>
<h2 class="section-title">📈 13-Month Daily Nap Timeline & Moving Average</h2>
<p class="section-desc" style="margin-bottom: 0;">Each point represents a daily daycare nap. Solid curve shows the 14-day rolling trendline.</p>
</div>
</div>
<div class="canvas-container">
<canvas id="timelineChart"></canvas>
</div>
</section>
<!-- Dual Charts: Start Time vs Duration & Weekday Distribution -->
<section class="dual-chart-grid">
<!-- Correlation Scatter 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;">⏰ Start Time vs. Duration (Ceiling Effect)</h2>
<p class="section-desc" style="margin-bottom: 0;">Scatter plot illustrating the r = -0.781 negative correlation.</p>
</div>
</div>
<div class="canvas-container" style="height: 280px;">
<canvas id="correlationChart"></canvas>
</div>
</div>
<!-- Monthly Evolution Bar 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;">📊 Monthly Average Sleep Progression</h2>
<p class="section-desc" style="margin-bottom: 0;">Transition from infant/toddler sleep into preschool stamina.</p>
</div>
</div>
<div class="canvas-container" style="height: 280px;">
<canvas id="monthlyChart"></canvas>
</div>
</div>
</section>
<!-- Calendar Sleep Heatmap -->
<section class="glass-card calendar-card" style="margin-top: 2rem;">
<div class="section-title-wrap">
<div>
<h2 class="section-title">📅 13-Month Daycare Sleep Calendar Heatmap</h2>
<p class="section-desc" style="margin-bottom: 0;">Complete school year sleep map. Hover over any block to see exact timestamps.</p>
</div>
</div>
<div class="heatmap-grid" id="calendarHeatmap">
<!-- Generated via JS -->
</div>
<div class="heatmap-legend">
<span>Less Sleep</span>
<div class="legend-box c-none" title="No School / Weekend"></div>
<div class="legend-box c-short" title="Power Nap (<60 min)"></div>
<div class="legend-box c-med" title="Consolidated (60-89 min)"></div>
<div class="legend-box c-opt" title="Optimal (90-119 min)"></div>
<div class="legend-box c-deep" title="Deep Rest (120+ min)"></div>
<span>More Sleep</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">📋 Nap Session Explorer</h2>
<p class="section-desc" style="margin-bottom: 0;">Browse, search, and filter all 208 paired nap records.</p>
</div>
</div>
<div class="table-controls">
<input type="text" id="searchInput" class="search-input" placeholder="🔍 Search by date, month, staff..." oninput="filterTable()">
<div class="filter-tags">
<button class="filter-btn active" onclick="setDurationFilter('all', this)">All Naps</button>
<button class="filter-btn" onclick="setDurationFilter('deep', this)">Deep Rest (120m+)</button>
<button class="filter-btn" onclick="setDurationFilter('optimal', this)">Optimal (90–119m)</button>
<button class="filter-btn" onclick="setDurationFilter('consolidated', this)">Consolidated (60–89m)</button>
<button class="filter-btn" onclick="setDurationFilter('short', this)">Power Naps (&lt;60m)</button>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Date</th>
<th>Day</th>
<th>Start Time</th>
<th>Wake Time</th>
<th>Duration</th>
<th>Rolling 14d Avg</th>
<th>Logged 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 = __NAP_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 Sleep Profile (${sessions.length} paired sessions 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_sleep_hours || 0}h`;
document.getElementById('kpiTotalNaps').textContent = stats.total_sessions || sessions.length;
document.getElementById('kpiMeanDuration').textContent = `${stats.mean_duration_minutes || 0}m`;
document.getElementById('kpiMedianDuration').textContent = `${stats.median_duration_minutes || 0}m`;
document.getElementById('kpiIQR').textContent = `${stats.p25_minutes || 0}m–${stats.p75_minutes || 0}m`;
document.getElementById('kpiConsistency').textContent = `${stats.consistency_score_pct || 0}%`;
document.getElementById('kpiAvgStart').textContent = stats.avg_start_time || '--';
document.getElementById('kpiWakeWindow').textContent = stats.avg_end_time || '--';
document.getElementById('kpiCorrelation').textContent = `r = ${stats.correlation_start_to_duration || 0}`;
// Dynamic insight numbers
const longest = stats.longest_session || {};
const shortest = stats.shortest_session || {};
const slopeMins = Math.abs(Math.round(((stats.regression_slope || -72.7) / 4) * 10) / 10);
const optCount = (stats.distribution_brackets?.['Optimal (90–119 mins)'] || 0) + (stats.distribution_brackets?.['Consolidated (60–89 mins)'] || 0);
const optPct = sessions.length ? Math.round((optCount / sessions.length) * 1000) / 10 : 90;
if (document.getElementById('dynCorr')) document.getElementById('dynCorr').textContent = stats.correlation_start_to_duration || '-0.781';
if (document.getElementById('dynSlopeMins')) document.getElementById('dynSlopeMins').textContent = slopeMins || '14.2';
if (document.getElementById('dynSessionCount')) document.getElementById('dynSessionCount').textContent = sessions.length;
if (document.getElementById('dynLongestRecord')) document.getElementById('dynLongestRecord').textContent = `${longest.duration_minutes || 172} mins (${longest.duration_hours || 2.87} hrs)`;
if (document.getElementById('dynShortestRecord')) document.getElementById('dynShortestRecord').textContent = `${shortest.duration_minutes || 29} mins`;
if (document.getElementById('dynOptRate')) document.getElementById('dynOptRate').textContent = `${optPct}%`;
// Render Visualizations
renderTimelineChart(sessions);
renderCorrelationChart(sessions, stats);
renderMonthlyChart(stats.monthly_breakdown || []);
renderCalendarHeatmap(sessions);
renderTable(sessions);
}
function renderTimelineChart(sessions) {
const ctx = document.getElementById('timelineChart').getContext('2d');
const labels = sessions.map(s => s.date);
const durations = sessions.map(s => s.duration_minutes);
const rollingAvg = sessions.map(s => s.rolling_avg_14d);
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Daily Nap Duration (min)',
data: durations,
borderColor: 'rgba(99, 102, 241, 0.4)',
backgroundColor: 'rgba(99, 102, 241, 0.7)',
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: '#818CF8',
showLine: false,
order: 2
},
{
label: '14-Day Rolling Trendline',
data: rollingAvg,
borderColor: '#38BDF8',
borderWidth: 3,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 0,
fill: false,
order: 1
},
{
label: '90m Target Baseline',
data: Array(sessions.length).fill(90),
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', font: { family: 'Inter', size: 12 } }
},
tooltip: {
backgroundColor: '#0F172A',
titleColor: '#F8FAFC',
bodyColor: '#CBD5E1',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
padding: 12,
callbacks: {
afterLabel: function(context) {
if (context.datasetIndex === 0) {
const s = sessions[context.dataIndex];
return `Time: ${s.start_time_str} – ${s.end_time_str}\nTeacher: ${s.teacher}`;
}
}
}
}
},
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 + ' min' },
min: 20,
max: 180
}
}
}
});
}
function renderCorrelationChart(sessions, stats) {
const ctx = document.getElementById('correlationChart').getContext('2d');
const points = sessions.map(s => ({
x: s.start_dec_hour,
y: s.duration_minutes,
raw: s
}));
// Regression line points
const minX = 12.8;
const maxX = 14.5;
const slope = stats.regression_slope || -60;
const intercept = stats.regression_intercept || 900;
const regLine = [
{ x: minX, y: slope * minX + intercept },
{ x: maxX, y: slope * maxX + intercept }
];
new Chart(ctx, {
type: 'scatter',
data: {
datasets: [
{
label: 'Nap Sessions',
data: points,
backgroundColor: '#818CF8',
pointRadius: 4.5,
pointHoverRadius: 7
},
{
label: 'Regression Slope (r = -0.781)',
data: regLine,
type: 'line',
borderColor: '#FB7185',
borderWidth: 2,
pointRadius: 0,
fill: false
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94A3B8' } },
tooltip: {
backgroundColor: '#0F172A',
callbacks: {
label: function(ctx) {
const p = ctx.raw.raw;
if (!p) return `Expected Duration: ${Math.round(ctx.parsed.y)}m`;
return `${p.date}: Started at ${p.start_time_str} ➔ Duration: ${p.duration_minutes}m (${p.start_time_str} - ${p.end_time_str})`;
}
}
}
},
scales: {
x: {
title: { display: true, text: 'Nap Start Time (Hour of Day)', 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);
const h12 = h > 12 ? h - 12 : h;
return `${h12}:${m < 10 ? '0' : ''}${m} PM`;
}
},
min: 12.8,
max: 14.5
},
y: {
title: { display: true, text: 'Nap Duration (Mins)', color: '#94A3B8' },
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: { color: '#64748B' },
min: 20,
max: 180
}
}
}
});
}
function renderMonthlyChart(monthlyData) {
const ctx = document.getElementById('monthlyChart').getContext('2d');
const labels = monthlyData.map(m => m.month);
const avgs = monthlyData.map(m => m.avg_duration);
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Avg Nap Duration (Mins)',
data: avgs,
backgroundColor: avgs.map(val => val > 100 ? '#818CF8' : (val > 80 ? '#38BDF8' : '#34D399')),
borderRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0F172A',
callbacks: {
label: ctx => `Avg: ${ctx.parsed.y} mins (${(ctx.parsed.y / 60).toFixed(2)} hrs)`
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#64748B', maxRotation: 45 }
},
y: {
grid: { color: 'rgba(255,255,255,0.04)' },
ticks: { color: '#64748B' },
min: 40,
max: 130
}
}
}
});
}
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.duration_minutes;
if (dur >= 120) cell.classList.add('c-deep');
else if (dur >= 90) cell.classList.add('c-opt');
else if (dur >= 60) cell.classList.add('c-med');
else cell.classList.add('c-short');
cell.title = `${dStr} (${session.weekday_short})\nDuration: ${dur} mins (${session.start_time_str} – ${session.end_time_str})\nStaff: ${session.teacher}`;
} else {
cell.classList.add('c-none');
cell.title = `${dStr} (No Nap Tracked / 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 === 'deep' && s.duration_minutes < 120) return false;
if (activeFilter === 'optimal' && (s.duration_minutes < 90 || s.duration_minutes >= 120)) return false;
if (activeFilter === 'consolidated' && (s.duration_minutes < 60 || s.duration_minutes >= 90)) return false;
if (activeFilter === 'short' && s.duration_minutes >= 60) return false;
// Search query
if (query) {
const matchStr = `${s.date} ${s.weekday} ${s.teacher} ${s.start_time_str} ${s.end_time_str}`.toLowerCase();
if (!matchStr.includes(query)) return false;
}
return true;
});
filtered.forEach(s => {
const tr = document.createElement('tr');
let badgeClass = 'dur-med';
if (s.duration_minutes >= 120) badgeClass = 'dur-deep';
else if (s.duration_minutes >= 90) badgeClass = 'dur-opt';
else if (s.duration_minutes < 60) badgeClass = 'dur-short';
tr.innerHTML = `
<td><strong>${s.date}</strong></td>
<td style="color: var(--text-secondary);">${s.weekday}</td>
<td>${s.start_time_str}</td>
<td>${s.end_time_str}</td>
<td><span class="duration-badge ${badgeClass}">${s.duration_minutes} mins</span></td>
<td style="color: var(--text-muted);">${s.rolling_avg_14d || '--'}m</td>
<td style="color: var(--text-secondary);">${s.teacher}</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 exportDataCSV() {
const sessions = DATA.sessions || [];
if (!sessions.length) return;
let csv = 'Date,Month,Day,Start Time,End Time,Duration (Mins),Duration (Hours),Rolling 14d Avg,Staff\n';
sessions.forEach(s => {
csv += `"${s.date}","${s.month}","${s.weekday}","${s.start_time_str}","${s.end_time_str}",${s.duration_minutes},${s.duration_hours},${s.rolling_avg_14d || ''},"${s.teacher}"\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 = `nap_sessions_${DATA.student_name || 'child'}.csv`;
a.click();
URL.revokeObjectURL(url);
}
</script>
</body>
</html>