1179 lines
34 KiB
HTML
1179 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>Feedings - Baby Monitor</title>
|
||
<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: #f0f0f0;
|
||
min-height: 100vh;
|
||
max-width: 800px;
|
||
margin: 2rem auto;
|
||
padding: 0 1rem;
|
||
}
|
||
|
||
.container {
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||
padding: 40px;
|
||
}
|
||
|
||
h1 {
|
||
color: #333;
|
||
margin-bottom: 10px;
|
||
font-size: 28px;
|
||
}
|
||
|
||
.subtitle {
|
||
color: #666;
|
||
margin-bottom: 30px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.button {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
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(102, 126, 234, 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: #666;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: #666;
|
||
}
|
||
|
||
.empty-state-icon {
|
||
font-size: 64px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.empty-state h2 {
|
||
color: #333;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.empty-state p {
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.chart-container {
|
||
margin-bottom: 40px;
|
||
padding: 20px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.chart-container h2 {
|
||
color: #333;
|
||
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: #f8f9fa;
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-card h3 {
|
||
color: #666;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.stat-card .stat-value {
|
||
color: #333;
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.back-button {
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.controls {
|
||
margin-bottom: 30px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.controls label {
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.controls select {
|
||
padding: 8px 16px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
background: white;
|
||
}
|
||
|
||
.controls select:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.feedings-list {
|
||
margin-top: 40px;
|
||
}
|
||
|
||
.feedings-list h2 {
|
||
color: #333;
|
||
margin-bottom: 20px;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.feedings-scroll {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
border: 1px solid #ddd;
|
||
border-radius: 8px;
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.feeding-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 15px 20px;
|
||
background: white;
|
||
border-bottom: 1px solid #eee;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.feeding-item:hover {
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.feeding-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.feeding-info-left {
|
||
flex: 1;
|
||
}
|
||
|
||
.feeding-time {
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.feeding-details {
|
||
font-size: 14px;
|
||
color: #666;
|
||
}
|
||
|
||
.feeding-type-badge {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.feeding-type-badge.left-breast {
|
||
background: rgba(102, 126, 234, 0.1);
|
||
color: rgba(102, 126, 234, 1);
|
||
}
|
||
|
||
.feeding-type-badge.right-breast {
|
||
background: rgba(118, 75, 162, 0.1);
|
||
color: rgba(118, 75, 162, 1);
|
||
}
|
||
|
||
.feeding-type-badge.bottle {
|
||
background: rgba(255, 159, 64, 0.1);
|
||
color: rgba(255, 159, 64, 1);
|
||
}
|
||
|
||
.edit-button {
|
||
background: #667eea;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.edit-button:hover {
|
||
background: #5568d3;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🍼 Feedings</h1>
|
||
<p class="subtitle">Overview of all feeding sessions</p>
|
||
|
||
<div id="content" class="loading">
|
||
<p>Loading...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||
<script src="/menu.js"></script>
|
||
<script src="/auth-utils.js"></script>
|
||
<script>
|
||
// Check if user is authenticated
|
||
const token = getToken();
|
||
|
||
// Initialize burger menu
|
||
initBurgerMenu({ includeHome: true });
|
||
|
||
let allFeedings = [];
|
||
let allChildren = [];
|
||
let currentDaysRange =
|
||
parseInt(localStorage.getItem("lastFeedingTimeRange")) || 7;
|
||
let selectedChildId = null; // null means show first/only child
|
||
let barChartInstance = null;
|
||
|
||
// Restore last selected child from localStorage
|
||
const lastChildId = localStorage.getItem("lastFeedingChildFilter");
|
||
if (lastChildId && lastChildId !== "all") {
|
||
selectedChildId = parseInt(lastChildId);
|
||
}
|
||
|
||
// Load feedings data
|
||
async function loadFeedings() {
|
||
try {
|
||
const response = await fetch("/api/feedings", {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
|
||
if (response.ok) {
|
||
allFeedings = await response.json();
|
||
await loadChildren();
|
||
displayFeedings(allFeedings);
|
||
} else {
|
||
showError("Failed to load feedings");
|
||
}
|
||
} catch (error) {
|
||
console.error("Error:", error);
|
||
showError("Network error. Please try again.");
|
||
}
|
||
}
|
||
|
||
// Load children data
|
||
async function loadChildren() {
|
||
try {
|
||
const response = await fetch("/api/children", {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
|
||
if (response.ok) {
|
||
allChildren = await response.json();
|
||
|
||
// Auto-select first child if no selection exists
|
||
if (selectedChildId === null && allChildren.length > 0) {
|
||
selectedChildId = allChildren[0].id;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Error loading children:", error);
|
||
}
|
||
}
|
||
|
||
function onDaysRangeChange(event) {
|
||
currentDaysRange = parseInt(event.target.value);
|
||
localStorage.setItem("lastFeedingTimeRange", currentDaysRange);
|
||
displayFeedings(allFeedings);
|
||
}
|
||
|
||
function onChildChange(event) {
|
||
selectedChildId = parseInt(event.target.value);
|
||
localStorage.setItem("lastFeedingChildFilter", event.target.value);
|
||
displayFeedings(allFeedings);
|
||
}
|
||
|
||
function displayFeedings(feedings) {
|
||
const content = document.getElementById("content");
|
||
|
||
if (feedings.length === 0) {
|
||
content.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">🍼</div>
|
||
<h2>No Feedings Yet</h2>
|
||
<p>Start tracking feeding sessions from the home page</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
// Prepare chart data
|
||
const barChartData = prepareBarChartData(feedings);
|
||
const pieChartData = preparePieChartData(feedings);
|
||
const durationChartData = prepareDurationChartData(feedings);
|
||
|
||
// Build child dropdown options
|
||
const childOptions = allChildren
|
||
.map(
|
||
(child) =>
|
||
`<option value="${child.id}" ${selectedChildId === child.id ? "selected" : ""}>${child.name}</option>`,
|
||
)
|
||
.join("");
|
||
|
||
content.innerHTML = `
|
||
<div class="controls">
|
||
${
|
||
allChildren.length === 1
|
||
? `
|
||
<label for="childDisplay">Child:</label>
|
||
<input
|
||
type="text"
|
||
id="childDisplay"
|
||
value="${allChildren[0].name}"
|
||
readonly
|
||
style="background-color: #f5f5f5; cursor: default; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;"
|
||
/>
|
||
`
|
||
: `
|
||
<label for="childFilter">Child:</label>
|
||
<select id="childFilter" onchange="onChildChange(event)">
|
||
${childOptions}
|
||
</select>
|
||
`
|
||
}
|
||
|
||
<label for="daysRange">Time Range:</label>
|
||
<select id="daysRange" onchange="onDaysRangeChange(event)">
|
||
<option value="1" ${currentDaysRange === 1 ? "selected" : ""}>Last 24 Hours</option>
|
||
<option value="3" ${currentDaysRange === 3 ? "selected" : ""}>Last 3 Days</option>
|
||
<option value="7" ${currentDaysRange === 7 ? "selected" : ""}>Last 7 Days</option>
|
||
<option value="14" ${currentDaysRange === 14 ? "selected" : ""}>Last 14 Days</option>
|
||
<option value="30" ${currentDaysRange === 30 ? "selected" : ""}>Last 30 Days</option>
|
||
<option value="90" ${currentDaysRange === 90 ? "selected" : ""}>Last 90 Days</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="chart-container">
|
||
<h2>Total Feeding Time (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? "s" : ""})</h2>
|
||
<div class="chart-wrapper">
|
||
<canvas id="feedingsChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="charts-row">
|
||
<div class="chart-container">
|
||
<h2>Feeding Type Distribution</h2>
|
||
<div class="chart-wrapper">
|
||
<canvas id="pieChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-container">
|
||
<h2>Feeding Duration Distribution</h2>
|
||
<div class="chart-wrapper">
|
||
<canvas id="durationChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Render charts
|
||
renderBarChart(barChartData);
|
||
renderPieChart(pieChartData);
|
||
renderDurationChart(durationChartData);
|
||
|
||
// Render feeding list
|
||
renderFeedingList(feedings);
|
||
}
|
||
}
|
||
|
||
function renderFeedingList(feedings) {
|
||
// Filter feedings by current time range
|
||
const now = new Date();
|
||
const daysAgo = new Date(now);
|
||
daysAgo.setDate(daysAgo.getDate() - currentDaysRange);
|
||
daysAgo.setHours(0, 0, 0, 0);
|
||
|
||
let filteredFeedings = feedings.filter((f) => {
|
||
const feedingDate = new Date(f.start_time);
|
||
return feedingDate >= daysAgo;
|
||
});
|
||
|
||
// Filter by selected child if applicable
|
||
if (selectedChildId !== null) {
|
||
filteredFeedings = filteredFeedings.filter(
|
||
(f) => f.child_id === selectedChildId,
|
||
);
|
||
}
|
||
|
||
// Sort by most recent first
|
||
filteredFeedings.sort((a, b) => {
|
||
return new Date(b.start_time) - new Date(a.start_time);
|
||
});
|
||
|
||
const listHtml = filteredFeedings
|
||
.map((feeding) => {
|
||
const startTime = new Date(feeding.start_time);
|
||
const endTime = feeding.end_time
|
||
? new Date(feeding.end_time)
|
||
: null;
|
||
|
||
const dateStr = startTime.toLocaleDateString("en-US", {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
});
|
||
|
||
const timeStr = startTime.toLocaleTimeString("en-US", {
|
||
hour: "numeric",
|
||
minute: "2-digit",
|
||
});
|
||
|
||
const duration = endTime
|
||
? Math.round((endTime - startTime) / 60000)
|
||
: null;
|
||
|
||
const typeClass = feeding.feeding_type.replace("_", "-");
|
||
const typeLabel = formatFeedingType(feeding.feeding_type);
|
||
|
||
// Get child name
|
||
const child = allChildren.find((c) => c.id === feeding.child_id);
|
||
const childName = child ? child.name : "Unknown";
|
||
|
||
return `
|
||
<div class="feeding-item">
|
||
<div class="feeding-info-left">
|
||
<div class="feeding-time">${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ""}</div>
|
||
<div class="feeding-details">
|
||
<span class="feeding-type-badge ${typeClass}">${typeLabel}</span>
|
||
${duration !== null ? `${duration} minutes` : "Ongoing"}
|
||
</div>
|
||
</div>
|
||
<button class="edit-button" onclick="editFeeding(${feeding.id})">Edit</button>
|
||
</div>
|
||
`;
|
||
})
|
||
.join("");
|
||
|
||
const listContainer = `
|
||
<div class="feedings-list">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<h2 style="margin: 0;">Feeding History</h2>
|
||
<button class="button" onclick="addManualFeeding()" style="padding: 8px 16px; font-size: 14px;">➕ Add Manual Entry</button>
|
||
</div>
|
||
<div class="feedings-scroll">
|
||
${listHtml || '<div class="feeding-item">No feedings in this time range</div>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const content = document.getElementById("content");
|
||
content.insertAdjacentHTML("beforeend", listContainer);
|
||
}
|
||
|
||
function formatFeedingType(type) {
|
||
const types = {
|
||
left_breast: "Left Breast",
|
||
right_breast: "Right Breast",
|
||
bottle: "Bottle",
|
||
};
|
||
return types[type] || type;
|
||
}
|
||
|
||
function editFeeding(feedingId) {
|
||
// TODO: Implement edit functionality
|
||
// For now, redirect to log-feeding page with ID parameter
|
||
window.location.href = `/log-feeding.html?id=${feedingId}`;
|
||
}
|
||
|
||
function addManualFeeding() {
|
||
window.location.href = `/log-feeding.html`;
|
||
}
|
||
|
||
function preparePieChartData(feedings) {
|
||
const now = new Date();
|
||
const daysAgo = new Date(now);
|
||
daysAgo.setDate(daysAgo.getDate() - currentDaysRange);
|
||
daysAgo.setHours(0, 0, 0, 0);
|
||
|
||
// Filter feedings from selected time range
|
||
let recentFeedings = feedings.filter((f) => {
|
||
const feedingDate = new Date(f.start_time);
|
||
return feedingDate >= daysAgo;
|
||
});
|
||
|
||
// Filter by selected child if applicable
|
||
if (selectedChildId !== null) {
|
||
recentFeedings = recentFeedings.filter(
|
||
(f) => f.child_id === selectedChildId,
|
||
);
|
||
}
|
||
|
||
const typeCounts = {
|
||
left_breast: 0,
|
||
right_breast: 0,
|
||
bottle: 0,
|
||
};
|
||
|
||
recentFeedings.forEach((f) => {
|
||
if (typeCounts.hasOwnProperty(f.feeding_type)) {
|
||
typeCounts[f.feeding_type]++;
|
||
}
|
||
});
|
||
|
||
// Only include types with data
|
||
const labels = [];
|
||
const data = [];
|
||
const labelMap = {
|
||
left_breast: "Left Breast",
|
||
right_breast: "Right Breast",
|
||
bottle: "Bottle",
|
||
};
|
||
|
||
Object.entries(typeCounts).forEach(([type, count]) => {
|
||
if (count > 0) {
|
||
labels.push(labelMap[type]);
|
||
data.push(count);
|
||
}
|
||
});
|
||
|
||
return { labels, data };
|
||
}
|
||
|
||
function prepareDurationChartData(feedings) {
|
||
const now = new Date();
|
||
const daysAgo = new Date(now);
|
||
daysAgo.setDate(daysAgo.getDate() - currentDaysRange);
|
||
daysAgo.setHours(0, 0, 0, 0);
|
||
|
||
// Filter feedings from selected time range that have end_time
|
||
let completedFeedings = feedings.filter((f) => {
|
||
const feedingDate = new Date(f.start_time);
|
||
return feedingDate >= daysAgo && f.end_time !== null;
|
||
});
|
||
|
||
// Filter by selected child if applicable
|
||
if (selectedChildId !== null) {
|
||
completedFeedings = completedFeedings.filter(
|
||
(f) => f.child_id === selectedChildId,
|
||
);
|
||
}
|
||
|
||
// Calculate durations in minutes
|
||
const durations = completedFeedings.map((f) => {
|
||
const start = new Date(f.start_time);
|
||
const end = new Date(f.end_time);
|
||
return (end - start) / 60000; // Convert to minutes
|
||
});
|
||
|
||
// Find max duration to determine how many bins we need
|
||
const maxDuration = Math.max(...durations, 0);
|
||
const maxBin = Math.ceil(maxDuration / 5) * 5; // Round up to nearest 5
|
||
|
||
// Create 5-minute duration buckets dynamically
|
||
const buckets = {};
|
||
for (let i = 0; i < maxBin; i += 5) {
|
||
const label = `${i}-${i + 5}`;
|
||
buckets[label] = 0;
|
||
}
|
||
|
||
// Add a final bucket for anything over the max
|
||
if (maxBin > 0) {
|
||
buckets[`${maxBin}+`] = 0;
|
||
}
|
||
|
||
// Fill buckets
|
||
durations.forEach((duration) => {
|
||
const binIndex = Math.floor(duration / 5) * 5;
|
||
const label = `${binIndex}-${binIndex + 5}`;
|
||
|
||
if (buckets[label] !== undefined) {
|
||
buckets[label]++;
|
||
} else {
|
||
buckets[`${maxBin}+`]++;
|
||
}
|
||
});
|
||
|
||
return {
|
||
labels: Object.keys(buckets),
|
||
data: Object.values(buckets),
|
||
};
|
||
}
|
||
|
||
function prepareBarChartData(feedings) {
|
||
const now = new Date();
|
||
const labels = [];
|
||
const durations = []; // Array of arrays, each containing individual feeding durations
|
||
const feedingCounts = []; // For reference
|
||
|
||
// Filter by selected child if applicable
|
||
let filteredFeedings = feedings;
|
||
if (selectedChildId !== null) {
|
||
filteredFeedings = feedings.filter(
|
||
(f) => f.child_id === selectedChildId,
|
||
);
|
||
}
|
||
|
||
// Only use completed feedings
|
||
const completedFeedings = filteredFeedings.filter((f) => f.end_time);
|
||
|
||
// If 24 hours selected, show 3-hour aggregates
|
||
if (currentDaysRange === 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 feeding durations for this 3-hour period (in minutes)
|
||
const periodFeedings = completedFeedings.filter((f) => {
|
||
const feedingDate = new Date(f.start_time);
|
||
return feedingDate >= periodStart && feedingDate < periodEnd;
|
||
});
|
||
|
||
const feedingDurations = periodFeedings.map((feeding) => {
|
||
const start = new Date(feeding.start_time);
|
||
const end = new Date(feeding.end_time);
|
||
return (end - start) / 60000; // Convert to minutes
|
||
});
|
||
|
||
durations.push(feedingDurations);
|
||
feedingCounts.push(periodFeedings.length);
|
||
}
|
||
} else if (currentDaysRange === 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 feeding durations for this 8-hour period (in minutes)
|
||
const periodFeedings = completedFeedings.filter((f) => {
|
||
const feedingDate = new Date(f.start_time);
|
||
return feedingDate >= periodStart && feedingDate < periodEnd;
|
||
});
|
||
|
||
const feedingDurations = periodFeedings.map((feeding) => {
|
||
const start = new Date(feeding.start_time);
|
||
const end = new Date(feeding.end_time);
|
||
return (end - start) / 60000; // Convert to minutes
|
||
});
|
||
|
||
durations.push(feedingDurations);
|
||
feedingCounts.push(periodFeedings.length);
|
||
}
|
||
} else {
|
||
// Create days based on currentDaysRange (including today)
|
||
for (let i = currentDaysRange - 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 feeding durations for this day (in minutes)
|
||
const nextDay = new Date(date);
|
||
nextDay.setDate(nextDay.getDate() + 1);
|
||
|
||
const dayFeedings = completedFeedings.filter((f) => {
|
||
const feedingDate = new Date(f.start_time);
|
||
return feedingDate >= date && feedingDate < nextDay;
|
||
});
|
||
|
||
const feedingDurations = dayFeedings.map((feeding) => {
|
||
const start = new Date(feeding.start_time);
|
||
const end = new Date(feeding.end_time);
|
||
return (end - start) / 60000; // Convert to minutes
|
||
});
|
||
|
||
durations.push(feedingDurations);
|
||
feedingCounts.push(dayFeedings.length);
|
||
}
|
||
}
|
||
|
||
return { labels, durations, feedingCounts };
|
||
}
|
||
|
||
function renderBarChart(chartData) {
|
||
const ctx = document.getElementById("feedingsChart").getContext("2d");
|
||
|
||
// Destroy existing chart if it exists
|
||
if (barChartInstance) {
|
||
barChartInstance.destroy();
|
||
}
|
||
|
||
// Find the maximum number of feedings in any period
|
||
const maxFeedings = Math.max(
|
||
...chartData.durations.map((d) => d.length),
|
||
0,
|
||
);
|
||
|
||
// If no feedings at all, show empty chart
|
||
if (maxFeedings === 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(102, 126, 234, 0.3)",
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
max: 60,
|
||
title: {
|
||
display: true,
|
||
text: "Minutes",
|
||
},
|
||
},
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Create color palette for different feedings
|
||
const colors = [
|
||
"rgba(102, 126, 234, 0.8)",
|
||
"rgba(118, 75, 162, 0.8)",
|
||
"rgba(255, 159, 64, 0.8)",
|
||
"rgba(76, 175, 80, 0.8)",
|
||
"rgba(244, 67, 54, 0.8)",
|
||
"rgba(156, 39, 176, 0.8)",
|
||
"rgba(102, 126, 234, 0.6)",
|
||
"rgba(118, 75, 162, 0.6)",
|
||
"rgba(255, 159, 64, 0.6)",
|
||
"rgba(76, 175, 80, 0.6)",
|
||
];
|
||
|
||
// Create datasets - one for each feeding position
|
||
const datasets = [];
|
||
for (let feedingIndex = 0; feedingIndex < maxFeedings; feedingIndex++) {
|
||
const dataForFeeding = chartData.durations.map(
|
||
(periodDurations) => periodDurations[feedingIndex] || 0,
|
||
);
|
||
|
||
datasets.push({
|
||
label: `Feeding ${feedingIndex + 1}`,
|
||
data: dataForFeeding,
|
||
backgroundColor: colors[feedingIndex % colors.length],
|
||
borderColor: colors[feedingIndex % colors.length]
|
||
.replace("0.8", "1")
|
||
.replace("0.6", "1"),
|
||
borderWidth: 1,
|
||
});
|
||
}
|
||
|
||
barChartInstance = new Chart(ctx, {
|
||
type: "bar",
|
||
data: {
|
||
labels: chartData.labels,
|
||
datasets: datasets,
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
ticks: {
|
||
font: {
|
||
size: 12,
|
||
},
|
||
},
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
y: {
|
||
stacked: true,
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: "Minutes",
|
||
font: {
|
||
size: 13,
|
||
weight: "600",
|
||
},
|
||
},
|
||
ticks: {
|
||
font: {
|
||
size: 12,
|
||
},
|
||
callback: function (value) {
|
||
return Math.round(value) + "m";
|
||
},
|
||
},
|
||
grid: {
|
||
color: "rgba(0, 0, 0, 0.05)",
|
||
},
|
||
},
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||
padding: 12,
|
||
titleFont: {
|
||
size: 14,
|
||
},
|
||
bodyFont: {
|
||
size: 13,
|
||
},
|
||
callbacks: {
|
||
label: function (context) {
|
||
const minutes = Math.round(context.parsed.y);
|
||
return `${context.dataset.label}: ${minutes}m`;
|
||
},
|
||
footer: function (tooltipItems) {
|
||
const periodIndex = tooltipItems[0].dataIndex;
|
||
const totalMinutes = chartData.durations[
|
||
periodIndex
|
||
].reduce((sum, val) => sum + val, 0);
|
||
const feedingCount = chartData.feedingCounts[periodIndex];
|
||
return `Total: ${Math.round(totalMinutes)}m (${feedingCount} feeding${feedingCount !== 1 ? "s" : ""})`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderPieChart(chartData) {
|
||
const ctx = document.getElementById("pieChart").getContext("2d");
|
||
|
||
// Color mapping for each type
|
||
const colorMap = {
|
||
"Left Breast": {
|
||
background: "rgba(102, 126, 234, 0.8)",
|
||
border: "rgba(102, 126, 234, 1)",
|
||
},
|
||
"Right Breast": {
|
||
background: "rgba(118, 75, 162, 0.8)",
|
||
border: "rgba(118, 75, 162, 1)",
|
||
},
|
||
Bottle: {
|
||
background: "rgba(255, 159, 64, 0.8)",
|
||
border: "rgba(255, 159, 64, 1)",
|
||
},
|
||
};
|
||
|
||
// Generate colors based on labels present
|
||
const backgroundColors = chartData.labels.map(
|
||
(label) => colorMap[label]?.background || "rgba(200, 200, 200, 0.8)",
|
||
);
|
||
const borderColors = chartData.labels.map(
|
||
(label) => colorMap[label]?.border || "rgba(200, 200, 200, 1)",
|
||
);
|
||
|
||
new Chart(ctx, {
|
||
type: "pie",
|
||
data: {
|
||
labels: chartData.labels,
|
||
datasets: [
|
||
{
|
||
data: chartData.data,
|
||
backgroundColor: backgroundColors,
|
||
borderColor: borderColors,
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: "bottom",
|
||
labels: {
|
||
padding: 20,
|
||
font: {
|
||
size: 13,
|
||
},
|
||
},
|
||
},
|
||
tooltip: {
|
||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||
padding: 12,
|
||
titleFont: {
|
||
size: 14,
|
||
},
|
||
bodyFont: {
|
||
size: 13,
|
||
},
|
||
callbacks: {
|
||
label: function (context) {
|
||
const count = context.parsed;
|
||
const total = context.dataset.data.reduce(
|
||
(a, b) => a + b,
|
||
0,
|
||
);
|
||
const percentage =
|
||
total > 0 ? ((count / total) * 100).toFixed(1) : 0;
|
||
return `${context.label}: ${count} (${percentage}%)`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function renderDurationChart(chartData) {
|
||
const ctx = document.getElementById("durationChart").getContext("2d");
|
||
|
||
new Chart(ctx, {
|
||
type: "bar",
|
||
data: {
|
||
labels: chartData.labels,
|
||
datasets: [
|
||
{
|
||
label: "Feedings",
|
||
data: chartData.data,
|
||
backgroundColor: "rgba(76, 175, 80, 0.7)",
|
||
borderColor: "rgba(76, 175, 80, 1)",
|
||
borderWidth: 2,
|
||
borderRadius: 6,
|
||
},
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||
padding: 12,
|
||
titleFont: {
|
||
size: 14,
|
||
},
|
||
bodyFont: {
|
||
size: 13,
|
||
},
|
||
callbacks: {
|
||
label: function (context) {
|
||
const count = context.parsed.y;
|
||
return `${count} feeding${count !== 1 ? "s" : ""}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: "Number of Feedings",
|
||
font: {
|
||
size: 13,
|
||
weight: "600",
|
||
},
|
||
},
|
||
ticks: {
|
||
stepSize: 1,
|
||
font: {
|
||
size: 12,
|
||
},
|
||
},
|
||
grid: {
|
||
color: "rgba(0, 0, 0, 0.05)",
|
||
},
|
||
},
|
||
x: {
|
||
title: {
|
||
display: true,
|
||
text: "Duration (minutes)",
|
||
font: {
|
||
size: 13,
|
||
weight: "600",
|
||
},
|
||
},
|
||
ticks: {
|
||
font: {
|
||
size: 12,
|
||
},
|
||
},
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
function showError(message) {
|
||
const content = document.getElementById("content");
|
||
content.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">⚠️</div>
|
||
<h2>Error</h2>
|
||
<p>${message}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function logout() {
|
||
const token = localStorage.getItem("access_token");
|
||
|
||
try {
|
||
await fetch("/api/logout", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error("Logout error:", error);
|
||
} finally {
|
||
// Clear local storage and redirect
|
||
localStorage.removeItem("access_token");
|
||
localStorage.removeItem("username");
|
||
window.location.href = "/login.html";
|
||
}
|
||
}
|
||
|
||
// Load data on page load
|
||
loadFeedings();
|
||
</script>
|
||
</body>
|
||
</html>
|