67 Commits
Author SHA1 Message Date
brian 97fe5429ea Merge pull request 'add-dark-mode' (#41) from add-dark-mode into main
Python Code Quality / python-code-quality (push) Successful in 12s
Python Test / python-test (push) Successful in 19s
Build and Push Docker Image / build-and-push (push) Successful in 26s
Refresh Showcase Data / refresh-data (push) Successful in 17s
Reviewed-on: #41
2026-01-24 23:07:23 +01:00
Brian Bjarke Jensen b7d80e7f26 added support for dark mode
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m5s
Python Code Quality / python-code-quality (pull_request) Successful in 11s
Python Test / python-test (pull_request) Successful in 18s
2026-01-24 23:04:44 +01:00
Brian Bjarke Jensen d9948d271f changes startup waiting time 2026-01-24 23:04:40 +01:00
brian adb96b5b5f Merge pull request 'update package registry url' (#40) from update-package-registry-ip-address into main
Build and Push Docker Image / build-and-push (push) Successful in 27s
Python Code Quality / python-code-quality (push) Successful in 11s
Python Test / python-test (push) Successful in 19s
Refresh Showcase Data / refresh-data (push) Successful in 22s
Reviewed-on: #40
2026-01-14 00:13:26 +01:00
Brian Bjarke Jensen 8d3b6fa5a2 updated url
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m3s
Python Code Quality / python-code-quality (pull_request) Successful in 12s
Python Test / python-test (pull_request) Successful in 19s
2026-01-14 00:10:11 +01:00
brian 8c007aa6f3 Merge pull request 'show-most-recent-feeding-session' (#39) from show-most-recent-feeding-session into main
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 19s
Build and Push Docker Image / build-and-push (push) Successful in 26s
Refresh Showcase Data / refresh-data (push) Failing after 2m10s
Reviewed-on: #39
2025-12-03 21:41:12 +01:00
Brian Bjarke Jensen 63fc4b618c CQ fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m0s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 19s
2025-12-03 21:29:36 +01:00
Brian Bjarke Jensen 93eb1dc458 added feeding session calculation and displayed values 2025-12-03 21:29:04 +01:00
Brian Bjarke Jensen 5626408e2d vertically stacked recent activity boxes 2025-12-03 20:18:06 +01:00
brian fd1472c5bd Merge pull request 'add-sleep-entry-page' (#38) from add-sleep-entry-page into main
Build and Push Docker Image / build-and-push (push) Successful in 27s
Python Code Quality / python-code-quality (push) Successful in 11s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #38
2025-12-03 19:57:12 +01:00
Brian Bjarke Jensen 5b17508ade CQ fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m11s
Python Code Quality / python-code-quality (pull_request) Successful in 11s
Python Test / python-test (pull_request) Successful in 19s
2025-12-03 19:49:48 +01:00
Brian Bjarke Jensen 95c2bf6744 added peopulation of field values based on most recent entry 2025-12-03 19:45:03 +01:00
Brian Bjarke Jensen 2c4336a5e4 added edit button to entries 2025-12-03 19:40:07 +01:00
Brian Bjarke Jensen 32946e1165 added redirect to overview page when clicking most recent entry box 2025-12-03 19:39:48 +01:00
Brian Bjarke Jensen fdcfb3c8a5 feeding type dropdown now defaults to last entry feeding type 2025-12-03 19:23:26 +01:00
Brian Bjarke Jensen 1bac464268 streamlined logging workflow 2025-12-03 19:20:56 +01:00
brian 320696c57c Merge pull request 'added recent activity cards to home page' (#37) from add-most-recent-entries-to-home-page into main
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 18s
Build and Push Docker Image / build-and-push (push) Successful in 28s
Refresh Showcase Data / refresh-data (push) Successful in 17s
Reviewed-on: #37
2025-11-13 22:57:07 +01:00
Brian Bjarke Jensen e26c53456f fixed chart destruction and recreation
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 11s
Python Test / python-test (pull_request) Successful in 18s
2025-11-13 22:55:06 +01:00
Brian Bjarke Jensen 1eeb8ad148 added recent activity cards to home page
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-13 22:44:41 +01:00
brian 1d641288e5 Merge pull request 'Make login token last 8 hours' (#35) from make-login-token-last-8-hours into main
Build and Push Docker Image / build-and-push (push) Successful in 26s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #35
2025-11-13 14:49:10 +01:00
Brian Bjarke Jensen e58a58e766 centrally defined TTL and cleanup of imports
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-13 14:45:28 +01:00
brian e0bcda6e7c Merge pull request 'fixed updating charts with selected time range' (#34) from fix-sleep-overview-not-updating into main
Build and Push Docker Image / build-and-push (push) Successful in 26s
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #34
2025-11-13 14:12:13 +01:00
Brian Bjarke Jensen f4419f6a72 fixed updating charts with selected time range
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m7s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-13 14:10:09 +01:00
Brian Bjarke Jensen 6de91fa6a9 added env vars
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 18s
Build and Push Docker Image / build-and-push (push) Successful in 26s
Refresh Showcase Data / refresh-data (push) Successful in 18s
2025-11-12 18:24:35 +01:00
brian a1a914ee4d Merge pull request 'added showcase data, users and CI workflow to refresh data daily' (#33) from add-showcase-data-and-users into main
Build and Push Docker Image / build-and-push (push) Successful in 26s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #33
2025-11-12 18:20:06 +01:00
Brian Bjarke Jensen 824358f846 CQ fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 11s
Python Test / python-test (pull_request) Successful in 18s
2025-11-12 18:18:17 +01:00
Brian Bjarke Jensen cb20abfb85 added showcase data, users and CI workflow to refresh data daily
Build and Push Docker Image / build-and-push (pull_request) Successful in 25s
Python Code Quality / python-code-quality (pull_request) Failing after 9s
Python Test / python-test (pull_request) Successful in 17s
2025-11-12 18:14:28 +01:00
brian ca1e74154f Merge pull request 'fixed redirect' (#32) from fix-edit-child-cancel-redirect 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: #32
2025-11-12 17:34:41 +01:00
Brian Bjarke Jensen 4dd10240ce fixed redirect
Build and Push Docker Image / build-and-push (pull_request) Successful in 58s
Python Code Quality / python-code-quality (pull_request) Successful in 9s
Python Test / python-test (pull_request) Successful in 17s
2025-11-12 17:32:02 +01:00
brian 2a6d470a9a Merge pull request 'Added edit child functionality' (#31) from add-edit-child-functionality 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: #31
2025-11-12 17:28:48 +01:00
Brian Bjarke Jensen 4898cce969 added functionality to edit child and show shared status directly
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-12 17:23:32 +01:00
brian c7ef7b6f1f Merge pull request 'Show who children are shared with' (#30) from show-who-children-are-shared-with 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: #30
2025-11-12 17:08:30 +01:00
Brian Bjarke Jensen 80156c8909 CQ 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-12 17:01:40 +01:00
Brian Bjarke Jensen 05560d766a added shared parent info and edit child info modal 2025-11-12 17:01:06 +01:00
brian 1887de14e0 Merge pull request 'Improved single child UX' (#29) from improve-single-child-UX into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #29
2025-11-12 16:42:30 +01:00
Brian Bjarke Jensen 28a5df4f12 changed dropdown to read-only textfield when only 1 associated child and fixed redirects after manual entries
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-12 16:39:16 +01:00
brian 9d1ca55905 Merge pull request 'streamlined manual entry redirects back to related overview page' (#28) from ensure-manual-logs-go-back-to-overview-page into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #28
2025-11-12 15:58:31 +01:00
Brian Bjarke Jensen 5b8a19b2ad streamlined manual entry redirects back to related overview page
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-12 15:53:35 +01:00
brian 4f3bc36168 Merge pull request 'Support sliding login token expiration' (#27) from support-sliding-login-token-expiration into main
Build and Push Docker Image / build-and-push (push) Successful in 26s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #27
2025-11-12 15:43:01 +01:00
Brian Bjarke Jensen a302dc51c6 CQ fixes
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m0s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 18s
2025-11-12 15:34:10 +01:00
Brian Bjarke Jensen d46fd51aa0 added reset of TTL for login tokens when user is active
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 17s
2025-11-12 15:31:06 +01:00
brian 6e402cb1df Merge pull request 'added support for manual sleep logging' (#26) from support-manually-adding-sleep-entries into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 18s
Reviewed-on: #26
2025-11-12 15:27:51 +01:00
Brian Bjarke Jensen 1e93fb8783 added support for manual sleep logging
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 18s
2025-11-12 15:23:51 +01:00
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
brian 143c3a0323 Merge pull request 'added settings page' (#21) from add-user-settings-page into main
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 18s
Build and Push Docker Image / build-and-push (push) Successful in 25s
Reviewed-on: #21
2025-11-11 01:01:04 +01:00
Brian Bjarke Jensen d066c6d399 added settings page with ability to update password, delete user and CRUD and share abilities for associated children
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 00:58:55 +01:00
brian 0d27c47f39 Merge pull request 'updated admin page with more functionality, simplified burger menu and added many-to-many parent-child relationship' (#20) from expand-admin-page into main
Build and Push Docker Image / build-and-push (push) Successful in 25s
Python Code Quality / python-code-quality (push) Successful in 9s
Python Test / python-test (push) Successful in 17s
Reviewed-on: #20
2025-11-10 23:01:33 +01:00
Brian Bjarke Jensen 402b5898bb updated admin page with more functionality, simplified burger menu and added many-to-many parent-child relationship
Build and Push Docker Image / build-and-push (pull_request) Successful in 59s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2025-11-10 22:58:52 +01:00
brian df28af880a Merge pull request 'added sleep logging workflow and overview page' (#17) from support-logging-sleep into main
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 18s
Build and Push Docker Image / build-and-push (push) Successful in 26s
Reviewed-on: #17
2025-11-10 21:20:55 +01:00
Brian Bjarke Jensen 6c3d301027 formatting 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-10 21:19:13 +01:00
Brian Bjarke Jensen cd3db4fb86 added sleep logging workflow and overview page
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 17s
2025-11-10 21:00:03 +01:00
74 changed files with 7362 additions and 1082 deletions
@@ -0,0 +1,43 @@
name: Refresh Showcase Data
on:
schedule:
# Run daily at 3 AM UTC
- cron: "0 3 * * *"
workflow_dispatch: # Allow manual triggering
jobs:
refresh-data:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ vars.PYTHON_VERSION }}
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
uv sync --no-dev --all-extras
- name: Run showcase data seeding script
env:
ADMIN_USERNAME: ${{ secrets.ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
POSTGRES_URI: ${{ secrets.POSTGRES_URI }}
run: |
uv run python scripts/seed_showcase_data.py
- name: Notify on success
if: success()
run: echo "✅ Showcase data refreshed successfully"
- name: Notify on failure
if: failure()
run: echo "❌ Failed to refresh showcase data"
+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
+16 -7
View File
@@ -82,12 +82,13 @@ 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 |
| `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
+3 -6
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:
@@ -37,7 +34,7 @@ services:
interval: 10s
timeout: 5s
retries: 5
start_period: 1s
start_period: 3s
postgres:
image: postgres:14-alpine
environment:
@@ -54,7 +51,7 @@ services:
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
start_period: 3s
volumes:
redis-data:
pgdata:
+5 -1
View File
@@ -41,7 +41,7 @@ dev = [
[[tool.uv.index]]
name = "threadripper-proxpi-cache"
url = "http://10.0.0.2:5001/index/"
url = "http://10.184.20.2:5001/index/"
default = true
[tool.mypy]
@@ -71,3 +71,7 @@ markers = [
[[tool.mypy.overrides]]
module = "redis.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "showcase_data_definitions.*"
ignore_missing_imports = true
+185
View File
@@ -0,0 +1,185 @@
"""Seed test data into the database for demo purposes."""
import sys
from pathlib import Path
from datetime import datetime
# Add parent directory to path to import baby_monitor modules
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
# Import after path is set up
from baby_monitor.repositories.dependencies.get_database import ( # noqa: E402
get_database,
init_db,
)
from baby_monitor.repositories.user.sqlite_user import ( # noqa: E402
DatabaseUserRepository,
)
from baby_monitor.repositories.child.sqlite_child import ( # noqa: E402
DatabaseChildRepository,
)
from baby_monitor.repositories.feeding.sqlite_feeding import ( # noqa: E402
DatabaseFeedingRepository,
)
from baby_monitor.repositories.diaper_change.sqlite_diaper_change import ( # noqa: E402, E501
DatabaseDiaperChangeRepository,
)
from baby_monitor.repositories.sleep.sqlite_sleep import ( # noqa: E402
DatabaseSleepRepository,
)
from baby_monitor.routers.auth import hash_password # noqa: E402
from showcase_data_definitions import generate_showcase_data # noqa: E402
def seed_database(reference_time: datetime | None = None) -> None:
"""
Seed the database with test data.
This function is idempotent - it will check if test users exist
and either create them or update timestamps of existing data.
Args:
reference_time: The "now" time to use for generating timestamps.
Defaults to current time.
"""
# Initialize database
init_db()
db = next(get_database())
try:
# Initialize repositories
user_repo = DatabaseUserRepository(db)
child_repo = DatabaseChildRepository(db)
feeding_repo = DatabaseFeedingRepository(db)
diaper_repo = DatabaseDiaperChangeRepository(db)
sleep_repo = DatabaseSleepRepository(db)
# Generate showcase data
test_data = generate_showcase_data(reference_time)
print("Starting database seeding...")
print(f"Reference time: {test_data['reference_time']}")
# Create or get showcase users
user_ids = []
for user_data in test_data["users"]:
# Check if user exists
existing_user = user_repo.get_by_username(user_data["username"])
if existing_user:
print(f"✓ User '{user_data['username']}' already exists")
user_ids.append(existing_user["id"])
else:
# Create new user
hashed_pw = hash_password(user_data["password"])
user_id = user_repo.create(
username=user_data["username"],
hashed_password=hashed_pw,
)
user_ids.append(user_id["id"])
print(f"✓ Created user '{user_data['username']}'")
# Create or get shared child
child_data = test_data["child"]
# Check if child exists for first user
existing_children = child_repo.get_by_user_id(user_ids[0])
child_id: int | None = None
# Look for existing child with matching name
for child in existing_children:
if child["name"] == child_data["name"]:
child_id = child["id"]
print(f"✓ Child '{child_data['name']}' already exists")
break
if child_id is None:
# Create new child
created_child = child_repo.create(
name=child_data["name"],
birth_time=child_data["birth_time"],
birth_weight=child_data["birth_weight"],
user_id=user_ids[0],
)
child_id = created_child["id"]
print(f"✓ Created child '{child_data['name']}'")
# Ensure child_id is set
assert child_id is not None, "Child ID must be set at this point"
# Share child with second user if not already shared
parent_ids = child_repo.get_parent_ids(child_id)
if user_ids[1] not in parent_ids:
child_repo.add_parent(child_id, user_ids[1])
msg = f"✓ Shared child with '{test_data['users'][1]['username']}'"
print(msg)
# Clear existing logs to avoid duplicates
print("Clearing existing logs...")
# Get all logs for this child
existing_feedings = feeding_repo.get_by_child_id(child_id)
for feeding in existing_feedings:
feeding_repo.delete(feeding["id"])
existing_diapers = diaper_repo.get_by_child_id(child_id)
for diaper in existing_diapers:
diaper_repo.delete(diaper["id"])
existing_sleep = sleep_repo.get_by_child_id(child_id)
for sleep in existing_sleep:
sleep_repo.delete(sleep["id"])
print("✓ Cleared existing logs")
# Add feeding logs
print(f"Adding {len(test_data['feedings'])} feeding logs...")
for feeding in test_data["feedings"]:
feeding_repo.create(
child_id=child_id,
start_time=feeding["start_time"],
end_time=feeding["end_time"],
feeding_type=feeding["feeding_type"],
)
print(f"✓ Added {len(test_data['feedings'])} feedings")
# Add diaper changes
print(f"Adding {len(test_data['diapers'])} diaper changes...")
for diaper in test_data["diapers"]:
diaper_repo.create(
child_id=child_id,
change_time=diaper["time"],
poop_color=diaper.get("poop_color"),
poop_amount=diaper.get("poop_amount"),
pee_color=diaper.get("pee_color"),
pee_amount=diaper.get("pee_amount"),
)
print(f"✓ Added {len(test_data['diapers'])} diaper changes")
# Add sleep sessions
print(f"Adding {len(test_data['sleep'])} sleep sessions...")
for sleep in test_data["sleep"]:
sleep_repo.create(
child_id=child_id,
start_time=sleep["start_time"],
end_time=sleep["end_time"],
)
print(f"✓ Added {len(test_data['sleep'])} sleep sessions")
db.commit()
print("\n✅ Database seeding completed successfully!")
print("\nTest user credentials:")
print(f" Username: {test_data['users'][0]['username']}")
print(f" Password: {test_data['users'][0]['password']}")
print(" (Both users share the same password)")
except Exception as e:
db.rollback()
print(f"\n❌ Error seeding database: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
seed_database()
+278
View File
@@ -0,0 +1,278 @@
"""Definition of showcase data to be used for presenting the app."""
from datetime import datetime, timedelta
import random
# User credentials
SHOWCASE_USERS = [
{
"username": "user",
"password": "demo123", # Will be hashed when inserted
},
{
"username": "user2",
"password": "demo123", # Will be hashed when inserted
},
]
# Shared child (14 days old)
CHILD_AGE_DAYS = 14
def get_child_data(reference_time: datetime) -> dict:
"""Get child data with birth time relative to reference time."""
birth_time = reference_time - timedelta(days=CHILD_AGE_DAYS)
return {
"name": "Emma Rose",
"birth_time": birth_time,
"birth_weight": 3420, # grams (7.5 lbs)
}
def get_feeding_schedule(reference_time: datetime) -> list[dict]:
"""
Generate realistic feeding schedule for the last 14 days.
Newborns feed every 2-3 hours initially, gradually spacing out
to 3-4 hours.
"""
feedings: list[dict] = []
current_time = reference_time - timedelta(days=CHILD_AGE_DAYS)
end_time = reference_time
feeding_types = ["left_breast", "right_breast", "bottle"]
# Day-by-day feeding patterns
day_number = 0
while current_time < end_time:
age_in_days = day_number
# Newborns feed more frequently
if age_in_days < 3:
interval_hours = random.uniform(2, 2.5)
duration_minutes = random.randint(15, 25)
elif age_in_days < 7:
interval_hours = random.uniform(2.5, 3)
duration_minutes = random.randint(20, 30)
else:
interval_hours = random.uniform(3, 3.5)
duration_minutes = random.randint(20, 35)
# Alternate between breasts and occasional bottle
if random.random() < 0.2: # 20% bottle feeding
feeding_type = "bottle"
else:
# Alternate breasts
feeding_type = feeding_types[len(feedings) % 2]
start_time = current_time
end_time_feeding = start_time + timedelta(minutes=duration_minutes)
feedings.append(
{
"start_time": start_time,
"end_time": end_time_feeding,
"feeding_type": feeding_type,
}
)
current_time += timedelta(hours=interval_hours)
# Move to next day
prev_date = (current_time - timedelta(hours=interval_hours)).date()
if current_time.date() != prev_date:
day_number += 1
return feedings
def get_diaper_changes(reference_time: datetime) -> list[dict]:
"""
Generate realistic diaper changes for the last 14 days.
- First 2-3 days: Black/dark (meconium), minimal pee
- Days 3-5: Transition to yellow, more frequent
- Days 5+: Regular yellow stools, normal pee patterns
- 8-10 diaper changes per day initially, settling to 6-8
"""
diapers = []
current_time = reference_time - timedelta(days=CHILD_AGE_DAYS)
end_time = reference_time
day_number = 0
while current_time < end_time:
age_in_days = day_number
# Determine number of diaper changes per day based on age
if age_in_days < 3:
changes_per_day = random.randint(8, 10)
elif age_in_days < 7:
changes_per_day = random.randint(7, 9)
else:
changes_per_day = random.randint(6, 8)
# Distribute changes throughout the day
day_start = current_time.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start + timedelta(days=1)
for _ in range(changes_per_day):
# Random time during the day
random_seconds = random.randint(0, 86400)
change_time = day_start + timedelta(seconds=random_seconds)
if change_time >= end_time:
break
# Determine diaper contents
has_poop = random.random() < 0.4 # 40% have poop
# 90% have pee, or always if no poop
has_pee = random.random() < 0.9 or not has_poop
diaper = {
"time": change_time,
"has_poop": has_poop,
"has_pee": has_pee,
}
# Poop characteristics based on age
if has_poop:
if age_in_days < 2:
# Meconium phase
diaper["poop_color"] = "black"
diaper["poop_amount"] = "light"
elif age_in_days < 4:
# Transition phase
colors = ["black", "green", "yellow"]
diaper["poop_color"] = random.choice(colors)
amounts = ["light", "medium"]
diaper["poop_amount"] = random.choice(amounts)
else:
# Normal phase
colors = ["yellow", "yellow", "yellow", "green"]
diaper["poop_color"] = random.choice(colors)
# Amount increases with age
if age_in_days < 7:
amounts = ["light", "medium"]
diaper["poop_amount"] = random.choice(amounts)
else:
amounts = ["medium", "heavy"]
diaper["poop_amount"] = random.choice(amounts)
# Pee characteristics based on age
if has_pee:
if age_in_days < 2:
# Very light pee initially
diaper["pee_amount"] = "light"
diaper["pee_color"] = random.choice(["clear", "yellow"])
elif age_in_days < 5:
# Increasing output
diaper["pee_amount"] = random.choice(["light", "medium"])
diaper["pee_color"] = random.choice(["clear", "yellow"])
else:
# Normal output
if age_in_days < 10:
amounts = ["medium", "medium", "heavy"]
diaper["pee_amount"] = random.choice(amounts)
else:
amounts = ["medium", "heavy", "heavy"]
diaper["pee_amount"] = random.choice(amounts)
colors = ["clear", "yellow", "yellow"]
diaper["pee_color"] = random.choice(colors)
diapers.append(diaper)
day_number += 1
current_time = day_end
# Sort by time (type: ignore for mypy - we know time is datetime)
diapers.sort(
key=lambda x: x["time"] # type: ignore[arg-type,return-value]
)
return diapers
def get_sleep_sessions(reference_time: datetime) -> list[dict]:
"""
Generate realistic sleep patterns for the last 14 days.
Newborns sleep 16-18 hours per day in short bursts (30min - 4 hours).
Sleep gradually consolidates over time.
"""
sleep_sessions = []
current_time = reference_time - timedelta(days=CHILD_AGE_DAYS)
end_time = reference_time
day_number = 0
while current_time < end_time:
age_in_days = day_number
# Sleep patterns evolve with age
if age_in_days < 3:
# Very short, frequent naps
avg_wake_time = 1.5 # hours
sleep_duration_range = (30, 120) # minutes
elif age_in_days < 7:
# Slightly longer sleep periods
avg_wake_time = 2
sleep_duration_range = (45, 180)
else:
# More consolidated sleep
avg_wake_time = 2.5
sleep_duration_range = (60, 240)
# Random wake time with variation
wake_minutes = random.randint(int(avg_wake_time * 45), int(avg_wake_time * 75))
# Sleep start time
start_time = current_time + timedelta(minutes=wake_minutes)
if start_time >= end_time:
break
# Sleep duration
duration_minutes = random.randint(*sleep_duration_range)
# Longer sleep at night
if 20 <= start_time.hour or start_time.hour < 6:
duration_minutes = int(duration_minutes * 1.3)
end_time_sleep = start_time + timedelta(minutes=duration_minutes)
sleep_sessions.append(
{
"start_time": start_time,
"end_time": end_time_sleep,
}
)
current_time = end_time_sleep
birth_time = reference_time - timedelta(days=CHILD_AGE_DAYS)
day_number = (current_time - birth_time).days
return sleep_sessions
def generate_showcase_data(reference_time: datetime | None = None) -> dict:
"""
Generate all test data with timestamps relative to reference time.
Args:
reference_time: The "now" time to use as reference.
Defaults to current time.
Returns:
Dictionary containing all test data
"""
if reference_time is None:
reference_time = datetime.now()
return {
"users": SHOWCASE_USERS,
"child": get_child_data(reference_time),
"feedings": get_feeding_schedule(reference_time),
"diapers": get_diaper_changes(reference_time),
"sleep": get_sleep_sessions(reference_time),
"reference_time": reference_time,
}
+50
View File
@@ -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,54 @@ 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("/log-sleep.html", include_in_schema=False)
def serve_log_sleep() -> FileResponse:
"""Serve the log sleep page."""
return FileResponse(static_path / "log-sleep.html")
@app.get("/settings.html", include_in_schema=False)
def serve_settings() -> FileResponse:
"""Serve the settings page."""
return FileResponse(static_path / "settings.html")
@app.get("/menu.css", include_in_schema=False)
def serve_menu_css() -> FileResponse:
"""Serve the shared menu CSS."""
return FileResponse(static_path / "menu.css")
@app.get("/menu.js", include_in_schema=False)
def serve_menu_js() -> FileResponse:
"""Serve the shared menu JavaScript."""
return FileResponse(static_path / "menu.js")
@app.get("/dark-mode.css", include_in_schema=False)
def serve_dark_mode_css() -> FileResponse:
"""Serve the dark mode CSS."""
return FileResponse(static_path / "dark-mode.css")
@app.get("/theme-init.js", include_in_schema=False)
def serve_theme_init_js() -> FileResponse:
"""Serve the theme initialization JavaScript."""
return FileResponse(static_path / "theme-init.js")
@app.get("/chart-theme.js", include_in_schema=False)
def serve_chart_theme_js() -> FileResponse:
"""Serve the chart theming JavaScript."""
return FileResponse(static_path / "chart-theme.js")
@app.get("/api/")
def read_root(token: Annotated[str, Depends(verify_token)]) -> dict:
"""API root endpoint (requires authentication)."""
+2 -1
View File
@@ -19,5 +19,6 @@ class ChildResponse(BaseModel):
name: str
birth_time: datetime
birth_weight: float
user_id: int
created_at: datetime
parent_count: int | None = None
parent_usernames: list[str] | None = None
@@ -0,0 +1,41 @@
"""Child invitation model for sharing child access."""
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, Integer, String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase
else:
from baby_monitor.repositories.dependencies.get_database import Base
class ChildInvitation(Base):
"""Invitation code for sharing child access with other users."""
__tablename__ = "child_invitations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String, unique=True, nullable=False)
child_id: Mapped[int] = mapped_column(
Integer, ForeignKey("children.id"), nullable=False
)
created_by_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
is_consumed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
consumed_by_user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True
)
consumed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+6 -5
View File
@@ -1,7 +1,7 @@
"""Child database model."""
from typing import TYPE_CHECKING
from sqlalchemy import Column, Integer, String, DateTime, Float, ForeignKey
from sqlalchemy import Column, Integer, String, DateTime, Float
from datetime import datetime
if TYPE_CHECKING:
@@ -19,10 +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
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 f"<Child(id={self.id}, name='{self.name}', user_id={self.user_id})>"
return f"<Child(id={self.id}, name='{self.name}')>"
@@ -0,0 +1,31 @@
"""Child-Parent junction table for many-to-many relationship."""
from typing import TYPE_CHECKING
from sqlalchemy import Column, Integer, ForeignKey, DateTime
from datetime import datetime
if TYPE_CHECKING:
from sqlalchemy.orm import DeclarativeBase
Base = DeclarativeBase
else:
from baby_monitor.repositories.dependencies.get_database import Base
class ChildParent(Base):
"""Junction table linking children to their parents (users)."""
__tablename__ = "child_parents"
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(timezone=True), default=datetime.utcnow, nullable=False
)
def __repr__(self) -> str:
return (
f"<ChildParent(id={self.id}, "
f"child_id={self.child_id}, user_id={self.user_id})>"
)
+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})>"
+29
View File
@@ -0,0 +1,29 @@
"""Sleep database model."""
from typing import TYPE_CHECKING
from datetime import datetime, UTC
from sqlalchemy import Column, Integer, DateTime, ForeignKey
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(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}')>"
+29
View File
@@ -0,0 +1,29 @@
"""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
end_time: datetime | None = None
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
+10
View File
@@ -1,13 +1,23 @@
"""Repository implementations package."""
from baby_monitor.repositories.dependencies import (
get_child_repository,
get_credentials_repository,
get_diaper_change_repository,
get_feeding_repository,
get_invitation_repository,
get_sleep_repository,
get_token_repository,
get_user_repository,
)
__all__ = [
"get_child_repository",
"get_credentials_repository",
"get_diaper_change_repository",
"get_feeding_repository",
"get_invitation_repository",
"get_sleep_repository",
"get_token_repository",
"get_user_repository",
]
@@ -1,16 +1,23 @@
"""SQLite implementation of child repository."""
"""Database-agnostic implementation of child repository.
from datetime import datetime
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
@@ -18,23 +25,29 @@ class SQLiteChildRepository(ChildRepositoryInterface):
def create(
self, name: str, birth_time: datetime, birth_weight: float, user_id: int
) -> dict:
"""Create a new child record."""
"""Create a new child record and link to user."""
child = Child(
name=name,
birth_time=birth_time,
birth_weight=birth_weight,
user_id=user_id,
)
self.db.add(child)
self.db.commit()
self.db.refresh(child)
# Create the child-parent relationship
child_parent = ChildParent(
child_id=child.id,
user_id=user_id,
)
self.db.add(child_parent)
self.db.commit()
return {
"id": child.id,
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
@@ -49,13 +62,18 @@ class SQLiteChildRepository(ChildRepositoryInterface):
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
def get_by_user_id(self, user_id: int) -> list[dict]:
"""Get all children for a specific user."""
children = self.db.query(Child).filter(Child.user_id == user_id).all()
"""Get all children for a specific user via junction table."""
# Join Child and ChildParent tables
children = (
self.db.query(Child)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.user_id == user_id)
.all()
)
return [
{
@@ -63,12 +81,59 @@ class SQLiteChildRepository(ChildRepositoryInterface):
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
for child in children
]
def get_parent_ids(self, child_id: int) -> list[int]:
"""Get all parent user IDs for a specific child."""
parent_ids = (
self.db.query(ChildParent.user_id)
.filter(ChildParent.child_id == child_id)
.all()
)
return [user_id for (user_id,) in parent_ids]
def remove_parent(self, child_id: int, user_id: int) -> bool:
"""Remove a parent-child relationship."""
link = (
self.db.query(ChildParent)
.filter(
ChildParent.child_id == child_id,
ChildParent.user_id == user_id,
)
.first()
)
if link:
self.db.delete(link)
self.db.commit()
return True
return False
def add_parent(self, child_id: int, user_id: int) -> bool:
"""Add a parent-child relationship."""
# Check if relationship already exists
existing = (
self.db.query(ChildParent)
.filter(
ChildParent.child_id == child_id,
ChildParent.user_id == user_id,
)
.first()
)
if existing:
return False
link = ChildParent(
child_id=child_id,
user_id=user_id,
created_at=datetime.now(UTC),
)
self.db.add(link)
self.db.commit()
return True
def update(
self,
child_id: int,
@@ -96,16 +161,19 @@ class SQLiteChildRepository(ChildRepositoryInterface):
"name": child.name,
"birth_time": child.birth_time,
"birth_weight": child.birth_weight,
"user_id": child.user_id,
"created_at": child.created_at,
}
def delete(self, child_id: int) -> bool:
"""Delete a child record."""
"""Delete a child record and all parent relationships."""
child = self.db.query(Child).filter(Child.id == child_id).first()
if not child:
return False
# Delete all parent relationships first
self.db.query(ChildParent).filter(ChildParent.child_id == child_id).delete()
# Delete the child
self.db.delete(child)
self.db.commit()
return True
@@ -0,0 +1,13 @@
"""Child invitation repository implementations."""
from baby_monitor.repositories.child_invitation.sqlite_child_invitation import ( # noqa: E501
DatabaseChildInvitationRepository,
)
# Keep old name for backward compatibility
SQLiteChildInvitationRepository = DatabaseChildInvitationRepository
__all__ = [
"DatabaseChildInvitationRepository",
"SQLiteChildInvitationRepository",
]
@@ -0,0 +1,118 @@
"""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 import (
ChildInvitationRepositoryInterface,
)
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,
child_id: int,
created_by_user_id: int,
expires_at: datetime,
) -> int:
"""Create a new child invitation."""
invitation = ChildInvitation(
code=code,
child_id=child_id,
created_by_user_id=created_by_user_id,
created_at=datetime.now(UTC),
expires_at=expires_at,
is_consumed=False,
)
self.db.add(invitation)
self.db.commit()
self.db.refresh(invitation)
return invitation.id
def get_by_code(self, code: str) -> dict | None:
"""Get invitation by code."""
invitation = (
self.db.query(ChildInvitation).filter(ChildInvitation.code == code).first()
)
if not invitation:
return None
return {
"id": invitation.id,
"code": invitation.code,
"child_id": invitation.child_id,
"created_by_user_id": invitation.created_by_user_id,
"created_at": invitation.created_at,
"expires_at": invitation.expires_at,
"is_consumed": invitation.is_consumed,
"consumed_by_user_id": invitation.consumed_by_user_id,
"consumed_at": invitation.consumed_at,
}
def verify_invitation(self, code: str) -> bool:
"""Verify if an invitation code is valid and not expired."""
invitation = (
self.db.query(ChildInvitation).filter(ChildInvitation.code == code).first()
)
if not invitation:
return False
if invitation.is_consumed:
return False
# Get current time matching database timezone format
now_utc = self._get_now_utc(invitation.expires_at)
if invitation.expires_at < now_utc:
return False
return True
def consume_invitation(
self, code: str, consumed_by_user_id: int, consumed_at: datetime
) -> bool:
"""Mark invitation as consumed."""
invitation = (
self.db.query(ChildInvitation).filter(ChildInvitation.code == code).first()
)
if not invitation:
return False
invitation.is_consumed = True
invitation.consumed_by_user_id = consumed_by_user_id
invitation.consumed_at = consumed_at
self.db.commit()
return True
@@ -1,11 +1,23 @@
"""Dependency injection for repositories."""
from .get_child_invitation_repository import get_child_invitation_repository
from .get_child_repository import get_child_repository
from .get_credentials_repository import get_credentials_repository
from .get_diaper_change_repository import get_diaper_change_repository
from .get_feeding_repository import get_feeding_repository
from .get_invitation_repository import get_invitation_repository
from .get_sleep_repository import get_sleep_repository
from .get_token_repository import get_token_repository
from .get_user_repository import get_user_repository
__all__ = [
"get_user_repository",
"get_token_repository",
"get_child_invitation_repository",
"get_child_repository",
"get_credentials_repository",
"get_diaper_change_repository",
"get_feeding_repository",
"get_invitation_repository",
"get_sleep_repository",
"get_token_repository",
"get_user_repository",
]
@@ -0,0 +1,19 @@
"""Dependency for child invitation repository."""
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
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)],
) -> 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,12 +10,24 @@ 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")
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,
@@ -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,12 +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 ( # 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 ( # noqa: F401
ChildInvitation,
)
# Ensure data directory exists
# Ensure data directory exists (only needed for SQLite)
if not POSTGRES_URI:
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)
@@ -0,0 +1,18 @@
"""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 DatabaseSleepRepository
from baby_monitor.repositories.interfaces.sleep_repository_interface import (
SleepRepositoryInterface,
)
def get_sleep_repository(
db: Annotated[Session, Depends(get_database)],
) -> SleepRepositoryInterface:
"""Get sleep repository instance (works with any database)."""
return DatabaseSleepRepository(db)
@@ -6,7 +6,7 @@ import logging
from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface,
)
from baby_monitor.repositories.token.memory_token import (
from baby_monitor.repositories.token import (
InMemoryTokenRepository,
)
@@ -43,7 +43,7 @@ def _initialize_token_repository() -> TokenRepositoryInterface:
) from e
try:
from baby_monitor.repositories.token.redis_token import (
from baby_monitor.repositories.token import (
RedisTokenRepository,
)
@@ -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,17 +1,24 @@
"""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
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
@@ -65,8 +72,9 @@ class SQLiteDiaperChangeRepository(DiaperChangeRepositoryInterface):
"""Get all diaper change logs for a specific user's children."""
diaper_changes = (
self.db.query(DiaperChange)
.join(Child)
.filter(Child.user_id == user_id)
.join(Child, DiaperChange.child_id == Child.id)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.user_id == user_id)
.order_by(DiaperChange.change_time.desc())
.all()
)
@@ -1,17 +1,24 @@
"""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
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
@@ -57,8 +64,9 @@ class SQLiteFeedingRepository(FeedingRepositoryInterface):
"""Get all feeding logs for a specific user's children."""
feedings = (
self.db.query(Feeding)
.join(Child)
.filter(Child.user_id == user_id)
.join(Child, Feeding.child_id == Child.id)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.user_id == user_id)
.order_by(Feeding.start_time.desc())
.all()
)
@@ -1,5 +1,8 @@
"""Repository interfaces package."""
from .child_invitation_interface import (
ChildInvitationRepositoryInterface,
)
from .credentials_repository_interface import (
CredentialsRepositoryInterface,
)
@@ -12,10 +15,27 @@ from .token_repository_interface import (
from .user_repository_interface import (
UserRepositoryInterface,
)
from .child_repository_interface import (
ChildRepositoryInterface,
)
from .feeding_repository_interface import (
FeedingRepositoryInterface,
)
from .diaper_change_repository_interface import (
DiaperChangeRepositoryInterface,
)
from .sleep_repository_interface import (
SleepRepositoryInterface,
)
__all__ = [
"UserRepositoryInterface",
"TokenRepositoryInterface",
"ChildInvitationRepositoryInterface",
"ChildRepositoryInterface",
"CredentialsRepositoryInterface",
"DiaperChangeRepositoryInterface",
"FeedingRepositoryInterface",
"InvitationRepositoryInterface",
"SleepRepositoryInterface",
"TokenRepositoryInterface",
"UserRepositoryInterface",
]
@@ -0,0 +1,36 @@
"""Repository interface for child invitation operations."""
from abc import ABC, abstractmethod
from datetime import datetime
class ChildInvitationRepositoryInterface(ABC):
"""Interface for child invitation repository operations."""
@abstractmethod
def create_invitation(
self,
code: str,
child_id: int,
created_by_user_id: int,
expires_at: datetime,
) -> int:
"""Create a new child invitation."""
pass
@abstractmethod
def get_by_code(self, code: str) -> dict | None:
"""Get invitation by code."""
pass
@abstractmethod
def verify_invitation(self, code: str) -> bool:
"""Verify if an invitation code is valid and not expired."""
pass
@abstractmethod
def consume_invitation(
self, code: str, consumed_by_user_id: int, consumed_at: datetime
) -> bool:
"""Mark invitation as consumed."""
pass
@@ -34,3 +34,15 @@ class ChildRepositoryInterface(ABC):
@abstractmethod
def delete(self, child_id: int) -> bool:
"""Delete a child record."""
@abstractmethod
def get_parent_ids(self, child_id: int) -> list[int]:
"""Get all parent user IDs for a specific child."""
@abstractmethod
def remove_parent(self, child_id: int, user_id: int) -> bool:
"""Remove a parent-child relationship."""
@abstractmethod
def add_parent(self, child_id: int, user_id: int) -> bool:
"""Add a parent-child relationship."""
@@ -0,0 +1,41 @@
"""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,
end_time: datetime | None = None,
) -> 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."""
@@ -17,3 +17,15 @@ class UserRepositoryInterface(ABC):
@abstractmethod
def get_by_id(self, user_id: int) -> dict | None:
"""Get user by ID."""
@abstractmethod
def get_all(self) -> list[dict]:
"""Get all users."""
@abstractmethod
def delete(self, user_id: int) -> bool:
"""Delete a user by ID. Returns True if successful."""
@abstractmethod
def update_password(self, user_id: int, hashed_password: str) -> bool:
"""Update user password. Returns True if successful."""
@@ -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."""
# 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()
@@ -0,0 +1,108 @@
"""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 import (
SleepRepositoryInterface,
)
from baby_monitor.models.db.sleep import Sleep
from baby_monitor.models.db.child import Child
from baby_monitor.models.db.child_parent import ChildParent
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
def create(
self,
child_id: int,
start_time: datetime,
end_time: datetime | None = None,
) -> dict:
"""Create a new sleep log entry."""
sleep = Sleep(
child_id=child_id,
start_time=start_time,
end_time=end_time,
)
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, Sleep.child_id == Child.id)
.join(ChildParent, Child.id == ChildParent.child_id)
.filter(ChildParent.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,
}
@@ -0,0 +1,11 @@
"""Token repository implementations and configurations."""
from .default_ttl import DEFAULT_TTL
from .memory_token import InMemoryTokenRepository
from .redis_token import RedisTokenRepository
__all__ = [
"DEFAULT_TTL",
"InMemoryTokenRepository",
"RedisTokenRepository",
]
@@ -0,0 +1,3 @@
"""Definition of Token default TTL."""
DEFAULT_TTL = 28800 # seconds -> 8 hours
@@ -3,6 +3,7 @@
from datetime import datetime, timedelta, UTC
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
from baby_monitor.repositories.token import DEFAULT_TTL
class InMemoryTokenRepository(TokenRepositoryInterface):
@@ -17,13 +18,16 @@ class InMemoryTokenRepository(TokenRepositoryInterface):
# token -> (user_id, expiry_time)
self._tokens: dict[str, tuple[int, datetime]] = {}
def store(self, token: str, user_id: int, ttl: int = 3600) -> None:
def store(self, token: str, user_id: int, ttl: int = DEFAULT_TTL) -> None:
"""Store a token with TTL (time to live in seconds)."""
expiry = datetime.now(UTC) + timedelta(seconds=ttl)
self._tokens[token] = (user_id, expiry)
def verify(self, token: str) -> int | None:
"""Verify token and return user_id if valid, None otherwise."""
"""Verify token and return user_id if valid, None otherwise.
Implements sliding expiration: extends token lifetime on each verification.
"""
if token not in self._tokens:
return None
@@ -34,6 +38,10 @@ class InMemoryTokenRepository(TokenRepositoryInterface):
del self._tokens[token]
return None
# Sliding expiration: extend the token lifetime
new_expiry = datetime.now(UTC) + timedelta(seconds=DEFAULT_TTL)
self._tokens[token] = (user_id, new_expiry)
return user_id
def invalidate(self, token: str) -> None:
@@ -3,14 +3,14 @@
from typing import Any
from baby_monitor.repositories.interfaces import TokenRepositoryInterface
from baby_monitor.repositories.token import DEFAULT_TTL
class RedisTokenRepository(TokenRepositoryInterface):
"""
Redis-based token storage.
Requires redis package: pip install redis
Set REDIS_URI environment variable (e.g., redis://localhost:6379/0)
This implementation uses Redis to store tokens with TTL. Implements sliding expiration.
"""
def __init__(self, redis_client: Any) -> None:
@@ -22,16 +22,21 @@ class RedisTokenRepository(TokenRepositoryInterface):
"""
self.redis = redis_client
def store(self, token: str, user_id: int, ttl: int = 3600) -> None:
def store(self, token: str, user_id: int, ttl: int = DEFAULT_TTL) -> None:
"""Store a token with TTL (time to live in seconds)."""
key = f"token:{token}"
self.redis.setex(key, ttl, str(user_id))
def verify(self, token: str) -> int | None:
"""Verify token and return user_id if valid, None otherwise."""
"""Verify token and return user_id if valid, None otherwise.
Implements sliding expiration: extends token lifetime on verification.
"""
key = f"token:{token}"
user_id_str = self.redis.get(key)
if user_id_str:
# Sliding expiration: extend the token lifetime
self.redis.expire(key, DEFAULT_TTL)
return int(user_id_str)
return None
@@ -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
@@ -55,3 +61,35 @@ class SQLiteUserRepository(UserRepositoryInterface):
"created_at": user.created_at,
}
return None
def get_all(self) -> list[dict]:
"""Get all users."""
users = self.db.query(User).all()
return [
{
"id": user.id,
"username": user.username,
"hashed_password": user.hashed_password,
"is_admin": user.is_admin,
"created_at": user.created_at,
}
for user in users
]
def delete(self, user_id: int) -> bool:
"""Delete a user by ID. Returns True if successful."""
user = self.db.query(User).filter(User.id == user_id).first()
if user:
self.db.delete(user)
self.db.commit()
return True
return False
def update_password(self, user_id: int, hashed_password: str) -> bool:
"""Update user password. Returns True if successful."""
user = self.db.query(User).filter(User.id == user_id).first()
if user:
user.hashed_password = hashed_password # type: ignore
self.db.commit()
return True
return False
+181 -2
View File
@@ -3,15 +3,25 @@
import secrets
from datetime import datetime, timedelta, UTC
from typing import Annotated
from fastapi import APIRouter, Depends
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,
get_user_repository,
get_child_repository,
get_feeding_repository,
get_diaper_change_repository,
get_sleep_repository,
)
from baby_monitor.repositories.interfaces import (
InvitationRepositoryInterface,
UserRepositoryInterface,
ChildRepositoryInterface,
FeedingRepositoryInterface,
DiaperChangeRepositoryInterface,
SleepRepositoryInterface,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -64,3 +74,172 @@ async def generate_invitation_link(
expires_at=expires_at.isoformat(),
message="Invitation link generated successfully. Valid for 24 hours.",
)
class UserResponse(BaseModel):
"""Response model for user data."""
id: int
username: str
is_admin: bool
created_at: datetime
class DeleteUserResponse(BaseModel):
"""Response model for user deletion."""
message: str
deleted_children: list[int]
deleted_feedings: int
deleted_diapers: int
deleted_sleeps: int
@router.get("/users", response_model=list[UserResponse])
async def get_all_users(
admin_id: Annotated[int, Depends(verify_admin)],
user_repository: Annotated[
UserRepositoryInterface,
Depends(get_user_repository),
],
) -> list[UserResponse]:
"""Get all users in the system.
Requires admin role.
Args:
admin_id: Admin user ID (from verify_admin)
user_repository: User storage backend
Returns:
List of all users with their basic information
"""
users = user_repository.get_all()
return [
UserResponse(
id=user["id"],
username=user["username"],
is_admin=user["is_admin"],
created_at=user["created_at"],
)
for user in users
]
@router.delete("/users/{user_id}", response_model=DeleteUserResponse)
async def delete_user(
user_id: int,
admin_id: Annotated[int, Depends(verify_admin)],
user_repository: Annotated[
UserRepositoryInterface,
Depends(get_user_repository),
],
child_repository: Annotated[
ChildRepositoryInterface,
Depends(get_child_repository),
],
feeding_repository: Annotated[
FeedingRepositoryInterface,
Depends(get_feeding_repository),
],
diaper_repository: Annotated[
DiaperChangeRepositoryInterface,
Depends(get_diaper_change_repository),
],
sleep_repository: Annotated[
SleepRepositoryInterface,
Depends(get_sleep_repository),
],
) -> DeleteUserResponse:
"""Delete a user and cascade delete orphaned children and their logs.
When a user is deleted:
1. Find all children belonging to this user
2. For each child, check if other users also have this child
3. Delete only children that have no other parents (orphaned)
4. For orphaned children, delete all associated logs (feedings, diapers, sleep)
5. Finally delete the user
Requires admin role.
Args:
user_id: ID of user to delete
admin_id: Admin user ID (from verify_admin)
user_repository: User storage backend
child_repository: Child storage backend
feeding_repository: Feeding storage backend
diaper_repository: Diaper change storage backend
sleep_repository: Sleep storage backend
Returns:
DeleteUserResponse with deletion summary
Raises:
HTTPException: If user not found or trying to delete yourself
"""
# Prevent admin from deleting themselves
if user_id == admin_id:
raise HTTPException(status_code=400, detail="Cannot delete your own account")
# Check if user exists
user = user_repository.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get all children belonging to this user
children = child_repository.get_by_user_id(user_id)
deleted_children_ids = []
total_feedings_deleted = 0
total_diapers_deleted = 0
total_sleeps_deleted = 0
# For each child, check if they have other parents
for child in children:
child_id = child["id"]
# Remove this user's relationship with the child
child_repository.remove_parent(child_id, user_id)
# Check if child has any remaining parents
remaining_parents = child_repository.get_parent_ids(child_id)
# Only delete child and logs if they have no other parents
if len(remaining_parents) == 0:
# Delete all logs for this orphaned child
feedings = feeding_repository.get_by_child_id(child_id)
for feeding in feedings:
feeding_repository.delete(feeding["id"])
total_feedings_deleted += 1
diapers = diaper_repository.get_by_child_id(child_id)
for diaper in diapers:
diaper_repository.delete(diaper["id"])
total_diapers_deleted += 1
sleeps = sleep_repository.get_by_child_id(child_id)
for sleep in sleeps:
sleep_repository.delete(sleep["id"])
total_sleeps_deleted += 1
# Delete the orphaned child
child_repository.delete(child_id)
deleted_children_ids.append(child_id)
# Finally, delete the user
user_repository.delete(user_id)
message = f"User '{user['username']}' deleted successfully"
if deleted_children_ids:
message += (
f" with {len(deleted_children_ids)} orphaned "
f"child{'ren' if len(deleted_children_ids) != 1 else ''}"
)
return DeleteUserResponse(
message=message,
deleted_children=deleted_children_ids,
deleted_feedings=total_feedings_deleted,
deleted_diapers=total_diapers_deleted,
deleted_sleeps=total_sleeps_deleted,
)
+113 -5
View File
@@ -1,8 +1,9 @@
"""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 (
LoginRequest,
@@ -11,22 +12,35 @@ from baby_monitor.models.auth import (
RegisterResponse,
)
from baby_monitor.repositories import (
get_child_repository,
get_diaper_change_repository,
get_feeding_repository,
get_invitation_repository,
get_sleep_repository,
get_token_repository,
get_user_repository,
)
from baby_monitor.repositories.dependencies.get_invitation_repository import (
get_invitation_repository,
)
from baby_monitor.repositories.interfaces import (
TokenRepositoryInterface,
UserRepositoryInterface,
InvitationRepositoryInterface,
ChildRepositoryInterface,
FeedingRepositoryInterface,
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"])
class ChangePasswordRequest(BaseModel):
"""Request model for password change."""
current_password: str
new_password: str
def verify_token(
authorization: Annotated[str | None, Header()] = None,
token_repo: TokenRepositoryInterface = Depends(get_token_repository),
@@ -191,3 +205,97 @@ def get_current_user(
"username": user["username"],
"is_admin": user.get("is_admin", False),
}
@router.post("/change-password")
def change_password(
request: ChangePasswordRequest,
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], # noqa: E501
) -> dict:
"""Change password for the current user."""
# Get user with credentials
user = user_repo.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verify current password
if not verify_password(request.current_password, user["hashed_password"]):
raise HTTPException(status_code=401, detail="Current password is incorrect")
# Hash new password
new_password_hash = hash_password(request.new_password)
# Update password
success = user_repo.update_password(user_id, new_password_hash)
if not success:
raise HTTPException(status_code=500, detail="Failed to update password")
return {"message": "Password changed successfully"}
@router.delete("/account")
def delete_account(
user_id: Annotated[int, Depends(verify_token)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)], # noqa: E501
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)], # noqa: E501
feeding_repo: Annotated[
FeedingRepositoryInterface, Depends(get_feeding_repository)
], # noqa: E501
diaper_repo: Annotated[
DiaperChangeRepositoryInterface, Depends(get_diaper_change_repository)
], # noqa: E501
sleep_repo: Annotated[SleepRepositoryInterface, Depends(get_sleep_repository)], # noqa: E501
) -> dict:
"""Delete current user's account and cascade delete orphaned children."""
# Get user's children
children = child_repo.get_by_user_id(user_id)
deleted_children = []
deleted_feedings = 0
deleted_diapers = 0
deleted_sleeps = 0
# Process each child
for child in children:
child_id = child["id"]
# Remove the parent-child relationship
child_repo.remove_parent(child_id, user_id)
# Check if child has any remaining parents
remaining_parents = child_repo.get_parent_ids(child_id)
if len(remaining_parents) == 0:
# Child is orphaned, delete all logs first
feedings = feeding_repo.get_by_child_id(child_id)
for feeding in feedings:
feeding_repo.delete(feeding["id"])
deleted_feedings += 1
diapers = diaper_repo.get_by_child_id(child_id)
for diaper in diapers:
diaper_repo.delete(diaper["id"])
deleted_diapers += 1
sleeps = sleep_repo.get_by_child_id(child_id)
for sleep in sleeps:
sleep_repo.delete(sleep["id"])
deleted_sleeps += 1
# Now delete the child
child_repo.delete(child_id)
deleted_children.append(child["name"])
# Finally, delete the user
user_repo.delete(user_id)
return {
"message": "Account deleted successfully",
"deleted_children": deleted_children,
"deleted_logs": {
"feedings": deleted_feedings,
"diaper_changes": deleted_diapers,
"sleep_sessions": deleted_sleeps,
},
}
+150 -9
View File
@@ -1,23 +1,53 @@
"""Child management router."""
import secrets
from datetime import datetime, timedelta, UTC
from typing import Annotated
from fastapi import APIRouter, HTTPException, Depends
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 (
from baby_monitor.repositories.interfaces import (
ChildRepositoryInterface,
ChildInvitationRepositoryInterface,
UserRepositoryInterface,
)
from baby_monitor.repositories.dependencies import (
get_child_repository,
get_child_invitation_repository,
get_user_repository,
)
router = APIRouter(prefix="/api/children", tags=["children"])
class CreateChildInvitationRequest(BaseModel):
"""Request model for creating a child invitation."""
child_id: int
class ChildInvitationResponse(BaseModel):
"""Response model for child invitation."""
code: str
child_id: int
expires_at: str
message: str
class RedeemChildInvitationRequest(BaseModel):
"""Request model for redeeming a child invitation."""
code: str
@router.post("", response_model=ChildResponse, status_code=201)
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(
@@ -33,18 +63,37 @@ 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)],
user_repo: Annotated[UserRepositoryInterface, Depends(get_user_repository)],
) -> list[ChildResponse]:
"""Get all children for the authenticated user."""
children = child_repo.get_by_user_id(user_id)
return [ChildResponse(**child) for child in children]
# Add parent information to each child
result = []
for child in children:
child_data = child.copy()
parent_ids = child_repo.get_parent_ids(child["id"])
child_data["parent_count"] = len(parent_ids)
# Get usernames of all parents
parent_usernames = []
for parent_id in parent_ids:
parent = user_repo.get_by_id(parent_id)
if parent:
parent_usernames.append(parent["username"])
child_data["parent_usernames"] = parent_usernames
result.append(ChildResponse(**child_data))
return result
@router.get("/{child_id}", response_model=ChildResponse)
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)
@@ -53,7 +102,8 @@ def get_child(
raise HTTPException(status_code=404, detail="Child not found")
# Verify the child belongs to the authenticated user
if child["user_id"] != user_id:
parent_ids = child_repo.get_parent_ids(child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied")
return ChildResponse(**child)
@@ -64,7 +114,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
@@ -73,7 +123,8 @@ def update_child(
if not child:
raise HTTPException(status_code=404, detail="Child not found")
if child["user_id"] != user_id:
parent_ids = child_repo.get_parent_ids(child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied")
# Update the child
@@ -88,3 +139,93 @@ def update_child(
raise HTTPException(status_code=500, detail="Update failed")
return ChildResponse(**updated_child)
@router.post("/invitations", response_model=ChildInvitationResponse)
def create_child_invitation(
request: CreateChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
invitation_repo: Annotated[
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
child = child_repo.get_by_id(request.child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
parent_ids = child_repo.get_parent_ids(request.child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied")
# Generate a short invitation code (8 characters)
code = secrets.token_urlsafe(6)[:8].upper()
# Calculate expiration (7 days from now)
expires_at = datetime.now(UTC) + timedelta(days=7)
# Create the invitation
invitation_repo.create_invitation(
code=code,
child_id=request.child_id,
created_by_user_id=user_id,
expires_at=expires_at,
)
return ChildInvitationResponse(
code=code,
child_id=request.child_id,
expires_at=expires_at.isoformat(),
message="Invitation code created. Valid for 7 days.",
)
@router.post("/invitations/redeem")
def redeem_child_invitation(
request: RedeemChildInvitationRequest,
user_id: Annotated[int, Depends(verify_token)],
child_repo: Annotated[ChildRepositoryInterface, Depends(get_child_repository)],
invitation_repo: Annotated[
ChildInvitationRepositoryInterface,
Depends(get_child_invitation_repository),
],
) -> dict:
"""Redeem an invitation code to gain access to a child."""
# Verify the invitation code
if not invitation_repo.verify_invitation(request.code):
raise HTTPException(
status_code=400, detail="Invalid or expired invitation code"
)
# Get the invitation details
invitation = invitation_repo.get_by_code(request.code)
if not invitation:
raise HTTPException(status_code=400, detail="Invalid invitation code")
child_id = invitation["child_id"]
# Check if user already has access to this child
parent_ids = child_repo.get_parent_ids(child_id)
if user_id in parent_ids:
raise HTTPException(
status_code=400, detail="You already have access to this child"
)
# Add user as a parent
child_repo.add_parent(child_id, user_id)
# Consume the invitation
invitation_repo.consume_invitation(request.code, user_id, datetime.now(UTC))
# Get child details
child = child_repo.get_by_id(child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
return {
"message": f"Successfully added access to {child['name']}",
"child": ChildResponse(**child),
}
+18 -30
View File
@@ -9,16 +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.verify_child_access import verify_child_access
router = APIRouter(prefix="/api/diaper-changes", tags=["diaper-changes"])
@@ -28,18 +27,13 @@ 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
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")
verify_child_access(child_repo, request.child_id, user_id)
diaper_change = diaper_repo.create(
child_id=request.child_id,
@@ -57,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."""
@@ -70,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)
@@ -81,9 +75,7 @@ def get_diaper_change(
raise HTTPException(status_code=404, detail="Diaper change log not found")
# Verify the diaper change belongs to user's child
child = child_repo.get_by_id(diaper_change["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, diaper_change["child_id"], user_id)
return DiaperChangeResponse(**diaper_change)
@@ -94,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)
@@ -105,9 +97,7 @@ def update_diaper_change(
raise HTTPException(status_code=404, detail="Diaper change log not found")
# Verify the diaper change belongs to user's child
child = child_repo.get_by_id(diaper_change["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, diaper_change["child_id"], user_id)
updated = diaper_repo.update(
diaper_change_id=diaper_change_id,
@@ -131,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)
@@ -142,9 +132,7 @@ def delete_diaper_change(
raise HTTPException(status_code=404, detail="Diaper change log not found")
# Verify the diaper change belongs to user's child
child = child_repo.get_by_id(diaper_change["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, diaper_change["child_id"], user_id)
success = diaper_repo.delete(diaper_change_id)
if not success:
+34 -33
View File
@@ -9,16 +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 import verify_child_access
router = APIRouter(prefix="/api/feedings", tags=["feedings"])
@@ -27,17 +26,14 @@ 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
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")
verify_child_access(child_repo, request.child_id, user_id)
feeding = feeding_repo.create(
child_id=request.child_id,
@@ -52,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)
@@ -74,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)
@@ -85,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)
@@ -95,9 +97,7 @@ def get_feeding(
raise HTTPException(status_code=404, detail="Feeding log not found")
# Verify the feeding belongs to user's child
child = child_repo.get_by_id(feeding["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, feeding["child_id"], user_id)
return FeedingResponse(**feeding)
@@ -107,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)
@@ -117,16 +119,15 @@ def update_feeding(
raise HTTPException(status_code=404, detail="Feeding log not found")
# Verify ownership
child = child_repo.get_by_id(feeding["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
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:
@@ -139,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)
@@ -149,9 +152,7 @@ def delete_feeding(
raise HTTPException(status_code=404, detail="Feeding log not found")
# Verify ownership
child = child_repo.get_by_id(feeding["child_id"])
if not child or child["user_id"] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
verify_child_access(child_repo, feeding["child_id"], user_id)
success = feeding_repo.delete(feeding_id)
if not success:
+142
View File
@@ -0,0 +1,142 @@
"""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.interfaces import (
SleepRepositoryInterface,
ChildRepositoryInterface,
)
from baby_monitor.repositories.dependencies import (
get_sleep_repository,
get_child_repository,
)
from baby_monitor.utils import verify_child_access
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[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
verify_child_access(child_repo, request.child_id, user_id)
sleep = sleep_repo.create(
child_id=request.child_id,
start_time=request.start_time,
end_time=request.end_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[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)
# 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[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)
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[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)
if not sleep:
raise HTTPException(status_code=404, detail="Sleep log not found")
# Verify the sleep belongs to user's child
verify_child_access(child_repo, sleep["child_id"], user_id)
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[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)
if not sleep:
raise HTTPException(status_code=404, detail="Sleep log not found")
# Verify the sleep belongs to user's child
verify_child_access(child_repo, sleep["child_id"], user_id)
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[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)
if not sleep:
raise HTTPException(status_code=404, detail="Sleep log not found")
# Verify the sleep belongs to user's child
verify_child_access(child_repo, sleep["child_id"], user_id)
success = sleep_repo.delete(sleep_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete sleep log")
+43 -10
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Add Child - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<style>
* {
margin: 0;
@@ -15,7 +17,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #667eea;
min-height: 100vh;
display: flex;
justify-content: center;
@@ -33,13 +35,13 @@
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
@@ -51,7 +53,7 @@
label {
display: block;
margin-bottom: 8px;
color: #333;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
}
@@ -59,19 +61,22 @@
input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border: 2px solid #ccc;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background-color: var(--input-bg);
color: var(--input-text);
}
input:focus {
outline: none;
border-color: #667eea;
border-color: var(--input-focus-border);
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border: none;
padding: 12px 24px;
@@ -135,7 +140,7 @@
.help-text {
font-size: 12px;
color: #666;
color: var(--text-secondary);
margin-top: 4px;
}
</style>
@@ -192,6 +197,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 +227,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";
@@ -290,9 +318,9 @@
: "Child added successfully!",
"success",
);
// Redirect to home page after 1 second
// Redirect to settings page if editing, otherwise home page
setTimeout(() => {
window.location.href = "/";
window.location.href = isEditMode ? "/settings.html" : "/";
}, 1000);
} else {
showMessage(
@@ -315,6 +343,11 @@
}
function goBack() {
// Redirect to settings page if editing, otherwise home page
window.location.href = isEditMode ? "/settings.html" : "/";
}
function skipToHome() {
window.location.href = "/";
}
</script>
+361 -10
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Admin</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<style>
* {
margin: 0;
@@ -15,7 +17,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #667eea;
min-height: 100vh;
display: flex;
justify-content: center;
@@ -33,13 +35,13 @@
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
@@ -62,7 +64,8 @@
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border: none;
padding: 12px 24px;
@@ -95,7 +98,7 @@
display: none;
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
background: var(--bg-tertiary);
border-radius: 8px;
border: 2px solid #667eea;
}
@@ -106,7 +109,7 @@
.link-label {
font-size: 12px;
color: #666;
color: var(--text-secondary);
margin-bottom: 8px;
font-weight: 600;
text-transform: uppercase;
@@ -121,13 +124,13 @@
.link-text {
flex: 1;
padding: 10px;
background: white;
border: 1px solid #ddd;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 13px;
word-break: break-all;
color: #333;
color: var(--text-primary);
}
.copy-button {
@@ -185,6 +188,172 @@
background: #c82333;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
}
.user-management h2::before {
content: "👥";
}
.search-box {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
margin-bottom: 15px;
}
.user-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid var(--border-lighter);
background: var(--bg-secondary);
}
.user-item:last-child {
border-bottom: none;
}
.user-item:hover {
background: var(--bg-tertiary);
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.user-meta {
font-size: 12px;
color: var(--text-secondary);
}
.admin-badge {
display: inline-block;
padding: 2px 8px;
background: #667eea;
color: white;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
margin-left: 8px;
}
.delete-user-btn {
padding: 6px 12px;
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.delete-user-btn:hover {
background: #c82333;
}
.delete-user-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 8px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.modal h3 {
color: #333;
margin-bottom: 15px;
}
.modal p {
color: #666;
margin-bottom: 20px;
line-height: 1.5;
}
.modal-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal-button {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.modal-button.cancel {
background: #6c757d;
color: white;
}
.modal-button.cancel:hover {
background: #5a6268;
}
.modal-button.confirm {
background: #dc3545;
color: white;
}
.modal-button.confirm:hover {
background: #c82333;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
</style>
</head>
<body>
@@ -218,9 +387,46 @@
<div id="error" class="error"></div>
</div>
<div class="section user-management">
<h2>User Management</h2>
<p style="color: #666; margin-bottom: 15px; font-size: 14px">
View and manage all users in the system.
</p>
<input
type="text"
id="userSearch"
class="search-box"
placeholder="Search users by username..."
oninput="filterUsers()"
/>
<div id="userListContainer" class="loading">Loading users...</div>
</div>
<button class="button logout-button" onclick="logout()">Logout</button>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal" id="deleteModal">
<div class="modal-content">
<h3>⚠️ Confirm User Deletion</h3>
<p id="deleteMessage"></p>
<p style="color: #dc3545; font-weight: 600">
This action cannot be undone. All associated children and their logs
(feedings, diapers, sleep) will be permanently deleted.
</p>
<div class="modal-buttons">
<button class="modal-button cancel" onclick="closeDeleteModal()">
Cancel
</button>
<button class="modal-button confirm" onclick="confirmDelete()">
Delete User
</button>
</div>
</div>
</div>
<script>
// Check if user is authenticated and is admin
const token = localStorage.getItem("access_token");
@@ -228,7 +434,10 @@
window.location.href = "/login.html";
}
// Verify user is admin
let allUsers = [];
let userToDelete = null;
// Verify user is admin and load data
fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
@@ -240,12 +449,154 @@
alert("Access denied. Admin privileges required.");
window.location.href = "/";
}
// Load users after verifying admin status
loadUsers();
})
.catch((error) => {
console.error("Error verifying admin status:", error);
window.location.href = "/login.html";
});
async function loadUsers() {
const container = document.getElementById("userListContainer");
try {
const response = await fetch("/api/admin/users", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to load users");
}
allUsers = await response.json();
renderUsers(allUsers);
} catch (error) {
console.error("Error loading users:", error);
container.innerHTML = `
<div class="empty-state">
❌ Failed to load users: ${error.message}
</div>
`;
}
}
function renderUsers(users) {
const container = document.getElementById("userListContainer");
if (users.length === 0) {
container.innerHTML = `
<div class="empty-state">
No users found.
</div>
`;
return;
}
container.className = "user-list";
container.innerHTML = users
.map(
(user) => `
<div class="user-item">
<div class="user-info">
<div class="user-name">
${user.username}
${user.is_admin ? '<span class="admin-badge">ADMIN</span>' : ""}
</div>
<div class="user-meta">
ID: ${user.id} • Created: ${new Date(user.created_at).toLocaleDateString()}
</div>
</div>
<button
class="delete-user-btn"
onclick="showDeleteModal(${user.id}, '${user.username}', ${user.is_admin})"
${user.is_admin ? 'disabled title="Cannot delete admin users"' : ""}
>
Delete
</button>
</div>
`,
)
.join("");
}
function filterUsers() {
const searchTerm = document
.getElementById("userSearch")
.value.toLowerCase();
const filtered = allUsers.filter((user) =>
user.username.toLowerCase().includes(searchTerm),
);
renderUsers(filtered);
}
function showDeleteModal(userId, username, isAdmin) {
if (isAdmin) {
return; // Don't allow deleting admin users
}
userToDelete = { id: userId, username };
const modal = document.getElementById("deleteModal");
const message = document.getElementById("deleteMessage");
message.textContent = `Are you sure you want to delete user "${username}"?`;
modal.classList.add("show");
}
function closeDeleteModal() {
const modal = document.getElementById("deleteModal");
modal.classList.remove("show");
userToDelete = null;
}
async function confirmDelete() {
if (!userToDelete) return;
const modal = document.getElementById("deleteModal");
const confirmBtn = modal.querySelector(".modal-button.confirm");
confirmBtn.disabled = true;
confirmBtn.textContent = "Deleting...";
try {
const response = await fetch(`/api/admin/users/${userToDelete.id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to delete user");
}
const result = await response.json();
// Show success message
alert(
`${result.message}\n\n` +
`Deleted:\n` +
`${result.deleted_children.length} children\n` +
`${result.deleted_feedings} feedings\n` +
`${result.deleted_diapers} diaper changes\n` +
`${result.deleted_sleeps} sleep sessions`,
);
// Reload user list
await loadUsers();
closeDeleteModal();
} catch (error) {
console.error("Error deleting user:", error);
alert(`Failed to delete user: ${error.message}`);
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = "Delete User";
}
}
async function generateLink() {
const btn = document.getElementById("generateBtn");
const linkContainer = document.getElementById("linkContainer");
+203
View File
@@ -0,0 +1,203 @@
/* Chart.js Dark Mode Helper
* Provides theme-aware color palettes and chart options for Chart.js
* Usage: Include this before your chart initialization code
*/
(function() {
'use strict';
// Helper to get effective theme
function getEffectiveTheme() {
return window.getEffectiveTheme ? window.getEffectiveTheme() : 'light';
}
// Get theme-appropriate colors for Chart.js
window.getChartColors = function() {
const isDark = getEffectiveTheme() === 'dark';
return {
// Grid lines
grid: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
gridMajor: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
// Text colors
text: isDark ? '#b0b0b0' : '#666666',
textPrimary: isDark ? '#e0e0e0' : '#333333',
// Data series colors (optimized for background)
series: isDark ? [
'rgba(139, 158, 255, 0.8)', // Brighter purple
'rgba(110, 197, 255, 0.8)', // Brighter blue
'rgba(255, 179, 255, 0.8)', // Brighter pink
'rgba(129, 199, 132, 0.8)', // Brighter green
'rgba(255, 183, 77, 0.8)', // Brighter orange
'rgba(156, 107, 184, 0.8)', // Brighter deep purple
'rgba(77, 255, 255, 0.8)', // Brighter cyan
'rgba(255, 138, 155, 0.8)', // Brighter red
] : [
'rgba(102, 126, 234, 0.8)', // Purple
'rgba(118, 75, 162, 0.8)', // Deep purple
'rgba(255, 159, 64, 0.8)', // Orange
'rgba(76, 175, 80, 0.8)', // Green
'rgba(244, 67, 54, 0.8)', // Red
'rgba(156, 39, 176, 0.8)', // Violet
'rgba(102, 126, 234, 0.6)', // Light purple
'rgba(118, 75, 162, 0.6)', // Light deep purple
],
// Solid data colors (for pie charts, etc.)
solidSeries: isDark ? [
'#8b9eff',
'#6ec5ff',
'#ffb3ff',
'#81c784',
'#ffb74d',
'#9c6bb8',
'#4dffff',
'#ff8a9b',
] : [
'#667eea',
'#764ba2',
'#ff9f40',
'#4caf50',
'#f44336',
'#9c27b0',
'#667eea',
'#764ba2',
],
// Background colors
background: isDark ? '#2d2d2d' : '#f8f9fa',
};
};
// Get default Chart.js options with theme support
window.getChartOptions = function(customOptions = {}) {
const colors = window.getChartColors();
const defaults = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: colors.text,
font: {
size: 12
}
}
},
tooltip: {
backgroundColor: getEffectiveTheme() === 'dark' ? '#3a3a3a' : '#ffffff',
titleColor: colors.textPrimary,
bodyColor: colors.text,
borderColor: colors.grid,
borderWidth: 1,
}
},
scales: {}
};
// Deep merge customOptions with defaults
return mergeDeep(defaults, customOptions);
};
// Get theme-appropriate scale options
window.getChartScaleOptions = function(axis = 'x', customOptions = {}) {
const colors = window.getChartColors();
const defaults = {
grid: {
color: colors.grid,
borderColor: colors.gridMajor,
},
ticks: {
color: colors.text,
font: {
size: 12
}
},
title: {
color: colors.textPrimary,
font: {
size: 13,
weight: '600'
}
}
};
return mergeDeep(defaults, customOptions);
};
// Helper function to deep merge objects
function mergeDeep(target, source) {
const output = Object.assign({}, target);
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach(key => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = mergeDeep(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
}
return output;
}
function isObject(item) {
return item && typeof item === 'object' && !Array.isArray(item);
}
// Function to update all charts on theme change
window.updateChartsForTheme = function(chartInstances) {
if (!Array.isArray(chartInstances)) {
chartInstances = [chartInstances];
}
const colors = window.getChartColors();
chartInstances.forEach(chart => {
if (!chart) return;
// Update grid colors
if (chart.options.scales) {
Object.keys(chart.options.scales).forEach(axis => {
if (chart.options.scales[axis].grid) {
chart.options.scales[axis].grid.color = colors.grid;
chart.options.scales[axis].grid.borderColor = colors.gridMajor;
}
if (chart.options.scales[axis].ticks) {
chart.options.scales[axis].ticks.color = colors.text;
}
if (chart.options.scales[axis].title) {
chart.options.scales[axis].title.color = colors.textPrimary;
}
});
}
// Update legend colors
if (chart.options.plugins && chart.options.plugins.legend) {
if (chart.options.plugins.legend.labels) {
chart.options.plugins.legend.labels.color = colors.text;
}
}
// Update tooltip colors
if (chart.options.plugins && chart.options.plugins.tooltip) {
chart.options.plugins.tooltip.backgroundColor =
getEffectiveTheme() === 'dark' ? '#3a3a3a' : '#ffffff';
chart.options.plugins.tooltip.titleColor = colors.textPrimary;
chart.options.plugins.tooltip.bodyColor = colors.text;
chart.options.plugins.tooltip.borderColor = colors.grid;
}
// Update the chart
chart.update();
});
};
})();
+258
View File
@@ -0,0 +1,258 @@
/* Dark Mode CSS Variables and Theming System
* Supports automatic detection via prefers-color-scheme and manual override via .dark-mode class
*/
:root {
/* ===== LIGHT MODE (Default) ===== */
/* Background colors */
--bg-primary: #f0f0f0;
--bg-secondary: #ffffff;
--bg-tertiary: #f8f9fa;
--bg-card: #ffffff;
/* Text colors */
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--text-inverse: #ffffff;
/* Border colors */
--border-color: #dddddd;
--border-light: #e0e0e0;
--border-lighter: #eeeeee;
/* Gradients - Brand */
--gradient-purple: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-blue: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-pink: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-green: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
--gradient-orange: linear-gradient(135deg, #f2994a 0%, #f2c94c 100%);
/* Action colors */
--color-success: #4caf50;
--color-error: #dc3545;
--color-warning: #ff9800;
--color-info: #2196f3;
--color-danger: #f44336;
/* Action gradients */
--gradient-success: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
--gradient-error: linear-gradient(135deg, #dc3545 0%, #c62828 100%);
--gradient-danger: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
/* Interactive elements */
--input-bg: #ffffff;
--input-border: #dddddd;
--input-focus-border: #667eea;
--input-text: #333333;
--input-placeholder: #999999;
/* Shadows */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 2px 10px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.15);
--shadow-hover: 0 5px 15px rgba(0, 0, 0, 0.2);
/* Modal/Overlay */
--overlay-bg: rgba(0, 0, 0, 0.5);
/* Chart colors (vibrant for light backgrounds) */
--chart-grid: rgba(0, 0, 0, 0.1);
--chart-text: #666666;
--chart-color-1: #667eea;
--chart-color-2: #4facfe;
--chart-color-3: #f093fb;
--chart-color-4: #56ab2f;
--chart-color-5: #f2994a;
--chart-color-6: #764ba2;
--chart-color-7: #00f2fe;
--chart-color-8: #f5576c;
}
/* ===== DARK MODE ===== */
/* Applied via @media query (auto-detect) OR .dark-mode class (manual override) */
@media (prefers-color-scheme: dark) {
:root:not(.light-mode) {
/* Background colors - Dark theme */
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3a3a3a;
--bg-card: #2d2d2d;
/* Text colors - Light on dark */
--text-primary: #e0e0e0;
--text-secondary: #b0b0b0;
--text-muted: #808080;
--text-inverse: #1a1a1a;
/* Border colors - Lighter for visibility */
--border-color: #444444;
--border-light: #4a4a4a;
--border-lighter: #505050;
/* Gradients - Dimmed (~50% brightness reduction) */
--gradient-purple: linear-gradient(135deg, #3d4d8c 0%, #452d61 100%);
--gradient-blue: linear-gradient(135deg, #2f667f 0%, #00919a 100%);
--gradient-pink: linear-gradient(135deg, #905997 0%, #924344 100%);
--gradient-green: linear-gradient(135deg, #33661c 0%, #64873c 100%);
--gradient-orange: linear-gradient(135deg, #915b2d 0%, #917a2e 100%);
/* Action colors - Slightly brighter for visibility */
--color-success: #66bb6a;
--color-error: #ef5350;
--color-warning: #ffa726;
--color-info: #42a5f5;
--color-danger: #ff5252;
/* Action gradients - Dimmed */
--gradient-success: linear-gradient(135deg, #33661c 0%, #64873c 100%);
--gradient-error: linear-gradient(135deg, #a32a2a 0%, #8b2020 100%);
--gradient-danger: linear-gradient(135deg, #a32929 0%, #8b1e1e 100%);
/* Interactive elements */
--input-bg: #2d2d2d;
--input-border: #444444;
--input-focus-border: #667eea;
--input-text: #e0e0e0;
--input-placeholder: #808080;
/* Shadows - Lighter/softer for dark mode */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 10px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.5);
--shadow-hover: 0 5px 15px rgba(0, 0, 0, 0.6);
/* Modal/Overlay - Same opacity */
--overlay-bg: rgba(0, 0, 0, 0.5);
/* Chart colors - Brighter and more saturated for dark backgrounds */
--chart-grid: rgba(255, 255, 255, 0.1);
--chart-text: #b0b0b0;
--chart-color-1: #8b9eff;
--chart-color-2: #6ec5ff;
--chart-color-3: #ffb3ff;
--chart-color-4: #81c784;
--chart-color-5: #ffb74d;
--chart-color-6: #9c6bb8;
--chart-color-7: #4dffff;
--chart-color-8: #ff8a9b;
}
}
/* Manual override with .dark-mode class */
body.dark-mode {
/* Background colors - Dark theme */
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3a3a3a;
--bg-card: #2d2d2d;
/* Text colors - Light on dark */
--text-primary: #e0e0e0;
--text-secondary: #b0b0b0;
--text-muted: #808080;
--text-inverse: #1a1a1a;
/* Border colors - Lighter for visibility */
--border-color: #444444;
--border-light: #4a4a4a;
--border-lighter: #505050;
/* Gradients - Dimmed (~50% brightness reduction) */
--gradient-purple: linear-gradient(135deg, #3d4d8c 0%, #452d61 100%);
--gradient-blue: linear-gradient(135deg, #2f667f 0%, #00919a 100%);
--gradient-pink: linear-gradient(135deg, #905997 0%, #924344 100%);
--gradient-green: linear-gradient(135deg, #33661c 0%, #64873c 100%);
--gradient-orange: linear-gradient(135deg, #915b2d 0%, #917a2e 100%);
/* Action colors - Slightly brighter for visibility */
--color-success: #66bb6a;
--color-error: #ef5350;
--color-warning: #ffa726;
--color-info: #42a5f5;
--color-danger: #ff5252;
/* Action gradients - Dimmed */
--gradient-success: linear-gradient(135deg, #33661c 0%, #64873c 100%);
--gradient-error: linear-gradient(135deg, #a32a2a 0%, #8b2020 100%);
--gradient-danger: linear-gradient(135deg, #a32929 0%, #8b1e1e 100%);
/* Interactive elements */
--input-bg: #2d2d2d;
--input-border: #444444;
--input-focus-border: #667eea;
--input-text: #e0e0e0;
--input-placeholder: #808080;
/* Shadows - Lighter/softer for dark mode */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 10px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.5);
--shadow-hover: 0 5px 15px rgba(0, 0, 0, 0.6);
/* Modal/Overlay - Same opacity */
--overlay-bg: rgba(0, 0, 0, 0.5);
/* Chart colors - Brighter and more saturated for dark backgrounds */
--chart-grid: rgba(255, 255, 255, 0.1);
--chart-text: #b0b0b0;
--chart-color-1: #8b9eff;
--chart-color-2: #6ec5ff;
--chart-color-3: #ffb3ff;
--chart-color-4: #81c784;
--chart-color-5: #ffb74d;
--chart-color-6: #9c6bb8;
--chart-color-7: #4dffff;
--chart-color-8: #ff8a9b;
}
/* Manual override with .light-mode class (forces light even when browser prefers dark) */
body.light-mode {
/* Force light mode values - same as root defaults */
--bg-primary: #f0f0f0;
--bg-secondary: #ffffff;
--bg-tertiary: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #333333;
--text-secondary: #666666;
--text-muted: #999999;
--text-inverse: #ffffff;
--border-color: #dddddd;
--border-light: #e0e0e0;
--border-lighter: #eeeeee;
--gradient-purple: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-blue: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-pink: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-green: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
--gradient-orange: linear-gradient(135deg, #f2994a 0%, #f2c94c 100%);
--color-success: #4caf50;
--color-error: #dc3545;
--color-warning: #ff9800;
--color-info: #2196f3;
--color-danger: #f44336;
--gradient-success: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
--gradient-error: linear-gradient(135deg, #dc3545 0%, #c62828 100%);
--gradient-danger: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
--input-bg: #ffffff;
--input-border: #dddddd;
--input-focus-border: #667eea;
--input-text: #333333;
--input-placeholder: #999999;
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-md: 0 2px 10px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.15);
--shadow-hover: 0 5px 15px rgba(0, 0, 0, 0.2);
--overlay-bg: rgba(0, 0, 0, 0.5);
--chart-grid: rgba(0, 0, 0, 0.1);
--chart-text: #666666;
--chart-color-1: #667eea;
--chart-color-2: #4facfe;
--chart-color-3: #f093fb;
--chart-color-4: #56ab2f;
--chart-color-5: #f2994a;
--chart-color-6: #764ba2;
--chart-color-7: #00f2fe;
--chart-color-8: #f5576c;
}
+296 -199
View File
@@ -3,7 +3,11 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Diaper Changes - Baby Monitor</title>
<title>Diapers - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<script src="/chart-theme.js"></script>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
@@ -15,34 +19,35 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
background-color: var(--bg-primary);
min-height: 100vh;
padding: 20px;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
.button {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
background: var(--gradient-pink);
background-color: #f093fb;
color: white;
border: none;
padding: 12px 24px;
@@ -71,16 +76,20 @@
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
color: var(--text-secondary);
}
.empty-state-icon {
@@ -89,7 +98,7 @@
}
.empty-state h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
}
@@ -100,12 +109,12 @@
.chart-container {
margin-bottom: 40px;
padding: 20px;
background: #f8f9fa;
background: var(--bg-tertiary);
border-radius: 8px;
}
.chart-container h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 20px;
font-size: 20px;
}
@@ -113,6 +122,13 @@
.chart-wrapper {
position: relative;
height: 300px;
max-width: 100%;
margin: 0 auto;
}
.chart-wrapper canvas {
max-width: 100%;
max-height: 100%;
}
.charts-row {
@@ -138,29 +154,37 @@
.controls label {
font-weight: 600;
color: #333;
color: var(--text-primary);
}
.controls select {
.controls select,
.controls input {
padding: 8px 16px;
border: 1px solid #ddd;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
cursor: pointer;
background: white;
background: var(--bg-secondary);
color: var(--text-primary);
}
.controls select:focus {
.controls select:focus,
.controls input:focus {
outline: none;
border-color: #f093fb;
}
.controls input[readonly] {
opacity: 0.6;
cursor: default;
}
.diapers-list {
margin-top: 40px;
}
.diapers-list h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 20px;
font-size: 20px;
}
@@ -168,9 +192,9 @@
.diapers-scroll {
max-height: 500px;
overflow-y: auto;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #f8f9fa;
background: var(--bg-tertiary);
}
.diaper-item {
@@ -178,13 +202,13 @@
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: white;
border-bottom: 1px solid #eee;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-lighter);
transition: background 0.2s;
}
.diaper-item:hover {
background: #f8f9fa;
background: var(--bg-tertiary);
}
.diaper-item:last-child {
@@ -197,13 +221,13 @@
.diaper-time {
font-weight: 600;
color: #333;
color: var(--text-primary);
margin-bottom: 5px;
}
.diaper-details {
font-size: 14px;
color: #666;
color: var(--text-secondary);
}
.diaper-badge {
@@ -240,92 +264,9 @@
.edit-button:hover {
background: #d77ee6;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuHome">🏠 Home</div>
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuFeedings">🍼 Feedings</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<h1>🧷 Diaper Changes</h1>
<p class="subtitle">Overview of all diaper change sessions</p>
@@ -336,6 +277,7 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="/menu.js"></script>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
@@ -343,46 +285,24 @@
window.location.href = "/login.html";
}
// Initialize burger menu
initBurgerMenu({ includeHome: true });
let allDiaperChanges = [];
let allChildren = [];
let currentDaysRange = 7;
let selectedChildId = null; // null means "All Children"
let currentDaysRange =
parseInt(localStorage.getItem("lastDiaperTimeRange")) || 7;
let selectedChildId = null; // null means show first/only child
let barChartInstance = null; // Track chart instance for proper cleanup
let poopPieChartInstance = null;
let peePieChartInstance = null;
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
// Restore last selected child from localStorage
const lastChildId = localStorage.getItem("lastDiaperChildFilter");
if (lastChildId && lastChildId !== "all") {
selectedChildId = parseInt(lastChildId);
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuHome").addEventListener("click", () => {
window.location.href = "/";
});
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuFeedings").addEventListener("click", () => {
window.location.href = "/feedings.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Load diaper changes data
async function loadDiaperChanges() {
try {
@@ -416,6 +336,11 @@
if (response.ok) {
allChildren = await response.json();
// Auto-select first child if no selection exists
if (selectedChildId === null && allChildren.length > 0) {
selectedChildId = allChildren[0].id;
}
}
} catch (error) {
console.error("Error loading children:", error);
@@ -424,11 +349,13 @@
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);
selectedChildId = parseInt(event.target.value);
localStorage.setItem("lastDiaperChildFilter", event.target.value);
displayDiaperChanges(allDiaperChanges);
}
@@ -441,6 +368,7 @@
<div class="empty-state-icon">🧷</div>
<h2>No Diaper Changes Yet</h2>
<p>Start tracking diaper changes from the home page</p>
<button class="button" onclick="window.location.href='/'">Go Home</button>
</div>
`;
} else {
@@ -450,31 +378,47 @@
const peePieData = preparePeePieChartData(diaperChanges);
// Build child dropdown options
const childOptions = allChildren.map(child =>
`<option value="${child.id}" ${selectedChildId === child.id ? 'selected' : ''}>${child.name}</option>`
).join('');
const childOptions = allChildren
.map(
(child) =>
`<option value="${child.id}" ${selectedChildId === child.id ? "selected" : ""}>${child.name}</option>`,
)
.join("");
content.innerHTML = `
<div class="controls">
${
allChildren.length === 1
? `
<label for="childDisplay">Child:</label>
<input
type="text"
id="childDisplay"
value="${allChildren[0].name}"
readonly
/>
`
: `
<label for="childFilter">Child:</label>
<select id="childFilter" onchange="onChildChange(event)">
<option value="all" ${selectedChildId === null ? 'selected' : ''}>All Children</option>
${childOptions}
</select>
`
}
<label for="daysRange">Time Range:</label>
<select id="daysRange" onchange="onDaysRangeChange(event)">
<option value="1" ${currentDaysRange === 1 ? 'selected' : ''}>Last 24 Hours</option>
<option value="3" ${currentDaysRange === 3 ? 'selected' : ''}>Last 3 Days</option>
<option value="7" ${currentDaysRange === 7 ? 'selected' : ''}>Last 7 Days</option>
<option value="14" ${currentDaysRange === 14 ? 'selected' : ''}>Last 14 Days</option>
<option value="30" ${currentDaysRange === 30 ? 'selected' : ''}>Last 30 Days</option>
<option value="90" ${currentDaysRange === 90 ? 'selected' : ''}>Last 90 Days</option>
<option value="1" ${currentDaysRange === 1 ? "selected" : ""}>Last 24 Hours</option>
<option value="3" ${currentDaysRange === 3 ? "selected" : ""}>Last 3 Days</option>
<option value="7" ${currentDaysRange === 7 ? "selected" : ""}>Last 7 Days</option>
<option value="14" ${currentDaysRange === 14 ? "selected" : ""}>Last 14 Days</option>
<option value="30" ${currentDaysRange === 30 ? "selected" : ""}>Last 30 Days</option>
<option value="90" ${currentDaysRange === 90 ? "selected" : ""}>Last 90 Days</option>
</select>
</div>
<div class="chart-container">
<h2>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>
@@ -521,7 +465,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,7 +475,8 @@
return new Date(b.change_time) - new Date(a.change_time);
});
const listHtml = filteredChanges.map((dc) => {
const listHtml = filteredChanges
.map((dc) => {
const changeTime = new Date(dc.change_time);
const dateStr = changeTime.toLocaleDateString("en-US", {
@@ -546,33 +493,44 @@
// 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(`<span class="diaper-badge poop">💩 ${poopInfo}</span>`);
const poopInfo = [dc.poop_amount, dc.poop_color]
.filter(Boolean)
.join(", ");
badges.push(
`<span class="diaper-badge poop">💩 ${poopInfo}</span>`,
);
}
if (dc.pee_amount || dc.pee_color) {
const peeInfo = [dc.pee_amount, dc.pee_color].filter(Boolean).join(", ");
badges.push(`<span class="diaper-badge pee">💧 ${peeInfo}</span>`);
const peeInfo = [dc.pee_amount, dc.pee_color]
.filter(Boolean)
.join(", ");
badges.push(
`<span class="diaper-badge pee">💧 ${peeInfo}</span>`,
);
}
if (badges.length === 0) {
badges.push('<span class="diaper-badge" style="background: #f0f0f0; color: #666;">Clean diaper</span>');
badges.push(
'<span class="diaper-badge" style="background: #f0f0f0; color: #666;">Clean diaper</span>',
);
}
// Get child name
const child = allChildren.find(c => c.id === dc.child_id);
const child = allChildren.find((c) => c.id === dc.child_id);
const childName = child ? child.name : "Unknown";
return `
<div class="diaper-item">
<div class="diaper-info-left">
<div class="diaper-time">${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ''}</div>
<div class="diaper-time">${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ""}</div>
<div class="diaper-details">
${badges.join('')}
${badges.join("")}
</div>
</div>
<button class="edit-button" onclick="editDiaperChange(${dc.id})">Edit</button>
</div>
`;
}).join("");
})
.join("");
const listContainer = `
<div class="diapers-list">
@@ -587,7 +545,7 @@
`;
const content = document.getElementById("content");
content.insertAdjacentHTML('beforeend', listContainer);
content.insertAdjacentHTML("beforeend", listContainer);
}
function editDiaperChange(diaperChangeId) {
@@ -612,7 +570,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 +625,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,9 +675,89 @@
// 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,
);
}
// If 24 hours selected, show 3-hour aggregates
if (currentDaysRange === 1) {
// Create 8 three-hour buckets
for (let i = 7; i >= 0; i--) {
const periodStart = new Date(now);
periodStart.setHours(periodStart.getHours() - i * 3, 0, 0, 0);
const periodEnd = new Date(periodStart);
periodEnd.setHours(periodEnd.getHours() + 3);
const startLabel = periodStart.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
const endLabel = periodEnd.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
labels.push(`${startLabel}-${endLabel}`);
// 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 = 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 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);
@@ -737,12 +779,17 @@
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);
}
}
return { labels, poopData, peeData };
}
@@ -750,7 +797,12 @@
function renderBarChart(chartData) {
const ctx = document.getElementById("diaperChart").getContext("2d");
new Chart(ctx, {
// Destroy existing chart instance if it exists
if (barChartInstance) {
barChartInstance.destroy();
}
barChartInstance = new Chart(ctx, {
type: "bar",
data: {
labels: chartData.labels,
@@ -805,11 +857,11 @@
},
},
scales: {
y: {
y: getChartScaleOptions('y', {
beginAtZero: true,
title: {
display: true,
text: "Number of Diapers",
text: "Number of Changes",
font: {
size: 13,
weight: "600",
@@ -821,10 +873,7 @@
size: 12,
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
}),
x: {
ticks: {
font: {
@@ -843,21 +892,38 @@
function renderPoopPieChart(chartData) {
const ctx = document.getElementById("poopPieChart").getContext("2d");
// Destroy existing chart instance if it exists
if (poopPieChartInstance) {
poopPieChartInstance.destroy();
}
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, {
poopPieChartInstance = new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
@@ -895,8 +961,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}%)`;
},
},
@@ -909,21 +979,38 @@
function renderPeePieChart(chartData) {
const ctx = document.getElementById("peePieChart").getContext("2d");
// Destroy existing chart instance if it exists
if (peePieChartInstance) {
peePieChartInstance.destroy();
}
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, {
peePieChartInstance = new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
@@ -961,8 +1048,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}%)`;
},
},
@@ -1005,6 +1096,12 @@
// Load data on page load
loadDiaperChanges();
// Listen for theme changes and update charts
window.addEventListener('themechange', () => {
const charts = [barChartInstance, poopPieChartInstance, peePieChartInstance].filter(c => c);
updateChartsForTheme(charts);
});
</script>
</body>
</html>
+385 -237
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Feedings - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<script src="/chart-theme.js"></script>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
@@ -15,34 +19,35 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: var(--bg-primary);
min-height: 100vh;
padding: 20px;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
max-width: 1000px;
margin: 0 auto;
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border: none;
padding: 12px 24px;
@@ -71,16 +76,20 @@
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.hidden {
display: none;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
color: var(--text-secondary);
}
.empty-state-icon {
@@ -89,7 +98,7 @@
}
.empty-state h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
}
@@ -100,12 +109,12 @@
.chart-container {
margin-bottom: 40px;
padding: 20px;
background: #f8f9fa;
background: var(--bg-tertiary);
border-radius: 8px;
}
.chart-container h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 20px;
font-size: 20px;
}
@@ -113,6 +122,13 @@
.chart-wrapper {
position: relative;
height: 300px;
max-width: 100%;
margin: 0 auto;
}
.chart-wrapper canvas {
max-width: 100%;
max-height: 100%;
}
.charts-row {
@@ -136,14 +152,14 @@
}
.stat-card {
background: #f8f9fa;
background: var(--bg-tertiary);
padding: 20px;
border-radius: 8px;
text-align: center;
}
.stat-card h3 {
color: #666;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
@@ -151,7 +167,7 @@
}
.stat-card .stat-value {
color: #333;
color: var(--text-primary);
font-size: 32px;
font-weight: 700;
}
@@ -170,21 +186,34 @@
.controls label {
font-weight: 600;
color: #333;
color: var(--text-primary);
}
.controls select {
.controls select,
.controls input {
padding: 8px 16px;
border: 1px solid #ddd;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
cursor: pointer;
background: white;
background: var(--bg-secondary);
color: var(--text-primary);
}
.controls select:focus,
.controls input:focus {
outline: none;
border-color: var(--input-focus-border);
}
.controls input[readonly] {
opacity: 0.6;
cursor: default;
}
.controls select:focus {
outline: none;
border-color: #667eea;
border-color: var(--input-focus-border);
}
.feedings-list {
@@ -192,7 +221,7 @@
}
.feedings-list h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 20px;
font-size: 20px;
}
@@ -200,9 +229,9 @@
.feedings-scroll {
max-height: 500px;
overflow-y: auto;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #f8f9fa;
background: var(--bg-tertiary);
}
.feeding-item {
@@ -210,13 +239,13 @@
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: white;
border-bottom: 1px solid #eee;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-lighter);
transition: background 0.2s;
}
.feeding-item:hover {
background: #f8f9fa;
background: var(--bg-tertiary);
}
.feeding-item:last-child {
@@ -229,13 +258,13 @@
.feeding-time {
font-weight: 600;
color: #333;
color: var(--text-primary);
margin-bottom: 5px;
}
.feeding-details {
font-size: 14px;
color: #666;
color: var(--text-secondary);
}
.feeding-type-badge {
@@ -277,92 +306,9 @@
.edit-button:hover {
background: #5568d3;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuHome">🏠 Home</div>
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuDiapers">🧷 Diapers</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<h1>🍼 Feedings</h1>
<p class="subtitle">Overview of all feeding sessions</p>
@@ -373,6 +319,7 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="/menu.js"></script>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
@@ -380,46 +327,24 @@
window.location.href = "/login.html";
}
// Initialize burger menu
initBurgerMenu({ includeHome: true });
let allFeedings = [];
let allChildren = [];
let currentDaysRange = 7;
let selectedChildId = null; // null means "All Children"
let currentDaysRange =
parseInt(localStorage.getItem("lastFeedingTimeRange")) || 7;
let selectedChildId = null; // null means show first/only child
let barChartInstance = null;
let pieChartInstance = null;
let durationChartInstance = null;
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
// Restore last selected child from localStorage
const lastChildId = localStorage.getItem("lastFeedingChildFilter");
if (lastChildId && lastChildId !== "all") {
selectedChildId = parseInt(lastChildId);
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuHome").addEventListener("click", () => {
window.location.href = "/";
});
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuDiapers").addEventListener("click", () => {
window.location.href = "/diapers.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Load feedings data
async function loadFeedings() {
try {
@@ -453,6 +378,11 @@
if (response.ok) {
allChildren = await response.json();
// Auto-select first child if no selection exists
if (selectedChildId === null && allChildren.length > 0) {
selectedChildId = allChildren[0].id;
}
}
} catch (error) {
console.error("Error loading children:", error);
@@ -461,57 +391,79 @@
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);
selectedChildId = parseInt(event.target.value);
localStorage.setItem("lastFeedingChildFilter", event.target.value);
displayFeedings(allFeedings);
}
function displayFeedings(feedings) {
const content = document.getElementById("content");
if (feedings.length === 0) {
// Check if we have children data
if (allChildren.length === 0) {
content.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🍼</div>
<h2>No Feedings Yet</h2>
<p>Start tracking feeding sessions from the home page</p>
<div class="empty-state-icon">👶</div>
<h2>No Children Added</h2>
<p>Please add a child first before tracking feedings</p>
<button class="button" onclick="window.location.href='/add-child.html'">Add Child</button>
</div>
`;
} else {
// Prepare chart data
return;
}
// Prepare chart data (works with empty arrays too)
const barChartData = prepareBarChartData(feedings);
const pieChartData = preparePieChartData(feedings);
const durationChartData = prepareDurationChartData(feedings);
// Build child dropdown options
const childOptions = allChildren.map(child =>
`<option value="${child.id}" ${selectedChildId === child.id ? 'selected' : ''}>${child.name}</option>`
).join('');
const childOptions = allChildren
.map(
(child) =>
`<option value="${child.id}" ${selectedChildId === child.id ? "selected" : ""}>${child.name}</option>`,
)
.join("");
content.innerHTML = `
<div class="controls">
${
allChildren.length === 1
? `
<label for="childDisplay">Child:</label>
<input
type="text"
id="childDisplay"
value="${allChildren[0].name}"
readonly
/>
`
: `
<label for="childFilter">Child:</label>
<select id="childFilter" onchange="onChildChange(event)">
<option value="all" ${selectedChildId === null ? 'selected' : ''}>All Children</option>
${childOptions}
</select>
`
}
<label for="daysRange">Time Range:</label>
<select id="daysRange" onchange="onDaysRangeChange(event)">
<option value="1" ${currentDaysRange === 1 ? 'selected' : ''}>Last 24 Hours</option>
<option value="3" ${currentDaysRange === 3 ? 'selected' : ''}>Last 3 Days</option>
<option value="7" ${currentDaysRange === 7 ? 'selected' : ''}>Last 7 Days</option>
<option value="14" ${currentDaysRange === 14 ? 'selected' : ''}>Last 14 Days</option>
<option value="30" ${currentDaysRange === 30 ? 'selected' : ''}>Last 30 Days</option>
<option value="90" ${currentDaysRange === 90 ? 'selected' : ''}>Last 90 Days</option>
<option value="1" ${currentDaysRange === 1 ? "selected" : ""}>Last 24 Hours</option>
<option value="3" ${currentDaysRange === 3 ? "selected" : ""}>Last 3 Days</option>
<option value="7" ${currentDaysRange === 7 ? "selected" : ""}>Last 7 Days</option>
<option value="14" ${currentDaysRange === 14 ? "selected" : ""}>Last 14 Days</option>
<option value="30" ${currentDaysRange === 30 ? "selected" : ""}>Last 30 Days</option>
<option value="90" ${currentDaysRange === 90 ? "selected" : ""}>Last 90 Days</option>
</select>
</div>
<div class="chart-container">
<h2>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>
@@ -542,7 +494,6 @@
// Render feeding list
renderFeedingList(feedings);
}
}
function renderFeedingList(feedings) {
// Filter feedings by current time range
@@ -558,7 +509,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,9 +519,12 @@
return new Date(b.start_time) - new Date(a.start_time);
});
const listHtml = filteredFeedings.map((feeding) => {
const listHtml = filteredFeedings
.map((feeding) => {
const startTime = new Date(feeding.start_time);
const endTime = feeding.end_time ? new Date(feeding.end_time) : null;
const endTime = feeding.end_time
? new Date(feeding.end_time)
: null;
const dateStr = startTime.toLocaleDateString("en-US", {
month: "short",
@@ -589,22 +545,23 @@
const typeLabel = formatFeedingType(feeding.feeding_type);
// Get child name
const child = allChildren.find(c => c.id === feeding.child_id);
const child = allChildren.find((c) => c.id === feeding.child_id);
const childName = child ? child.name : "Unknown";
return `
<div class="feeding-item">
<div class="feeding-info-left">
<div class="feeding-time">${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ''}</div>
<div class="feeding-time">${dateStr} at ${timeStr}${selectedChildId === null ? ` - ${childName}` : ""}</div>
<div class="feeding-details">
<span class="feeding-type-badge ${typeClass}">${typeLabel}</span>
${duration !== null ? `${duration} minutes` : 'Ongoing'}
${duration !== null ? `${duration} minutes` : "Ongoing"}
</div>
</div>
<button class="edit-button" onclick="editFeeding(${feeding.id})">Edit</button>
</div>
`;
}).join("");
})
.join("");
const listContainer = `
<div class="feedings-list">
@@ -619,7 +576,7 @@
`;
const content = document.getElementById("content");
content.insertAdjacentHTML('beforeend', listContainer);
content.insertAdjacentHTML("beforeend", listContainer);
}
function formatFeedingType(type) {
@@ -655,7 +612,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 +662,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
@@ -750,14 +711,96 @@
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;
if (selectedChildId !== null) {
filteredFeedings = feedings.filter((f) => f.child_id === selectedChildId);
filteredFeedings = feedings.filter(
(f) => f.child_id === selectedChildId,
);
}
// Only use completed feedings
const completedFeedings = filteredFeedings.filter((f) => f.end_time);
// If 24 hours selected, show 3-hour aggregates
if (currentDaysRange === 1) {
// Create 8 three-hour buckets
for (let i = 7; i >= 0; i--) {
const periodStart = new Date(now);
periodStart.setHours(periodStart.getHours() - i * 3, 0, 0, 0);
const periodEnd = new Date(periodStart);
periodEnd.setHours(periodEnd.getHours() + 3);
const startLabel = periodStart.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
const endLabel = periodEnd.toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
labels.push(`${startLabel}-${endLabel}`);
// Get individual feeding durations for this 3-hour period (in minutes)
const periodFeedings = completedFeedings.filter((f) => {
const feedingDate = new Date(f.start_time);
return feedingDate >= periodStart && feedingDate < periodEnd;
});
const feedingDurations = periodFeedings.map((feeding) => {
const start = new Date(feeding.start_time);
const end = new Date(feeding.end_time);
return (end - start) / 60000; // Convert to minutes
});
durations.push(feedingDurations);
feedingCounts.push(periodFeedings.length);
}
} else if (currentDaysRange === 3) {
// Show 8-hour aggregates for 3 days (9 buckets total)
for (let i = 8; i >= 0; i--) {
const periodStart = new Date(now);
periodStart.setHours(periodStart.getHours() - i * 8, 0, 0, 0);
const periodEnd = new Date(periodStart);
periodEnd.setHours(periodEnd.getHours() + 8);
// Format label based on the 8-hour period
const dayStr = periodStart.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
const startHour = periodStart.getHours();
let periodLabel;
if (startHour >= 0 && startHour < 8) {
periodLabel = `${dayStr} Night`;
} else if (startHour >= 8 && startHour < 16) {
periodLabel = `${dayStr} Morning`;
} else {
periodLabel = `${dayStr} Evening`;
}
labels.push(periodLabel);
// Get individual feeding durations for this 8-hour period (in minutes)
const periodFeedings = completedFeedings.filter((f) => {
const feedingDate = new Date(f.start_time);
return feedingDate >= periodStart && feedingDate < periodEnd;
});
const feedingDurations = periodFeedings.map((feeding) => {
const start = new Date(feeding.start_time);
const end = new Date(feeding.end_time);
return (end - start) / 60000; // Convert to minutes
});
durations.push(feedingDurations);
feedingCounts.push(periodFeedings.length);
}
} else {
// Create days based on currentDaysRange (including today)
for (let i = currentDaysRange - 1; i >= 0; i--) {
const date = new Date(now);
@@ -770,42 +813,154 @@
});
labels.push(dateStr);
// Count feedings for this day
// Get individual feeding durations for this day (in minutes)
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const count = filteredFeedings.filter((f) => {
const dayFeedings = completedFeedings.filter((f) => {
const feedingDate = new Date(f.start_time);
return feedingDate >= date && feedingDate < nextDay;
}).length;
});
data.push(count);
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: "Feedings",
data: chartData.data,
backgroundColor: "rgba(102, 126, 234, 0.7)",
borderColor: "rgba(102, 126, 234, 1)",
borderWidth: 2,
borderRadius: 6,
label: "No Data",
data: new Array(chartData.labels.length).fill(0),
backgroundColor: "rgba(102, 126, 234, 0.3)",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 60,
title: {
display: true,
text: "Minutes",
},
},
},
plugins: {
legend: {
display: false,
},
},
},
});
return;
}
// Create color palette for different feedings
const colors = [
"rgba(102, 126, 234, 0.8)",
"rgba(118, 75, 162, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(76, 175, 80, 0.8)",
"rgba(244, 67, 54, 0.8)",
"rgba(156, 39, 176, 0.8)",
"rgba(102, 126, 234, 0.6)",
"rgba(118, 75, 162, 0.6)",
"rgba(255, 159, 64, 0.6)",
"rgba(76, 175, 80, 0.6)",
];
// Create datasets - one for each feeding position
const datasets = [];
for (let feedingIndex = 0; feedingIndex < maxFeedings; feedingIndex++) {
const dataForFeeding = chartData.durations.map(
(periodDurations) => periodDurations[feedingIndex] || 0,
);
datasets.push({
label: `Feeding ${feedingIndex + 1}`,
data: dataForFeeding,
backgroundColor: colors[feedingIndex % colors.length],
borderColor: colors[feedingIndex % colors.length]
.replace("0.8", "1")
.replace("0.6", "1"),
borderWidth: 1,
});
}
barChartInstance = new Chart(ctx, {
type: "bar",
data: {
labels: chartData.labels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
ticks: {
font: {
size: 12,
},
},
grid: {
display: false,
},
},
y: getChartScaleOptions('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";
},
},
}),
},
plugins: {
legend: {
display: false,
@@ -821,43 +976,19 @@
},
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`;
},
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" : ""})`;
},
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: "Number of Feedings",
font: {
size: 13,
weight: "600",
},
},
ticks: {
stepSize: 1,
font: {
size: 12,
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
x: {
ticks: {
font: {
size: 12,
},
},
grid: {
display: false,
},
},
},
},
});
@@ -866,6 +997,11 @@
function renderPieChart(chartData) {
const ctx = document.getElementById("pieChart").getContext("2d");
// Destroy existing chart instance if it exists
if (pieChartInstance) {
pieChartInstance.destroy();
}
// Color mapping for each type
const colorMap = {
"Left Breast": {
@@ -876,7 +1012,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,13 +1020,13 @@
// 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, {
pieChartInstance = new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
@@ -928,8 +1064,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}%)`;
},
},
@@ -942,7 +1082,12 @@
function renderDurationChart(chartData) {
const ctx = document.getElementById("durationChart").getContext("2d");
new Chart(ctx, {
// Destroy existing chart instance if it exists
if (durationChartInstance) {
durationChartInstance.destroy();
}
durationChartInstance = new Chart(ctx, {
type: "bar",
data: {
labels: chartData.labels,
@@ -982,7 +1127,7 @@
},
},
scales: {
y: {
y: getChartScaleOptions('y', {
beginAtZero: true,
title: {
display: true,
@@ -998,11 +1143,8 @@
size: 12,
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
x: {
}),
x: getChartScaleOptions('x', {
title: {
display: true,
text: "Duration (minutes)",
@@ -1019,7 +1161,7 @@
grid: {
display: false,
},
},
}),
},
},
});
@@ -1058,6 +1200,12 @@
// Load data on page load
loadFeedings();
// Listen for theme changes and update charts
window.addEventListener('themechange', () => {
const charts = [barChartInstance, pieChartInstance, durationChartInstance].filter(c => c);
updateChartsForTheme(charts);
});
</script>
</body>
</html>
+536 -145
View File
@@ -3,93 +3,38 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor</title>
<title>Baby Monitor</title> <link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<script src="/chart-theme.js"></script> <link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
background-color: #f0f0f0;
}
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: #333;
margin: 5px 0;
transition: 0.3s;
}
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
}
.menu-overlay.open {
left: 0;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:hover {
background: #f5f5f5;
}
.menu-item.admin {
color: #667eea;
font-weight: 600;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
background-color: var(--bg-primary);
overflow-x: hidden;
}
.container {
background: white;
background: var(--bg-secondary);
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 1rem;
}
.user-info {
background: #e3f2fd;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
.greeting {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 2rem;
}
button {
padding: 0.75rem 1.5rem;
@@ -122,7 +67,9 @@
padding: 2rem;
font-size: 1.5rem;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border-radius: 8px;
transition:
transform 0.2s,
@@ -131,13 +78,16 @@
.feeding-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
background: var(--gradient-purple);
background-color: #667eea;
}
.feeding-button.active {
background: linear-gradient(135deg, #f44336 0%, #e91e63 100%);
background-color: #f44336;
}
.feeding-button.active:hover {
background: linear-gradient(135deg, #e91e63 0%, #f44336 100%);
background-color: #e91e63;
}
.feeding-info {
font-size: 0.9rem;
@@ -160,7 +110,7 @@
display: flex;
}
.modal-content {
background: white;
background: var(--bg-secondary);
padding: 2rem;
border-radius: 8px;
max-width: 400px;
@@ -170,7 +120,7 @@
.modal h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
color: var(--text-primary);
}
.form-group {
margin-bottom: 1rem;
@@ -178,13 +128,13 @@
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
color: var(--text-primary);
font-weight: 600;
}
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
}
@@ -203,24 +153,87 @@
.cancel-button:hover {
background-color: #5a6268;
}
.sleep-button {
width: 100%;
padding: 2rem;
font-size: 1.5rem;
margin-bottom: 1.5rem;
background: var(--gradient-blue);
background-color: #4facfe;
color: white;
border-radius: 8px;
transition:
transform 0.2s,
box-shadow 0.2s;
}
.sleep-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(79, 172, 254, 0.4);
background: var(--gradient-blue);
background-color: #4facfe;
}
.sleep-button.active {
background: var(--gradient-blue);
background-color: #4facfe;
}
.sleep-button.active:hover {
background: var(--gradient-blue);
background-color: #4facfe;
}
.sleep-info {
font-size: 0.9rem;
margin-top: 0.5rem;
opacity: 0.9;
}
.recent-activity {
margin-top: 2rem;
padding: 1.5rem;
background: var(--bg-tertiary);
border-radius: 8px;
}
.recent-activity h3 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1.1rem;
}
.activity-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-card {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.activity-card .icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.activity-card .type {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.activity-card .time {
font-size: 0.85rem;
color: var(--text-secondary);
}
.activity-card .details {
font-size: 0.85rem;
color: #888;
margin-top: 0.5rem;
}
.activity-card.empty {
opacity: 0.5;
font-style: italic;
color: #999;
}
</style>
</head>
<body>
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
<div class="menu-item" id="menuAddChild">👶 Add Child</div>
<div class="menu-item" id="menuFeedings">🍼 Feedings</div>
<div class="menu-item" id="menuDiapers">🧷 Diapers</div>
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
<div class="container">
<div id="content" class="loading">
<p>Loading...</p>
@@ -261,44 +274,38 @@
</div>
</div>
<!-- Start Sleep Modal -->
<div class="modal" id="startSleepModal">
<div class="modal-content">
<h2>😴 Start Sleep</h2>
<form id="startSleepForm">
<div class="form-group">
<label for="modalSleepChildId">Child</label>
<select id="modalSleepChildId" required>
<option value="">Select a child</option>
</select>
</div>
<div class="modal-buttons">
<button
type="button"
class="cancel-button"
onclick="closeStartSleepModal()"
>
Cancel
</button>
<button type="submit">Start</button>
</div>
</form>
</div>
</div>
<script src="/menu.js"></script>
<script>
const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username");
// Burger menu functionality
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
document.getElementById("menuAddChild").addEventListener("click", () => {
window.location.href = "/add-child.html";
});
document.getElementById("menuFeedings").addEventListener("click", () => {
window.location.href = "/feedings.html";
});
document.getElementById("menuDiapers").addEventListener("click", () => {
window.location.href = "/diapers.html";
});
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
logout();
});
// Initialize burger menu
initBurgerMenu({ includeHome: true });
// Check if user is logged in
if (!token) {
@@ -330,9 +337,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;
}
@@ -345,10 +352,38 @@
});
}
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;
let updateTimerInterval = null;
let activeSleep = null;
let lastSleep = null;
let sleepUpdateTimerInterval = null;
let lastDiaperChange = null;
async function loadFeedingStatus(children) {
userChildren = children;
@@ -365,8 +400,8 @@
activeFeeding = await activeResponse.json();
}
// Fetch all feedings to get the last one
const feedingsResponse = await fetch("/api/feedings", {
// Fetch the 10 most recent feedings to calculate the most recent session
const feedingsResponse = await fetch("/api/feedings?limit=10", {
headers: {
Authorization: `Bearer ${token}`,
},
@@ -375,8 +410,107 @@
if (feedingsResponse.ok) {
const feedings = await feedingsResponse.json();
if (feedings.length > 0) {
// Feedings are ordered by start_time desc, so first is most recent
lastFeeding = feedings[0];
// Calculate the most recent feeding session
let sessionStart = feedings[0].start_time;
let sessionEnd = feedings[0].end_time || feedings[0].start_time;
let sessionChildId = feedings[0].child_id;
let sessionEntries = [feedings[0]];
for (let i = 1; i < feedings.length; i++) {
const entry = feedings[i];
// Only group entries for the same child (ignore feeding type)
if (entry.child_id !== sessionChildId) {
break;
}
// If the gap between this entry's end_time and current sessionStart is more than 30 min, stop
const prevEnd = entry.end_time || entry.start_time;
const sessionStartDate = new Date(sessionStart);
const prevEndDate = new Date(prevEnd);
const diffMins = Math.abs(
(sessionStartDate - prevEndDate) / 60000,
);
if (diffMins > 30) {
break;
}
// Extend session to include this entry
sessionStart = entry.start_time;
sessionEntries.push(entry);
}
// Calculate accumulated feeding time for all events in the session
let accumulatedMinutes = 0;
sessionEntries.forEach((entry) => {
if (entry.end_time && entry.start_time) {
const start = new Date(entry.start_time);
const end = new Date(entry.end_time);
const diffMs = end - start;
if (diffMs > 0) {
accumulatedMinutes += Math.floor(diffMs / 60000);
}
}
});
// Build a string showing all feeding types used during the session
const feedingTypesSet = new Set();
sessionEntries.forEach((entry) => {
if (entry.feeding_type) {
feedingTypesSet.add(entry.feeding_type);
}
});
const feedingTypesString = Array.from(feedingTypesSet)
.map(formatFeedingType)
.join(", ");
// Compose a session object
lastFeeding = {
child_id: sessionChildId,
feeding_type: feedings[0].feeding_type,
start_time: sessionStart,
end_time: sessionEnd,
entries: sessionEntries,
child_name: feedings[0].child_name,
accumulated_minutes: accumulatedMinutes,
feeding_types_string: feedingTypesString,
};
}
}
// Fetch active sleep
const activeSleepResponse = await fetch("/api/sleep/active", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (activeSleepResponse.ok) {
activeSleep = await activeSleepResponse.json();
}
// Fetch all sleep logs to get the last one
const sleepResponse = await fetch("/api/sleep", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (sleepResponse.ok) {
const sleeps = await sleepResponse.json();
if (sleeps.length > 0) {
lastSleep = sleeps[0];
}
}
// Fetch all diaper changes to get the last one
const diaperResponse = await fetch("/api/diaper-changes", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (diaperResponse.ok) {
const diapers = await diaperResponse.json();
if (diapers.length > 0) {
lastDiaperChange = diapers[0];
}
}
} catch (error) {
@@ -385,6 +519,7 @@
renderContent();
startUpdateTimer();
startSleepUpdateTimer();
}
function startUpdateTimer() {
@@ -406,6 +541,25 @@
}
}
function startSleepUpdateTimer() {
// Clear any existing timer
if (sleepUpdateTimerInterval) {
clearInterval(sleepUpdateTimerInterval);
}
// Update every 30 seconds if there's an active sleep
if (activeSleep) {
sleepUpdateTimerInterval = setInterval(() => {
if (activeSleep) {
updateSleepTimeDisplay();
} else {
clearInterval(sleepUpdateTimerInterval);
sleepUpdateTimerInterval = null;
}
}, 30000); // Update every 30 seconds
}
}
function updateFeedingTimeDisplay() {
const timeElement = document.getElementById("feedingTimeInfo");
if (timeElement && activeFeeding) {
@@ -413,11 +567,18 @@
}
}
function updateSleepTimeDisplay() {
const timeElement = document.getElementById("sleepTimeInfo");
if (timeElement && activeSleep) {
timeElement.textContent = formatTime(activeSleep.start_time);
}
}
function renderContent() {
const feedingButtonHtml = activeFeeding
? `
<button class="feeding-button active" onclick="stopFeeding()">
🛑 Stop Feeding
🛑 Stopped Feeding
<div class="feeding-info">
${activeFeeding.child_name || "Child"} - ${formatFeedingType(activeFeeding.feeding_type)}<br>
Started <span id="feedingTimeInfo">${formatTime(activeFeeding.start_time)}</span>
@@ -425,23 +586,129 @@
</button>
`
: `
<button class="feeding-button" onclick="showStartFeedingModal()">
▶️ Start Feeding
<button class="feeding-button" onclick="window.location.href='/log-feeding.html'">
🍼 Started Feeding
</button>
`;
const sleepButtonHtml = activeSleep
? `
<button class="sleep-button active" onclick="stopSleep()">
🛑 Stopped Sleeping
<div class="sleep-info">
${activeSleep.child_name || "Child"}<br>
Started <span id="sleepTimeInfo">${formatTime(activeSleep.start_time)}</span>
</div>
</button>
`
: `
<button class="sleep-button" onclick="showStartSleepModal()">
😴 Started Sleeping
</button>
`;
document.getElementById("content").innerHTML = `
<h1>Welcome to Baby Monitor!</h1>
<div class="user-info">
<p><strong>Logged in as:</strong> ${username}</p>
</div>
${feedingButtonHtml}
<button class="feeding-button" onclick="window.location.href='/log-diaper.html'" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); margin-top: 20px;">
🧷 Log Diaper Change
<h1>Hi ${username}</h1>
<p class="greeting">Let's keep track of your baby's care.</p>
<button class="feeding-button" onclick="window.location.href='/log-diaper.html'" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); margin-bottom: 20px;">
🧷 Changed Diaper
</button>
${feedingButtonHtml}
${sleepButtonHtml}
${buildRecentActivitySection()}
`;
}
function buildRecentActivitySection() {
// Build diaper card
const diaperCard = lastDiaperChange
? `
<a href="/diapers.html" class="activity-card" style="text-decoration:none; color:inherit;">
<div class="icon">🧷</div>
<div class="type">Last Diaper</div>
<div class="time">${formatTime(lastDiaperChange.change_time)}</div>
<div class="details">
${lastDiaperChange.poop_amount ? `💩 ${lastDiaperChange.poop_amount}` : ""}
${lastDiaperChange.poop_amount && lastDiaperChange.pee_amount ? " • " : ""}
${lastDiaperChange.pee_amount ? `💧 ${lastDiaperChange.pee_amount}` : ""}
</div>
</a>
`
: `
<a href="/diapers.html" class="activity-card empty" style="text-decoration:none; color:inherit;">
<div class="icon">🧷</div>
<div class="type">No diaper changes yet</div>
</a>
`;
// Build feeding card
const feedingCard = lastFeeding
? `
<a href="/feedings.html" class="activity-card" style="text-decoration:none; color:inherit;">
<div class="icon">🍼</div>
<div class="type">Last Feeding Session</div>
<div class="time">${formatTime(lastFeeding.start_time)}</div>
<div class="details">
${lastFeeding.feeding_types_string ? `${lastFeeding.feeding_types_string}` : ""}
${lastFeeding.feeding_types_string && lastFeeding.accumulated_minutes ? " • " : ""}
${lastFeeding.accumulated_minutes} minutes total
${lastFeeding.end_time ? "" : " (ongoing)"}
</div>
</a>
`
: `
<a href="/feedings.html" class="activity-card empty" style="text-decoration:none; color:inherit;">
<div class="icon">🍼</div>
<div class="type">No feedings yet</div>
</a>
`;
// Build sleep card
const sleepCard = lastSleep
? `
<a href="/sleep.html" class="activity-card" style="text-decoration:none; color:inherit;">
<div class="icon">😴</div>
<div class="type">Last Sleep</div>
<div class="time">${formatTime(lastSleep.start_time)}</div>
<div class="details">
Duration: ${lastSleep.end_time ? calculateDuration(lastSleep.start_time, lastSleep.end_time) : "ongoing"}
</div>
</a>
`
: `
<a href="/sleep.html" class="activity-card empty" style="text-decoration:none; color:inherit;">
<div class="icon">😴</div>
<div class="type">No sleep sessions yet</div>
</a>
`;
return `
<div class="recent-activity">
<h3>📊 Recent Activity</h3>
<div class="activity-grid">
${diaperCard}
${feedingCard}
${sleepCard}
</div>
</div>
`;
}
function calculateDuration(startTime, endTime) {
const start = new Date(startTime);
const end = new Date(endTime);
const diffMs = end - start;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 60) {
return `${diffMins} min`;
}
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
return `${hours}h ${mins}m`;
}
function formatFeedingType(type) {
const types = {
left_breast: "Left Breast",
@@ -471,7 +738,24 @@
const childSelect = document.getElementById("modalChildId");
const typeSelect = document.getElementById("modalFeedingType");
// Clear and populate child select
// Clear and populate child select or show single child
if (userChildren.length === 1) {
// Single child: replace dropdown with text display
const child = userChildren[0];
const formGroup = childSelect.parentElement;
formGroup.innerHTML = `
<label for="modalChildName">Child</label>
<input
type="text"
id="modalChildName"
value="${child.name}"
readonly
style="background-color: #f5f5f5; cursor: default;"
/>
<input type="hidden" id="modalChildId" value="${child.id}" />
`;
} else {
// Multiple children: show dropdown
childSelect.innerHTML = '<option value="">Select a child</option>';
userChildren.forEach((child) => {
const option = document.createElement("option");
@@ -483,6 +767,11 @@
// Set defaults based on last feeding
if (lastFeeding) {
childSelect.value = lastFeeding.child_id;
}
}
// Set default feeding type based on last feeding
if (lastFeeding) {
typeSelect.value = lastFeeding.feeding_type;
}
@@ -590,6 +879,108 @@
}
}
function showStartSleepModal() {
window.location.href = "/log-sleep.html";
}
function closeStartSleepModal() {
const modal = document.getElementById("startSleepModal");
modal.classList.remove("show");
document.getElementById("startSleepForm").reset();
}
document
.getElementById("startSleepForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const childId = parseInt(
document.getElementById("modalSleepChildId").value,
);
try {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 19);
const response = await fetch("/api/sleep", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
child_id: childId,
start_time: localDateTime,
}),
});
if (response.ok) {
const sleep = await response.json();
activeSleep = sleep;
lastSleep = sleep;
// Add child name to active sleep
const child = userChildren.find((c) => c.id === childId);
if (child) {
activeSleep.child_name = child.name;
}
closeStartSleepModal();
renderContent();
startSleepUpdateTimer();
} else {
const data = await response.json();
alert(data.detail || "Failed to start sleep");
}
} catch (error) {
console.error("Error starting sleep:", error);
alert("Network error. Please try again.");
}
});
async function stopSleep() {
if (!activeSleep) return;
try {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 19);
const response = await fetch(`/api/sleep/${activeSleep.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
end_time: localDateTime,
}),
});
if (response.ok) {
activeSleep = null;
if (sleepUpdateTimerInterval) {
clearInterval(sleepUpdateTimerInterval);
sleepUpdateTimerInterval = null;
}
renderContent();
} else {
const data = await response.json();
alert(data.detail || "Failed to stop sleep");
}
} catch (error) {
console.error("Error stopping sleep:", error);
alert("Network error. Please try again.");
}
}
async function logout() {
const token = localStorage.getItem("access_token");
+107 -30
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Diaper Change - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<style>
* {
margin: 0;
@@ -15,7 +17,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: var(--bg-primary);
min-height: 100vh;
display: flex;
justify-content: center;
@@ -24,22 +26,22 @@
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-md);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
@@ -51,7 +53,7 @@
label {
display: block;
margin-bottom: 8px;
color: #333;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
}
@@ -60,20 +62,28 @@
select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background-color: var(--input-bg);
color: var(--input-text);
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
border-color: #f093fb;
}
input[readonly] {
opacity: 0.6;
cursor: default;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-pink);
background-color: #f093fb;
color: white;
border: none;
padding: 12px 24px;
@@ -90,7 +100,7 @@
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
box-shadow: 0 4px 12px rgba(240, 147, 251, 0.4);
}
.button:active {
@@ -137,25 +147,25 @@
.help-text {
font-size: 12px;
color: #666;
color: var(--text-secondary);
margin-top: 4px;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
color: var(--text-secondary);
}
.section-title {
color: #666;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 25px 0 15px 0;
padding-top: 15px;
border-top: 1px solid #eee;
border-top: 1px solid var(--border-lighter);
}
.section-title:first-of-type {
@@ -232,7 +242,11 @@
<button type="submit" class="button" id="submitBtn">
Log Diaper Change
</button>
<button type="button" class="button secondary" onclick="window.location.href='/'">
<button
type="button"
class="button secondary"
onclick="window.location.href=getReturnUrl()"
>
Cancel
</button>
</form>
@@ -245,6 +259,16 @@
window.location.href = "/login.html";
}
// Determine return URL based on referrer
function getReturnUrl() {
const referrer = document.referrer;
if (referrer.includes("/diapers.html")) {
return "/diapers.html";
}
// Default to home page
return "/";
}
// Check if we're in edit mode
const urlParams = new URLSearchParams(window.location.search);
const diaperChangeId = urlParams.get("id");
@@ -252,8 +276,10 @@
// Update UI for edit mode
if (isEditMode) {
document.getElementById("pageTitle").textContent = "🧷 Edit Diaper Change";
document.getElementById("submitBtn").textContent = "Update Diaper Change";
document.getElementById("pageTitle").textContent =
"🧷 Edit Diaper Change";
document.getElementById("submitBtn").textContent =
"Update Diaper Change";
}
let children = [];
@@ -271,12 +297,31 @@
children = await response.json();
const select = document.getElementById("childSelect");
// Populate select dropdown or show single child
if (children.length === 1) {
// Single child: replace dropdown with text display
const child = children[0];
const formGroup = select.parentElement;
formGroup.innerHTML = `
<label for="childName">Child *</label>
<input
type="text"
id="childName"
value="${child.name}"
readonly
/>
<input type="hidden" id="childSelect" name="childSelect" value="${child.id}" />
`;
} else {
// Multiple children: show dropdown (clear the placeholder first)
select.innerHTML = "";
children.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
select.appendChild(option);
});
}
// If editing, load the diaper change data
if (isEditMode) {
@@ -289,9 +334,10 @@
.toISOString()
.slice(0, 16);
// Load last used child from localStorage
// Load last used child from localStorage (only for multiple children)
if (children.length > 1) {
const lastDiaperChange = JSON.parse(
localStorage.getItem("lastDiaperChange") || "{}"
localStorage.getItem("lastDiaperChange") || "{}",
);
if (lastDiaperChange.child_id) {
@@ -299,6 +345,30 @@
lastDiaperChange.child_id;
}
}
// Prepopulate poop and pee fields from most recent entry
try {
const response = await fetch("/api/diaper-changes", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const changes = await response.json();
if (changes.length > 0) {
const last = changes[0];
document.getElementById("poopAmount").value =
last.poop_amount || "";
document.getElementById("poopColor").value =
last.poop_color || "";
document.getElementById("peeAmount").value =
last.pee_amount || "";
document.getElementById("peeColor").value =
last.pee_color || "";
}
}
} catch (error) {
// Ignore error, just use default
}
}
} else {
showMessage("Failed to load children", "error");
}
@@ -311,22 +381,26 @@
// Load diaper change data for editing
async function loadDiaperChangeData() {
try {
const response = await fetch(`/api/diaper-changes/${diaperChangeId}`, {
const response = await fetch(
`/api/diaper-changes/${diaperChangeId}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
});
},
);
if (response.ok) {
const diaperChange = await response.json();
// Populate form fields
document.getElementById("childSelect").value = diaperChange.child_id;
document.getElementById("childSelect").value =
diaperChange.child_id;
// Convert UTC time to local datetime-local format
const changeTime = new Date(diaperChange.change_time);
changeTime.setMinutes(
changeTime.getMinutes() - changeTime.getTimezoneOffset()
changeTime.getMinutes() - changeTime.getTimezoneOffset(),
);
document.getElementById("changeTime").value = changeTime
.toISOString()
@@ -355,11 +429,14 @@
.addEventListener("submit", async (e) => {
e.preventDefault();
const childId = parseInt(document.getElementById("childSelect").value);
const childId = parseInt(
document.getElementById("childSelect").value,
);
const changeTime = new Date(
document.getElementById("changeTime").value
document.getElementById("changeTime").value,
).toISOString();
const poopAmount = document.getElementById("poopAmount").value || null;
const poopAmount =
document.getElementById("poopAmount").value || null;
const poopColor = document.getElementById("poopColor").value || null;
const peeAmount = document.getElementById("peeAmount").value || null;
const peeColor = document.getElementById("peeColor").value || null;
@@ -407,7 +484,7 @@
"lastDiaperChange",
JSON.stringify({
child_id: childId,
})
}),
);
}
@@ -415,16 +492,16 @@
isEditMode
? "Diaper change updated successfully!"
: "Diaper change logged successfully!",
"success"
"success",
);
setTimeout(() => {
window.location.href = "/";
window.location.href = getReturnUrl();
}, 1000);
} else {
const errorData = await response.json();
showMessage(
errorData.detail || "Failed to log diaper change",
"error"
"error",
);
submitBtn.disabled = false;
submitBtn.textContent = isEditMode
+87 -23
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Feeding - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<style>
* {
margin: 0;
@@ -15,7 +17,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-color: var(--bg-primary);
min-height: 100vh;
display: flex;
justify-content: center;
@@ -24,22 +26,22 @@
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-md);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
@@ -51,7 +53,7 @@
label {
display: block;
margin-bottom: 8px;
color: #333;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
}
@@ -60,10 +62,12 @@
select {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background-color: var(--input-bg);
color: var(--input-text);
}
input:focus,
@@ -72,8 +76,14 @@
border-color: #667eea;
}
input[readonly] {
opacity: 0.6;
cursor: default;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border: none;
padding: 12px 24px;
@@ -137,14 +147,14 @@
.help-text {
font-size: 12px;
color: #666;
color: var(--text-secondary);
margin-top: 4px;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
color: var(--text-secondary);
}
</style>
</head>
@@ -206,6 +216,16 @@
window.location.href = "/login.html";
}
// Determine return URL based on referrer
function getReturnUrl() {
const referrer = document.referrer;
if (referrer.includes("/feedings.html")) {
return "/feedings.html";
}
// Default to home page
return "/";
}
const form = document.getElementById("logFeedingForm");
const messageDiv = document.getElementById("message");
const submitBtn = document.getElementById("submitBtn");
@@ -231,7 +251,8 @@
// Update title and button text if editing
if (isEditMode) {
document.querySelector("h1").textContent = "🍼 Edit Feeding";
document.querySelector(".subtitle").textContent = "Update feeding session details";
document.querySelector(".subtitle").textContent =
"Update feeding session details";
submitBtn.textContent = "Update Feeding";
}
@@ -258,13 +279,31 @@
return;
}
// Populate select dropdown
// Populate select dropdown or show single child
if (children.length === 1) {
// Single child: replace dropdown with text display
const child = children[0];
const formGroup = childSelect.parentElement;
formGroup.innerHTML = `
<label for="childName">Child</label>
<input
type="text"
id="childName"
value="${child.name}"
readonly
/>
<input type="hidden" id="childId" name="childId" value="${child.id}" />
`;
} else {
// Multiple children: show dropdown (clear placeholder first)
childSelect.innerHTML = "";
children.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
}
// Show form, hide loading
loadingDiv.style.display = "none";
@@ -273,6 +312,22 @@
// If editing, load the feeding data
if (isEditMode) {
loadFeedingData();
} else {
// Set default feeding type to most recent log entry
try {
const response = await fetch("/api/feedings", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const feedings = await response.json();
if (feedings.length > 0) {
document.getElementById("feedingType").value =
feedings[0].feeding_type;
}
}
} catch (error) {
// Ignore error, just use default
}
}
} else {
showMessage("Failed to load children", "error");
@@ -299,15 +354,19 @@
// Format datetime for datetime-local input
const startTime = new Date(feeding.start_time);
const startLocalDateTime = new Date(
startTime.getTime() - startTime.getTimezoneOffset() * 60000
).toISOString().slice(0, 16);
startTime.getTime() - startTime.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("startTime").value = startLocalDateTime;
if (feeding.end_time) {
const endTime = new Date(feeding.end_time);
const endLocalDateTime = new Date(
endTime.getTime() - endTime.getTimezoneOffset() * 60000
).toISOString().slice(0, 16);
endTime.getTime() - endTime.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("endTime").value = endLocalDateTime;
}
@@ -336,7 +395,9 @@
messageDiv.classList.remove("show");
try {
const url = isEditMode ? `/api/feedings/${feedingId}` : "/api/feedings";
const url = isEditMode
? `/api/feedings/${feedingId}`
: "/api/feedings";
const method = isEditMode ? "PUT" : "POST";
const response = await fetch(url, {
@@ -357,16 +418,19 @@
if (response.ok) {
showMessage(
isEditMode ? "Feeding updated successfully!" : "Feeding logged successfully!",
"success"
isEditMode
? "Feeding updated successfully!"
: "Feeding logged successfully!",
"success",
);
// Redirect to feedings page after 1 second
// Redirect to appropriate page after 1 second
setTimeout(() => {
window.location.href = "/feedings.html";
window.location.href = getReturnUrl();
}, 1000);
} else {
showMessage(
data.detail || `Failed to ${isEditMode ? 'update' : 'log'} feeding. Please try again.`,
data.detail ||
`Failed to ${isEditMode ? "update" : "log"} feeding. Please try again.`,
"error",
);
}
@@ -384,7 +448,7 @@
}
function goBack() {
window.location.href = "/";
window.location.href = getReturnUrl();
}
</script>
</body>
+423
View File
@@ -0,0 +1,423 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Log Sleep - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background-color: var(--bg-primary);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: var(--shadow-md);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 {
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
}
input,
select {
width: 100%;
padding: 12px;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background-color: var(--input-bg);
color: var(--input-text);
}
input:focus,
select:focus {
outline: none;
border-color: #4facfe;
}
input[readonly] {
opacity: 0.6;
cursor: default;
}
.button {
background: var(--gradient-blue);
background-color: #4facfe;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
width: 100%;
margin-top: 10px;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
}
.button:active {
transform: translateY(0);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button.secondary {
background: #6c757d;
margin-top: 10px;
}
.button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.message {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.message.show {
display: block;
}
.message.error {
background: #ffebee;
border-left: 4px solid #f44336;
color: #c62828;
}
.message.success {
background: #e8f5e9;
border-left: 4px solid #4caf50;
color: #2e7d32;
}
.help-text {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.loading {
text-align: center;
padding: 20px;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="container">
<h1>😴 Log Sleep</h1>
<p class="subtitle">Record a sleep session</p>
<div id="message" class="message"></div>
<div id="loadingChildren" class="loading">Loading children...</div>
<form id="logSleepForm" style="display: none">
<div class="form-group">
<label for="childId">Child *</label>
<select id="childId" name="childId" required>
<option value="">Select a child</option>
</select>
</div>
<div class="form-group">
<label for="startTime">Start Time *</label>
<input
type="datetime-local"
id="startTime"
name="startTime"
required
/>
<p class="help-text">Defaults to current time</p>
</div>
<div class="form-group">
<label for="endTime">End Time</label>
<input type="datetime-local" id="endTime" name="endTime" />
<p class="help-text">Leave empty if sleep is ongoing</p>
</div>
<button type="submit" class="button" id="submitBtn">Log Sleep</button>
<button type="button" class="button secondary" onclick="goBack()">
Cancel
</button>
</form>
</div>
<script>
// Check if user is authenticated
const token = localStorage.getItem("access_token");
if (!token) {
window.location.href = "/login.html";
}
// Determine return URL based on referrer
function getReturnUrl() {
const referrer = document.referrer;
if (referrer.includes("/sleep.html")) {
return "/sleep.html";
}
// Default to home page
return "/";
}
const form = document.getElementById("logSleepForm");
const messageDiv = document.getElementById("message");
const submitBtn = document.getElementById("submitBtn");
const loadingDiv = document.getElementById("loadingChildren");
const childSelect = document.getElementById("childId");
// Check if we're editing an existing sleep session
const urlParams = new URLSearchParams(window.location.search);
const sleepId = urlParams.get("id");
const isEditMode = !!sleepId;
// Set default start time to now (only for new sleep sessions)
if (!isEditMode) {
const now = new Date();
const localDateTime = new Date(
now.getTime() - now.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("startTime").value = localDateTime;
}
// Update title and button text if editing
if (isEditMode) {
document.querySelector("h1").textContent = "😴 Edit Sleep";
document.querySelector(".subtitle").textContent =
"Update sleep session details";
submitBtn.textContent = "Update Sleep";
}
// Load user's children
async function loadChildren() {
try {
const response = await fetch("/api/children", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const children = await response.json();
if (children.length === 0) {
showMessage(
"No children found. Please add a child first.",
"error",
);
setTimeout(() => {
window.location.href = "/add-child.html";
}, 2000);
return;
}
// Populate select dropdown or show single child
if (children.length === 1) {
// Single child: replace dropdown with text display
const child = children[0];
const formGroup = childSelect.parentElement;
formGroup.innerHTML = `
<label for="childName">Child</label>
<input
type="text"
id="childName"
value="${child.name}"
readonly
/>
<input type="hidden" id="childId" name="childId" value="${child.id}" />
`;
} else {
// Multiple children: show dropdown (clear placeholder first)
childSelect.innerHTML = "";
children.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
}
// Show form, hide loading
loadingDiv.style.display = "none";
form.style.display = "block";
// If editing, load the sleep data
if (isEditMode) {
loadSleepData();
}
} else {
showMessage("Failed to load children", "error");
}
} catch (error) {
showMessage("Network error loading children", "error");
}
}
async function loadSleepData() {
try {
const response = await fetch(`/api/sleep/${sleepId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const sleep = await response.json();
// Populate form with existing data
document.getElementById("childId").value = sleep.child_id;
// Format datetime for datetime-local input
const startTime = new Date(sleep.start_time);
const startLocalDateTime = new Date(
startTime.getTime() - startTime.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("startTime").value = startLocalDateTime;
if (sleep.end_time) {
const endTime = new Date(sleep.end_time);
const endLocalDateTime = new Date(
endTime.getTime() - endTime.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
document.getElementById("endTime").value = endLocalDateTime;
}
} else {
showMessage("Failed to load sleep data", "error");
}
} catch (error) {
showMessage("Network error loading sleep", "error");
}
}
loadChildren();
form.addEventListener("submit", async (e) => {
e.preventDefault();
const childId = parseInt(document.getElementById("childId").value);
const startTime = document.getElementById("startTime").value;
const endTimeValue = document.getElementById("endTime").value;
// Disable button during request
submitBtn.disabled = true;
submitBtn.textContent = isEditMode ? "Updating..." : "Logging...";
messageDiv.classList.remove("show");
try {
const url = isEditMode ? `/api/sleep/${sleepId}` : "/api/sleep";
const method = isEditMode ? "PUT" : "POST";
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
child_id: childId,
start_time: startTime,
end_time: endTimeValue || null,
}),
});
const data = await response.json();
if (response.ok) {
showMessage(
isEditMode
? "Sleep updated successfully!"
: "Sleep logged successfully!",
"success",
);
// Redirect to appropriate page after 1 second
setTimeout(() => {
window.location.href = getReturnUrl();
}, 1000);
} else {
showMessage(
data.detail ||
`Failed to ${isEditMode ? "update" : "log"} sleep. Please try again.`,
"error",
);
}
} catch (error) {
showMessage("Network error. Please try again.", "error");
} finally {
submitBtn.disabled = false;
submitBtn.textContent = isEditMode ? "Update Sleep" : "Log Sleep";
}
});
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = `message ${type} show`;
}
function goBack() {
window.location.href = getReturnUrl();
}
</script>
</body>
</html>
+13 -5
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<style>
body {
font-family: Arial, sans-serif;
@@ -12,27 +14,33 @@
align-items: center;
height: 100vh;
margin: 0;
background-color: #f0f0f0;
background-color: var(--bg-primary);
}
.login-container {
background: white;
background: var(--bg-secondary);
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
width: 300px;
}
h1 {
text-align: center;
color: #333;
color: var(--text-primary);
margin-bottom: 1.5rem;
}
input {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
box-sizing: border-box;
background-color: var(--input-bg);
color: var(--input-text);
}
input:focus {
outline: none;
border-color: var(--input-focus-border);
}
button {
width: 100%;
+76
View File
@@ -0,0 +1,76 @@
/* Burger Menu Styles */
.burger-menu {
position: fixed;
top: 1rem;
left: 1rem;
cursor: pointer;
z-index: 1000;
background: var(--bg-secondary, #ffffff);
padding: 0.5rem;
border-radius: 4px;
box-shadow: var(--shadow-md, 0 2px 10px rgba(0, 0, 0, 0.1));
transition: background 0.3s, box-shadow 0.3s;
}
.burger-menu div {
width: 25px;
height: 3px;
background-color: var(--text-primary, #333333);
margin: 5px 0;
transition: background-color 0.3s;
}
.menu-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: none;
z-index: 998;
}
.menu-backdrop.open {
display: block;
}
.menu-overlay {
position: fixed;
top: 0;
left: -100%;
width: 250px;
height: 100vh;
background: var(--bg-secondary, #ffffff);
box-shadow: var(--shadow-md, 2px 0 10px rgba(0, 0, 0, 0.1));
transition: left 0.3s, background 0.3s;
z-index: 999;
padding: 4rem 1rem 1rem 1rem;
overflow: hidden;
}
.menu-overlay.open {
left: 0;
}
.menu-item {
padding: 1rem;
border-bottom: 1px solid var(--border-color, #eeeeee);
cursor: pointer;
transition: background 0.2s, color 0.3s, border-color 0.3s;
color: var(--text-primary, #333333);
}
.menu-item:hover {
background: var(--bg-tertiary, #f5f5f5);
}
.menu-item.admin {
color: #667eea;
font-weight: 600;
}
.menu-item.logout {
color: #dc3545;
font-weight: 600;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Initializes the burger menu navigation
* @param {Object} options - Configuration options
* @param {boolean} options.includeHome - Whether to include Home menu item
* @param {boolean} options.includeFeedings - Whether to include Feedings menu item
* @param {boolean} options.includeDiapers - Whether to include Diapers menu item
* @param {boolean} options.includeSleep - Whether to include Sleep menu item
* @param {boolean} options.includeSettings - Whether to include Settings menu item
*/
function initBurgerMenu(options = {}) {
const {
includeHome = true,
includeFeedings = true,
includeDiapers = true,
includeSleep = true,
includeSettings = true,
} = options;
// Create burger menu HTML
const burgerMenuHTML = `
<div class="burger-menu" id="burgerMenu">
<div></div>
<div></div>
<div></div>
</div>
<div class="menu-backdrop" id="menuBackdrop"></div>
<div class="menu-overlay" id="menuOverlay">
${includeHome ? '<div class="menu-item" id="menuHome">🏠 Home</div>' : ""}
${includeDiapers ? '<div class="menu-item" id="menuDiapers">🧷 Diapers</div>' : ""}
${includeFeedings ? '<div class="menu-item" id="menuFeedings">🍼 Feedings</div>' : ""}
${includeSleep ? '<div class="menu-item" id="menuSleep">😴 Sleep</div>' : ""}
${includeSettings ? '<div class="menu-item" id="menuSettings">⚙️ Settings</div>' : ""}
<div class="menu-item logout" id="menuLogout">🚪 Logout</div>
</div>
`;
// Insert menu at the beginning of body
document.body.insertAdjacentHTML("afterbegin", burgerMenuHTML);
// Get elements
const burgerMenu = document.getElementById("burgerMenu");
const menuOverlay = document.getElementById("menuOverlay");
const menuBackdrop = document.getElementById("menuBackdrop");
// Toggle menu function
function toggleMenu() {
menuOverlay.classList.toggle("open");
menuBackdrop.classList.toggle("open");
}
// Close menu function
function closeMenu() {
menuOverlay.classList.remove("open");
menuBackdrop.classList.remove("open");
}
// Event listeners for menu toggle
burgerMenu.addEventListener("click", toggleMenu);
menuBackdrop.addEventListener("click", closeMenu);
// Navigation event listeners
if (includeHome) {
document.getElementById("menuHome")?.addEventListener("click", () => {
window.location.href = "/";
});
}
if (includeFeedings) {
document.getElementById("menuFeedings")?.addEventListener("click", () => {
window.location.href = "/feedings.html";
});
}
if (includeDiapers) {
document.getElementById("menuDiapers")?.addEventListener("click", () => {
window.location.href = "/diapers.html";
});
}
if (includeSleep) {
document.getElementById("menuSleep")?.addEventListener("click", () => {
window.location.href = "/sleep.html";
});
}
if (includeSettings) {
document.getElementById("menuSettings")?.addEventListener("click", () => {
window.location.href = "/settings.html";
});
}
document.getElementById("menuLogout").addEventListener("click", () => {
closeMenu();
// Call the page's logout function if it exists, otherwise use default
if (typeof logout === "function") {
logout();
} else {
defaultLogout();
}
});
// Default logout function
function defaultLogout() {
localStorage.removeItem("access_token");
localStorage.removeItem("username");
window.location.href = "/login.html";
}
}
+13 -8
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Baby Monitor - Register</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<style>
* {
margin: 0;
@@ -15,7 +17,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: #667eea;
min-height: 100vh;
display: flex;
justify-content: center;
@@ -33,14 +35,14 @@
}
h1 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
text-align: center;
}
.subtitle {
color: #666;
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
text-align: center;
@@ -61,20 +63,23 @@
input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border: 2px solid #ccc;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s;
background-color: var(--input-bg);
color: var(--input-text);
}
input:focus {
outline: none;
border-color: #667eea;
border-color: var(--input-focus-border);
}
.button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border: none;
padding: 14px;
@@ -173,7 +178,7 @@
.password-requirements {
font-size: 12px;
color: #666;
color: var(--text-secondary);
margin-top: 5px;
}
@@ -186,7 +191,7 @@
<div class="container">
<div id="loadingContainer" class="loading-container">
<div class="spinner"></div>
<p style="color: #666">Validating invitation...</p>
<p style="color: var(--text-secondary)">Validating invitation...</p>
</div>
<div id="registerContainer" class="hidden">
+935
View File
@@ -0,0 +1,935 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background-color: var(--bg-primary);
min-height: 100vh;
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.container {
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
}
h1 {
color: var(--text-primary);
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: var(--text-secondary);
margin-bottom: 30px;
font-size: 14px;
}
.section {
margin-bottom: 40px;
padding: 20px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.section h2 {
color: var(--text-primary);
font-size: 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background-color: var(--input-bg);
color: var(--input-text);
}
.form-group input:focus {
outline: none;
border-color: var(--input-focus-border);
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.button {
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
text-decoration: none;
display: inline-block;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.button.secondary {
background: #6c757d;
}
.button.secondary:hover {
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
.button.success {
background: var(--gradient-success);
}
.button.success:hover {
box-shadow: 0 4px 12px rgba(86, 171, 47, 0.4);
}
.children-list {
display: grid;
gap: 15px;
margin-top: 20px;
}
.child-card {
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow-md);
}
.child-card h3 {
color: var(--text-primary);
margin-bottom: 10px;
font-size: 18px;
}
.child-info {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 15px;
}
.child-info p {
margin-bottom: 5px;
}
.child-actions {
display: flex;
gap: 10px;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.alert {
padding: 12px 20px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.hidden {
display: none;
}
.quick-action {
display: block;
width: 100%;
padding: 15px;
margin-bottom: 15px;
background: var(--bg-secondary);
border: 2px solid #667eea;
color: #667eea;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.quick-action:hover {
background: #667eea;
color: white;
transform: translateX(5px);
}
</style>
</head>
<body>
<div class="container">
<h1>⚙️ Settings</h1>
<p class="subtitle">Manage your account and children</p>
<div id="alert" class="hidden"></div>
<div class="section">
<h2>👤 Account Information</h2>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" disabled />
</div>
</div>
<div class="section">
<h2>🌓 Theme Preference</h2>
<p style="color: var(--text-secondary); margin-bottom: 20px; font-size: 14px">
Choose how Baby Monitor looks to you.
</p>
<div class="form-group">
<div style="display: flex; flex-direction: column; gap: 15px">
<label style="display: flex; align-items: center; cursor: pointer; padding: 12px; border: 2px solid var(--border-light); border-radius: 8px; transition: all 0.2s;" class="theme-option" data-theme="light">
<input type="radio" name="theme" value="light" style="margin-right: 12px; width: 20px; height: 20px; cursor: pointer;" />
<div>
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">☀️ Light Mode</div>
<div style="font-size: 12px; color: var(--text-secondary);">Always use light theme</div>
</div>
</label>
<label style="display: flex; align-items: center; cursor: pointer; padding: 12px; border: 2px solid var(--border-light); border-radius: 8px; transition: all 0.2s;" class="theme-option" data-theme="dark">
<input type="radio" name="theme" value="dark" style="margin-right: 12px; width: 20px; height: 20px; cursor: pointer;" />
<div>
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">🌙 Dark Mode</div>
<div style="font-size: 12px; color: var(--text-secondary);">Always use dark theme</div>
</div>
</label>
<label style="display: flex; align-items: center; cursor: pointer; padding: 12px; border: 2px solid var(--border-light); border-radius: 8px; transition: all 0.2s;" class="theme-option" data-theme="auto">
<input type="radio" name="theme" value="auto" style="margin-right: 12px; width: 20px; height: 20px; cursor: pointer;" />
<div>
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">🔄 Auto</div>
<div style="font-size: 12px; color: var(--text-secondary);">Match your browser settings</div>
</div>
</label>
</div>
</div>
</div>
<div class="section">
<h2>🔑 Change Password</h2>
<form id="userForm">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" />
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" />
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" />
</div>
<button type="submit" class="button" id="saveUserBtn">
💾 Save Changes
</button>
</form>
<div
style="
margin-top: 40px;
padding-top: 30px;
border-top: 2px solid #dc3545;
"
>
<h3 style="color: #dc3545; margin-bottom: 15px">⚠️ Danger Zone</h3>
<p style="color: var(--text-secondary); margin-bottom: 15px; font-size: 14px">
Deleting your account is permanent and cannot be undone. This will:
</p>
<ul
style="
color: var(--text-secondary);
margin-bottom: 20px;
margin-left: 20px;
font-size: 14px;
"
>
<li>Delete your account permanently</li>
<li>Delete all children that have no other parents</li>
<li>
Delete all logs (feedings, diaper changes, sleep) for orphaned
children
</li>
<li>
Remove you as a parent from shared children (they will remain for
other parents)
</li>
</ul>
<button
class="button"
style="
background: var(--gradient-error);
"
onclick="confirmDeleteAccount()"
>
🗑️ Delete Account
</button>
</div>
</div>
<div class="section">
<h2>👶 Your Children</h2>
<button
class="quick-action"
onclick="window.location.href='/add-child.html'"
>
Add New Child
</button>
<button
class="quick-action"
onclick="showRedeemCodeModal()"
style="border-color: #56ab2f; color: #56ab2f"
>
🔗 Redeem Share Code
</button>
<div id="childrenContent" class="loading">
<p>Loading children...</p>
</div>
</div>
</div>
<!-- Redeem Code Modal -->
<div
class="modal"
id="redeemCodeModal"
style="
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
"
>
<div
style="
background: var(--bg-secondary);
padding: 30px;
border-radius: 12px;
max-width: 400px;
width: 90%;
"
>
<h2 style="margin-top: 0; color: var(--text-primary);">🔗 Redeem Share Code</h2>
<p style="color: var(--text-secondary); margin-bottom: 20px">
Enter the share code to get access to a child.
</p>
<form id="redeemCodeForm">
<div class="form-group">
<label for="shareCode">Share Code</label>
<input
type="text"
id="shareCode"
placeholder="Enter code"
required
style="
width: 100%;
padding: 12px;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
background-color: var(--input-bg);
color: var(--input-text);
"
/>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px">
<button
type="button"
class="button secondary"
onclick="closeRedeemCodeModal()"
style="flex: 1"
>
Cancel
</button>
<button type="submit" class="button success" style="flex: 1">
Redeem
</button>
</div>
</form>
</div>
</div>
<!-- Share Code Modal -->
<div
class="modal"
id="shareCodeModal"
style="
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
"
>
<div
style="
background: var(--bg-secondary);
padding: 30px;
border-radius: 12px;
max-width: 400px;
width: 90%;
"
>
<h2 style="margin-top: 0; color: var(--text-primary);">🔗 Share Code</h2>
<p style="color: var(--text-secondary); margin-bottom: 20px" id="shareCodeChildName"></p>
<div
style="
background: var(--bg-tertiary);
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
"
>
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
"
>
<span
style="
font-size: 24px;
font-weight: bold;
color: var(--text-primary);
letter-spacing: 2px;
"
id="shareCodeValue"
></span>
<button
onclick="copyShareCode()"
style="
background: #667eea;
color: white;
border: none;
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
"
>
📋 Copy
</button>
</div>
</div>
<p style="color: var(--text-secondary); font-size: 14px; margin-bottom: 20px">
Share this code with another user. They can use it in Settings >
Redeem Share Code to get access to this child.
<br /><br />
This code expires in 7 days.
</p>
<button
type="button"
class="button"
onclick="closeShareCodeModal()"
style="width: 100%"
>
Close
</button>
</div>
</div>
<script src="/menu.js"></script>
<script>
const token = localStorage.getItem("access_token");
if (!token) {
window.location.href = "/login.html";
}
// Initialize burger menu
initBurgerMenu({ includeHome: true });
// Initialize theme selector
function initThemeSelector() {
const currentTheme = window.getThemePreference();
const themeRadio = document.querySelector(`input[name="theme"][value="${currentTheme}"]`);
if (themeRadio) {
themeRadio.checked = true;
}
// Add visual feedback for selected theme option
updateThemeOptionStyles();
// Listen for theme changes
document.querySelectorAll('input[name="theme"]').forEach(radio => {
radio.addEventListener('change', (e) => {
window.setTheme(e.target.value);
updateThemeOptionStyles();
});
});
}
function updateThemeOptionStyles() {
const selectedValue = document.querySelector('input[name="theme"]:checked')?.value;
document.querySelectorAll('.theme-option').forEach(option => {
const isSelected = option.dataset.theme === selectedValue;
if (isSelected) {
option.style.borderColor = '#667eea';
option.style.background = 'rgba(102, 126, 234, 0.05)';
} else {
option.style.borderColor = '#e0e0e0';
option.style.background = 'transparent';
}
});
}
// Initialize theme selector on load
initThemeSelector();
let currentUser = null;
let children = [];
// Load user data and children
async function loadData() {
try {
// Fetch user info
const userResponse = await fetch("/api/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (userResponse.ok) {
currentUser = await userResponse.json();
document.getElementById("username").value = currentUser.username;
} else {
window.location.href = "/login.html";
return;
}
// Fetch children
const childrenResponse = await fetch("/api/children", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (childrenResponse.ok) {
children = await childrenResponse.json();
displayChildren();
} else {
showAlert("Failed to load children", "error");
}
} catch (error) {
console.error("Error loading data:", error);
showAlert("Network error. Please try again.", "error");
}
}
function displayChildren() {
const container = document.getElementById("childrenContent");
if (children.length === 0) {
container.innerHTML = `
<p style="color: #666; text-align: center; padding: 20px;">
No children added yet. Click "Add New Child" to get started.
</p>
`;
return;
}
container.innerHTML = `
<div class="children-list">
${children
.map((child) => {
// Get list of other users (excluding current user)
const otherUsers =
child.parent_usernames && child.parent_usernames.length > 1
? child.parent_usernames.filter(
(username) => username !== currentUser.username,
)
: [];
return `
<div class="child-card">
<h3 style="margin: 0;">👶 ${child.name}</h3>
<div class="child-info">
<p><strong>Birth Date:</strong> ${formatDate(child.birth_time)}</p>
<p><strong>Birth Weight:</strong> ${child.birth_weight}g</p>
<p><strong>Added:</strong> ${formatDate(child.created_at)}</p>
${otherUsers.length > 0 ? `<p><strong>Shared with:</strong> ${otherUsers.join(", ")}</p>` : ""}
</div>
<div class="child-actions">
<button class="button secondary" onclick="editChild(${child.id})">
✏️ Edit
</button>
<button class="button success" onclick="shareChild(${child.id})">
🔗 Share
</button>
</div>
</div>
`;
})
.join("")}
</div>
`;
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function editChild(childId) {
const child = children.find((c) => c.id === childId);
if (!child) return;
// Redirect to add-child page with child data in query params
const params = new URLSearchParams({
id: child.id,
name: child.name,
birth_time: child.birth_time,
birth_weight: child.birth_weight,
});
window.location.href = `/add-child.html?${params.toString()}`;
}
async function shareChild(childId) {
const child = children.find((c) => c.id === childId);
if (!child) return;
try {
const response = await fetch("/api/children/invitations", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
child_id: childId,
}),
});
if (!response.ok) {
throw new Error("Failed to create invitation");
}
const invitation = await response.json();
// Show the share code modal
document.getElementById("shareCodeChildName").textContent =
`Share code for ${child.name}`;
document.getElementById("shareCodeValue").textContent =
invitation.code;
window.currentShareCode = invitation.code; // Store for copy function
document.getElementById("shareCodeModal").style.display = "flex";
} catch (error) {
console.error("Error creating share code:", error);
showAlert("Failed to create share code. Please try again.", "error");
}
}
function closeShareCodeModal() {
document.getElementById("shareCodeModal").style.display = "none";
window.currentShareCode = null;
}
function copyShareCode() {
const code = window.currentShareCode;
if (!code) return;
if (navigator.clipboard) {
navigator.clipboard
.writeText(code)
.then(() => {
showAlert("Share code copied to clipboard!", "success");
})
.catch((err) => {
console.error("Failed to copy to clipboard:", err);
showAlert(
"Failed to copy. Please copy the code manually.",
"error",
);
});
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
showAlert("Share code copied to clipboard!", "success");
} catch (err) {
showAlert(
"Failed to copy. Please copy the code manually.",
"error",
);
}
document.body.removeChild(textArea);
}
}
function showRedeemCodeModal() {
document.getElementById("redeemCodeModal").style.display = "flex";
}
function closeRedeemCodeModal() {
document.getElementById("redeemCodeModal").style.display = "none";
document.getElementById("redeemCodeForm").reset();
}
document
.getElementById("redeemCodeForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const code = document.getElementById("shareCode").value.trim();
try {
const response = await fetch("/api/children/invitations/redeem", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
// Show the specific error message from the backend in a popup
alert(error.detail || "Failed to redeem code");
return;
}
const result = await response.json();
alert(result.message);
closeRedeemCodeModal();
// Reload children list
await loadData();
} catch (error) {
console.error("Error redeeming code:", error);
// Display the actual error message in a popup
alert(error.message);
}
});
async function confirmDeleteAccount() {
const confirmed = confirm(
"⚠️ WARNING: This action cannot be undone!\n\n" +
"Are you absolutely sure you want to delete your account?\n\n" +
"This will:\n" +
"• Delete your account permanently\n" +
"• Delete all children that have no other parents\n" +
"• Delete ALL logs for orphaned children\n" +
"• Remove you as a parent from shared children\n\n" +
'Type "DELETE" in the next prompt to confirm.',
);
if (!confirmed) {
return;
}
const confirmText = prompt("Type DELETE to confirm account deletion:");
if (confirmText !== "DELETE") {
showAlert(
"Account deletion cancelled. You must type DELETE exactly.",
"info",
);
return;
}
try {
const response = await fetch("/api/account", {
method: "DELETE",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
if (!response.ok) {
throw new Error("Failed to delete account");
}
const result = await response.json();
// Show deletion summary
let message = "✅ Account deleted successfully!\n\n";
if (result.deleted_children && result.deleted_children.length > 0) {
message += `Deleted children: ${result.deleted_children.join(", ")}\n`;
}
if (result.deleted_logs) {
message += `\nDeleted logs:\n`;
message += `• Feedings: ${result.deleted_logs.feedings}\n`;
message += `• Diaper Changes: ${result.deleted_logs.diaper_changes}\n`;
message += `• Sleep Sessions: ${result.deleted_logs.sleep_sessions}`;
}
alert(message);
// Logout and redirect
localStorage.removeItem("token");
window.location.href = "/login.html";
} catch (error) {
console.error("Error deleting account:", error);
showAlert("Failed to delete account. Please try again.", "error");
}
}
function showAlert(message, type) {
const alert = document.getElementById("alert");
alert.className = `alert alert-${type}`;
alert.textContent = message;
alert.classList.remove("hidden");
setTimeout(() => {
alert.classList.add("hidden");
}, 5000);
}
// Handle user form submission
document
.getElementById("userForm")
.addEventListener("submit", async (e) => {
e.preventDefault();
const currentPassword =
document.getElementById("currentPassword").value;
const newPassword = document.getElementById("newPassword").value;
const confirmPassword =
document.getElementById("confirmPassword").value;
// Validate passwords
if (newPassword && newPassword !== confirmPassword) {
alert("New passwords do not match");
return;
}
if (newPassword && !currentPassword) {
alert("Current password is required to change password");
return;
}
// If no password change requested, just show success
if (!newPassword) {
alert("No changes to save");
return;
}
const saveBtn = document.getElementById("saveUserBtn");
saveBtn.disabled = true;
saveBtn.textContent = "Saving...";
try {
const response = await fetch("/api/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to change password");
}
const result = await response.json();
alert(result.message);
// Clear form
document.getElementById("userForm").reset();
} catch (error) {
console.error("Error updating password:", error);
alert(error.message || "Failed to update password");
} finally {
saveBtn.disabled = false;
saveBtn.textContent = "💾 Save Changes";
}
});
// Load data on page load
loadData();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
/* Theme Initialization Script
* Handles theme detection and application based on localStorage preference or browser settings
* Must be loaded in <head> before page content to prevent flash of unstyled content
*/
(function() {
'use strict';
// Get saved theme preference from localStorage
const savedTheme = localStorage.getItem('theme-preference');
// Apply theme immediately to prevent FOUC (Flash of Unstyled Content)
function applyTheme(theme) {
const body = document.body || document.documentElement;
if (theme === 'dark') {
body.classList.remove('light-mode');
body.classList.add('dark-mode');
} else if (theme === 'light') {
body.classList.remove('dark-mode');
body.classList.add('light-mode');
} else {
// 'auto' mode - remove manual override classes
body.classList.remove('dark-mode', 'light-mode');
}
}
// Apply theme based on preference
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'auto') {
applyTheme(savedTheme);
}
// If no preference saved, default to 'auto' (browser preference)
// Make theme switching function available globally
window.setTheme = function(theme) {
if (theme === 'light' || theme === 'dark' || theme === 'auto') {
localStorage.setItem('theme-preference', theme);
applyTheme(theme);
// Trigger custom event for components that need to update (e.g., charts)
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } }));
}
};
// Get current effective theme (resolves 'auto' to actual theme)
window.getEffectiveTheme = function() {
const savedTheme = localStorage.getItem('theme-preference');
if (savedTheme === 'dark') return 'dark';
if (savedTheme === 'light') return 'light';
// 'auto' or no preference - check system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
};
// Get saved theme preference (returns 'light', 'dark', 'auto', or null)
window.getThemePreference = function() {
return localStorage.getItem('theme-preference') || 'auto';
};
// Listen for system theme changes when in auto mode
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', function(e) {
const savedTheme = localStorage.getItem('theme-preference');
// Only react to system changes if in auto mode
if (!savedTheme || savedTheme === 'auto') {
// Trigger theme change event for components that need to update
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: 'auto' } }));
}
});
}
})();
+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")
@@ -0,0 +1,28 @@
"""Definition of verify_child_access function."""
from fastapi import HTTPException
from baby_monitor.repositories.interfaces import (
ChildRepositoryInterface,
)
def verify_child_access(
child_repo: ChildRepositoryInterface, child_id: int, user_id: int
) -> None:
"""Verify that user has access to the child.
Args:
child_repo: Child repository instance
child_id: ID of the child to verify access for
user_id: ID of the user requesting access
Raises:
HTTPException: 404 if child not found, 403 if user is not a parent
"""
child = child_repo.get_by_id(child_id)
if not child:
raise HTTPException(status_code=404, detail="Child not found")
parent_ids = child_repo.get_parent_ids(child_id)
if user_id not in parent_ids:
raise HTTPException(status_code=403, detail="Access denied to this 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.