51 Commits
Author SHA1 Message Date
brian 120c7ec309 Merge pull request 'Make recent activity cards dynamic' (#45) from make-recent-activity-last-sleep-dynamic into main
Python Code Quality / python-code-quality (push) Successful in 12s
Python Test / python-test (push) Successful in 21s
Build and Push Docker Image / build-and-push (push) Successful in 39s
Reviewed-on: https://gitea.lille-vemmelund.dk/brian/baby-monitor/pulls/45
2026-05-21 15:29:16 +02:00
Brian Bjarke Jensen 6c74358d8b changed formatting of recent activity cards
Build and Push Docker Image / build-and-push (pull_request) Successful in 3m23s
Python Code Quality / python-code-quality (pull_request) Successful in 13s
Python Test / python-test (pull_request) Successful in 20s
2026-05-21 15:20:42 +02:00
brian 993612a90f Merge pull request 'added support for setting start time of sleep entry' (#44) from fix(#43)--enable-editing-sleep-entries into main
Python Code Quality / python-code-quality (push) Successful in 12s
Python Test / python-test (push) Successful in 20s
Build and Push Docker Image / build-and-push (push) Successful in 41s
Refresh Showcase Data / refresh-data (push) Successful in 22s
Reviewed-on: #44
2026-03-29 20:29:28 +02:00
Brian Bjarke Jensen 0b06aefa03 added support for setting start time of sleep entry
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m25s
Python Code Quality / python-code-quality (pull_request) Successful in 12s
Python Test / python-test (pull_request) Successful in 21s
2026-03-29 20:22:26 +02:00
brian ff88d0fcb6 Merge pull request 'implement-ready-endpoint-logic' (#42) from implement-ready-endpoint-logic into main
Build and Push Docker Image / build-and-push (push) Successful in 27s
Python Code Quality / python-code-quality (push) Successful in 10s
Python Test / python-test (push) Successful in 19s
Refresh Showcase Data / refresh-data (push) Successful in 47s
Reviewed-on: #42
2026-01-27 21:26:06 +01:00
Brian Bjarke Jensen ec0e1f6140 code quality changes
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m3s
Python Code Quality / python-code-quality (pull_request) Successful in 10s
Python Test / python-test (pull_request) Successful in 17s
2026-01-27 21:21:55 +01:00
Brian Bjarke Jensen b2aa71b95b updated ready endpoints logic
Build and Push Docker Image / build-and-push (pull_request) Successful in 1m6s
Python Code Quality / python-code-quality (pull_request) Failing after 10s
Python Test / python-test (pull_request) Successful in 18s
2026-01-27 21:17:47 +01:00
Brian Bjarke Jensen 826cfde2ab recreated lock file 2026-01-27 21:17:34 +01:00
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
35 changed files with 3248 additions and 869 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"
+1 -1
View File
@@ -34,7 +34,7 @@ services:
interval: 10s
timeout: 5s
retries: 5
start_period: 1s
start_period: 3s
postgres:
image: postgres:14-alpine
environment:
+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,
}
+24
View File
@@ -119,6 +119,12 @@ def serve_sleep() -> FileResponse:
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."""
@@ -137,6 +143,24 @@ def serve_menu_js() -> FileResponse:
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
View File
@@ -20,3 +20,5 @@ class ChildResponse(BaseModel):
birth_time: datetime
birth_weight: float
created_at: datetime
parent_count: int | None = None
parent_usernames: list[str] | None = None
+2
View File
@@ -9,11 +9,13 @@ class CreateSleepRequest(BaseModel):
child_id: int = Field(..., gt=0)
start_time: datetime
end_time: datetime | None = None
class UpdateSleepRequest(BaseModel):
"""Request model for updating a sleep log."""
start_time: datetime | None = None
end_time: datetime | None = None
@@ -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,
)
@@ -12,6 +12,7 @@ class SleepRepositoryInterface(ABC):
self,
child_id: int,
start_time: datetime,
end_time: datetime | None = None,
) -> dict:
"""Create a new sleep log entry."""
@@ -31,6 +32,7 @@ class SleepRepositoryInterface(ABC):
def update(
self,
sleep_id: int,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> dict | None:
"""Update a sleep log entry."""
@@ -27,12 +27,13 @@ class DatabaseSleepRepository(SleepRepositoryInterface):
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=None,
end_time=end_time,
)
self.db.add(sleep)
self.db.commit()
@@ -72,6 +73,7 @@ class DatabaseSleepRepository(SleepRepositoryInterface):
def update(
self,
sleep_id: int,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> dict | None:
"""Update a sleep log entry."""
@@ -79,6 +81,8 @@ class DatabaseSleepRepository(SleepRepositoryInterface):
if not sleep:
return None
if start_time is not None:
sleep.start_time = start_time # type: ignore[assignment]
if end_time is not None:
sleep.end_time = end_time # type: ignore[assignment]
@@ -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
+22 -1
View File
@@ -11,10 +11,12 @@ from baby_monitor.routers.auth import verify_token
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"])
@@ -62,10 +64,29 @@ def create_child(
def get_user_children(
user_id: Annotated[int, Depends(verify_token)],
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)
+50 -7
View File
@@ -1,6 +1,7 @@
"""Health check router for monitoring endpoints."""
from fastapi import APIRouter
import os
from fastapi import APIRouter, HTTPException
router = APIRouter(tags=["health"])
@@ -20,11 +21,53 @@ def readiness_check() -> dict[str, str]:
"""
Readiness check endpoint for container orchestration.
Returns 200 if the service is ready to accept traffic.
This can include checks for database connectivity, external services, etc.
Returns 200 if the service is ready to accept traffic, else 503.
"""
# Add dependency checks here if needed (database, cache, etc.)
# For now, simple readiness check
# Determine if configured to use Redis
redis_uri = os.getenv("REDIS_URI")
# If Redis is used, check connectivity
if redis_uri:
try:
import redis
redis_client = redis.from_url(redis_uri)
redis_client.ping()
except Exception as exc:
raise HTTPException(
status_code=503,
detail={
"status": "not ready",
"reason": "No connection to Redis",
"exception": str(exc),
},
) from exc
# Determine if configured to use PostgreSQL
postgres_uri = os.getenv("POSTGRES_URI")
# If PostgreSQL is used, check connectivity
if postgres_uri:
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
engine = create_engine(postgres_uri)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
with SessionLocal() as db:
db.execute(text("SELECT 1"))
except Exception as exc:
raise HTTPException(
status_code=503,
detail={
"status": "not ready",
"reason": "No connection to PostgreSQL",
"exception": str(exc),
},
) from exc
# Unable to prove not ready, so assume ready
return {"status": "ready"}
@@ -60,9 +103,9 @@ def service_info() -> dict:
# Check current configuration
config = {
"environment": os.getenv("ENVIRONMENT", "production"),
"data_dir": os.getenv("DATA_DIR", "/data"),
"redis_configured": bool(os.getenv("REDIS_URI")),
}
if not features["postgresql"]:
config["data_dir"] = os.getenv("DATA_DIR", "/data")
return {
"service": "baby-monitor",
+2
View File
@@ -36,6 +36,7 @@ def create_sleep(
sleep = sleep_repo.create(
child_id=request.child_id,
start_time=request.start_time,
end_time=request.end_time,
)
return SleepResponse(**sleep)
@@ -111,6 +112,7 @@ def update_sleep(
updated = sleep_repo.update(
sleep_id=sleep_id,
start_time=request.start_time,
end_time=request.end_time,
)
+17 -11
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>
@@ -313,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(
@@ -338,7 +343,8 @@
}
function goBack() {
window.location.href = "/";
// Redirect to settings page if editing, otherwise home page
window.location.href = isEditMode ? "/settings.html" : "/";
}
function skipToHome() {
+21 -18
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 {
@@ -193,7 +196,7 @@
.search-box {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
margin-bottom: 15px;
@@ -202,7 +205,7 @@
.user-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
border: 1px solid var(--border-color);
border-radius: 4px;
}
@@ -211,8 +214,8 @@
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid #eee;
background: white;
border-bottom: 1px solid var(--border-lighter);
background: var(--bg-secondary);
}
.user-item:last-child {
@@ -220,7 +223,7 @@
}
.user-item:hover {
background: #f8f9fa;
background: var(--bg-tertiary);
}
.user-info {
@@ -229,13 +232,13 @@
.user-name {
font-weight: 600;
color: #333;
color: var(--text-primary);
margin-bottom: 4px;
}
.user-meta {
font-size: 12px;
color: #666;
color: var(--text-secondary);
}
.admin-badge {
@@ -506,8 +509,8 @@
ID: ${user.id} • Created: ${new Date(user.created_at).toLocaleDateString()}
</div>
</div>
<button
class="delete-user-btn"
<button
class="delete-user-btn"
onclick="showDeleteModal(${user.id}, '${user.username}', ${user.is_admin})"
${user.is_admin ? 'disabled title="Cannot delete admin users"' : ""}
>
+207
View File
@@ -0,0 +1,207 @@
/* 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;
}
+94 -39
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
* {
@@ -16,7 +19,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background-color: #f0f0f0;
background-color: var(--bg-primary);
min-height: 100vh;
max-width: 800px;
margin: 2rem auto;
@@ -24,26 +27,27 @@
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
}
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;
@@ -79,13 +83,13 @@
.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 {
@@ -94,7 +98,7 @@
}
.empty-state h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
}
@@ -105,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;
}
@@ -150,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;
}
@@ -180,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 {
@@ -190,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 {
@@ -209,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 {
@@ -280,7 +292,10 @@
let allChildren = [];
let currentDaysRange =
parseInt(localStorage.getItem("lastDiaperTimeRange")) || 7;
let selectedChildId = null; // null means "All Children"
let selectedChildId = null; // null means show first/only child
let barChartInstance = null; // Track chart instance for proper cleanup
let poopPieChartInstance = null;
let peePieChartInstance = null;
// Restore last selected child from localStorage
const lastChildId = localStorage.getItem("lastDiaperChildFilter");
@@ -321,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);
@@ -334,8 +354,7 @@
}
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);
}
@@ -349,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 {
@@ -367,11 +387,24 @@
content.innerHTML = `
<div class="controls">
<label for="childFilter">Child:</label>
<select id="childFilter" onchange="onChildChange(event)">
<option value="all" ${selectedChildId === null ? "selected" : ""}>All Children</option>
${childOptions}
</select>
${
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)">
${childOptions}
</select>
`
}
<label for="daysRange">Time Range:</label>
<select id="daysRange" onchange="onDaysRangeChange(event)">
@@ -764,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,
@@ -819,7 +857,7 @@
},
},
scales: {
y: {
y: getChartScaleOptions("y", {
beginAtZero: true,
title: {
display: true,
@@ -835,10 +873,7 @@
size: 12,
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
}),
x: {
ticks: {
font: {
@@ -857,6 +892,11 @@
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)",
@@ -883,7 +923,7 @@
(label) => colors[label]?.border || "rgba(200, 200, 200, 1)",
);
new Chart(ctx, {
poopPieChartInstance = new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
@@ -939,6 +979,11 @@
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)",
@@ -965,7 +1010,7 @@
(label) => colors[label]?.border || "rgba(200, 200, 200, 1)",
);
new Chart(ctx, {
peePieChartInstance = new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
@@ -1051,6 +1096,16 @@
// 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>
+125 -72
View File
@@ -4,6 +4,9 @@
<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>
* {
@@ -16,7 +19,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background-color: #f0f0f0;
background-color: var(--bg-primary);
min-height: 100vh;
max-width: 800px;
margin: 2rem auto;
@@ -24,26 +27,27 @@
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
}
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;
@@ -79,13 +83,13 @@
.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 {
@@ -94,7 +98,7 @@
}
.empty-state h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
}
@@ -105,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;
}
@@ -148,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;
@@ -163,7 +167,7 @@
}
.stat-card .stat-value {
color: #333;
color: var(--text-primary);
font-size: 32px;
font-weight: 700;
}
@@ -182,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 {
@@ -204,7 +221,7 @@
}
.feedings-list h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 20px;
font-size: 20px;
}
@@ -212,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 {
@@ -222,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 {
@@ -241,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 {
@@ -317,8 +334,10 @@
let allChildren = [];
let currentDaysRange =
parseInt(localStorage.getItem("lastFeedingTimeRange")) || 7;
let selectedChildId = null; // null means "All Children"
let selectedChildId = null; // null means show first/only child
let barChartInstance = null;
let pieChartInstance = null;
let durationChartInstance = null;
// Restore last selected child from localStorage
const lastChildId = localStorage.getItem("lastFeedingChildFilter");
@@ -359,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);
@@ -372,8 +396,7 @@
}
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);
}
@@ -381,35 +404,52 @@
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
const barChartData = prepareBarChartData(feedings);
const pieChartData = preparePieChartData(feedings);
const durationChartData = prepareDurationChartData(feedings);
return;
}
// Build child dropdown options
const childOptions = allChildren
.map(
(child) =>
`<option value="${child.id}" ${selectedChildId === child.id ? "selected" : ""}>${child.name}</option>`,
)
.join("");
// Prepare chart data (works with empty arrays too)
const barChartData = prepareBarChartData(feedings);
const pieChartData = preparePieChartData(feedings);
const durationChartData = prepareDurationChartData(feedings);
content.innerHTML = `
// Build child dropdown options
const childOptions = allChildren
.map(
(child) =>
`<option value="${child.id}" ${selectedChildId === child.id ? "selected" : ""}>${child.name}</option>`,
)
.join("");
content.innerHTML = `
<div class="controls">
<label for="childFilter">Child:</label>
<select id="childFilter" onchange="onChildChange(event)">
<option value="all" ${selectedChildId === null ? "selected" : ""}>All Children</option>
${childOptions}
</select>
${
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)">
${childOptions}
</select>
`
}
<label for="daysRange">Time Range:</label>
<select id="daysRange" onchange="onDaysRangeChange(event)">
@@ -446,14 +486,13 @@
</div>
`;
// Render charts
renderBarChart(barChartData);
renderPieChart(pieChartData);
renderDurationChart(durationChartData);
// Render charts
renderBarChart(barChartData);
renderPieChart(pieChartData);
renderDurationChart(durationChartData);
// Render feeding list
renderFeedingList(feedings);
}
// Render feeding list
renderFeedingList(feedings);
}
function renderFeedingList(feedings) {
@@ -901,7 +940,7 @@
display: false,
},
},
y: {
y: getChartScaleOptions("y", {
stacked: true,
beginAtZero: true,
title: {
@@ -920,10 +959,7 @@
return Math.round(value) + "m";
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
}),
},
plugins: {
legend: {
@@ -961,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": {
@@ -985,7 +1026,7 @@
(label) => colorMap[label]?.border || "rgba(200, 200, 200, 1)",
);
new Chart(ctx, {
pieChartInstance = new Chart(ctx, {
type: "pie",
data: {
labels: chartData.labels,
@@ -1041,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,
@@ -1081,7 +1127,7 @@
},
},
scales: {
y: {
y: getChartScaleOptions("y", {
beginAtZero: true,
title: {
display: true,
@@ -1097,11 +1143,8 @@
size: 12,
},
},
grid: {
color: "rgba(0, 0, 0, 0.05)",
},
},
x: {
}),
x: getChartScaleOptions("x", {
title: {
display: true,
text: "Duration (minutes)",
@@ -1118,7 +1161,7 @@
grid: {
display: false,
},
},
}),
},
},
});
@@ -1157,6 +1200,16 @@
// 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>
+289 -54
View File
@@ -4,27 +4,37 @@
<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>
<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;
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;
}
.greeting {
color: #666;
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 2rem;
}
@@ -59,7 +69,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,
@@ -68,13 +80,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;
@@ -97,7 +112,7 @@
display: flex;
}
.modal-content {
background: white;
background: var(--bg-secondary);
padding: 2rem;
border-radius: 8px;
max-width: 400px;
@@ -107,7 +122,7 @@
.modal h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
color: var(--text-primary);
}
.form-group {
margin-bottom: 1rem;
@@ -115,13 +130,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;
}
@@ -145,7 +160,9 @@
padding: 2rem;
font-size: 1.5rem;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-blue);
background-color: #4facfe;
color: white;
border-radius: 8px;
transition:
transform 0.2s,
@@ -153,20 +170,69 @@
}
.sleep-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
box-shadow: 0 6px 20px rgba(79, 172, 254, 0.4);
background: var(--gradient-blue);
background-color: #4facfe;
}
.sleep-button.active {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
background: var(--gradient-blue);
background-color: #4facfe;
}
.sleep-button.active:hover {
background: linear-gradient(135deg, #00f2fe 0%, #4facfe 100%);
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>
@@ -240,6 +306,9 @@
const token = localStorage.getItem("access_token");
const username = localStorage.getItem("username");
// Initialize burger menu
initBurgerMenu({ includeHome: true });
// Check if user is logged in
if (!token) {
window.location.href = "/login.html";
@@ -262,11 +331,6 @@
}
})
.then((user) => {
// Initialize burger menu
initBurgerMenu({
includeHome: true,
});
// Check if user has any children
return fetch("/api/children", {
headers: {
@@ -321,6 +385,7 @@
let activeSleep = null;
let lastSleep = null;
let sleepUpdateTimerInterval = null;
let lastDiaperChange = null;
async function loadFeedingStatus(children) {
userChildren = children;
@@ -337,8 +402,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}`,
},
@@ -347,8 +412,68 @@
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;
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,
};
}
}
@@ -376,6 +501,20 @@
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) {
console.error("Error loading feeding status:", error);
}
@@ -449,7 +588,7 @@
</button>
`
: `
<button class="feeding-button" onclick="showStartFeedingModal()">
<button class="feeding-button" onclick="window.location.href='/log-feeding.html'">
🍼 Started Feeding
</button>
`;
@@ -478,9 +617,100 @@
</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">${lastFeeding.end_time ? `stopped ${formatTime(lastFeeding.end_time)}` : `started ${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">${lastSleep.end_time ? `stopped ${formatTime(lastSleep.end_time)}` : `started ${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",
@@ -510,18 +740,40 @@
const childSelect = document.getElementById("modalChildId");
const typeSelect = document.getElementById("modalFeedingType");
// Clear and populate child select
childSelect.innerHTML = '<option value="">Select a child</option>';
userChildren.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
// 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");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
// Set defaults based on last feeding
// Set defaults based on last feeding
if (lastFeeding) {
childSelect.value = lastFeeding.child_id;
}
}
// Set default feeding type based on last feeding
if (lastFeeding) {
childSelect.value = lastFeeding.child_id;
typeSelect.value = lastFeeding.feeding_type;
}
@@ -630,24 +882,7 @@
}
function showStartSleepModal() {
const modal = document.getElementById("startSleepModal");
const childSelect = document.getElementById("modalSleepChildId");
// Clear and populate child select
childSelect.innerHTML = '<option value="">Select a child</option>';
userChildren.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
// Set default based on last sleep
if (lastSleep) {
childSelect.value = lastSleep.child_id;
}
modal.classList.add("show");
window.location.href = "/log-sleep.html";
}
function closeStartSleepModal() {
+93 -29
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 {
@@ -235,7 +245,7 @@
<button
type="button"
class="button secondary"
onclick="window.location.href='/'"
onclick="window.location.href = getReturnUrl()"
>
Cancel
</button>
@@ -249,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");
@@ -277,12 +297,31 @@
children = await response.json();
const select = document.getElementById("childSelect");
children.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
select.appendChild(option);
});
// 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) {
@@ -295,14 +334,39 @@
.toISOString()
.slice(0, 16);
// Load last used child from localStorage
const lastDiaperChange = JSON.parse(
localStorage.getItem("lastDiaperChange") || "{}",
);
// Load last used child from localStorage (only for multiple children)
if (children.length > 1) {
const lastDiaperChange = JSON.parse(
localStorage.getItem("lastDiaperChange") || "{}",
);
if (lastDiaperChange.child_id) {
document.getElementById("childSelect").value =
lastDiaperChange.child_id;
if (lastDiaperChange.child_id) {
document.getElementById("childSelect").value =
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 {
@@ -431,7 +495,7 @@
"success",
);
setTimeout(() => {
window.location.href = "/";
window.location.href = getReturnUrl();
}, 1000);
} else {
const errorData = await response.json();
+74 -20
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");
@@ -259,13 +279,31 @@
return;
}
// Populate select dropdown
children.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childSelect.appendChild(option);
});
// 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";
@@ -274,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");
@@ -369,9 +423,9 @@
: "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(
@@ -394,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%;
+21 -11
View File
@@ -5,18 +5,21 @@
left: 1rem;
cursor: pointer;
z-index: 1000;
background: white;
background: var(--bg-secondary, #ffffff);
padding: 0.5rem;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
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: #333;
background-color: var(--text-primary, #333333);
margin: 5px 0;
transition: 0.3s;
transition: background-color 0.3s;
}
.menu-backdrop {
@@ -37,14 +40,17 @@
.menu-overlay {
position: fixed;
top: 0;
left: -250px;
left: -100%;
width: 250px;
height: 100vh;
background: white;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
transition: left 0.3s;
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 {
@@ -53,13 +59,17 @@
.menu-item {
padding: 1rem;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--border-color, #eeeeee);
cursor: pointer;
transition: background 0.2s;
transition:
background 0.2s,
color 0.3s,
border-color 0.3s;
color: var(--text-primary, #333333);
}
.menu-item:hover {
background: #f5f5f5;
background: var(--bg-tertiary, #f5f5f5);
}
.menu-item.admin {
+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">
+255 -44
View File
@@ -4,6 +4,8 @@
<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>
* {
@@ -16,7 +18,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background-color: #f0f0f0;
background-color: var(--bg-primary);
min-height: 100vh;
max-width: 800px;
margin: 2rem auto;
@@ -24,20 +26,20 @@
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
}
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;
}
@@ -45,12 +47,12 @@
.section {
margin-bottom: 40px;
padding: 20px;
background: #f8f9fa;
background: var(--bg-tertiary);
border-radius: 8px;
}
.section h2 {
color: #333;
color: var(--text-primary);
font-size: 20px;
margin-bottom: 20px;
display: flex;
@@ -65,7 +67,7 @@
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
}
@@ -73,24 +75,27 @@
.form-group input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
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: #667eea;
border-color: var(--input-focus-border);
}
.form-group input:disabled {
background-color: #f5f5f5;
opacity: 0.6;
cursor: not-allowed;
}
.button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: var(--gradient-purple);
background-color: #667eea;
color: white;
border: none;
padding: 12px 24px;
@@ -125,7 +130,7 @@
}
.button.success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
background: var(--gradient-success);
}
.button.success:hover {
@@ -139,20 +144,20 @@
}
.child-card {
background: white;
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
.child-card h3 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
font-size: 18px;
}
.child-info {
color: #666;
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 15px;
}
@@ -169,7 +174,7 @@
.loading {
text-align: center;
padding: 40px;
color: #666;
color: var(--text-secondary);
}
.alert {
@@ -200,7 +205,7 @@
width: 100%;
padding: 15px;
margin-bottom: 15px;
background: white;
background: var(--bg-secondary);
border: 2px solid #667eea;
color: #667eea;
border-radius: 8px;
@@ -233,6 +238,140 @@
</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">
@@ -261,12 +400,18 @@
"
>
<h3 style="color: #dc3545; margin-bottom: 15px">⚠️ Danger Zone</h3>
<p style="color: #666; margin-bottom: 15px; font-size: 14px">
<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: #666;
color: var(--text-secondary);
margin-bottom: 20px;
margin-left: 20px;
font-size: 14px;
@@ -285,9 +430,7 @@
</ul>
<button
class="button"
style="
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
"
style="background: var(--gradient-error)"
onclick="confirmDeleteAccount()"
>
🗑️ Delete Account
@@ -300,7 +443,7 @@
<button
class="quick-action"
onclick="window.location.href='/add-child.html'"
onclick="window.location.href = '/add-child.html'"
>
Add New Child
</button>
@@ -338,15 +481,17 @@
>
<div
style="
background: white;
background: var(--bg-secondary);
padding: 30px;
border-radius: 12px;
max-width: 400px;
width: 90%;
"
>
<h2 style="margin-top: 0">🔗 Redeem Share Code</h2>
<p style="color: #666; margin-bottom: 20px">
<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">
@@ -360,9 +505,11 @@
style="
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border: 2px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 14px;
background-color: var(--input-bg);
color: var(--input-text);
"
/>
</div>
@@ -402,18 +549,21 @@
>
<div
style="
background: white;
background: var(--bg-secondary);
padding: 30px;
border-radius: 12px;
max-width: 400px;
width: 90%;
"
>
<h2 style="margin-top: 0">🔗 Share Code</h2>
<p style="color: #666; margin-bottom: 20px" id="shareCodeChildName"></p>
<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: #f8f9fa;
background: var(--bg-tertiary);
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
@@ -430,7 +580,7 @@
style="
font-size: 24px;
font-weight: bold;
color: #333;
color: var(--text-primary);
letter-spacing: 2px;
"
id="shareCodeValue"
@@ -451,7 +601,13 @@
</button>
</div>
</div>
<p style="color: #666; font-size: 14px; margin-bottom: 20px">
<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 />
@@ -478,6 +634,47 @@
// 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 = [];
@@ -533,14 +730,23 @@
container.innerHTML = `
<div class="children-list">
${children
.map(
(child) => `
.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>👶 ${child.name}</h3>
<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})">
@@ -551,8 +757,8 @@
</button>
</div>
</div>
`,
)
`;
})
.join("")}
</div>
`;
@@ -568,12 +774,17 @@
}
function editChild(childId) {
// Redirect to a child edit page or show a modal
// For now, just show an alert
const child = children.find((c) => c.id === childId);
if (child) {
alert(`Edit functionality for ${child.name} coming soon!`);
}
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) {
+137 -71
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sleep - Baby Monitor</title>
<link rel="stylesheet" href="/dark-mode.css" />
<script src="/theme-init.js"></script>
<script src="/chart-theme.js"></script>
<link rel="stylesheet" href="/menu.css" />
<style>
* {
@@ -16,7 +19,7 @@
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background-color: #f0f0f0;
background-color: var(--bg-primary);
min-height: 100vh;
max-width: 800px;
margin: 2rem auto;
@@ -24,26 +27,27 @@
}
.container {
background: white;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
}
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, #4facfe 0%, #00f2fe 100%);
background: var(--gradient-blue);
background-color: #4facfe;
color: white;
border: none;
padding: 12px 24px;
@@ -79,13 +83,13 @@
.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 {
@@ -94,7 +98,7 @@
}
.empty-state h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 10px;
}
@@ -105,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;
}
@@ -148,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;
@@ -169,13 +173,13 @@
}
.list-container {
background: #f8f9fa;
background: var(--bg-tertiary);
border-radius: 8px;
padding: 20px;
}
.list-container h2 {
color: #333;
color: var(--text-primary);
margin-bottom: 20px;
font-size: 20px;
}
@@ -186,14 +190,14 @@
}
.sleep-item {
background: white;
background: var(--bg-secondary);
padding: 16px;
border-radius: 6px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
box-shadow: var(--shadow-sm);
}
.sleep-item:last-child {
@@ -206,12 +210,12 @@
.sleep-child {
font-weight: 600;
color: #333;
color: var(--text-primary);
margin-bottom: 4px;
}
.sleep-time {
color: #666;
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 4px;
}
@@ -252,23 +256,31 @@
.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: #4facfe;
}
.controls input[readonly] {
opacity: 0.6;
cursor: default;
}
.badge {
display: inline-block;
padding: 4px 8px;
@@ -328,27 +340,33 @@
let allChildren = [];
let selectedChildId = "";
// Filter change handlers
document
.getElementById("childFilter")
.addEventListener("change", function () {
selectedChildId = this.value;
// Save selection to localStorage
if (selectedChildId) {
localStorage.setItem("lastSleepChildFilter", selectedChildId);
} else {
localStorage.removeItem("lastSleepChildFilter");
}
renderCharts();
});
// Filter change handlers (will be attached after children are loaded)
function attachChildFilterListener() {
const childFilter = document.getElementById("childFilter");
if (childFilter) {
childFilter.addEventListener("change", function () {
selectedChildId = this.value;
// Save selection to localStorage
if (selectedChildId) {
localStorage.setItem("lastSleepChildFilter", selectedChildId);
} else {
localStorage.removeItem("lastSleepChildFilter");
}
renderCharts();
});
}
}
document
.getElementById("timeRangeFilter")
.addEventListener("change", function () {
// Save selection to localStorage
localStorage.setItem("lastSleepTimeRange", this.value);
loadData();
});
function attachTimeRangeListener() {
const timeRangeFilter = document.getElementById("timeRangeFilter");
if (timeRangeFilter) {
timeRangeFilter.addEventListener("change", function () {
// Save selection to localStorage
localStorage.setItem("lastSleepTimeRange", this.value);
renderCharts();
});
}
}
async function loadData() {
try {
@@ -365,19 +383,51 @@
allChildren = await childrenResponse.json();
// Populate child filter
const childFilter = document.getElementById("childFilter");
childFilter.innerHTML = '<option value="">All Children</option>';
allChildren.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childFilter.appendChild(option);
});
// Auto-select first child if available
if (allChildren.length > 0 && !selectedChildId) {
selectedChildId = allChildren[0].id;
}
// Populate child filter or show single child
const childFilterContainer = document.querySelector(".controls");
if (allChildren.length === 1) {
// Single child: show as read-only text
const child = allChildren[0];
childFilterContainer.innerHTML = `
<label for="childDisplay">Child:</label>
<input
type="text"
id="childDisplay"
value="${child.name}"
readonly
/>
<label for="timeRangeFilter">Time Range:</label>
<select id="timeRangeFilter">
<option value="1">Last 24 hours</option>
<option value="3">Last 3 days</option>
<option value="7" selected>Last 7 days</option>
<option value="14">Last 14 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
`;
} else {
// Multiple children: show dropdown
const childFilter = document.getElementById("childFilter");
childFilter.innerHTML = "";
allChildren.forEach((child) => {
const option = document.createElement("option");
option.value = child.id;
option.textContent = child.name;
childFilter.appendChild(option);
});
}
// Restore last selected child from localStorage
const lastChildId = localStorage.getItem("lastSleepChildFilter");
if (lastChildId) {
if (lastChildId && allChildren.length > 1) {
const childFilter = document.getElementById("childFilter");
childFilter.value = lastChildId;
selectedChildId = lastChildId;
}
@@ -402,6 +452,14 @@
allSleeps = await sleepResponse.json();
// Attach event listener for child filter if multiple children
if (allChildren.length > 1) {
attachChildFilterListener();
}
// Always attach time range listener after recreating the controls
attachTimeRangeListener();
renderCharts();
} catch (error) {
console.error("Error loading data:", error);
@@ -432,19 +490,7 @@
return matchesTimeRange && matchesChild;
});
if (filteredSleeps.length === 0) {
document.getElementById("content").innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">😴</div>
<h2>No Sleep Data</h2>
<p>Start tracking sleep sessions from the home page.</p>
<button class="button" onclick="window.location.href='/'">Go Home</button>
</div>
`;
return;
}
// Calculate stats
// Calculate stats (works with empty arrays too)
const completedSleeps = filteredSleeps.filter(
(sleep) => sleep.end_time,
);
@@ -517,7 +563,10 @@
</div>
<div class="list-container">
<h2>Recent Sleep Sessions</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">Recent Sleep Sessions</h2>
<button class="button" onclick="addManualSleep()" style="padding: 8px 16px; font-size: 14px;"> Add Manual Entry</button>
</div>
<div class="sleep-list">
${filteredSleeps
.slice(0, 50)
@@ -846,7 +895,7 @@
x: {
stacked: true,
},
y: {
y: getChartScaleOptions("y", {
stacked: true,
beginAtZero: true,
title: {
@@ -862,7 +911,7 @@
}
},
},
},
}),
},
plugins: {
legend: {
@@ -1007,14 +1056,14 @@
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
y: getChartScaleOptions("y", {
beginAtZero: true,
ticks: {
callback: function (value) {
return value + "m";
},
},
},
}),
},
plugins: {
legend: {
@@ -1054,10 +1103,17 @@
</div>
<div class="sleep-duration">${durationText}</div>
</div>
<div class="sleep-actions">
<button class="edit-button" onclick="window.location.href='/log-sleep.html?id=${sleep.id}'">Edit</button>
</div>
</div>
`;
}
function addManualSleep() {
window.location.href = `/log-sleep.html`;
}
async function logout() {
try {
await fetch("/api/logout", {
@@ -1077,6 +1133,16 @@
// Load data on page load
loadData();
// Listen for theme changes and update charts
window.addEventListener("themechange", () => {
const charts = [
barChartInstance,
durationChartInstance,
avgDurationChartInstance,
].filter((c) => c);
updateChartsForTheme(charts);
});
</script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
/* 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" } }),
);
}
});
}
})();
Generated
+465 -468
View File
File diff suppressed because it is too large Load Diff