CI Verification / Lint & Extension Integrity Check (push) Successful in 2m58s
1194 lines
39 KiB
HTML
1194 lines
39 KiB
HTML
<!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> • <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> < 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 (<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> • 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>
|