1149 lines
34 KiB
HTML
1149 lines
34 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Sleep - Baby Monitor</title>
|
||
<link rel="stylesheet" href="/dark-mode.css" />
|
||
<script src="/theme-init.js"></script>
|
||
<script src="/chart-theme.js"></script>
|
||
<link rel="stylesheet" href="/menu.css" />
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family:
|
||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||
Cantarell, sans-serif;
|
||
background-color: var(--bg-primary);
|
||
min-height: 100vh;
|
||
max-width: 800px;
|
||
margin: 2rem auto;
|
||
padding: 0 1rem;
|
||
}
|
||
|
||
.container {
|
||
background: var(--bg-secondary);
|
||
border-radius: 12px;
|
||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||
padding: 40px;
|
||
}
|
||
|
||
h1 {
|
||
color: var(--text-primary);
|
||
margin-bottom: 10px;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.subtitle {
|
||
color: var(--text-secondary);
|
||
margin-bottom: 30px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.button {
|
||
background: var(--gradient-blue);
|
||
background-color: #4facfe;
|
||
color: white;
|
||
border: none;
|
||
padding: 12px 24px;
|
||
border-radius: 6px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition:
|
||
transform 0.2s,
|
||
box-shadow 0.2s;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
}
|
||
|
||
.button:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
|
||
}
|
||
|
||
.button.secondary {
|
||
background: #6c757d;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.button.secondary:hover {
|
||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.empty-state-icon {
|
||
font-size: 64px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.empty-state h2 {
|
||
color: var(--text-primary);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.empty-state p {
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.chart-container {
|
||
margin-bottom: 40px;
|
||
padding: 20px;
|
||
background: var(--bg-tertiary);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.chart-container h2 {
|
||
color: var(--text-primary);
|
||
margin-bottom: 20px;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.chart-wrapper {
|
||
position: relative;
|
||
height: 300px;
|
||
max-width: 100%;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.chart-wrapper canvas {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
}
|
||
|
||
.charts-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 20px;
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.charts-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 40px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--bg-tertiary);
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-card h3 {
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.stat-card .value {
|
||
color: #4facfe;
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.list-container {
|
||
background: var(--bg-tertiary);
|
||
border-radius: 8px;
|
||
padding: 20px;
|
||
}
|
||
|
||
.list-container h2 {
|
||
color: var(--text-primary);
|
||
margin-bottom: 20px;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.sleep-list {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.sleep-item {
|
||
background: var(--bg-secondary);
|
||
padding: 16px;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.sleep-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.sleep-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.sleep-child {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.sleep-time {
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.sleep-duration {
|
||
color: #4facfe;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.sleep-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.edit-button {
|
||
background: #4facfe;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.edit-button:hover {
|
||
background: #3a9be8;
|
||
}
|
||
|
||
.controls {
|
||
margin-bottom: 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.controls label {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.controls select,
|
||
.controls input {
|
||
padding: 8px 16px;
|
||
border: 2px solid var(--border-color, #ddd);
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
background: var(--bg-secondary);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.controls select:focus,
|
||
.controls input:focus {
|
||
outline: none;
|
||
border-color: #4facfe;
|
||
}
|
||
|
||
.controls input[readonly] {
|
||
opacity: 0.6;
|
||
cursor: default;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.badge.ongoing {
|
||
background: #4facfe;
|
||
color: white;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>😴 Sleep Tracking</h1>
|
||
<p class="subtitle">Monitor your baby's sleep patterns</p>
|
||
|
||
<div class="controls">
|
||
<label for="childFilter">Child:</label>
|
||
<select id="childFilter">
|
||
<option value="">All Children</option>
|
||
</select>
|
||
|
||
<label for="timeRangeFilter">Time Range:</label>
|
||
<select id="timeRangeFilter">
|
||
<option value="1">Last 24 hours</option>
|
||
<option value="3">Last 3 days</option>
|
||
<option value="7" selected>Last 7 days</option>
|
||
<option value="14">Last 14 days</option>
|
||
<option value="30">Last 30 days</option>
|
||
<option value="90">Last 90 days</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="content" class="loading">
|
||
<p>Loading sleep data...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||
<script src="/menu.js"></script>
|
||
<script>
|
||
const token = localStorage.getItem("access_token");
|
||
|
||
// Check if user is logged in
|
||
if (!token) {
|
||
window.location.href = "/login.html";
|
||
}
|
||
|
||
// Initialize burger menu
|
||
initBurgerMenu({ includeHome: true });
|
||
|
||
let allSleeps = [];
|
||
let allChildren = [];
|
||
let selectedChildId = "";
|
||
|
||
// Filter change handlers (will be attached after children are loaded)
|
||
function attachChildFilterListener() {
|
||
const childFilter = document.getElementById("childFilter");
|
||
if (childFilter) {
|
||
childFilter.addEventListener("change", function () {
|
||
selectedChildId = this.value;
|
||
// Save selection to localStorage
|
||
if (selectedChildId) {
|
||
localStorage.setItem("lastSleepChildFilter", selectedChildId);
|
||
} else {
|
||
localStorage.removeItem("lastSleepChildFilter");
|
||
}
|
||
renderCharts();
|
||
});
|
||
}
|
||
}
|
||
|
||
function attachTimeRangeListener() {
|
||
const timeRangeFilter = document.getElementById("timeRangeFilter");
|
||
if (timeRangeFilter) {
|
||
timeRangeFilter.addEventListener("change", function () {
|
||
// Save selection to localStorage
|
||
localStorage.setItem("lastSleepTimeRange", this.value);
|
||
renderCharts();
|
||
});
|
||
}
|
||
}
|
||
|
||
async function loadData() {
|
||
try {
|
||
// Fetch children
|
||
const childrenResponse = await fetch("/api/children", {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
|
||
if (!childrenResponse.ok) {
|
||
throw new Error("Failed to fetch children");
|
||
}
|
||
|
||
allChildren = await childrenResponse.json();
|
||
|
||
// Auto-select first child if available
|
||
if (allChildren.length > 0 && !selectedChildId) {
|
||
selectedChildId = allChildren[0].id;
|
||
}
|
||
|
||
// Populate child filter or show single child
|
||
const childFilterContainer = document.querySelector(".controls");
|
||
if (allChildren.length === 1) {
|
||
// Single child: show as read-only text
|
||
const child = allChildren[0];
|
||
childFilterContainer.innerHTML = `
|
||
<label for="childDisplay">Child:</label>
|
||
<input
|
||
type="text"
|
||
id="childDisplay"
|
||
value="${child.name}"
|
||
readonly
|
||
/>
|
||
|
||
<label for="timeRangeFilter">Time Range:</label>
|
||
<select id="timeRangeFilter">
|
||
<option value="1">Last 24 hours</option>
|
||
<option value="3">Last 3 days</option>
|
||
<option value="7" selected>Last 7 days</option>
|
||
<option value="14">Last 14 days</option>
|
||
<option value="30">Last 30 days</option>
|
||
<option value="90">Last 90 days</option>
|
||
</select>
|
||
`;
|
||
} else {
|
||
// Multiple children: show dropdown
|
||
const childFilter = document.getElementById("childFilter");
|
||
childFilter.innerHTML = "";
|
||
allChildren.forEach((child) => {
|
||
const option = document.createElement("option");
|
||
option.value = child.id;
|
||
option.textContent = child.name;
|
||
childFilter.appendChild(option);
|
||
});
|
||
}
|
||
|
||
// Restore last selected child from localStorage
|
||
const lastChildId = localStorage.getItem("lastSleepChildFilter");
|
||
if (lastChildId && allChildren.length > 1) {
|
||
const childFilter = document.getElementById("childFilter");
|
||
childFilter.value = lastChildId;
|
||
selectedChildId = lastChildId;
|
||
}
|
||
|
||
// Restore last selected time range from localStorage
|
||
const timeRangeFilter = document.getElementById("timeRangeFilter");
|
||
const lastTimeRange = localStorage.getItem("lastSleepTimeRange");
|
||
if (lastTimeRange) {
|
||
timeRangeFilter.value = lastTimeRange;
|
||
}
|
||
|
||
// Fetch sleep logs
|
||
const sleepResponse = await fetch("/api/sleep", {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
|
||
if (!sleepResponse.ok) {
|
||
throw new Error("Failed to fetch sleep data");
|
||
}
|
||
|
||
allSleeps = await sleepResponse.json();
|
||
|
||
// Attach event listener for child filter if multiple children
|
||
if (allChildren.length > 1) {
|
||
attachChildFilterListener();
|
||
}
|
||
|
||
// Always attach time range listener after recreating the controls
|
||
attachTimeRangeListener();
|
||
|
||
renderCharts();
|
||
} catch (error) {
|
||
console.error("Error loading data:", error);
|
||
document.getElementById("content").innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">😴</div>
|
||
<h2>Error Loading Data</h2>
|
||
<p>${error.message}</p>
|
||
<button class="button" onclick="window.location.href='/'">Go Home</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function renderCharts() {
|
||
const timeRange = parseInt(
|
||
document.getElementById("timeRangeFilter").value,
|
||
);
|
||
const cutoffDate = new Date();
|
||
cutoffDate.setDate(cutoffDate.getDate() - timeRange);
|
||
|
||
// Filter sleeps by time range and child
|
||
let filteredSleeps = allSleeps.filter((sleep) => {
|
||
const sleepDate = new Date(sleep.start_time);
|
||
const matchesTimeRange = sleepDate >= cutoffDate;
|
||
const matchesChild =
|
||
!selectedChildId || sleep.child_id === parseInt(selectedChildId);
|
||
return matchesTimeRange && matchesChild;
|
||
});
|
||
|
||
// Calculate stats (works with empty arrays too)
|
||
const completedSleeps = filteredSleeps.filter(
|
||
(sleep) => sleep.end_time,
|
||
);
|
||
const totalSessions = filteredSleeps.length;
|
||
const ongoingSessions = filteredSleeps.filter(
|
||
(sleep) => !sleep.end_time,
|
||
).length;
|
||
|
||
let totalMinutes = 0;
|
||
let longestMinutes = 0;
|
||
let shortestMinutes = Infinity;
|
||
|
||
completedSleeps.forEach((sleep) => {
|
||
const duration = calculateDuration(sleep.start_time, sleep.end_time);
|
||
totalMinutes += duration;
|
||
if (duration > longestMinutes) longestMinutes = duration;
|
||
if (duration < shortestMinutes) shortestMinutes = duration;
|
||
});
|
||
|
||
const avgMinutes =
|
||
completedSleeps.length > 0
|
||
? Math.round(totalMinutes / completedSleeps.length)
|
||
: 0;
|
||
|
||
if (completedSleeps.length === 0) {
|
||
shortestMinutes = 0;
|
||
}
|
||
|
||
// Prepare data for charts
|
||
const barChartData = prepareBarChartData(filteredSleeps, timeRange);
|
||
const durationDistData =
|
||
prepareDurationDistributionData(completedSleeps);
|
||
|
||
let html = `
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<h3>Total Sessions</h3>
|
||
<div class="value">${totalSessions}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>Avg Duration</h3>
|
||
<div class="value">${formatDuration(avgMinutes)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3>Longest Sleep</h3>
|
||
<div class="value">${formatDuration(longestMinutes)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-container">
|
||
<h2>Total Sleep Time (Last ${timeRange} Day${timeRange !== 1 ? "s" : ""})</h2>
|
||
<div class="chart-wrapper">
|
||
<canvas id="barChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="charts-row">
|
||
<div class="chart-container">
|
||
<h2>Duration Distribution</h2>
|
||
<div class="chart-wrapper">
|
||
<canvas id="durationChart"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="chart-container">
|
||
<h2>Average Duration by Day</h2>
|
||
<div class="chart-wrapper">
|
||
<canvas id="avgDurationChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="list-container">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="margin: 0;">Recent Sleep Sessions</h2>
|
||
<button class="button" onclick="addManualSleep()" style="padding: 8px 16px; font-size: 14px;">➕ Add Manual Entry</button>
|
||
</div>
|
||
<div class="sleep-list">
|
||
${filteredSleeps
|
||
.slice(0, 50)
|
||
.map((sleep) => renderSleepItem(sleep))
|
||
.join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById("content").innerHTML = html;
|
||
|
||
// Render charts
|
||
renderBarChart(barChartData);
|
||
renderDurationChart(durationDistData);
|
||
renderAvgDurationChart(filteredSleeps, timeRange);
|
||
}
|
||
|
||
function calculateDuration(startTime, endTime) {
|
||
if (!endTime) return 0;
|
||
const start = new Date(startTime);
|
||
const end = new Date(endTime);
|
||
return Math.round((end - start) / 60000); // minutes
|
||
}
|
||
|
||
function formatDuration(minutes) {
|
||
if (minutes === 0) return "0m";
|
||
const hours = Math.floor(minutes / 60);
|
||
const mins = minutes % 60;
|
||
if (hours === 0) return `${mins}m`;
|
||
if (mins === 0) return `${hours}h`;
|
||
return `${hours}h ${mins}m`;
|
||
}
|
||
|
||
function prepareBarChartData(sleepSessions, timeRange) {
|
||
const now = new Date();
|
||
const labels = [];
|
||
const durations = []; // Array of arrays, each containing individual session durations
|
||
const sessionCounts = []; // For reference
|
||
|
||
// Only use completed sleep sessions
|
||
const completedSessions = sleepSessions.filter((s) => s.end_time);
|
||
|
||
// If 24 hours selected, show 3-hour aggregates
|
||
if (timeRange === 1) {
|
||
// Create 8 three-hour buckets
|
||
for (let i = 7; i >= 0; i--) {
|
||
const periodStart = new Date(now);
|
||
periodStart.setHours(periodStart.getHours() - i * 3, 0, 0, 0);
|
||
|
||
const periodEnd = new Date(periodStart);
|
||
periodEnd.setHours(periodEnd.getHours() + 3);
|
||
|
||
const startLabel = periodStart.toLocaleTimeString("en-US", {
|
||
hour: "numeric",
|
||
hour12: true,
|
||
});
|
||
const endLabel = periodEnd.toLocaleTimeString("en-US", {
|
||
hour: "numeric",
|
||
hour12: true,
|
||
});
|
||
labels.push(`${startLabel}-${endLabel}`);
|
||
|
||
// Get individual session durations for this 3-hour period (in hours)
|
||
const periodSessions = completedSessions.filter((session) => {
|
||
const startDate = new Date(session.start_time);
|
||
return startDate >= periodStart && startDate < periodEnd;
|
||
});
|
||
|
||
const sessionDurations = periodSessions.map(
|
||
(session) =>
|
||
calculateDuration(session.start_time, session.end_time) / 60,
|
||
);
|
||
|
||
durations.push(sessionDurations);
|
||
sessionCounts.push(periodSessions.length);
|
||
}
|
||
} else if (timeRange === 3) {
|
||
// Show 8-hour aggregates for 3 days (9 buckets total)
|
||
for (let i = 8; i >= 0; i--) {
|
||
const periodStart = new Date(now);
|
||
periodStart.setHours(periodStart.getHours() - i * 8, 0, 0, 0);
|
||
|
||
const periodEnd = new Date(periodStart);
|
||
periodEnd.setHours(periodEnd.getHours() + 8);
|
||
|
||
// Format label based on the 8-hour period
|
||
const dayStr = periodStart.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
const startHour = periodStart.getHours();
|
||
let periodLabel;
|
||
if (startHour >= 0 && startHour < 8) {
|
||
periodLabel = `${dayStr} Night`;
|
||
} else if (startHour >= 8 && startHour < 16) {
|
||
periodLabel = `${dayStr} Morning`;
|
||
} else {
|
||
periodLabel = `${dayStr} Evening`;
|
||
}
|
||
labels.push(periodLabel);
|
||
|
||
// Get individual session durations for this 8-hour period (in hours)
|
||
const periodSessions = completedSessions.filter((session) => {
|
||
const startDate = new Date(session.start_time);
|
||
return startDate >= periodStart && startDate < periodEnd;
|
||
});
|
||
|
||
const sessionDurations = periodSessions.map(
|
||
(session) =>
|
||
calculateDuration(session.start_time, session.end_time) / 60,
|
||
);
|
||
|
||
durations.push(sessionDurations);
|
||
sessionCounts.push(periodSessions.length);
|
||
}
|
||
} else {
|
||
// Create days based on timeRange (including today)
|
||
for (let i = timeRange - 1; i >= 0; i--) {
|
||
const date = new Date(now);
|
||
date.setDate(date.getDate() - i);
|
||
date.setHours(0, 0, 0, 0);
|
||
|
||
const dateStr = date.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
labels.push(dateStr);
|
||
|
||
// Get individual session durations for this day (in hours)
|
||
const nextDay = new Date(date);
|
||
nextDay.setDate(nextDay.getDate() + 1);
|
||
|
||
const daySessions = completedSessions.filter((session) => {
|
||
const startDate = new Date(session.start_time);
|
||
return startDate >= date && startDate < nextDay;
|
||
});
|
||
|
||
const sessionDurations = daySessions.map((session) => {
|
||
const dur =
|
||
calculateDuration(session.start_time, session.end_time) / 60;
|
||
console.log(
|
||
` Session: ${session.start_time} to ${session.end_time}, duration: ${dur} hours (${calculateDuration(session.start_time, session.end_time)} minutes)`,
|
||
);
|
||
return dur;
|
||
});
|
||
|
||
console.log(
|
||
`${dateStr}: ${daySessions.length} sessions, durations:`,
|
||
sessionDurations,
|
||
);
|
||
|
||
durations.push(sessionDurations);
|
||
sessionCounts.push(daySessions.length);
|
||
}
|
||
}
|
||
|
||
console.log("Final prepareBarChartData result:", {
|
||
labels,
|
||
durations,
|
||
sessionCounts,
|
||
});
|
||
|
||
// Log the actual duration values for debugging
|
||
durations.forEach((periodDurations, idx) => {
|
||
if (periodDurations.length > 0) {
|
||
console.log(` ${labels[idx]}: durations =`, periodDurations);
|
||
}
|
||
});
|
||
|
||
return { labels, durations, sessionCounts };
|
||
}
|
||
|
||
function prepareDurationDistributionData(sleeps) {
|
||
const ranges = {
|
||
"0-30m": 0,
|
||
"30m-1h": 0,
|
||
"1-2h": 0,
|
||
"2-3h": 0,
|
||
"3-4h": 0,
|
||
"4h+": 0,
|
||
};
|
||
|
||
sleeps.forEach((sleep) => {
|
||
const duration = calculateDuration(sleep.start_time, sleep.end_time);
|
||
if (duration < 30) ranges["0-30m"]++;
|
||
else if (duration < 60) ranges["30m-1h"]++;
|
||
else if (duration < 120) ranges["1-2h"]++;
|
||
else if (duration < 180) ranges["2-3h"]++;
|
||
else if (duration < 240) ranges["3-4h"]++;
|
||
else ranges["4h+"]++;
|
||
});
|
||
|
||
return {
|
||
labels: Object.keys(ranges),
|
||
data: Object.values(ranges),
|
||
};
|
||
}
|
||
|
||
let barChartInstance = null;
|
||
let durationChartInstance = null;
|
||
let avgDurationChartInstance = null;
|
||
|
||
function renderBarChart(chartData) {
|
||
const ctx = document.getElementById("barChart");
|
||
|
||
// Destroy existing chart if it exists
|
||
if (barChartInstance) {
|
||
barChartInstance.destroy();
|
||
}
|
||
|
||
// Debug: Log the data
|
||
console.log("Bar chart data:", chartData);
|
||
|
||
// Find the maximum number of sessions in any period
|
||
const maxSessions = Math.max(
|
||
...chartData.durations.map((d) => d.length),
|
||
0,
|
||
);
|
||
|
||
console.log("Max sessions:", maxSessions);
|
||
|
||
// If no sessions at all, show empty chart
|
||
if (maxSessions === 0) {
|
||
barChartInstance = new Chart(ctx, {
|
||
type: "bar",
|
||
data: {
|
||
labels: chartData.labels,
|
||
datasets: [
|
||
{
|
||
label: "No Data",
|
||
data: new Array(chartData.labels.length).fill(0),
|
||
backgroundColor: "rgba(79, 172, 254, 0.3)",
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
max: 10,
|
||
title: {
|
||
display: true,
|
||
text: "Hours",
|
||
},
|
||
},
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Create color palette for different sessions
|
||
const colors = [
|
||
"rgba(79, 172, 254, 0.8)",
|
||
"rgba(0, 242, 254, 0.8)",
|
||
"rgba(58, 155, 232, 0.8)",
|
||
"rgba(32, 137, 220, 0.8)",
|
||
"rgba(21, 119, 208, 0.8)",
|
||
"rgba(11, 101, 196, 0.8)",
|
||
"rgba(79, 172, 254, 0.6)",
|
||
"rgba(0, 242, 254, 0.6)",
|
||
"rgba(58, 155, 232, 0.6)",
|
||
"rgba(32, 137, 220, 0.6)",
|
||
];
|
||
|
||
// Create datasets - one for each session position
|
||
const datasets = [];
|
||
for (let sessionIndex = 0; sessionIndex < maxSessions; sessionIndex++) {
|
||
const dataForSession = chartData.durations.map(
|
||
(periodDurations) => periodDurations[sessionIndex] || 0,
|
||
);
|
||
|
||
console.log(`Session ${sessionIndex + 1} data:`, dataForSession);
|
||
|
||
datasets.push({
|
||
label: `Session ${sessionIndex + 1}`,
|
||
data: dataForSession,
|
||
backgroundColor: colors[sessionIndex % colors.length],
|
||
borderColor: colors[sessionIndex % colors.length]
|
||
.replace("0.8", "1")
|
||
.replace("0.6", "1"),
|
||
borderWidth: 1,
|
||
});
|
||
}
|
||
|
||
console.log("Datasets:", datasets);
|
||
|
||
// Determine the maximum total duration across all periods (in hours)
|
||
const maxDuration = Math.max(
|
||
...chartData.durations.map((periodDurations) =>
|
||
periodDurations.reduce((sum, val) => sum + val, 0),
|
||
),
|
||
0,
|
||
);
|
||
|
||
console.log("Max duration (hours):", maxDuration);
|
||
|
||
// Decide whether to use minutes or hours based on max duration
|
||
const useMinutes = maxDuration < 2; // Use minutes if max is less than 2 hours
|
||
|
||
// Convert data to minutes if needed
|
||
const finalDatasets = useMinutes
|
||
? datasets.map((dataset) => ({
|
||
...dataset,
|
||
data: dataset.data.map((hours) => hours * 60), // Convert hours to minutes
|
||
}))
|
||
: datasets;
|
||
|
||
barChartInstance = new Chart(ctx, {
|
||
type: "bar",
|
||
data: {
|
||
labels: chartData.labels,
|
||
datasets: finalDatasets,
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
},
|
||
y: getChartScaleOptions("y", {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: useMinutes ? "Minutes" : "Hours",
|
||
},
|
||
ticks: {
|
||
callback: function (value) {
|
||
if (useMinutes) {
|
||
return Math.round(value) + "m";
|
||
} else {
|
||
return value.toFixed(1) + "h";
|
||
}
|
||
},
|
||
},
|
||
}),
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context) {
|
||
const value = useMinutes
|
||
? context.parsed.y / 60
|
||
: context.parsed.y;
|
||
const hours = Math.floor(value);
|
||
const minutes = Math.round((value - hours) * 60);
|
||
if (hours === 0) {
|
||
return `${context.dataset.label}: ${minutes}m`;
|
||
}
|
||
return `${context.dataset.label}: ${hours}h ${minutes}m`;
|
||
},
|
||
footer: function (tooltipItems) {
|
||
const periodIndex = tooltipItems[0].dataIndex;
|
||
const totalHours = chartData.durations[periodIndex].reduce(
|
||
(sum, val) => sum + val,
|
||
0,
|
||
);
|
||
const hours = Math.floor(totalHours);
|
||
const minutes = Math.round((totalHours - hours) * 60);
|
||
const sessionCount = chartData.sessionCounts[periodIndex];
|
||
if (hours === 0) {
|
||
return `Total: ${minutes}m (${sessionCount} session${sessionCount !== 1 ? "s" : ""})`;
|
||
}
|
||
return `Total: ${hours}h ${minutes}m (${sessionCount} session${sessionCount !== 1 ? "s" : ""})`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderDurationChart(chartData) {
|
||
const ctx = document.getElementById("durationChart");
|
||
|
||
// Destroy existing chart if it exists
|
||
if (durationChartInstance) {
|
||
durationChartInstance.destroy();
|
||
}
|
||
|
||
durationChartInstance = new Chart(ctx, {
|
||
type: "pie",
|
||
data: {
|
||
labels: chartData.labels,
|
||
datasets: [
|
||
{
|
||
data: chartData.data,
|
||
backgroundColor: [
|
||
"rgba(79, 172, 254, 0.8)",
|
||
"rgba(0, 242, 254, 0.8)",
|
||
"rgba(58, 155, 232, 0.8)",
|
||
"rgba(32, 137, 220, 0.8)",
|
||
"rgba(21, 119, 208, 0.8)",
|
||
"rgba(11, 101, 196, 0.8)",
|
||
],
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: "bottom",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderAvgDurationChart(sleeps, timeRange) {
|
||
// Destroy existing chart if it exists
|
||
if (avgDurationChartInstance) {
|
||
avgDurationChartInstance.destroy();
|
||
}
|
||
|
||
const days = {};
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
// Initialize all days
|
||
for (let i = timeRange - 1; i >= 0; i--) {
|
||
const date = new Date(today);
|
||
date.setDate(date.getDate() - i);
|
||
const dateStr = date.toISOString().split("T")[0];
|
||
days[dateStr] = { total: 0, count: 0 };
|
||
}
|
||
|
||
// Calculate totals
|
||
sleeps
|
||
.filter((sleep) => sleep.end_time)
|
||
.forEach((sleep) => {
|
||
const date = new Date(sleep.start_time);
|
||
date.setHours(0, 0, 0, 0);
|
||
const dateStr = date.toISOString().split("T")[0];
|
||
if (days.hasOwnProperty(dateStr)) {
|
||
const duration = calculateDuration(
|
||
sleep.start_time,
|
||
sleep.end_time,
|
||
);
|
||
days[dateStr].total += duration;
|
||
days[dateStr].count++;
|
||
}
|
||
});
|
||
|
||
const labels = Object.keys(days).map((date) => {
|
||
const d = new Date(date);
|
||
return d.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
});
|
||
});
|
||
|
||
const data = Object.values(days).map((day) =>
|
||
day.count > 0 ? Math.round(day.total / day.count) : 0,
|
||
);
|
||
|
||
const ctx = document.getElementById("avgDurationChart");
|
||
avgDurationChartInstance = new Chart(ctx, {
|
||
type: "line",
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: "Avg Duration (minutes)",
|
||
data,
|
||
borderColor: "rgba(79, 172, 254, 1)",
|
||
backgroundColor: "rgba(79, 172, 254, 0.1)",
|
||
fill: true,
|
||
tension: 0.4,
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: getChartScaleOptions("y", {
|
||
beginAtZero: true,
|
||
ticks: {
|
||
callback: function (value) {
|
||
return value + "m";
|
||
},
|
||
},
|
||
}),
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderSleepItem(sleep) {
|
||
const child = allChildren.find((c) => c.id === sleep.child_id);
|
||
const childName = child ? child.name : "Unknown";
|
||
const startDate = new Date(sleep.start_time);
|
||
const isOngoing = !sleep.end_time;
|
||
|
||
let durationText = "";
|
||
if (isOngoing) {
|
||
durationText = '<span class="badge ongoing">Ongoing</span>';
|
||
} else {
|
||
const duration = calculateDuration(sleep.start_time, sleep.end_time);
|
||
durationText = `Duration: ${formatDuration(duration)}`;
|
||
}
|
||
|
||
return `
|
||
<div class="sleep-item">
|
||
<div class="sleep-info">
|
||
<div class="sleep-child">${childName}</div>
|
||
<div class="sleep-time">
|
||
${startDate.toLocaleString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
})}
|
||
${isOngoing ? "" : ` - ${new Date(sleep.end_time).toLocaleString("en-US", { hour: "numeric", minute: "2-digit" })}`}
|
||
</div>
|
||
<div class="sleep-duration">${durationText}</div>
|
||
</div>
|
||
<div class="sleep-actions">
|
||
<button class="edit-button" onclick="window.location.href='/log-sleep.html?id=${sleep.id}'">Edit</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function addManualSleep() {
|
||
window.location.href = `/log-sleep.html`;
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await fetch("/api/logout", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("Logout error:", error);
|
||
} finally {
|
||
localStorage.removeItem("access_token");
|
||
localStorage.removeItem("username");
|
||
window.location.href = "/login.html";
|
||
}
|
||
}
|
||
|
||
// Load data on page load
|
||
loadData();
|
||
|
||
// Listen for theme changes and update charts
|
||
window.addEventListener("themechange", () => {
|
||
const charts = [
|
||
barChartInstance,
|
||
durationChartInstance,
|
||
avgDurationChartInstance,
|
||
].filter((c) => c);
|
||
updateChartsForTheme(charts);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|