diff --git a/src/baby_monitor/main.py b/src/baby_monitor/main.py index 80540d7..fe17988 100644 --- a/src/baby_monitor/main.py +++ b/src/baby_monitor/main.py @@ -15,6 +15,7 @@ from baby_monitor.routers.admin import router as admin_router from baby_monitor.routers.child import router as child_router from baby_monitor.routers.feeding import router as feeding_router from baby_monitor.routers.diaper_change import router as diaper_change_router +from baby_monitor.routers.sleep import router as sleep_router from baby_monitor.routers.health import router as health_router from baby_monitor.repositories.dependencies.get_database import init_db @@ -53,6 +54,7 @@ app.include_router(admin_router) app.include_router(child_router) app.include_router(feeding_router) app.include_router(diaper_change_router) +app.include_router(sleep_router) app.include_router(health_router) @@ -111,6 +113,12 @@ def serve_log_diaper() -> FileResponse: return FileResponse(static_path / "log-diaper.html") +@app.get("/sleep.html", include_in_schema=False) +def serve_sleep() -> FileResponse: + """Serve the sleep overview page.""" + return FileResponse(static_path / "sleep.html") + + @app.get("/api/") def read_root(token: Annotated[str, Depends(verify_token)]) -> dict: """API root endpoint (requires authentication).""" diff --git a/src/baby_monitor/models/db/sleep.py b/src/baby_monitor/models/db/sleep.py new file mode 100644 index 0000000..0b99521 --- /dev/null +++ b/src/baby_monitor/models/db/sleep.py @@ -0,0 +1,27 @@ +"""Sleep database model.""" + +from typing import TYPE_CHECKING +from sqlalchemy import Column, Integer, DateTime, ForeignKey +from datetime import datetime, UTC + +if TYPE_CHECKING: + from sqlalchemy.orm import DeclarativeBase + + Base = DeclarativeBase +else: + from baby_monitor.repositories.dependencies.get_database import Base + + +class Sleep(Base): + """Sleep log database model.""" + + __tablename__ = "sleep_logs" + + id = Column(Integer, primary_key=True, index=True) + child_id = Column(Integer, ForeignKey("children.id"), nullable=False) + start_time = Column(DateTime, nullable=False) + end_time = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.now(UTC), nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/src/baby_monitor/models/sleep.py b/src/baby_monitor/models/sleep.py new file mode 100644 index 0000000..87bb685 --- /dev/null +++ b/src/baby_monitor/models/sleep.py @@ -0,0 +1,28 @@ +"""Sleep-related request and response models.""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class CreateSleepRequest(BaseModel): + """Request model for creating a sleep log.""" + + child_id: int = Field(..., gt=0) + start_time: datetime + + +class UpdateSleepRequest(BaseModel): + """Request model for updating a sleep log.""" + + end_time: datetime | None = None + + +class SleepResponse(BaseModel): + """Response model for sleep log data.""" + + id: int + child_id: int + start_time: datetime + end_time: datetime | None + created_at: datetime + child_name: str | None = None # Optional, populated when needed diff --git a/src/baby_monitor/repositories/dependencies/get_database.py b/src/baby_monitor/repositories/dependencies/get_database.py index 8b3832b..c724566 100644 --- a/src/baby_monitor/repositories/dependencies/get_database.py +++ b/src/baby_monitor/repositories/dependencies/get_database.py @@ -80,6 +80,7 @@ def init_db() -> None: from baby_monitor.models.db.child import Child # noqa: F401 from baby_monitor.models.db.feeding import Feeding # noqa: F401 from baby_monitor.models.db.diaper_change import DiaperChange # noqa: F401 + from baby_monitor.models.db.sleep import Sleep # noqa: F401 from baby_monitor.models.invitation import Invitation # noqa: F401 # Ensure data directory exists diff --git a/src/baby_monitor/repositories/dependencies/get_sleep_repository.py b/src/baby_monitor/repositories/dependencies/get_sleep_repository.py new file mode 100644 index 0000000..b3f5bea --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_sleep_repository.py @@ -0,0 +1,15 @@ +"""Dependency for getting sleep repository instance.""" + +from typing import Annotated +from fastapi import Depends +from sqlalchemy.orm import Session + +from baby_monitor.repositories.dependencies.get_database import get_database +from baby_monitor.repositories.sleep.sqlite_sleep import SQLiteSleepRepository + + +def get_sleep_repository( + db: Annotated[Session, Depends(get_database)], +) -> SQLiteSleepRepository: + """Get sleep repository instance.""" + return SQLiteSleepRepository(db) diff --git a/src/baby_monitor/repositories/interfaces/sleep_repository_interface.py b/src/baby_monitor/repositories/interfaces/sleep_repository_interface.py new file mode 100644 index 0000000..371ca3e --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/sleep_repository_interface.py @@ -0,0 +1,40 @@ +"""Interface for sleep repository operations.""" + +from abc import ABC, abstractmethod +from datetime import datetime + + +class SleepRepositoryInterface(ABC): + """Interface for sleep log data access operations.""" + + @abstractmethod + def create( + self, + child_id: int, + start_time: datetime, + ) -> dict: + """Create a new sleep log entry.""" + + @abstractmethod + def get_by_id(self, sleep_id: int) -> dict | None: + """Get a sleep log by ID.""" + + @abstractmethod + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all sleep logs for a specific child.""" + + @abstractmethod + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all sleep logs for a specific user's children.""" + + @abstractmethod + def update( + self, + sleep_id: int, + end_time: datetime | None = None, + ) -> dict | None: + """Update a sleep log entry.""" + + @abstractmethod + def delete(self, sleep_id: int) -> bool: + """Delete a sleep log entry.""" diff --git a/src/baby_monitor/repositories/sleep/sqlite_sleep.py b/src/baby_monitor/repositories/sleep/sqlite_sleep.py new file mode 100644 index 0000000..cf3cc7e --- /dev/null +++ b/src/baby_monitor/repositories/sleep/sqlite_sleep.py @@ -0,0 +1,99 @@ +"""SQLite implementation of sleep repository.""" + +from datetime import datetime +from sqlalchemy.orm import Session + +from baby_monitor.repositories.interfaces.sleep_repository_interface import ( + SleepRepositoryInterface, +) +from baby_monitor.models.db.sleep import Sleep +from baby_monitor.models.db.child import Child + + +class SQLiteSleepRepository(SleepRepositoryInterface): + """SQLite implementation for sleep log data access.""" + + def __init__(self, db: Session): + self.db = db + + def create( + self, + child_id: int, + start_time: datetime, + ) -> dict: + """Create a new sleep log entry.""" + sleep = Sleep( + child_id=child_id, + start_time=start_time, + end_time=None, + ) + self.db.add(sleep) + self.db.commit() + self.db.refresh(sleep) + + return self._to_dict(sleep) + + def get_by_id(self, sleep_id: int) -> dict | None: + """Get a sleep log by ID.""" + sleep = self.db.query(Sleep).filter(Sleep.id == sleep_id).first() + if not sleep: + return None + return self._to_dict(sleep) + + def get_by_child_id(self, child_id: int) -> list[dict]: + """Get all sleep logs for a specific child.""" + sleeps = ( + self.db.query(Sleep) + .filter(Sleep.child_id == child_id) + .order_by(Sleep.start_time.desc()) + .all() + ) + return [self._to_dict(sleep) for sleep in sleeps] + + def get_by_user_id(self, user_id: int) -> list[dict]: + """Get all sleep logs for a specific user's children.""" + sleeps = ( + self.db.query(Sleep) + .join(Child) + .filter(Child.user_id == user_id) + .order_by(Sleep.start_time.desc()) + .all() + ) + return [self._to_dict(sleep) for sleep in sleeps] + + def update( + self, + sleep_id: int, + end_time: datetime | None = None, + ) -> dict | None: + """Update a sleep log entry.""" + sleep = self.db.query(Sleep).filter(Sleep.id == sleep_id).first() + if not sleep: + return None + + if end_time is not None: + sleep.end_time = end_time # type: ignore[assignment] + + self.db.commit() + self.db.refresh(sleep) + return self._to_dict(sleep) + + def delete(self, sleep_id: int) -> bool: + """Delete a sleep log entry.""" + sleep = self.db.query(Sleep).filter(Sleep.id == sleep_id).first() + if not sleep: + return False + + self.db.delete(sleep) + self.db.commit() + return True + + def _to_dict(self, sleep: Sleep) -> dict: + """Convert Sleep model to dictionary.""" + return { + "id": sleep.id, + "child_id": sleep.child_id, + "start_time": sleep.start_time, + "end_time": sleep.end_time, + "created_at": sleep.created_at, + } diff --git a/src/baby_monitor/routers/sleep.py b/src/baby_monitor/routers/sleep.py new file mode 100644 index 0000000..89cb9e6 --- /dev/null +++ b/src/baby_monitor/routers/sleep.py @@ -0,0 +1,151 @@ +"""Sleep log management router.""" + +from typing import Annotated +from fastapi import APIRouter, HTTPException, Depends + +from baby_monitor.models.sleep import ( + CreateSleepRequest, + UpdateSleepRequest, + SleepResponse, +) +from baby_monitor.routers.auth import verify_token +from baby_monitor.repositories.sleep.sqlite_sleep import SQLiteSleepRepository +from baby_monitor.repositories.dependencies.get_sleep_repository import ( + get_sleep_repository, +) +from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository +from baby_monitor.repositories.dependencies.get_child_repository import ( + get_child_repository, +) + +router = APIRouter(prefix="/api/sleep", tags=["sleep"]) + + +@router.post("", response_model=SleepResponse, status_code=201) +def create_sleep( + request: CreateSleepRequest, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse: + """Create a new sleep log entry.""" + # Verify the child belongs to the authenticated user + child = child_repo.get_by_id(request.child_id) + if not child: + raise HTTPException(status_code=404, detail="Child not found") + + if child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied to this child") + + sleep = sleep_repo.create( + child_id=request.child_id, + start_time=request.start_time, + ) + + return SleepResponse(**sleep) + + +@router.get("/active", response_model=SleepResponse | None) +def get_active_sleep( + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse | None: + """Get the current active sleep (where end_time is null) for the user.""" + sleeps = sleep_repo.get_by_user_id(user_id) + + # Find the first sleep with no end_time + for sleep in sleeps: + if sleep["end_time"] is None: + # Get child info to include in response + child = child_repo.get_by_id(sleep["child_id"]) + response_data = sleep.copy() + if child: + response_data["child_name"] = child["name"] + return SleepResponse(**response_data) + + return None + + +@router.get("", response_model=list[SleepResponse]) +def get_user_sleeps( + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], +) -> list[SleepResponse]: + """Get all sleep logs for the authenticated user's children.""" + sleeps = sleep_repo.get_by_user_id(user_id) + return [SleepResponse(**sleep) for sleep in sleeps] + + +@router.get("/{sleep_id}", response_model=SleepResponse) +def get_sleep( + sleep_id: int, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse: + """Get a specific sleep log by ID.""" + sleep = sleep_repo.get_by_id(sleep_id) + + if not sleep: + raise HTTPException(status_code=404, detail="Sleep log not found") + + # Verify the sleep belongs to user's child + child = child_repo.get_by_id(sleep["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + return SleepResponse(**sleep) + + +@router.put("/{sleep_id}", response_model=SleepResponse) +def update_sleep( + sleep_id: int, + request: UpdateSleepRequest, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> SleepResponse: + """Update an existing sleep log.""" + sleep = sleep_repo.get_by_id(sleep_id) + + if not sleep: + raise HTTPException(status_code=404, detail="Sleep log not found") + + # Verify the sleep belongs to user's child + child = child_repo.get_by_id(sleep["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + updated = sleep_repo.update( + sleep_id=sleep_id, + end_time=request.end_time, + ) + + if not updated: + raise HTTPException(status_code=500, detail="Failed to update sleep log") + + return SleepResponse(**updated) + + +@router.delete("/{sleep_id}", status_code=204) +def delete_sleep( + sleep_id: int, + user_id: Annotated[int, Depends(verify_token)], + sleep_repo: Annotated[SQLiteSleepRepository, Depends(get_sleep_repository)], + child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], +) -> None: + """Delete a sleep log.""" + sleep = sleep_repo.get_by_id(sleep_id) + + if not sleep: + raise HTTPException(status_code=404, detail="Sleep log not found") + + # Verify the sleep belongs to user's child + child = child_repo.get_by_id(sleep["child_id"]) + if not child or child["user_id"] != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + success = sleep_repo.delete(sleep_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete sleep log") diff --git a/src/baby_monitor/static/diapers.html b/src/baby_monitor/static/diapers.html index 1723dd9..1eaa498 100644 --- a/src/baby_monitor/static/diapers.html +++ b/src/baby_monitor/static/diapers.html @@ -323,6 +323,7 @@ + @@ -378,6 +379,10 @@ window.location.href = "/feedings.html"; }); + document.getElementById("menuSleep").addEventListener("click", () => { + window.location.href = "/sleep.html"; + }); + document.getElementById("menuLogout").addEventListener("click", () => { closeMenu(); logout(); @@ -428,7 +433,8 @@ } function onChildChange(event) { - selectedChildId = event.target.value === "all" ? null : parseInt(event.target.value); + selectedChildId = + event.target.value === "all" ? null : parseInt(event.target.value); displayDiaperChanges(allDiaperChanges); } @@ -450,31 +456,34 @@ const peePieData = preparePeePieChartData(diaperChanges); // Build child dropdown options - const childOptions = allChildren.map(child => - `` - ).join(''); + const childOptions = allChildren + .map( + (child) => + ``, + ) + .join(""); content.innerHTML = `
-

Diaper Changes Per Day (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? 's' : ''})

+

Diaper Changes Per Day (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? "s" : ""})

@@ -501,7 +510,7 @@ renderBarChart(barChartData); renderPoopPieChart(poopPieData); renderPeePieChart(peePieData); - + // Render diaper change list renderDiaperList(diaperChanges); } @@ -521,7 +530,9 @@ // Filter by selected child if applicable if (selectedChildId !== null) { - filteredChanges = filteredChanges.filter((dc) => dc.child_id === selectedChildId); + filteredChanges = filteredChanges.filter( + (dc) => dc.child_id === selectedChildId, + ); } // Sort by most recent first @@ -529,50 +540,62 @@ return new Date(b.change_time) - new Date(a.change_time); }); - const listHtml = filteredChanges.map((dc) => { - const changeTime = new Date(dc.change_time); - - const dateStr = changeTime.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - - const timeStr = changeTime.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - }); + const listHtml = filteredChanges + .map((dc) => { + const changeTime = new Date(dc.change_time); - // Build badges for poop and pee - let badges = []; - if (dc.poop_amount || dc.poop_color) { - const poopInfo = [dc.poop_amount, dc.poop_color].filter(Boolean).join(", "); - badges.push(`๐Ÿ’ฉ ${poopInfo}`); - } - if (dc.pee_amount || dc.pee_color) { - const peeInfo = [dc.pee_amount, dc.pee_color].filter(Boolean).join(", "); - badges.push(`๐Ÿ’ง ${peeInfo}`); - } - if (badges.length === 0) { - badges.push('Clean diaper'); - } + const dateStr = changeTime.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); - // Get child name - const child = allChildren.find(c => c.id === dc.child_id); - const childName = child ? child.name : "Unknown"; + const timeStr = changeTime.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); - return ` + // Build badges for poop and pee + let badges = []; + if (dc.poop_amount || dc.poop_color) { + const poopInfo = [dc.poop_amount, dc.poop_color] + .filter(Boolean) + .join(", "); + badges.push( + `๐Ÿ’ฉ ${poopInfo}`, + ); + } + if (dc.pee_amount || dc.pee_color) { + const peeInfo = [dc.pee_amount, dc.pee_color] + .filter(Boolean) + .join(", "); + badges.push( + `๐Ÿ’ง ${peeInfo}`, + ); + } + if (badges.length === 0) { + badges.push( + 'Clean diaper', + ); + } + + // Get child name + const child = allChildren.find((c) => c.id === dc.child_id); + const childName = child ? child.name : "Unknown"; + + return `
-
${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ''}
+
${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ""}
- ${badges.join('')} + ${badges.join("")}
`; - }).join(""); + }) + .join(""); const listContainer = `
@@ -587,7 +610,7 @@ `; const content = document.getElementById("content"); - content.insertAdjacentHTML('beforeend', listContainer); + content.insertAdjacentHTML("beforeend", listContainer); } function editDiaperChange(diaperChangeId) { @@ -612,7 +635,9 @@ // Filter by selected child if applicable if (selectedChildId !== null) { - recentChanges = recentChanges.filter((dc) => dc.child_id === selectedChildId); + recentChanges = recentChanges.filter( + (dc) => dc.child_id === selectedChildId, + ); } // Count poop amounts @@ -665,7 +690,9 @@ // Filter by selected child if applicable if (selectedChildId !== null) { - recentChanges = recentChanges.filter((dc) => dc.child_id === selectedChildId); + recentChanges = recentChanges.filter( + (dc) => dc.child_id === selectedChildId, + ); } // Count pee amounts @@ -713,7 +740,9 @@ // Filter by selected child if applicable let filteredChanges = diaperChanges; if (selectedChildId !== null) { - filteredChanges = diaperChanges.filter((dc) => dc.child_id === selectedChildId); + filteredChanges = diaperChanges.filter( + (dc) => dc.child_id === selectedChildId, + ); } // Create days based on currentDaysRange (including today) @@ -737,8 +766,12 @@ return changeDate >= date && changeDate < nextDay; }); - const poopCount = dayChanges.filter(dc => dc.poop_amount !== null).length; - const peeCount = dayChanges.filter(dc => dc.pee_amount !== null).length; + const poopCount = dayChanges.filter( + (dc) => dc.poop_amount !== null, + ).length; + const peeCount = dayChanges.filter( + (dc) => dc.pee_amount !== null, + ).length; poopData.push(poopCount); peeData.push(peeCount); @@ -749,7 +782,7 @@ function renderBarChart(chartData) { const ctx = document.getElementById("diaperChart").getContext("2d"); - + new Chart(ctx, { type: "bar", data: { @@ -842,21 +875,33 @@ function renderPoopPieChart(chartData) { const ctx = document.getElementById("poopPieChart").getContext("2d"); - + const colors = { - "Light": { bg: "rgba(139, 69, 19, 0.5)", border: "rgba(139, 69, 19, 1)" }, - "Medium": { bg: "rgba(139, 69, 19, 0.7)", border: "rgba(139, 69, 19, 1)" }, - "Heavy": { bg: "rgba(139, 69, 19, 0.9)", border: "rgba(139, 69, 19, 1)" }, - "No Poop": { bg: "rgba(200, 200, 200, 0.5)", border: "rgba(200, 200, 200, 1)" }, + Light: { + bg: "rgba(139, 69, 19, 0.5)", + border: "rgba(139, 69, 19, 1)", + }, + Medium: { + bg: "rgba(139, 69, 19, 0.7)", + border: "rgba(139, 69, 19, 1)", + }, + Heavy: { + bg: "rgba(139, 69, 19, 0.9)", + border: "rgba(139, 69, 19, 1)", + }, + "No Poop": { + bg: "rgba(200, 200, 200, 0.5)", + border: "rgba(200, 200, 200, 1)", + }, }; const backgroundColors = chartData.labels.map( - (label) => colors[label]?.bg || "rgba(200, 200, 200, 0.5)" + (label) => colors[label]?.bg || "rgba(200, 200, 200, 0.5)", ); const borderColors = chartData.labels.map( - (label) => colors[label]?.border || "rgba(200, 200, 200, 1)" + (label) => colors[label]?.border || "rgba(200, 200, 200, 1)", ); - + new Chart(ctx, { type: "pie", data: { @@ -895,8 +940,12 @@ 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; + 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}%)`; }, }, @@ -908,21 +957,33 @@ function renderPeePieChart(chartData) { const ctx = document.getElementById("peePieChart").getContext("2d"); - + const colors = { - "Light": { bg: "rgba(255, 215, 0, 0.5)", border: "rgba(255, 215, 0, 1)" }, - "Medium": { bg: "rgba(255, 215, 0, 0.7)", border: "rgba(255, 215, 0, 1)" }, - "Heavy": { bg: "rgba(255, 215, 0, 0.9)", border: "rgba(255, 215, 0, 1)" }, - "No Pee": { bg: "rgba(200, 200, 200, 0.5)", border: "rgba(200, 200, 200, 1)" }, + Light: { + bg: "rgba(255, 215, 0, 0.5)", + border: "rgba(255, 215, 0, 1)", + }, + Medium: { + bg: "rgba(255, 215, 0, 0.7)", + border: "rgba(255, 215, 0, 1)", + }, + Heavy: { + bg: "rgba(255, 215, 0, 0.9)", + border: "rgba(255, 215, 0, 1)", + }, + "No Pee": { + bg: "rgba(200, 200, 200, 0.5)", + border: "rgba(200, 200, 200, 1)", + }, }; const backgroundColors = chartData.labels.map( - (label) => colors[label]?.bg || "rgba(200, 200, 200, 0.5)" + (label) => colors[label]?.bg || "rgba(200, 200, 200, 0.5)", ); const borderColors = chartData.labels.map( - (label) => colors[label]?.border || "rgba(200, 200, 200, 1)" + (label) => colors[label]?.border || "rgba(200, 200, 200, 1)", ); - + new Chart(ctx, { type: "pie", data: { @@ -961,8 +1022,12 @@ 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; + 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}%)`; }, }, diff --git a/src/baby_monitor/static/feedings.html b/src/baby_monitor/static/feedings.html index 7c9825d..58c8c9b 100644 --- a/src/baby_monitor/static/feedings.html +++ b/src/baby_monitor/static/feedings.html @@ -360,6 +360,7 @@ +
@@ -415,6 +416,10 @@ window.location.href = "/diapers.html"; }); + document.getElementById("menuSleep").addEventListener("click", () => { + window.location.href = "/sleep.html"; + }); + document.getElementById("menuLogout").addEventListener("click", () => { closeMenu(); logout(); @@ -465,7 +470,8 @@ } function onChildChange(event) { - selectedChildId = event.target.value === "all" ? null : parseInt(event.target.value); + selectedChildId = + event.target.value === "all" ? null : parseInt(event.target.value); displayFeedings(allFeedings); } @@ -487,31 +493,34 @@ const durationChartData = prepareDurationChartData(feedings); // Build child dropdown options - const childOptions = allChildren.map(child => - `` - ).join(''); + const childOptions = allChildren + .map( + (child) => + ``, + ) + .join(""); content.innerHTML = `
-

Feedings Per Day (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? 's' : ''})

+

Feedings Per Day (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? "s" : ""})

@@ -538,7 +547,7 @@ renderBarChart(barChartData); renderPieChart(pieChartData); renderDurationChart(durationChartData); - + // Render feeding list renderFeedingList(feedings); } @@ -558,7 +567,9 @@ // Filter by selected child if applicable if (selectedChildId !== null) { - filteredFeedings = filteredFeedings.filter((f) => f.child_id === selectedChildId); + filteredFeedings = filteredFeedings.filter( + (f) => f.child_id === selectedChildId, + ); } // Sort by most recent first @@ -566,45 +577,49 @@ 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 listHtml = filteredFeedings + .map((feeding) => { + const startTime = new Date(feeding.start_time); + const endTime = feeding.end_time + ? new Date(feeding.end_time) + : null; - const duration = endTime - ? Math.round((endTime - startTime) / 60000) - : null; + const dateStr = startTime.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); - const typeClass = feeding.feeding_type.replace("_", "-"); - const typeLabel = formatFeedingType(feeding.feeding_type); + const timeStr = startTime.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }); - // Get child name - const child = allChildren.find(c => c.id === feeding.child_id); - const childName = child ? child.name : "Unknown"; + const duration = endTime + ? Math.round((endTime - startTime) / 60000) + : null; - return ` + 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 `
-
${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ''}
+
${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ""}
${typeLabel} - ${duration !== null ? `${duration} minutes` : 'Ongoing'} + ${duration !== null ? `${duration} minutes` : "Ongoing"}
`; - }).join(""); + }) + .join(""); const listContainer = `
@@ -619,7 +634,7 @@ `; const content = document.getElementById("content"); - content.insertAdjacentHTML('beforeend', listContainer); + content.insertAdjacentHTML("beforeend", listContainer); } function formatFeedingType(type) { @@ -655,7 +670,9 @@ // Filter by selected child if applicable if (selectedChildId !== null) { - recentFeedings = recentFeedings.filter((f) => f.child_id === selectedChildId); + recentFeedings = recentFeedings.filter( + (f) => f.child_id === selectedChildId, + ); } const typeCounts = { @@ -703,7 +720,9 @@ // Filter by selected child if applicable if (selectedChildId !== null) { - completedFeedings = completedFeedings.filter((f) => f.child_id === selectedChildId); + completedFeedings = completedFeedings.filter( + (f) => f.child_id === selectedChildId, + ); } // Calculate durations in minutes @@ -716,14 +735,14 @@ // 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; @@ -733,7 +752,7 @@ durations.forEach((duration) => { const binIndex = Math.floor(duration / 5) * 5; const label = `${binIndex}-${binIndex + 5}`; - + if (buckets[label] !== undefined) { buckets[label]++; } else { @@ -755,7 +774,9 @@ // Filter by selected child if applicable let filteredFeedings = feedings; if (selectedChildId !== null) { - filteredFeedings = feedings.filter((f) => f.child_id === selectedChildId); + filteredFeedings = feedings.filter( + (f) => f.child_id === selectedChildId, + ); } // Create days based on currentDaysRange (including today) @@ -787,7 +808,7 @@ function renderBarChart(chartData) { const ctx = document.getElementById("feedingsChart").getContext("2d"); - + new Chart(ctx, { type: "bar", data: { @@ -865,7 +886,7 @@ function renderPieChart(chartData) { const ctx = document.getElementById("pieChart").getContext("2d"); - + // Color mapping for each type const colorMap = { "Left Breast": { @@ -876,7 +897,7 @@ background: "rgba(118, 75, 162, 0.8)", border: "rgba(118, 75, 162, 1)", }, - "Bottle": { + Bottle: { background: "rgba(255, 159, 64, 0.8)", border: "rgba(255, 159, 64, 1)", }, @@ -884,12 +905,12 @@ // Generate colors based on labels present const backgroundColors = chartData.labels.map( - (label) => colorMap[label]?.background || "rgba(200, 200, 200, 0.8)" + (label) => colorMap[label]?.background || "rgba(200, 200, 200, 0.8)", ); const borderColors = chartData.labels.map( - (label) => colorMap[label]?.border || "rgba(200, 200, 200, 1)" + (label) => colorMap[label]?.border || "rgba(200, 200, 200, 1)", ); - + new Chart(ctx, { type: "pie", data: { @@ -928,8 +949,12 @@ 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; + 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}%)`; }, }, @@ -941,7 +966,7 @@ function renderDurationChart(chartData) { const ctx = document.getElementById("durationChart").getContext("2d"); - + new Chart(ctx, { type: "bar", data: { diff --git a/src/baby_monitor/static/index.html b/src/baby_monitor/static/index.html index 65932fd..6c8639b 100644 --- a/src/baby_monitor/static/index.html +++ b/src/baby_monitor/static/index.html @@ -203,6 +203,33 @@ .cancel-button:hover { background-color: #5a6268; } + .sleep-button { + width: 100%; + padding: 2rem; + font-size: 1.5rem; + margin-bottom: 1.5rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 8px; + transition: + transform 0.2s, + box-shadow 0.2s; + } + .sleep-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + } + .sleep-button.active { + background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); + } + .sleep-button.active:hover { + background: linear-gradient(135deg, #00f2fe 0%, #4facfe 100%); + } + .sleep-info { + font-size: 0.9rem; + margin-top: 0.5rem; + opacity: 0.9; + } @@ -218,6 +245,7 @@ +
@@ -261,6 +289,31 @@
+ + + + + +