17 Commits
Author SHA1 Message Date
brian 49242498e0 Merge pull request 'align-index-page' (#25) from align-index-page into main
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 17s
Build and Push Docker Image / build-and-push (push) Successful in 26s
Reviewed-on: #25
2025-11-11 21:34:48 +01:00
Brian Bjarke Jensen 8c93e62bef reverted style changes
Build and Push Docker Image / build-and-push (pull_request) Successful in 59s
Python Code Quality / python-code-quality (pull_request) Successful in 11s
Python Test / python-test (pull_request) Successful in 18s
2025-11-11 21:33:01 +01:00
Brian Bjarke Jensen 0fa011de8d shortened start period 2025-11-11 21:18:20 +01:00
Brian Bjarke Jensen 1f752eed5f aligned styling 2025-11-11 21:01:25 +01:00
brian 36854927a6 Merge pull request 'add-postgres-support' (#24) from add-postgres-support into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #24
2025-11-11 20:48:01 +01:00
Brian Bjarke Jensen a2b78f7e96 code quality fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 18s
2025-11-11 20:43:47 +01:00
Brian Bjarke Jensen 696de14e6b removed data volume, as it is not needed when postgres is used
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Failing after 10s
Python Test / python-test (pull_request) Successful in 18s
2025-11-11 20:39:46 +01:00
Brian Bjarke Jensen e7ed89e04e implemented postgres support in repositories 2025-11-11 18:52:19 +01:00
Brian Bjarke Jensen 65655db034 added postgres support 2025-11-11 15:16:24 +01:00
brian 7c602c8da7 Merge pull request 'improve overview graphs' (#22) from 24-hour-graphs-should-show-individual-hours into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 17s
Reviewed-on: #22
2025-11-11 14:55:59 +01:00
Brian Bjarke Jensen 752aee1148 corrected filter placement and barchart title
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m6s
Python Code Quality / python-code-quality (pull_request) Successful in 11s
Python Test / python-test (pull_request) Successful in 17s
2025-11-11 14:51:04 +01:00
Brian Bjarke Jensen 3285d496b4 changed barchart to stacked bars to show individual feeding durations 2025-11-11 14:50:50 +01:00
Brian Bjarke Jensen c745ab1b7a added skip add-child for new users to allow for adding a shared child 2025-11-11 14:39:59 +01:00
Brian Bjarke Jensen dea3b5d3b5 persisted selection of child and time range when switching between pages. Also added automatic total sleep time scaling on vertical axis 2025-11-11 14:29:43 +01:00
Brian Bjarke Jensen b1fae46f28 made stacked barchart to show individual sleep session lengths 2025-11-11 14:19:41 +01:00
Brian Bjarke Jensen 954e5a9f6f fixed bug with sleeping overview barchart 2025-11-11 13:58:04 +01:00
Brian Bjarke Jensen bb27d4437f changed overview graphs binning 2025-11-11 13:45:54 +01:00
45 changed files with 1194 additions and 467 deletions
+3 -1
View File
@@ -23,7 +23,9 @@ COPY pyproject.toml README.md ./
COPY src/ ./src/
# Install Python dependencies including all optional dependencies
# This makes the image self-contained and ready for any configuration
# This makes the image self-contained and ready for any configuration:
# - Redis support (redis extra)
# - PostgreSQL support (postgres extra)
RUN uv sync --no-dev --all-extras
# Add virtual environment to PATH
+21 -12
View File
@@ -81,13 +81,14 @@ uv run uvicorn src.baby_monitor.main:app --reload --host 0.0.0.0 --port 8000
### Environment Variables
| Variable | Default | Description |
| ---------------- | ------------ | ------------------------------------------- |
| `ENVIRONMENT` | `production` | Set to `development` to enable API docs |
| `ADMIN_PASSWORD` | _(required)_ | Admin user password |
| `ADMIN_USERNAME` | `admin` | Admin username |
| `DATA_DIR` | `/data` | Directory for SQLite database |
| `REDIS_URI` | _(optional)_ | Redis connection URI for distributed tokens |
| Variable | Default | Description |
| ---------------- | ------------ | ---------------------------------------------- |
| `ENVIRONMENT` | `production` | Set to `development` to enable API docs |
| `ADMIN_PASSWORD` | _(required)_ | Admin user password |
| `ADMIN_USERNAME` | `admin` | Admin username |
| `DATA_DIR` | `/data` | Directory for SQLite database (SQLite only) |
| `REDIS_URI` | _(optional)_ | Redis connection URI for distributed tokens |
| `POSTGRES_URI` | _(optional)_ | PostgreSQL connection URI for database storage |
### Storage Options
@@ -96,18 +97,26 @@ uv run uvicorn src.baby_monitor.main:app --reload --host 0.0.0.0 --port 8000
- Automatic setup, no configuration needed
- Data stored in `/data/baby_monitor.db`
- Perfect for single-server deployments
- Timezone-aware datetimes stored as naive UTC
**Redis (Optional)**
**Redis (Optional - Token Storage)**
```bash
# Enable Redis token storage
# Enable Redis token storage for distributed deployments
export REDIS_URI=redis://localhost:6379
```
**PostgreSQL (Future)**
**PostgreSQL (Optional - Database)**
- Repository interface ready
- Swap implementation in `dependencies.py`
```bash
# Enable PostgreSQL for production database
export POSTGRES_URI=postgresql://user:password@localhost:5432/baby_monitor
```
- Supports timezone-aware datetimes natively
- Recommended for production deployments
- Better performance for concurrent access
- Supports database migrations and backups
## 📁 Project Structure
+2 -5
View File
@@ -5,9 +5,6 @@ services:
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
# Persist database to local directory
- ./data:/data
environment:
# Set environment to production
- ENVIRONMENT=production
@@ -17,7 +14,7 @@ services:
# Use Redis for token storage
- REDIS_URI=redis://redis:6379/0
# Use PostgreSQL for database
- DATABASE_URL=postgresql://baby_monitor:securepassword@postgres:5432/baby_monitor_db
- POSTGRES_URI=postgresql://baby_monitor:securepassword@postgres:5432/baby_monitor_db
command: ["--host", "0.0.0.0", "--port", "8000"]
restart: unless-stopped
depends_on:
@@ -54,7 +51,7 @@ services:
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
start_period: 3s
volumes:
redis-data:
pgdata:
+4 -2
View File
@@ -19,9 +19,11 @@ class Child(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
birth_time = Column(DateTime, nullable=False)
birth_time = Column(DateTime(timezone=True), nullable=False)
birth_weight = Column(Float, nullable=False) # in grams
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at = Column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
def __repr__(self) -> str:
return f"<Child(id={self.id}, name='{self.name}')>"
+3 -1
View File
@@ -20,7 +20,9 @@ class ChildParent(Base):
id = Column(Integer, primary_key=True, index=True)
child_id = Column(Integer, ForeignKey("children.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at = Column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
def __repr__(self) -> str:
return (
+4 -2
View File
@@ -19,12 +19,14 @@ class DiaperChange(Base):
id = Column(Integer, primary_key=True, index=True)
child_id = Column(Integer, ForeignKey("children.id"), nullable=False)
change_time = Column(DateTime, nullable=False)
change_time = Column(DateTime(timezone=True), nullable=False)
poop_amount = Column(String, nullable=True) # stores PoopAmount enum
poop_color = Column(String, nullable=True) # stores PoopColor enum
pee_amount = Column(String, nullable=True) # stores PeeAmount enum
pee_color = Column(String, nullable=True) # stores PeeColor enum
created_at = Column(DateTime, default=datetime.now(UTC), nullable=False)
created_at = Column(
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
def __repr__(self) -> str:
return f"<DiaperChange(id={self.id}, child_id={self.child_id})>"
+5 -3
View File
@@ -19,10 +19,12 @@ class Feeding(Base):
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)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
feeding_type = Column(String, nullable=False) # stores FeedingType enum
created_at = Column(DateTime, default=datetime.now(UTC), nullable=False)
created_at = Column(
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
def __repr__(self) -> str:
return f"<Feeding(id={self.id}, child_id={self.child_id})>"
+6 -4
View File
@@ -1,8 +1,8 @@
"""Sleep database model."""
from typing import TYPE_CHECKING
from sqlalchemy import Column, Integer, DateTime, ForeignKey
from datetime import datetime, UTC
from sqlalchemy import Column, Integer, DateTime, ForeignKey
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
@@ -19,9 +19,11 @@ class Sleep(Base):
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)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True), default=datetime.now(UTC), nullable=False
)
def __repr__(self) -> str:
return f"<Sleep(id={self.id}, child_id={self.child_id})>"
+3 -1
View File
@@ -21,7 +21,9 @@ class User(Base):
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at = Column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
def __repr__(self) -> str:
return f"<User(id={self.id}, username='{self.username}')>"
@@ -1,17 +1,23 @@
"""SQLite implementation of child repository."""
"""Database-agnostic implementation of child repository.
Works with both SQLite and PostgreSQL.
"""
from datetime import datetime, UTC
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces.child_repository_interface import (
from baby_monitor.repositories.interfaces import (
ChildRepositoryInterface,
)
from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteChildRepository(ChildRepositoryInterface):
"""SQLite implementation for child data access."""
class DatabaseChildRepository(ChildRepositoryInterface):
"""Database implementation for child data access.
Works with both SQLite and PostgreSQL databases.
"""
def __init__(self, db: Session):
self.db = db
@@ -1,7 +1,13 @@
"""Child invitation repository implementations."""
from baby_monitor.repositories.child_invitation.sqlite_child_invitation import ( # noqa: E501
SQLiteChildInvitationRepository,
DatabaseChildInvitationRepository,
)
__all__ = ["SQLiteChildInvitationRepository"]
# Keep old name for backward compatibility
SQLiteChildInvitationRepository = DatabaseChildInvitationRepository
__all__ = [
"DatabaseChildInvitationRepository",
"SQLiteChildInvitationRepository",
]
@@ -1,20 +1,45 @@
"""SQLite implementation of child invitation repository."""
"""Database-agnostic implementation of child invitation repository.
Supports both SQLite and PostgreSQL:
- SQLite: stores datetimes as naive UTC
- PostgreSQL: stores datetimes as timezone-aware
"""
from datetime import datetime, UTC
from sqlalchemy.orm import Session
from baby_monitor.models.child_invitation import ChildInvitation
from baby_monitor.repositories.interfaces.child_invitation import (
from baby_monitor.repositories.interfaces import (
ChildInvitationRepositoryInterface,
)
class SQLiteChildInvitationRepository(ChildInvitationRepositoryInterface):
"""SQLite implementation of child invitation repository."""
class DatabaseChildInvitationRepository(ChildInvitationRepositoryInterface):
"""Database implementation of child invitation repository.
Works with both SQLite and PostgreSQL databases.
"""
def __init__(self, db: Session):
"""Initialize the repository with a database session."""
self.db = db
def _get_now_utc(self, reference_dt: datetime) -> datetime:
"""
Get current UTC time matching the timezone awareness of reference.
Args:
reference_dt: A datetime from DB to match timezone format
Returns:
Current UTC time (naive for SQLite, aware for PostgreSQL)
"""
now = datetime.now(UTC)
if reference_dt.tzinfo is None:
# SQLite: return naive UTC
return now.replace(tzinfo=None)
# PostgreSQL: return timezone-aware
return now
def create_invitation(
self,
code: str,
@@ -68,8 +93,8 @@ class SQLiteChildInvitationRepository(ChildInvitationRepositoryInterface):
if invitation.is_consumed:
return False
# Compare as naive UTC datetimes (SQLite stores without timezone)
now_utc = datetime.now(UTC).replace(tzinfo=None)
# Get current time matching database timezone format
now_utc = self._get_now_utc(invitation.expires_at)
if invitation.expires_at < now_utc:
return False
@@ -4,13 +4,16 @@ from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from baby_monitor.repositories.child_invitation.sqlite_child_invitation import ( # noqa: E501
SQLiteChildInvitationRepository,
DatabaseChildInvitationRepository,
)
from baby_monitor.repositories.interfaces import (
ChildInvitationRepositoryInterface,
)
from baby_monitor.repositories.dependencies.get_database import get_database
def get_child_invitation_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteChildInvitationRepository:
"""Get child invitation repository instance."""
return SQLiteChildInvitationRepository(db)
) -> ChildInvitationRepositoryInterface:
"""Get child invitation repository instance (works with any database)."""
return DatabaseChildInvitationRepository(db)
@@ -5,11 +5,14 @@ from fastapi import Depends
from sqlalchemy.orm import Session
from baby_monitor.repositories.dependencies.get_database import get_database
from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.child.sqlite_child import DatabaseChildRepository
from baby_monitor.repositories.interfaces.child_repository_interface import (
ChildRepositoryInterface,
)
def get_child_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteChildRepository:
"""Get child repository instance."""
return SQLiteChildRepository(db)
) -> ChildRepositoryInterface:
"""Get child repository instance (works with any database)."""
return DatabaseChildRepository(db)
@@ -10,18 +10,30 @@ from sqlalchemy.orm import declarative_base, sessionmaker, Session
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
# Database directory
# Database directory (used for SQLite)
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
# SQLite database URL
DATABASE_URL = f"sqlite:///{DATA_DIR}/baby_monitor.db"
# Check for PostgreSQL URI, otherwise use SQLite
POSTGRES_URI = os.getenv("POSTGRES_URI")
# Create engine with check_same_thread=False for SQLite
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False, # Set to True for SQL query logging
)
if POSTGRES_URI:
# Use PostgreSQL
DATABASE_URL = POSTGRES_URI
# PostgreSQL doesn't need special connect_args
engine = create_engine(
DATABASE_URL,
echo=False, # Set to True for SQL query logging
pool_pre_ping=True, # Verify connections before using them
)
else:
# Use SQLite (default)
DATABASE_URL = f"sqlite:///{DATA_DIR}/baby_monitor.db"
# Create engine with check_same_thread=False for SQLite
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
echo=False, # Set to True for SQL query logging
)
# Session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -33,7 +45,7 @@ Base: "type[DeclarativeBase]" = declarative_base()
def _ensure_admin_user() -> None:
"""Create or update admin user from environment variables."""
from baby_monitor.models.db.user import User
from baby_monitor.utils.password import hash_password
from baby_monitor.utils import hash_password
admin_username = os.getenv("ADMIN_USERNAME")
admin_password = os.getenv("ADMIN_PASSWORD")
@@ -78,15 +90,23 @@ def init_db() -> None:
# This must be done before create_all() is called
from baby_monitor.models.db.user import User # noqa: F401
from baby_monitor.models.db.child import Child # noqa: F401
from baby_monitor.models.db.child_parent import ChildParent # noqa: F401
from baby_monitor.models.db.child_parent import ( # noqa: F401
ChildParent,
)
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.diaper_change import ( # noqa: F401
DiaperChange,
)
from baby_monitor.models.db.sleep import Sleep # noqa: F401
from baby_monitor.models.invitation import Invitation # noqa: F401
from baby_monitor.models.child_invitation import ChildInvitation # noqa: F401
from baby_monitor.models.child_invitation import ( # noqa: F401
ChildInvitation,
)
# Ensure data directory exists (only needed for SQLite)
if not POSTGRES_URI:
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
# Create admin user if it doesn't exist
@@ -6,12 +6,15 @@ from sqlalchemy.orm import Session
from baby_monitor.repositories.dependencies.get_database import get_database
from baby_monitor.repositories.diaper_change.sqlite_diaper_change import (
SQLiteDiaperChangeRepository,
DatabaseDiaperChangeRepository,
)
from baby_monitor.repositories.interfaces.diaper_change_repository_interface import ( # noqa: E501
DiaperChangeRepositoryInterface,
)
def get_diaper_change_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteDiaperChangeRepository:
"""Get diaper change repository instance."""
return SQLiteDiaperChangeRepository(db)
) -> DiaperChangeRepositoryInterface:
"""Get diaper change repository instance (works with any database)."""
return DatabaseDiaperChangeRepository(db)
@@ -6,12 +6,15 @@ from sqlalchemy.orm import Session
from baby_monitor.repositories.dependencies.get_database import get_database
from baby_monitor.repositories.feeding.sqlite_feeding import (
SQLiteFeedingRepository,
DatabaseFeedingRepository,
)
from baby_monitor.repositories.interfaces.feeding_repository_interface import (
FeedingRepositoryInterface,
)
def get_feeding_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteFeedingRepository:
"""Get feeding repository instance."""
return SQLiteFeedingRepository(db)
) -> FeedingRepositoryInterface:
"""Get feeding repository instance (works with any database)."""
return DatabaseFeedingRepository(db)
@@ -6,13 +6,16 @@ from sqlalchemy.orm import Session
from baby_monitor.repositories.dependencies.get_database import get_database
from baby_monitor.repositories.invitation.sqlite_invitation import (
SQLiteInvitationRepository,
DatabaseInvitationRepository,
)
from baby_monitor.repositories.interfaces.invitation_repository_interface import (
InvitationRepositoryInterface,
)
def get_invitation_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteInvitationRepository:
) -> InvitationRepositoryInterface:
"""
Provide an invitation repository instance.
@@ -20,6 +23,6 @@ def get_invitation_repository(
db: Database session from dependency injection
Returns:
SQLiteInvitationRepository instance
InvitationRepositoryInterface instance (works with any database)
"""
return SQLiteInvitationRepository(db)
return DatabaseInvitationRepository(db)
@@ -5,11 +5,14 @@ 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
from baby_monitor.repositories.sleep.sqlite_sleep import DatabaseSleepRepository
from baby_monitor.repositories.interfaces.sleep_repository_interface import (
SleepRepositoryInterface,
)
def get_sleep_repository(
db: Annotated[Session, Depends(get_database)],
) -> SQLiteSleepRepository:
"""Get sleep repository instance."""
return SQLiteSleepRepository(db)
) -> SleepRepositoryInterface:
"""Get sleep repository instance (works with any database)."""
return DatabaseSleepRepository(db)
@@ -11,7 +11,7 @@ from baby_monitor.repositories.interfaces import (
UserRepositoryInterface,
)
from baby_monitor.repositories.user.sqlite_user import (
SQLiteUserRepository,
DatabaseUserRepository,
)
# Singleton instance
@@ -22,12 +22,12 @@ def get_user_repository(
db: Annotated[Session, Depends(get_database)],
) -> UserRepositoryInterface:
"""
Get user repository instance.
Get user repository instance (works with any database).
"""
global _user_repository
if _user_repository is None:
_user_repository = SQLiteUserRepository(db)
_user_repository = DatabaseUserRepository(db)
return _user_repository
@@ -1,9 +1,12 @@
"""SQLite implementation of diaper change repository."""
"""Database-agnostic implementation of diaper change repository.
Works with both SQLite and PostgreSQL.
"""
from datetime import datetime
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces.diaper_change_repository_interface import ( # noqa: E501
from baby_monitor.repositories.interfaces import ( # noqa: E501
DiaperChangeRepositoryInterface,
)
from baby_monitor.models.db.diaper_change import DiaperChange
@@ -11,8 +14,11 @@ from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteDiaperChangeRepository(DiaperChangeRepositoryInterface):
"""SQLite implementation for diaper change log data access."""
class DatabaseDiaperChangeRepository(DiaperChangeRepositoryInterface):
"""Database implementation for diaper change log data access.
Works with both SQLite and PostgreSQL databases.
"""
def __init__(self, db: Session):
self.db = db
@@ -1,9 +1,12 @@
"""SQLite implementation of feeding repository."""
"""Database-agnostic implementation of feeding repository.
Works with both SQLite and PostgreSQL.
"""
from datetime import datetime
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces.feeding_repository_interface import (
from baby_monitor.repositories.interfaces import (
FeedingRepositoryInterface,
)
from baby_monitor.models.db.feeding import Feeding
@@ -11,8 +14,11 @@ from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteFeedingRepository(FeedingRepositoryInterface):
"""SQLite implementation for feeding log data access."""
class DatabaseFeedingRepository(FeedingRepositoryInterface):
"""Database implementation for feeding log data access.
Works with both SQLite and PostgreSQL databases.
"""
def __init__(self, db: Session):
self.db = db
@@ -1,6 +1,6 @@
"""Repository interfaces package."""
from .child_invitation import (
from .child_invitation_interface import (
ChildInvitationRepositoryInterface,
)
from .credentials_repository_interface import (
@@ -1,7 +1,10 @@
"""Invitation repository implementations."""
from baby_monitor.repositories.invitation.sqlite_invitation import (
SQLiteInvitationRepository,
DatabaseInvitationRepository,
)
__all__ = ["SQLiteInvitationRepository"]
# Keep old name for backward compatibility
SQLiteInvitationRepository = DatabaseInvitationRepository
__all__ = ["DatabaseInvitationRepository", "SQLiteInvitationRepository"]
@@ -1,25 +1,49 @@
"""SQLite implementation of invitation repository.
"""Database-agnostic implementation of invitation repository.
Note: SQLite does not support timezone-aware datetimes natively.
All datetimes are stored as naive UTC and converted at the application layer.
Supports both SQLite and PostgreSQL:
- SQLite: stores datetimes as naive UTC
- PostgreSQL: stores datetimes as timezone-aware
The repository automatically adapts by checking the timezone awareness
of datetimes returned from the database.
"""
from datetime import datetime, UTC
from sqlalchemy.orm import Session
from baby_monitor.models.invitation import Invitation
from baby_monitor.repositories.interfaces.invitation_repository_interface import (
from baby_monitor.repositories.interfaces import (
InvitationRepositoryInterface,
)
class SQLiteInvitationRepository(InvitationRepositoryInterface):
"""SQLite implementation for managing invitation tokens."""
class DatabaseInvitationRepository(InvitationRepositoryInterface):
"""Database implementation for managing invitation tokens.
Works with both SQLite and PostgreSQL databases.
"""
def __init__(self, db: Session) -> None:
"""Initialize the repository with a database session."""
self.db = db
def _get_now_utc(self, reference_dt: datetime) -> datetime:
"""
Get current UTC time matching the timezone awareness of reference.
Args:
reference_dt: A datetime from DB to match timezone format
Returns:
Current UTC time (naive for SQLite, aware for PostgreSQL)
"""
now = datetime.now(UTC)
if reference_dt.tzinfo is None:
# SQLite: return naive UTC
return now.replace(tzinfo=None)
# PostgreSQL: return timezone-aware
return now
def create_invitation(
self, token: str, created_by_user_id: int, expires_at: datetime
) -> None:
@@ -63,8 +87,8 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
if not invitation:
return False
# Compare as naive UTC datetimes (SQLite stores without timezone)
now_utc = datetime.now(UTC).replace(tzinfo=None)
# Get current time matching database timezone format
now_utc = self._get_now_utc(invitation.expires_at)
if invitation.expires_at < now_utc:
return False
@@ -89,8 +113,8 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
if not invitation:
return False
# Compare as naive UTC datetimes (SQLite stores without timezone)
now_utc = datetime.now(UTC).replace(tzinfo=None)
# Get current time matching database timezone format
now_utc = self._get_now_utc(invitation.expires_at)
if invitation.expires_at < now_utc:
return False
@@ -107,6 +131,14 @@ class SQLiteInvitationRepository(InvitationRepositoryInterface):
def cleanup_expired(self) -> None:
"""Remove expired invitation tokens from storage."""
now_utc = datetime.now(UTC).replace(tzinfo=None)
# For cleanup, we need to get a sample to determine format
# Get any invitation to check timezone format
sample = self.db.query(Invitation).first()
if sample:
now_utc = self._get_now_utc(sample.expires_at)
else:
# No invitations, use naive UTC (safe default)
now_utc = datetime.now(UTC).replace(tzinfo=None)
self.db.query(Invitation).filter(Invitation.expires_at < now_utc).delete()
self.db.commit()
@@ -1,9 +1,12 @@
"""SQLite implementation of sleep repository."""
"""Database-agnostic implementation of sleep repository.
Works with both SQLite and PostgreSQL.
"""
from datetime import datetime
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces.sleep_repository_interface import (
from baby_monitor.repositories.interfaces import (
SleepRepositoryInterface,
)
from baby_monitor.models.db.sleep import Sleep
@@ -11,8 +14,11 @@ from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
class SQLiteSleepRepository(SleepRepositoryInterface):
"""SQLite implementation for sleep log data access."""
class DatabaseSleepRepository(SleepRepositoryInterface):
"""Database implementation for sleep log data access.
Works with both SQLite and PostgreSQL databases.
"""
def __init__(self, db: Session):
self.db = db
@@ -1,64 +0,0 @@
"""PostgreSQL user repository (future implementation)."""
from sqlalchemy.orm import Session
from baby_monitor.repositories.interfaces import UserRepositoryInterface
from baby_monitor.models.db.user import User
class PostgreSQLUserRepository(UserRepositoryInterface):
"""
PostgreSQL-based user repository.
This implementation uses the same SQLAlchemy models as SQLite,
just with a different database engine.
To use this implementation:
1. Add psycopg2-binary dependency
2. Update DATABASE_URL in database.py:
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://user:password@localhost:5432/baby_monitor"
)
3. Replace SQLiteUserRepository with this in dependencies
"""
def __init__(self, db: Session):
self.db = db
def get_by_username(self, username: str) -> dict | None:
"""Get user by username."""
user = self.db.query(User).filter(User.username == username).first()
if user:
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
return None
def create(self, username: str, hashed_password: str) -> dict:
"""Create a new user."""
user = User(username=username, hashed_password=hashed_password)
self.db.add(user)
self.db.commit()
self.db.refresh(user)
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
def get_by_id(self, user_id: int) -> dict | None:
"""Get user by ID."""
user = self.db.query(User).filter(User.id == user_id).first()
if user:
return {
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"created_at": user.created_at,
}
return None
@@ -1,4 +1,7 @@
"""SQLite implementation of user repository."""
"""Database-agnostic implementation of user repository.
Works with both SQLite and PostgreSQL.
"""
from sqlalchemy.orm import Session
@@ -6,8 +9,11 @@ from baby_monitor.repositories.interfaces import UserRepositoryInterface
from baby_monitor.models.db.user import User
class SQLiteUserRepository(UserRepositoryInterface):
"""SQLite-based user repository."""
class DatabaseUserRepository(UserRepositoryInterface):
"""Database-based user repository.
Works with both SQLite and PostgreSQL databases.
"""
def __init__(self, db: Session):
self.db = db
+1 -11
View File
@@ -7,22 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from baby_monitor.routers.auth import verify_admin
from baby_monitor.repositories.dependencies.get_invitation_repository import (
from baby_monitor.repositories.dependencies import (
get_invitation_repository,
)
from baby_monitor.repositories.dependencies.get_user_repository import (
get_user_repository,
)
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.repositories.dependencies.get_feeding_repository import (
get_feeding_repository,
)
from baby_monitor.repositories.dependencies.get_diaper_change_repository import (
get_diaper_change_repository,
)
from baby_monitor.repositories.dependencies.get_sleep_repository import (
get_sleep_repository,
)
from baby_monitor.repositories.interfaces import (
+2 -2
View File
@@ -1,8 +1,8 @@
"""Authentication router for login and user management."""
import secrets
from fastapi import APIRouter, HTTPException, Depends, Header
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends, Header
from pydantic import BaseModel
from baby_monitor.models.auth import (
@@ -29,7 +29,7 @@ from baby_monitor.repositories.interfaces import (
DiaperChangeRepositoryInterface,
SleepRepositoryInterface,
)
from baby_monitor.utils.password import hash_password, verify_password
from baby_monitor.utils import hash_password, verify_password
router = APIRouter(prefix="/api", tags=["authentication"])
+17 -15
View File
@@ -8,14 +8,14 @@ from pydantic import BaseModel
from baby_monitor.models.child import CreateChildRequest, ChildResponse
from baby_monitor.routers.auth import verify_token
from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
from baby_monitor.repositories.interfaces import (
ChildRepositoryInterface,
ChildInvitationRepositoryInterface,
)
from baby_monitor.repositories.dependencies.get_child_invitation_repository import ( # noqa: E501
from baby_monitor.repositories.dependencies import (
get_child_repository,
get_child_invitation_repository,
)
from baby_monitor.repositories.interfaces import ChildInvitationRepositoryInterface # noqa: E501
router = APIRouter(prefix="/api/children", tags=["children"])
@@ -45,7 +45,7 @@ class RedeemChildInvitationRequest(BaseModel):
def create_child(
request: CreateChildRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> ChildResponse:
"""Create a new child for the authenticated user."""
child = child_repo.create(
@@ -61,7 +61,7 @@ def create_child(
@router.get("", response_model=list[ChildResponse])
def get_user_children(
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> list[ChildResponse]:
"""Get all children for the authenticated user."""
children = child_repo.get_by_user_id(user_id)
@@ -72,7 +72,7 @@ def get_user_children(
def get_child(
child_id: int,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> ChildResponse:
"""Get a specific child by ID."""
child = child_repo.get_by_id(child_id)
@@ -93,7 +93,7 @@ def update_child(
child_id: int,
request: CreateChildRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> ChildResponse:
"""Update a child's information."""
# First check if child exists and belongs to user
@@ -124,10 +124,11 @@ def update_child(
def create_child_invitation(
request: CreateChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], # noqa: E501
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
invitation_repo: Annotated[
ChildInvitationRepositoryInterface, Depends(get_child_invitation_repository)
], # noqa: E501
ChildInvitationRepositoryInterface,
Depends(get_child_invitation_repository),
],
) -> ChildInvitationResponse:
"""Create an invitation code to share child access with another user."""
# Verify the child exists and user has access
@@ -165,10 +166,11 @@ def create_child_invitation(
def redeem_child_invitation(
request: RedeemChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)], # noqa: E501
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
invitation_repo: Annotated[
ChildInvitationRepositoryInterface, Depends(get_child_invitation_repository)
], # noqa: E501
ChildInvitationRepositoryInterface,
Depends(get_child_invitation_repository),
],
) -> dict:
"""Redeem an invitation code to gain access to a child."""
# Verify the invitation code
+14 -16
View File
@@ -9,17 +9,15 @@ from baby_monitor.models.diaper_change import (
DiaperChangeResponse,
)
from baby_monitor.routers.auth import verify_token
from baby_monitor.repositories.diaper_change.sqlite_diaper_change import (
SQLiteDiaperChangeRepository,
from baby_monitor.repositories.interfaces import (
DiaperChangeRepositoryInterface,
ChildRepositoryInterface,
)
from baby_monitor.repositories.dependencies.get_diaper_change_repository import ( # noqa: E501
from baby_monitor.repositories.dependencies import (
get_diaper_change_repository,
)
from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.utils.access_control import verify_child_access
from baby_monitor.utils.verify_child_access import verify_child_access
router = APIRouter(prefix="/api/diaper-changes", tags=["diaper-changes"])
@@ -29,9 +27,9 @@ def create_diaper_change(
request: CreateDiaperChangeRequest,
user_id: Annotated[int, Depends(verify_token)],
diaper_repo: Annotated[
SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository)
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> DiaperChangeResponse:
"""Create a new diaper change log entry."""
# Verify the child belongs to the authenticated user
@@ -53,7 +51,7 @@ def create_diaper_change(
def get_user_diaper_changes(
user_id: Annotated[int, Depends(verify_token)],
diaper_repo: Annotated[
SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository)
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
],
) -> list[DiaperChangeResponse]:
"""Get all diaper change logs for the authenticated user's children."""
@@ -66,9 +64,9 @@ def get_diaper_change(
diaper_change_id: int,
user_id: Annotated[int, Depends(verify_token)],
diaper_repo: Annotated[
SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository)
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> DiaperChangeResponse:
"""Get a specific diaper change log by ID."""
diaper_change = diaper_repo.get_by_id(diaper_change_id)
@@ -88,9 +86,9 @@ def update_diaper_change(
request: UpdateDiaperChangeRequest,
user_id: Annotated[int, Depends(verify_token)],
diaper_repo: Annotated[
SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository)
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> DiaperChangeResponse:
"""Update an existing diaper change log."""
diaper_change = diaper_repo.get_by_id(diaper_change_id)
@@ -123,9 +121,9 @@ def delete_diaper_change(
diaper_change_id: int,
user_id: Annotated[int, Depends(verify_token)],
diaper_repo: Annotated[
SQLiteDiaperChangeRepository, Depends(get_diaper_change_repository)
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> None:
"""Delete a diaper change log."""
diaper_change = diaper_repo.get_by_id(diaper_change_id)
+30 -19
View File
@@ -9,17 +9,15 @@ from baby_monitor.models.feeding import (
FeedingResponse,
)
from baby_monitor.routers.auth import verify_token
from baby_monitor.repositories.feeding.sqlite_feeding import (
SQLiteFeedingRepository,
from baby_monitor.repositories.interfaces import (
ChildRepositoryInterface,
FeedingRepositoryInterface,
)
from baby_monitor.repositories.dependencies.get_feeding_repository import (
from baby_monitor.repositories.dependencies import (
get_feeding_repository,
)
from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
get_child_repository,
)
from baby_monitor.utils.access_control import verify_child_access
from baby_monitor.utils import verify_child_access
router = APIRouter(prefix="/api/feedings", tags=["feedings"])
@@ -28,8 +26,10 @@ router = APIRouter(prefix="/api/feedings", tags=["feedings"])
def create_feeding(
request: CreateFeedingRequest,
user_id: Annotated[int, Depends(verify_token)],
feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> FeedingResponse:
"""Create a new feeding log entry."""
# Verify the child belongs to the authenticated user
@@ -48,8 +48,10 @@ def create_feeding(
@router.get("/active", response_model=FeedingResponse | None)
def get_active_feeding(
user_id: Annotated[int, Depends(verify_token)],
feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> FeedingResponse | None:
"""Get the current active feeding (where end_time is null) for the user."""
feedings = feeding_repo.get_by_user_id(user_id)
@@ -70,7 +72,9 @@ def get_active_feeding(
@router.get("", response_model=list[FeedingResponse])
def get_user_feedings(
user_id: Annotated[int, Depends(verify_token)],
feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)],
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
],
) -> list[FeedingResponse]:
"""Get all feeding logs for the authenticated user's children."""
feedings = feeding_repo.get_by_user_id(user_id)
@@ -81,8 +85,10 @@ def get_user_feedings(
def get_feeding(
feeding_id: int,
user_id: Annotated[int, Depends(verify_token)],
feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> FeedingResponse:
"""Get a specific feeding log by ID."""
feeding = feeding_repo.get_by_id(feeding_id)
@@ -101,8 +107,10 @@ def update_feeding(
feeding_id: int,
request: UpdateFeedingRequest,
user_id: Annotated[int, Depends(verify_token)],
feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> FeedingResponse:
"""Update a feeding log entry."""
feeding = feeding_repo.get_by_id(feeding_id)
@@ -114,11 +122,12 @@ def update_feeding(
verify_child_access(child_repo, feeding["child_id"], user_id)
# Update the feeding
feeding_type_value = request.feeding_type.value if request.feeding_type else None
updated_feeding = feeding_repo.update(
feeding_id=feeding_id,
start_time=request.start_time,
end_time=request.end_time,
feeding_type=request.feeding_type.value if request.feeding_type else None,
feeding_type=feeding_type_value,
)
if not updated_feeding:
@@ -131,8 +140,10 @@ def update_feeding(
def delete_feeding(
feeding_id: int,
user_id: Annotated[int, Depends(verify_token)],
feeding_repo: Annotated[SQLiteFeedingRepository, Depends(get_feeding_repository)],
child_repo: Annotated[SQLiteChildRepository, Depends(get_child_repository)],
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> None:
"""Delete a feeding log entry."""
feeding = feeding_repo.get_by_id(feeding_id)
+17 -17
View File
@@ -9,15 +9,15 @@ from baby_monitor.models.sleep import (
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.interfaces import (
SleepRepositoryInterface,
ChildRepositoryInterface,
)
from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.dependencies.get_child_repository import (
from baby_monitor.repositories.dependencies import (
get_sleep_repository,
get_child_repository,
)
from baby_monitor.utils.access_control import verify_child_access
from baby_monitor.utils import verify_child_access
router = APIRouter(prefix="/api/sleep", tags=["sleep"])
@@ -26,8 +26,8 @@ router = APIRouter(prefix="/api/sleep", tags=["sleep"])
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)],
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> SleepResponse:
"""Create a new sleep log entry."""
# Verify the child belongs to the authenticated user
@@ -44,8 +44,8 @@ def create_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)],
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)],
child_repo: Annotated[ChildRepositoryInterface, 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)
@@ -66,7 +66,7 @@ def get_active_sleep(
@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)],
sleep_repo: Annotated[SleepRepositoryInterface, 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)
@@ -77,8 +77,8 @@ def get_user_sleeps(
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)],
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> SleepResponse:
"""Get a specific sleep log by ID."""
sleep = sleep_repo.get_by_id(sleep_id)
@@ -97,8 +97,8 @@ 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)],
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> SleepResponse:
"""Update an existing sleep log."""
sleep = sleep_repo.get_by_id(sleep_id)
@@ -124,8 +124,8 @@ def update_sleep(
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)],
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
) -> None:
"""Delete a sleep log."""
sleep = sleep_repo.get_by_id(sleep_id)
+27
View File
@@ -192,6 +192,23 @@
Cancel
</button>
</form>
<div
id="skipOption"
style="text-align: center; margin-top: 20px; display: none"
>
<p style="color: #666; font-size: 14px; margin-bottom: 10px">
Want to accept a child share invitation first?
</p>
<button
type="button"
class="button secondary"
onclick="skipToHome()"
style="margin: 0"
>
Skip for now
</button>
</div>
</div>
<script>
@@ -205,11 +222,17 @@
const urlParams = new URLSearchParams(window.location.search);
const childId = urlParams.get("id");
const isEditMode = !!childId;
const fromHome = urlParams.get("from") === "home"; // Check if redirected from home
const form = document.getElementById("addChildForm");
const messageDiv = document.getElementById("message");
const submitBtn = document.getElementById("submitBtn");
// Show skip option if user was redirected from home (has no children)
if (fromHome && !isEditMode) {
document.getElementById("skipOption").style.display = "block";
}
// Update page title and button text if editing
if (isEditMode) {
document.querySelector("h1").textContent = "✏️ Edit Child";
@@ -317,6 +340,10 @@
function goBack() {
window.location.href = "/";
}
function skipToHome() {
window.location.href = "/";
}
</script>
</body>
</html>
+131 -32
View File
@@ -16,9 +16,11 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
background-color: #f0f0f0;
min-height: 100vh;
padding: 20px;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.container {
@@ -26,8 +28,6 @@
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
@@ -72,6 +72,10 @@
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 40px;
@@ -114,6 +118,13 @@
.chart-wrapper {
position: relative;
height: 300px;
max-width: 100%;
margin: 0 auto;
}
.chart-wrapper canvas {
max-width: 100%;
max-height: 100%;
}
.charts-row {
@@ -267,9 +278,16 @@
let allDiaperChanges = [];
let allChildren = [];
let currentDaysRange = 7;
let currentDaysRange =
parseInt(localStorage.getItem("lastDiaperTimeRange")) || 7;
let selectedChildId = null; // null means "All Children"
// Restore last selected child from localStorage
const lastChildId = localStorage.getItem("lastDiaperChildFilter");
if (lastChildId && lastChildId !== "all") {
selectedChildId = parseInt(lastChildId);
}
// Load diaper changes data
async function loadDiaperChanges() {
try {
@@ -311,12 +329,14 @@
function onDaysRangeChange(event) {
currentDaysRange = parseInt(event.target.value);
localStorage.setItem("lastDiaperTimeRange", currentDaysRange);
displayDiaperChanges(allDiaperChanges);
}
function onChildChange(event) {
selectedChildId =
event.target.value === "all" ? null : parseInt(event.target.value);
localStorage.setItem("lastDiaperChildFilter", event.target.value);
displayDiaperChanges(allDiaperChanges);
}
@@ -365,7 +385,7 @@
</div>
<div class="chart-container">
<h2>Diaper Changes Per Day (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? "s" : ""})</h2>
<h2>Diaper Changes (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? "s" : ""})</h2>
<div class="chart-wrapper">
<canvas id="diaperChart"></canvas>
</div>
@@ -627,36 +647,115 @@
);
}
// 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);
// 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 dateStr = date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
labels.push(dateStr);
const periodEnd = new Date(periodStart);
periodEnd.setHours(periodEnd.getHours() + 3);
// Count poop and pee diapers for this day
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const startLabel = periodStart.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
const endLabel = periodEnd.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
labels.push(`${startLabel}-${endLabel}`);
const dayChanges = filteredChanges.filter((dc) => {
const changeDate = new Date(dc.change_time);
return changeDate >= date && changeDate < nextDay;
});
// Count poop and pee diapers for this 3-hour period
const periodChanges = filteredChanges.filter((dc) => {
const changeDate = new Date(dc.change_time);
return changeDate >= periodStart && changeDate < periodEnd;
});
const poopCount = dayChanges.filter(
(dc) => dc.poop_amount !== null,
).length;
const peeCount = dayChanges.filter(
(dc) => dc.pee_amount !== null,
).length;
const poopCount = periodChanges.filter(
(dc) => dc.poop_amount !== null,
).length;
const peeCount = periodChanges.filter(
(dc) => dc.pee_amount !== null,
).length;
poopData.push(poopCount);
peeData.push(peeCount);
poopData.push(poopCount);
peeData.push(peeCount);
}
} 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);
// Count poop and pee diapers for this 8-hour period
const periodChanges = filteredChanges.filter((dc) => {
const changeDate = new Date(dc.change_time);
return changeDate >= periodStart && changeDate < periodEnd;
});
const poopCount = periodChanges.filter(
(dc) => dc.poop_amount !== null,
).length;
const peeCount = periodChanges.filter(
(dc) => dc.pee_amount !== null,
).length;
poopData.push(poopCount);
peeData.push(peeCount);
}
} 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);
// Count poop and pee diapers for this day
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const dayChanges = filteredChanges.filter((dc) => {
const changeDate = new Date(dc.change_time);
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;
poopData.push(poopCount);
peeData.push(peeCount);
}
}
return { labels, poopData, peeData };
@@ -724,7 +823,7 @@
beginAtZero: true,
title: {
display: true,
text: "Number of Diapers",
text: "Number of Changes",
font: {
size: 13,
weight: "600",
+262 -70
View File
@@ -16,9 +16,11 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: #f0f0f0;
min-height: 100vh;
padding: 20px;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.container {
@@ -26,8 +28,6 @@
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
@@ -72,6 +72,10 @@
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 40px;
@@ -114,6 +118,13 @@
.chart-wrapper {
position: relative;
height: 300px;
max-width: 100%;
margin: 0 auto;
}
.chart-wrapper canvas {
max-width: 100%;
max-height: 100%;
}
.charts-row {
@@ -304,8 +315,16 @@
let allFeedings = [];
let allChildren = [];
let currentDaysRange = 7;
let currentDaysRange =
parseInt(localStorage.getItem("lastFeedingTimeRange")) || 7;
let selectedChildId = null; // null means "All Children"
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() {
@@ -348,12 +367,14 @@
function onDaysRangeChange(event) {
currentDaysRange = parseInt(event.target.value);
localStorage.setItem("lastFeedingTimeRange", currentDaysRange);
displayFeedings(allFeedings);
}
function onChildChange(event) {
selectedChildId =
event.target.value === "all" ? null : parseInt(event.target.value);
localStorage.setItem("lastFeedingChildFilter", event.target.value);
displayFeedings(allFeedings);
}
@@ -402,7 +423,7 @@
</div>
<div class="chart-container">
<h2>Feedings Per Day (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? "s" : ""})</h2>
<h2>Total Feeding Time (Last ${currentDaysRange} Day${currentDaysRange !== 1 ? "s" : ""})</h2>
<div class="chart-wrapper">
<canvas id="feedingsChart"></canvas>
</div>
@@ -651,7 +672,8 @@
function prepareBarChartData(feedings) {
const now = new Date();
const labels = [];
const data = [];
const durations = []; // Array of arrays, each containing individual feeding durations
const feedingCounts = []; // For reference
// Filter by selected child if applicable
let filteredFeedings = feedings;
@@ -661,54 +683,248 @@
);
}
// 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);
// Only use completed feedings
const completedFeedings = filteredFeedings.filter((f) => f.end_time);
const dateStr = date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
labels.push(dateStr);
// 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);
// Count feedings for this day
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const periodEnd = new Date(periodStart);
periodEnd.setHours(periodEnd.getHours() + 3);
const count = filteredFeedings.filter((f) => {
const feedingDate = new Date(f.start_time);
return feedingDate >= date && feedingDate < nextDay;
}).length;
const startLabel = periodStart.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
const endLabel = periodEnd.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
labels.push(`${startLabel}-${endLabel}`);
data.push(count);
// 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, data };
return { labels, durations, feedingCounts };
}
function renderBarChart(chartData) {
const ctx = document.getElementById("feedingsChart").getContext("2d");
new Chart(ctx, {
// 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: [
{
label: "Feedings",
data: chartData.data,
backgroundColor: "rgba(102, 126, 234, 0.7)",
borderColor: "rgba(102, 126, 234, 1)",
borderWidth: 2,
borderRadius: 6,
},
],
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,
@@ -724,42 +940,18 @@
},
callbacks: {
label: function (context) {
const count = context.parsed.y;
return `${count} feeding${count !== 1 ? "s" : ""}`;
const minutes = Math.round(context.parsed.y);
return `${context.dataset.label}: ${minutes}m`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: "Number of Feedings",
font: {
size: 13,
weight: "600",
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" : ""})`;
},
},
ticks: {
stepSize: 1,
font: {
size: 12,
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
x: {
ticks: {
font: {
size: 12,
},
},
grid: {
display: false,
},
},
},
},
+27 -3
View File
@@ -264,7 +264,7 @@
.then((user) => {
// Initialize burger menu
initBurgerMenu({
includeHome: false,
includeHome: true,
});
// Check if user has any children
@@ -275,9 +275,9 @@
})
.then((response) => response.json())
.then((children) => {
// If no children, redirect to add-child page
// If no children, show welcome message with option to add
if (children.length === 0) {
window.location.href = "/add-child.html";
showNoChildrenMessage();
return;
}
@@ -290,6 +290,30 @@
});
}
function showNoChildrenMessage() {
const content = document.getElementById("content");
content.className = "";
content.innerHTML = `
<h1>👶 Welcome to Baby Monitor</h1>
<div style="text-align: center; padding: 40px 20px;">
<div style="font-size: 64px; margin-bottom: 20px;">🍼</div>
<h2 style="color: #333; margin-bottom: 15px;">No Children Added Yet</h2>
<p style="color: #666; margin-bottom: 30px; line-height: 1.6;">
You can add a child to start tracking or redeem a child share code<br>
from another user to access their child's information.
</p>
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<a href="/add-child.html" class="button" style="text-decoration: none; display: inline-block;">
Add Your Child
</a>
<a href="/settings.html" class="button secondary" style="text-decoration: none; display: inline-block;">
🔗 Redeem Share Code
</a>
</div>
</div>
`;
}
let activeFeeding = null;
let userChildren = [];
let lastFeeding = null;
+4 -4
View File
@@ -16,9 +16,11 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: #f0f0f0;
min-height: 100vh;
padding: 20px;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.container {
@@ -26,8 +28,6 @@
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 800px;
margin: 0 auto;
}
h1 {
+360 -82
View File
@@ -16,9 +16,11 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
background-color: #f0f0f0;
min-height: 100vh;
padding: 20px;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.container {
@@ -26,8 +28,6 @@
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
@@ -72,6 +72,10 @@
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 40px;
@@ -114,6 +118,13 @@
.chart-wrapper {
position: relative;
height: 300px;
max-width: 100%;
margin: 0 auto;
}
.chart-wrapper canvas {
max-width: 100%;
max-height: 100%;
}
.charts-row {
@@ -231,33 +242,31 @@
background: #3a9be8;
}
.filters {
display: flex;
gap: 15px;
.controls {
margin-bottom: 30px;
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 5px;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.filter-group label {
color: #666;
font-size: 14px;
.controls label {
font-weight: 600;
color: #333;
}
.filter-group select {
padding: 8px 12px;
.controls select {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
border-radius: 6px;
font-size: 14px;
background: white;
cursor: pointer;
background: white;
}
.controls select:focus {
outline: none;
border-color: #4facfe;
}
.badge {
@@ -280,24 +289,21 @@
<h1>😴 Sleep Tracking</h1>
<p class="subtitle">Monitor your baby's sleep patterns</p>
<div class="filters">
<div class="filter-group">
<label for="childFilter">Child</label>
<select id="childFilter">
<option value="">All Children</option>
</select>
</div>
<div class="filter-group">
<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 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">
@@ -339,6 +345,8 @@
document
.getElementById("timeRangeFilter")
.addEventListener("change", function () {
// Save selection to localStorage
localStorage.setItem("lastSleepTimeRange", this.value);
loadData();
});
@@ -374,6 +382,13 @@
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: {
@@ -480,7 +495,7 @@
</div>
<div class="chart-container">
<h2>Sleep Sessions per Day</h2>
<h2>Total Sleep Time (Last ${timeRange} Day${timeRange !== 1 ? "s" : ""})</h2>
<div class="chart-wrapper">
<canvas id="barChart"></canvas>
</div>
@@ -536,41 +551,143 @@
return `${hours}h ${mins}m`;
}
function prepareBarChartData(sleeps, timeRange) {
const days = {};
const today = new Date();
today.setHours(0, 0, 0, 0);
function prepareBarChartData(sleepSessions, timeRange) {
const now = new Date();
const labels = [];
const durations = []; // Array of arrays, each containing individual session durations
const sessionCounts = []; // For reference
// Initialize all days in range
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] = 0;
// 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);
}
}
// Count sleeps per day
sleeps.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)) {
days[dateStr]++;
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);
}
});
const labels = Object.keys(days).map((date) => {
const d = new Date(date);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
});
return {
labels,
data: Object.values(days),
};
return { labels, durations, sessionCounts };
}
function prepareDurationDistributionData(sleeps) {
@@ -599,30 +716,151 @@
};
}
let barChartInstance = null;
let durationChartInstance = null;
let avgDurationChartInstance = null;
function renderBarChart(chartData) {
const ctx = document.getElementById("barChart");
new Chart(ctx, {
// 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: [
{
label: "Sleep Sessions",
data: chartData.data,
backgroundColor: "rgba(79, 172, 254, 0.6)",
borderColor: "rgba(79, 172, 254, 1)",
borderWidth: 1,
},
],
datasets: finalDatasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
beginAtZero: true,
title: {
display: true,
text: useMinutes ? "Minutes" : "Hours",
},
ticks: {
stepSize: 1,
callback: function (value) {
if (useMinutes) {
return Math.round(value) + "m";
} else {
return value.toFixed(1) + "h";
}
},
},
},
},
@@ -630,6 +868,35 @@
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" : ""})`;
},
},
},
},
},
});
@@ -637,7 +904,13 @@
function renderDurationChart(chartData) {
const ctx = document.getElementById("durationChart");
new Chart(ctx, {
// Destroy existing chart if it exists
if (durationChartInstance) {
durationChartInstance.destroy();
}
durationChartInstance = new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
@@ -668,6 +941,11 @@
}
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);
@@ -710,7 +988,7 @@
);
const ctx = document.getElementById("avgDurationChart");
new Chart(ctx, {
avgDurationChartInstance = new Chart(ctx, {
type: "line",
data: {
labels,
+11
View File
@@ -0,0 +1,11 @@
"""Utility functions for the baby monitor application."""
from .hash_password import hash_password
from .verify_child_access import verify_child_access
from .verify_password import verify_password
__all__ = [
"verify_child_access",
"hash_password",
"verify_password",
]
+17
View File
@@ -0,0 +1,17 @@
"""Definition of hash_password function."""
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password to hash
Returns:
Hashed password as a string
"""
salt = bcrypt.gensalt()
hashed: bytes = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
@@ -1,11 +1,13 @@
"""Access control utility functions for verifying resource ownership."""
"""Definition of verify_child_access function."""
from fastapi import HTTPException
from baby_monitor.repositories.child.sqlite_child import SQLiteChildRepository
from baby_monitor.repositories.interfaces import (
ChildRepositoryInterface,
)
def verify_child_access(
child_repo: SQLiteChildRepository, child_id: int, user_id: int
child_repo: ChildRepositoryInterface, child_id: int, user_id: int
) -> None:
"""Verify that user has access to the child.
@@ -1,22 +1,8 @@
"""Password hashing utilities using bcrypt."""
"""Definition of verify_password function."""
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password using bcrypt.
Args:
password: Plain text password to hash
Returns:
Hashed password as a string
"""
salt = bcrypt.gensalt()
hashed: bytes = bcrypt.hashpw(password.encode("utf-8"), salt)
return hashed.decode("utf-8")
def verify_password(password: str, hashed_password: str) -> bool:
"""Verify a password against a hashed password.