Compare commits

31 Commits

Author SHA1 Message Date
sstent 1475190b90 docs(conductor): Synchronize docs for track 'Add real-time search and alphabetical sorting to the Add Meal and Add Food modals on the tracker page.'
Build and Push Docker Image (DEV) / build-and-push (push) Successful in 5m15s
2026-03-10 08:06:56 -07:00
sstent e0f72d4f1f chore(conductor): Mark track 'Add real-time search and alphabetical sorting to the Add Meal and Add Food modals on the tracker page.' as complete 2026-03-10 07:21:17 -07:00
sstent cc4b8301b3 chore(conductor): Add new track 'Add real-time search and alphabetical sorting to the Add Meal and Add Food modals on the tracker page.' 2026-03-10 06:35:28 -07:00
sstent e68a1f52af fix(dev): Fix syntax in nomad-deploy-dev workflow
Build and Push Docker Image (DEV) / build-and-push (push) Successful in 46s
2026-02-24 13:28:21 -08:00
sstent e47af2c839 feat(dev): Add development build workflows and Nomad job 2026-02-24 08:54:03 -08:00
sstent 7099577f92 chore(conductor): Mark track 'Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes'' as complete 2026-02-24 08:48:37 -08:00
sstent ced3c63f92 conductor(plan): Mark phase 'Phase 4: Database Migration & Cleanup' as complete 2026-02-24 08:48:31 -08:00
sstent 5c73ce9caf feat(phase): Complete Phase 4: Database Migration & Cleanup 2026-02-24 08:48:15 -08:00
sstent 0e4cb5a5ed conductor(plan): Mark phase 'Phase 3: UI & Cookbook Refinement' as complete 2026-02-24 08:46:41 -08:00
sstent b834e89a97 feat(phase): Complete Phase 3: UI & Cookbook Refinement 2026-02-24 08:46:32 -08:00
sstent 00600a76fa conductor(plan): Mark phase 'Phase 2: Logic & Calculation Updates' as complete 2026-02-24 08:19:15 -08:00
sstent cc6b4ca145 feat(phase): Complete Phase 2: Logic & Calculation Updates 2026-02-24 08:19:03 -08:00
sstent f0430c810b conductor(plan): Mark phase 'Phase 1: Preparation & Schema Updates' as complete 2026-02-24 07:18:19 -08:00
sstent 326a82ea5d feat(phase): Complete Phase 1: Preparation & Schema Updates 2026-02-24 07:18:10 -08:00
sstent afdf9fa5b7 chore(conductor): Add new track 'Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes'' 2026-02-23 13:26:12 -08:00
sstent 8d3e91a825 chore(conductor): Archive track 'Add Calcium bottom row of "Daily Totals" on the tracker page'
Build and Push Docker Image / build-and-push (push) Successful in 5m16s
2026-02-13 14:40:59 -08:00
sstent 042d8d93cc chore(conductor): Mark track 'Add Calcium bottom row of "Daily Totals" on the tracker page' as complete 2026-02-13 14:40:34 -08:00
sstent a66a5142b5 conductor(plan): Mark phase 'Implementation' as complete 2026-02-13 14:40:28 -08:00
sstent 7718a7f879 feat(tracker): Add Calcium display to Daily Totals 2026-02-13 14:40:21 -08:00
sstent 9574994abb conductor(setup): Add conductor setup files 2026-02-13 14:31:21 -08:00
sstent fe06c40a29 adding autopush to nomad
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-15 14:35:21 -08:00
sstent af38fcaace adding autopush to nomad 2026-01-15 14:34:11 -08:00
sstent c222360ca7 adding autopush to nomad
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-15 14:31:11 -08:00
sstent 4e5e70581c adding autopush to nomad
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-15 14:28:21 -08:00
sstent 1d9017c1a1 adding autopush to nomad
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-15 14:25:57 -08:00
sstent 48bd301db6 fixing build pipeline 2026-01-15 14:13:57 -08:00
sstent ce6b107560 adding fitbit shecdule + fixing macro balance chart 2026-01-15 13:05:46 -08:00
sstent 6972c9b8f9 Migrate to PostgreSQL and add Config Status page 2026-01-12 16:37:03 -08:00
sstent 9fa3380730 adding fitbit data capture 2026-01-12 15:13:50 -08:00
sstent 09653d7415 fixing daily track format and adding macro balance to tracker 2026-01-11 08:11:39 -08:00
sstent b48a7675dd adding new bug fixes - changed the way meals are tracked and logged to make them copies not references 2026-01-11 07:40:27 -08:00
66 changed files with 2827 additions and 352 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Build and Push Docker Image (DEV)
on:
workflow_dispatch:
push:
branches:
- dev
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
container_sha: ${{ github.sha }}
registry_url: ${{ steps.registry.outputs.url }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set registry URL
id: registry
run: |
if [ "${{ github.server_url }}" = "https://github.com" ]; then
echo "url=ghcr.io" >> $GITHUB_OUTPUT
else
echo "url=${{ github.server_url }}" | sed 's|https://||' >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.url }}
username: ${{ github.actor }}
password: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ steps.registry.outputs.url }}/${{ github.repository }}:dev
${{ steps.registry.outputs.url }}/${{ github.repository }}:${{ github.sha }}
build-args: |
COMMIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache-dev
cache-to: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache-dev,mode=max
labels: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
+36 -30
View File
@@ -1,58 +1,64 @@
name: Build and Push Multi-Arch Docker Image name: Build and Push Docker Image
on: on:
workflow_dispatch:
push: push:
branches: branches:
- main - main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
outputs:
container_sha: ${{ github.sha }}
registry_url: ${{ steps.registry.outputs.url }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set registry URL
id: registry
run: |
if [ "${{ github.server_url }}" = "https://github.com" ]; then
echo "url=ghcr.io" >> $GITHUB_OUTPUT
else
echo "url=${{ github.server_url }}" | sed 's|https://||' >> $GITHUB_OUTPUT
fi
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with: with:
platforms: arm64,amd64 registry: ${{ steps.registry.outputs.url }}
username: ${{ github.actor }}
password: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry - name: Build and push multi-arch Docker image
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} platforms: linux/amd64,linux/arm64
labels: ${{ steps.meta.outputs.labels }} tags: |
# Enable GitHub Actions cache (recommended over actions/cache) ${{ steps.registry.outputs.url }}/${{ github.repository }}:latest
cache-from: type=gha ${{ steps.registry.outputs.url }}/${{ github.repository }}:${{ github.sha }}
cache-to: type=gha,mode=max build-args: |
COMMIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache
cache-to: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache,mode=max
#cache-from: type=gha
#cache-to: type=gha,mode=max
# --- AUTOMATIC REPOSITORY LINKING ---
# This label adds an OCI annotation that Gitea uses to automatically
# link the package to the repository source code.
labels: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
+56
View File
@@ -0,0 +1,56 @@
name: Deploy to Nomad (DEV)
on:
workflow_run:
workflows: ["Build and Push Docker Image (DEV)"] # Must match your build workflow name exactly
types:
- completed
workflow_dispatch: # Allows manual triggering for testing
inputs:
container_sha:
description: 'Container SHA to deploy (leave empty for latest commit)'
required: false
type: string
jobs:
nomad:
runs-on: ubuntu-latest
name: Deploy to Nomad (DEV)
# Only run if the build workflow succeeded
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
# 1. Checkout Code
- name: Checkout Repository
uses: actions/checkout@v4
# 2. Install Nomad CLI
- name: Setup Nomad CLI
uses: hashicorp/setup-nomad@main
with:
version: '1.10.0' # Use your desired version or remove for 'latest'
# 3. Determine container version to deploy
- name: Set Container Version
id: container_version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.container_sha }}" ]; then
echo "sha=${{ inputs.container_sha }}" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT
else
echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT
fi
# 4. Deploy the Nomad Job
- name: Deploy Nomad Job
id: deploy
env:
# REQUIRED: Set the Nomad server address
NOMAD_ADDR: http://nomad.service.dc1.consul:4646
run: |
echo "Deploying container version: ${{ steps.container_version.outputs.sha }}"
nomad status
nomad job run \
-var="container_version=${{ steps.container_version.outputs.sha }}" \
foodplanner-dev.nomad
+56
View File
@@ -0,0 +1,56 @@
name: Deploy to Nomad
on:
workflow_run:
workflows: ["Build and Push Docker Image"] # Must match your build workflow name exactly
types:
- completed
workflow_dispatch: # Allows manual triggering for testing
inputs:
container_sha:
description: 'Container SHA to deploy (leave empty for latest commit)'
required: false
type: string
jobs:
nomad:
runs-on: ubuntu-latest
name: Deploy to Nomad
# Only run if the build workflow succeeded
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
# 1. Checkout Code
- name: Checkout Repository
uses: actions/checkout@v4
# 2. Install Nomad CLI
- name: Setup Nomad CLI
uses: hashicorp/setup-nomad@main
with:
version: '1.10.0' # Use your desired version or remove for 'latest'
# 3. Determine container version to deploy
- name: Set Container Version
id: container_version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.container_sha }}" ]; then
echo "sha=${{ inputs.container_sha }}" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT
else
echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT
fi
# 4. Deploy the Nomad Job
- name: Deploy Nomad Job
id: deploy
env:
# REQUIRED: Set the Nomad server address
NOMAD_ADDR: http://nomad.service.dc1.consul:4646
run: |
echo "Deploying container version: ${{ steps.container_version.outputs.sha }}"
nomad status
nomad job run \
-var="container_version=${{ steps.container_version.outputs.sha }}" \
foodplanner.nomad
+63
View File
@@ -0,0 +1,63 @@
# Database Migration Guide
This guide outlines the offline workflow to migrate your `meal_planner` data from SQLite to PostgreSQL.
## Prerequisites
- Docker Compose installed.
- The application running (or capable of running) via `docker-compose`.
## Migration Steps
### 1. Backup your SQLite Database
First, create a safety copy of your current database.
```bash
cp data/meal_planner.db meal_planner_backup.db
```
### 2. Stop the Application
Stop the running application container to ensure no new data is written.
```bash
docker-compose stop foodtracker
```
### 3. Start PostgreSQL
Ensure the new PostgreSQL service is running.
```bash
docker-compose up -d postgres
```
### 4. Run the Migration
Use a temporary container to run the migration script. We mount your backup file and connect to the postgres service.
```bash
# Syntax: python migrate_to_postgres.py --sqlite-path <path_to_db> --pg-url <postgres_url>
docker-compose run --rm \
-v $(pwd)/meal_planner_backup.db:/backup.db \
-v $(pwd)/migrate_to_postgres.py:/app/migrate_to_postgres.py \
foodtracker \
python migrate_to_postgres.py \
--sqlite-path /backup.db \
--pg-url postgresql://user:password@postgres/meal_planner
```
### 5. Update Configuration
Edit `docker-compose.yml` to switch the active database.
1. Comment out the SQLite `DATABASE_URL`.
2. Uncomment the PostgreSQL `DATABASE_URL`.
```yaml
environment:
# - DATABASE_URL=sqlite:////app/data/meal_planner.db
- DATABASE_URL=postgresql://user:password@postgres/meal_planner
```
### 6. Restart the Application
Rebuild and start the application to use the new database.
```bash
docker-compose up -d --build foodtracker
```
## Verification
1. Log in to the application.
2. Verify your Foods, Meals, and Plans are present.
3. Check `docker logs foodplanner-foodtracker-1` to ensure no database connection errors.
@@ -0,0 +1,101 @@
"""snapshot_existing_meals
Revision ID: 31fdce040eea
Revises: 4522e2de4143
Create Date: 2026-01-10 13:30:49.977264
"""
from typing import Sequence, Union
from sqlalchemy import orm, text
from app.database import Base
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '31fdce040eea'
down_revision: Union[str, None] = '4522e2de4143'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
session = orm.Session(bind=bind)
# Use reflection or raw SQL to avoid importing app models directly
# This ensures the migration remains valid even if app models change later
# 1. Get all tracked meals that are NOT already snapshots
# We join tracked_meals with meals to check the meal_type
sql = text("""
SELECT tm.id as tracked_meal_id, tm.meal_id, m.name, m.meal_time
FROM tracked_meals tm
JOIN meals m ON tm.meal_id = m.id
WHERE m.meal_type != 'tracked_snapshot'
""")
tracked_meals_to_snapshot = session.execute(sql).fetchall()
print(f"Found {len(tracked_meals_to_snapshot)} tracked meals to snapshot.")
for row in tracked_meals_to_snapshot:
tm_id = row.tracked_meal_id
original_meal_id = row.meal_id
original_name = row.name
original_meal_time = row.meal_time
# 2. Create a new snapshot meal
# We can't easily use ORM since we don't have the classes, so we use raw SQL
insert_meal_sql = text("""
INSERT INTO meals (name, meal_type, meal_time)
VALUES (:name, 'tracked_snapshot', :meal_time)
""")
# execution_options={"autocommit": True} might be needed for some drivers,
# but session.execute usually handles it.
# For SQLite, we can get the last inserted id via cursor, but SQLAlchemy does this via result.lastrowid
result = session.execute(insert_meal_sql, {
"name": original_name,
"meal_time": original_meal_time
})
new_meal_id = result.lastrowid
# 3. Copy ingredients from original meal to new snapshot
# Get ingredients
get_foods_sql = text("""
SELECT food_id, quantity
FROM meal_foods
WHERE meal_id = :meal_id
""")
foods = session.execute(get_foods_sql, {"meal_id": original_meal_id}).fetchall()
if foods:
insert_food_sql = text("""
INSERT INTO meal_foods (meal_id, food_id, quantity)
VALUES (:meal_id, :food_id, :quantity)
""")
for food in foods:
session.execute(insert_food_sql, {
"meal_id": new_meal_id,
"food_id": food.food_id,
"quantity": food.quantity
})
# 4. Update the stored tracked_meal to point to the new snapshot
update_tm_sql = text("""
UPDATE tracked_meals
SET meal_id = :new_meal_id
WHERE id = :tm_id
""")
session.execute(update_tm_sql, {"new_meal_id": new_meal_id, "tm_id": tm_id})
session.commit()
def downgrade() -> None:
pass
@@ -0,0 +1,27 @@
"""add_name_to_tracked_meal
Revision ID: 7fdcc454e056
Revises: e1c2d8d5c1a8
Create Date: 2026-02-24 06:29:46.441129
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7fdcc454e056'
down_revision: Union[str, None] = 'e1c2d8d5c1a8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('tracked_meals', sa.Column('name', sa.String(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('tracked_meals') as batch_op:
batch_op.drop_column('name')
@@ -0,0 +1,55 @@
"""add fitbit tables
Revision ID: e1c2d8d5c1a8
Revises: 4522e2de4143
Create Date: 2026-01-12 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e1c2d8d5c1a8'
down_revision: Union[str, None] = '31fdce040eea'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create fitbit_config table
op.create_table('fitbit_config',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('client_id', sa.String(), nullable=True),
sa.Column('client_secret', sa.String(), nullable=True),
sa.Column('redirect_uri', sa.String(), nullable=True),
sa.Column('access_token', sa.String(), nullable=True),
sa.Column('refresh_token', sa.String(), nullable=True),
sa.Column('expires_at', sa.Float(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_fitbit_config_id'), 'fitbit_config', ['id'], unique=False)
# Create weight_logs table
op.create_table('weight_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=True),
sa.Column('weight', sa.Float(), nullable=True),
sa.Column('source', sa.String(), nullable=True),
sa.Column('fitbit_log_id', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_weight_logs_date'), 'weight_logs', ['date'], unique=False)
op.create_index(op.f('ix_weight_logs_fitbit_log_id'), 'weight_logs', ['fitbit_log_id'], unique=True)
op.create_index(op.f('ix_weight_logs_id'), 'weight_logs', ['id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_weight_logs_id'), table_name='weight_logs')
op.drop_index(op.f('ix_weight_logs_fitbit_log_id'), table_name='weight_logs')
op.drop_index(op.f('ix_weight_logs_date'), table_name='weight_logs')
op.drop_table('weight_logs')
op.drop_index(op.f('ix_fitbit_config_id'), table_name='fitbit_config')
op.drop_table('fitbit_config')
+2
View File
@@ -11,6 +11,7 @@ from app.api.routes import (
templates, templates,
tracker, tracker,
weekly_menu, weekly_menu,
fitbit,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -20,6 +21,7 @@ api_router.include_router(meals.router, tags=["meals"])
api_router.include_router(templates.router, tags=["templates"]) api_router.include_router(templates.router, tags=["templates"])
api_router.include_router(charts.router, tags=["charts"]) api_router.include_router(charts.router, tags=["charts"])
api_router.include_router(admin.router, tags=["admin"]) api_router.include_router(admin.router, tags=["admin"])
api_router.include_router(fitbit.router, tags=["fitbit"])
api_router.include_router(weekly_menu.router, tags=["weekly_menu"]) api_router.include_router(weekly_menu.router, tags=["weekly_menu"])
api_router.include_router(plans.router, tags=["plans"]) api_router.include_router(plans.router, tags=["plans"])
api_router.include_router(export.router, tags=["export"]) api_router.include_router(export.router, tags=["export"])
+40 -1
View File
@@ -187,6 +187,45 @@ async def restore_backup(request: Request, backup_file: str = Form(...)):
# You might want to add some user-facing error feedback here # You might want to add some user-facing error feedback here
pass pass
# Redirect back to the backups page
# Redirect back to the backups page # Redirect back to the backups page
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url="/admin/backups", status_code=303) return RedirectResponse(url="/admin/backups", status_code=303)
@router.get("/admin/config-status", response_class=HTMLResponse)
async def admin_config_status_page(request: Request):
"""Display current system configuration and database status."""
from urllib.parse import urlparse
# Analyze DATABASE_URL securely
db_url = DATABASE_URL
masked_url = db_url
db_host = "Unknown"
db_type = "Unknown"
try:
# Simple parsing logic to avoid exposing credentials if urlparse fails or acts unexpectedly
if "sqlite" in db_url:
db_type = "SQLite"
db_host = db_url.replace("sqlite:///", "")
masked_url = "sqlite:///" + db_host
elif "postgresql" in db_url:
db_type = "PostgreSQL"
parsed = urlparse(db_url)
db_host = parsed.hostname
# Mask password
if parsed.password:
masked_url = db_url.replace(parsed.password, "******")
except Exception as e:
logging.error(f"Error parsing database URL: {e}")
masked_url = "Error parsing URL"
config_data = {
"database_url": db_url,
"database_url_masked": masked_url,
"database_type": db_type,
"database_host": db_host,
"debug": True
}
return templates.TemplateResponse(request, "admin/config.html", {"request": request, "config": config_data})
+96 -12
View File
@@ -3,7 +3,7 @@ from starlette.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import date, timedelta from datetime import date, timedelta
from typing import List from typing import List
from app.database import get_db, TrackedDay, TrackedMeal, calculate_day_nutrition_tracked from app.database import get_db, TrackedDay, TrackedMeal, calculate_day_nutrition_tracked, WeightLog
router = APIRouter(tags=["charts"]) router = APIRouter(tags=["charts"])
@@ -37,17 +37,101 @@ async def get_charts_data(
).order_by(TrackedDay.date.desc()).all() ).order_by(TrackedDay.date.desc()).all()
chart_data = [] chart_data = []
for tracked_day in tracked_days: # Fetch all tracked days and weight logs for the period
tracked_meals = db.query(TrackedMeal).filter( tracked_days_map = {
TrackedMeal.tracked_day_id == tracked_day.id d.date: d for d in db.query(TrackedDay).filter(
TrackedDay.person == person,
TrackedDay.date >= start_date,
TrackedDay.date <= end_date
).all() ).all()
day_totals = calculate_day_nutrition_tracked(tracked_meals, db) }
chart_data.append({
"date": tracked_day.date.isoformat(), # Sort logs desc
"calories": round(day_totals.get("calories", 0), 2), weight_logs_map = {
"protein": round(day_totals.get("protein", 0), 2), w.date: w for w in db.query(WeightLog).filter(
"fat": round(day_totals.get("fat", 0), 2), WeightLog.date >= start_date,
"net_carbs": round(day_totals.get("net_carbs", 0), 2) WeightLog.date <= end_date
}) ).order_by(WeightLog.date.desc()).all()
}
# Get last weight BEFORE start_date (for initial carry forward)
last_historical_weight_log = db.query(WeightLog).filter(
WeightLog.date < start_date
).order_by(WeightLog.date.desc()).first()
last_historical_weight_val = last_historical_weight_log.weight * 2.20462 if last_historical_weight_log else None
# Find the most recent weight available (either in range or history)
# This is for "Today" (end_date)
latest_weight_val = last_historical_weight_val
# Check if we have newer weights in the map
# Values in weight_logs_map are WeightLog objects.
# Find the one with max date <= end_date. Since map key is date, we can check.
# But filtering the map is tedious. Let's just iterate.
# Actually, we already have `weight_logs_map` (in range).
# If the range has weights, the newest one is the "latest" known weight relevant to the end of chart.
if weight_logs_map:
# Get max date
max_date = max(weight_logs_map.keys())
latest_weight_val = weight_logs_map[max_date].weight * 2.20462
chart_data = []
# Iterate dates. Note: i=0 is end_date (Today), i=days-1 is start_date (Oldest)
for i in range(days):
current_date = end_date - timedelta(days=i)
tracked_day = tracked_days_map.get(current_date)
weight_log = weight_logs_map.get(current_date)
calories = 0
protein = 0
fat = 0
net_carbs = 0
# Calculate nutrition
if tracked_day:
tracked_meals = db.query(TrackedMeal).filter(
TrackedMeal.tracked_day_id == tracked_day.id
).all()
day_totals = calculate_day_nutrition_tracked(tracked_meals, db)
calories = round(day_totals.get("calories", 0), 2)
protein = round(day_totals.get("protein", 0), 2)
fat = round(day_totals.get("fat", 0), 2)
net_carbs = round(day_totals.get("net_carbs", 0), 2)
weight_lbs = None
is_real = False
if weight_log:
weight_lbs = round(weight_log.weight * 2.20462, 2)
is_real = True
# Logic for Start and End Points (to ensure line connects across view)
# If this is the Oldest date in view (start_date) and no real weight
if i == days - 1 and weight_lbs is None:
# Use historical weight if available (to start the line)
if last_historical_weight_val is not None:
weight_lbs = round(last_historical_weight_val, 2)
# is_real remains False (inferred)
# If this is the Newest date in view (end_date/Today) and no real weight
if i == 0 and weight_lbs is None:
# Use latest known weight (to end the line)
if latest_weight_val is not None:
weight_lbs = round(latest_weight_val, 2)
# is_real remains False (inferred)
chart_data.append({
"date": current_date.isoformat(),
"calories": calories,
"protein": protein,
"fat": fat,
"net_carbs": net_carbs,
"weight_lbs": weight_lbs,
"weight_is_real": is_real
})
return chart_data return chart_data
+107 -87
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body, File, UploadFile from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body, File, UploadFile, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
from datetime import date, datetime from datetime import date, datetime
@@ -12,7 +12,7 @@ import re
import json import json
from app.database import get_db, Food, Meal, Plan, Template, WeeklyMenu, TrackedDay, MealFood, TemplateMeal, WeeklyMenuDay, TrackedMeal from app.database import get_db, Food, Meal, Plan, Template, WeeklyMenu, TrackedDay, MealFood, TemplateMeal, WeeklyMenuDay, TrackedMeal
from app.database import FoodCreate, FoodResponse, MealCreate, TrackedDayCreate, TrackedMealCreate, AllData, FoodExport, MealFoodExport, MealExport, PlanExport, TemplateMealExport, TemplateExport, TemplateMealDetail, TemplateDetail, WeeklyMenuDayExport, WeeklyMenuDayDetail, WeeklyMenuExport, WeeklyMenuDetail, TrackedMealExport, TrackedDayExport from app.database import FoodCreate, FoodResponse, MealCreate, TrackedDayCreate, TrackedMealCreate, AllData, FoodExport, MealFoodExport, MealExport, PlanExport, TemplateMealExport, TemplateExport, TemplateMealDetail, TemplateDetail, WeeklyMenuDayExport, WeeklyMenuDayDetail, WeeklyMenuExport, WeeklyMenuDetail, TrackedMealExport, TrackedDayExport, TrackedMealFoodExport
router = APIRouter() router = APIRouter()
@@ -66,96 +66,117 @@ def validate_import_data(data: AllData):
detail=f"Invalid tracked meal: meal_id {tracked_meal.meal_id} not found.", detail=f"Invalid tracked meal: meal_id {tracked_meal.meal_id} not found.",
) )
@router.get("/export/all", response_model=AllData) @router.get("/export/all")
async def export_all_data(db: Session = Depends(get_db)): async def export_all_data(db: Session = Depends(get_db)):
"""Export all data from the database as a single JSON file.""" """Export all data from the database as a single JSON file."""
foods = db.query(Food).all()
meals = db.query(Meal).all()
plans = db.query(Plan).all()
templates = db.query(Template).all()
weekly_menus = db.query(WeeklyMenu).all()
tracked_days = db.query(TrackedDay).all()
# Manual serialization to handle nested relationships try:
# ... (rest of the code)
# Meals with MealFoods foods = db.query(Food).all()
meals_export = [] meals = db.query(Meal).all()
for meal in meals: plans = db.query(Plan).all()
meal_foods_export = [ templates = db.query(Template).all()
MealFoodExport(food_id=mf.food_id, quantity=mf.quantity) weekly_menus = db.query(WeeklyMenu).all()
for mf in meal.meal_foods tracked_days = db.query(TrackedDay).all()
]
meals_export.append( # Manual serialization to handle nested relationships
MealExport(
id=meal.id, # Meals with MealFoods
name=meal.name, meals_export = []
meal_type=meal.meal_type, for meal in meals:
meal_time=meal.meal_time, meal_foods_export = [
meal_foods=meal_foods_export, MealFoodExport(food_id=mf.food_id, quantity=mf.quantity)
for mf in meal.meal_foods
]
meals_export.append(
MealExport(
id=meal.id,
name=meal.name,
meal_type=meal.meal_type,
meal_time=meal.meal_time,
meal_foods=meal_foods_export,
)
) )
# Templates with TemplateMeals
templates_export = []
for template in templates:
template_meals_export = [
TemplateMealExport(meal_id=tm.meal_id, meal_time=tm.meal_time)
for tm in template.template_meals
]
templates_export.append(
TemplateExport(
id=template.id,
name=template.name,
template_meals=template_meals_export,
)
)
# Weekly Menus with WeeklyMenuDays
weekly_menus_export = []
for weekly_menu in weekly_menus:
weekly_menu_days_export = [
WeeklyMenuDayExport(
day_of_week=wmd.day_of_week, template_id=wmd.template_id
)
for wmd in weekly_menu.weekly_menu_days
]
weekly_menus_export.append(
WeeklyMenuExport(
id=weekly_menu.id,
name=weekly_menu.name,
weekly_menu_days=weekly_menu_days_export,
)
)
# Tracked Days with TrackedMeals
tracked_days_export = []
for tracked_day in tracked_days:
tracked_meals_export = [
TrackedMealExport(
meal_id=tm.meal_id,
meal_time=tm.meal_time,
tracked_foods=[
TrackedMealFoodExport(
food_id=tmf.food_id,
quantity=tmf.quantity,
is_override=tmf.is_override
) for tmf in tm.tracked_foods
]
)
for tm in tracked_day.tracked_meals
]
tracked_days_export.append(
TrackedDayExport(
id=tracked_day.id,
person=tracked_day.person,
date=tracked_day.date,
is_modified=tracked_day.is_modified,
tracked_meals=tracked_meals_export,
)
)
data = AllData(
foods=[FoodExport.from_orm(f) for f in foods],
meals=meals_export,
plans=[PlanExport.from_orm(p) for p in plans],
templates=templates_export,
weekly_menus=weekly_menus_export,
tracked_days=tracked_days_export,
) )
# Templates with TemplateMeals json_content = data.model_dump_json()
templates_export = []
for template in templates: return Response(
template_meals_export = [ content=json_content,
TemplateMealExport(meal_id=tm.meal_id, meal_time=tm.meal_time) media_type="application/json",
for tm in template.template_meals headers={"Content-Disposition": "attachment; filename=meal_planner_backup.json"}
]
templates_export.append(
TemplateExport(
id=template.id,
name=template.name,
template_meals=template_meals_export,
)
) )
except Exception as e:
# Weekly Menus with WeeklyMenuDays import traceback
weekly_menus_export = [] logging.error(f"Error exporting data: {e}\n{traceback.format_exc()}")
for weekly_menu in weekly_menus: raise HTTPException(status_code=500, detail=str(e))
weekly_menu_days_export = [
WeeklyMenuDayExport(
day_of_week=wmd.day_of_week, template_id=wmd.template_id
)
for wmd in weekly_menu.weekly_menu_days
]
weekly_menus_export.append(
WeeklyMenuExport(
id=weekly_menu.id,
name=weekly_menu.name,
weekly_menu_days=weekly_menu_days_export,
)
)
# Tracked Days with TrackedMeals
tracked_days_export = []
for tracked_day in tracked_days:
tracked_meals_export = [
TrackedMealExport(
meal_id=tm.meal_id,
meal_time=tm.meal_time,
quantity=tm.quantity,
)
for tm in tracked_day.tracked_meals
]
tracked_days_export.append(
TrackedDayExport(
id=tracked_day.id,
person=tracked_day.person,
date=tracked_day.date,
is_modified=tracked_day.is_modified,
tracked_meals=tracked_meals_export,
)
)
return AllData(
foods=[FoodExport.from_orm(f) for f in foods],
meals=meals_export,
plans=[PlanExport.from_orm(p) for p in plans],
templates=templates_export,
weekly_menus=weekly_menus_export,
tracked_days=tracked_days_export,
)
@router.post("/import/all") @router.post("/import/all")
async def import_all_data(file: UploadFile = File(...), db: Session = Depends(get_db)): async def import_all_data(file: UploadFile = File(...), db: Session = Depends(get_db)):
@@ -259,7 +280,6 @@ async def import_all_data(file: UploadFile = File(...), db: Session = Depends(ge
tracked_day_id=tracked_day.id, tracked_day_id=tracked_day.id,
meal_id=tm_data.meal_id, meal_id=tm_data.meal_id,
meal_time=tm_data.meal_time, meal_time=tm_data.meal_time,
quantity=tm_data.quantity,
) )
) )
db.commit() db.commit()
+144
View File
@@ -0,0 +1,144 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from sqlalchemy.orm import Session
import requests
import base64
import json
import datetime
from datetime import date
from typing import Optional
from app.database import get_db, FitbitConfig, WeightLog
from main import templates
from app.services.fitbit_service import get_config, refresh_tokens, sync_fitbit_weight
from urllib.parse import quote
router = APIRouter()
# --- Helpers ---
# Moved to app.services.fitbit_service
# --- Routes ---
@router.get("/admin/fitbit", response_class=HTMLResponse)
async def fitbit_page(request: Request, db: Session = Depends(get_db)):
config = get_config(db)
# Mask secret
masked_secret = "*" * 8 if config.client_secret else ""
is_connected = bool(config.access_token)
# Get recent logs
logs = db.query(WeightLog).order_by(WeightLog.date.desc()).limit(30).all()
return templates.TemplateResponse("admin/fitbit.html", {
"request": request,
"config": config,
"masked_secret": masked_secret,
"is_connected": is_connected,
"logs": logs
})
@router.post("/admin/fitbit/config")
async def update_config(
request: Request,
client_id: str = Form(...),
client_secret: str = Form(...),
redirect_uri: str = Form(...),
db: Session = Depends(get_db)
):
config = get_config(db)
config.client_id = client_id
config.client_secret = client_secret
config.redirect_uri = redirect_uri
db.commit()
return RedirectResponse(url="/admin/fitbit", status_code=303)
@router.get("/admin/fitbit/auth_url")
async def get_auth_url(db: Session = Depends(get_db)):
config = get_config(db)
if not config.client_id or not config.redirect_uri:
return {"status": "error", "message": "Client ID and Redirect URI must be configured first."}
encoded_redirect_uri = quote(config.redirect_uri, safe='')
auth_url = (
"https://www.fitbit.com/oauth2/authorize"
f"?response_type=code&client_id={config.client_id}"
f"&redirect_uri={encoded_redirect_uri}"
"&scope=weight"
"&expires_in=604800"
)
return {"status": "success", "url": auth_url}
@router.post("/admin/fitbit/auth/exchange")
async def exchange_code(
request: Request,
code_input: str = Form(...),
db: Session = Depends(get_db)
):
config = get_config(db)
# Parse code from URL if provided
code = code_input.strip()
if "?" in code and "code=" in code:
from urllib.parse import urlparse, parse_qs
try:
query = parse_qs(urlparse(code).query)
if 'code' in query:
code = query['code'][0]
except:
pass
if code.endswith('#_=_'):
code = code[:-4]
# Exchange
token_url = "https://api.fitbit.com/oauth2/token"
auth_str = f"{config.client_id}:{config.client_secret}"
b64_auth = base64.b64encode(auth_str.encode()).decode()
headers = {
"Authorization": f"Basic {b64_auth}",
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"clientId": config.client_id,
"grant_type": "authorization_code",
"redirect_uri": config.redirect_uri,
"code": code
}
try:
response = requests.post(token_url, headers=headers, data=data)
if response.status_code == 200:
tokens = response.json()
config.access_token = tokens['access_token']
config.refresh_token = tokens['refresh_token']
db.commit()
return RedirectResponse(url="/admin/fitbit", status_code=303)
else:
return templates.TemplateResponse("error.html", {
"request": request,
"error_title": "Auth Failed",
"error_message": f"Fitbit Error: {response.text}",
"error_details": ""
})
except Exception as e:
return templates.TemplateResponse("error.html", {
"request": request,
"error_title": "Auth Error",
"error_message": str(e),
"error_details": ""
})
@router.post("/admin/fitbit/sync")
async def sync_data(
request: Request,
scope: str = Form("30d"),
db: Session = Depends(get_db)
):
result = sync_fitbit_weight(db, scope)
status_code = 200 if result['status'] == 'success' else 400
return JSONResponse(result, status_code=status_code)
+8 -5
View File
@@ -15,7 +15,10 @@ router = APIRouter()
@router.get("/meals", response_class=HTMLResponse) @router.get("/meals", response_class=HTMLResponse)
async def meals_page(request: Request, db: Session = Depends(get_db)): async def meals_page(request: Request, db: Session = Depends(get_db)):
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
meals = db.query(Meal).options(joinedload(Meal.meal_foods).joinedload(MealFood.food)).all() # Filter out single food entries and snapshots
meals = db.query(Meal).filter(
Meal.meal_type.notin_(["single_food", "tracked_snapshot"])
).options(joinedload(Meal.meal_foods).joinedload(MealFood.food)).all()
foods = db.query(Food).all() foods = db.query(Food).all()
return templates.TemplateResponse("meals.html", return templates.TemplateResponse("meals.html",
{"request": request, "meals": meals, "foods": foods}) {"request": request, "meals": meals, "foods": foods})
@@ -180,10 +183,10 @@ async def get_meal_foods(meal_id: int, db: Session = Depends(get_db)):
@router.post("/meals/{meal_id}/add_food") @router.post("/meals/{meal_id}/add_food")
async def add_food_to_meal(meal_id: int, food_id: int = Form(...), async def add_food_to_meal(meal_id: int, food_id: int = Form(...),
grams: float = Form(...), db: Session = Depends(get_db)): quantity: float = Form(...), db: Session = Depends(get_db)):
try: try:
meal_food = MealFood(meal_id=meal_id, food_id=food_id, quantity=grams) meal_food = MealFood(meal_id=meal_id, food_id=food_id, quantity=quantity)
db.add(meal_food) db.add(meal_food)
db.commit() db.commit()
return {"status": "success"} return {"status": "success"}
@@ -210,14 +213,14 @@ async def remove_food_from_meal(meal_food_id: int, db: Session = Depends(get_db)
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
@router.post("/meals/update_food_quantity") @router.post("/meals/update_food_quantity")
async def update_meal_food_quantity(meal_food_id: int = Form(...), grams: float = Form(...), db: Session = Depends(get_db)): async def update_meal_food_quantity(meal_food_id: int = Form(...), quantity: float = Form(...), db: Session = Depends(get_db)):
"""Update the quantity of a food in a meal""" """Update the quantity of a food in a meal"""
try: try:
meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first() meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first()
if not meal_food: if not meal_food:
return {"status": "error", "message": "Meal food not found"} return {"status": "error", "message": "Meal food not found"}
meal_food.quantity = grams meal_food.quantity = quantity
db.commit() db.commit()
return {"status": "success"} return {"status": "success"}
except ValueError as ve: except ValueError as ve:
+46 -17
View File
@@ -75,14 +75,17 @@ async def tracker_page(request: Request, person: str = "Sarah", date: str = None
).all() ).all()
# Template will handle filtering of deleted foods # Template will handle filtering of deleted foods
# Get all meals for dropdown # Get all meals for dropdown (exclude snapshots)
meals = db.query(Meal).all() meals = db.query(Meal).filter(Meal.meal_type != "tracked_snapshot").all()
meals.sort(key=lambda x: x.name.lower())
# Get all templates for template dropdown # Get all templates for template dropdown
templates_list = db.query(Template).all() templates_list = db.query(Template).all()
templates_list.sort(key=lambda x: x.name.lower())
# Get all foods for dropdown # Get all foods for dropdown
foods = db.query(Food).all() foods = db.query(Food).all()
foods.sort(key=lambda x: x.name.lower())
# Calculate day totals # Calculate day totals
day_totals = calculate_day_nutrition_tracked(tracked_meals, db) day_totals = calculate_day_nutrition_tracked(tracked_meals, db)
@@ -138,10 +141,34 @@ async def tracker_add_meal(request: Request, db: Session = Depends(get_db)):
db.commit() db.commit()
db.refresh(tracked_day) db.refresh(tracked_day)
# Create tracked meal # 1. Fetch the original meal
original_meal = db.query(Meal).filter(Meal.id == int(meal_id)).first()
if not original_meal:
return {"status": "error", "message": "Meal not found"}
# 2. Create a snapshot copy of the meal
snapshot_meal = Meal(
name=original_meal.name,
meal_type="tracked_snapshot",
meal_time=original_meal.meal_time
)
db.add(snapshot_meal)
db.flush() # get ID
# 3. Copy ingredients (MealFood)
meal_foods = db.query(MealFood).filter(MealFood.meal_id == original_meal.id).all()
for mf in meal_foods:
snapshot_food = MealFood(
meal_id=snapshot_meal.id,
food_id=mf.food_id,
quantity=mf.quantity
)
db.add(snapshot_food)
# 4. Create tracked meal pointing to the SNAPSHOT
tracked_meal = TrackedMeal( tracked_meal = TrackedMeal(
tracked_day_id=tracked_day.id, tracked_day_id=tracked_day.id,
meal_id=int(meal_id), meal_id=snapshot_meal.id,
meal_time=meal_time meal_time=meal_time
) )
db.add(tracked_meal) db.add(tracked_meal)
@@ -722,23 +749,25 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db)
# Store grams directly # Store grams directly
quantity = grams quantity = grams
# Create a new Meal for this single food entry # Create tracked meal entry without a parent Meal template
# This allows it to be treated like any other meal in the tracker view
new_meal = Meal(name=food_item.name, meal_type="single_food", meal_time=meal_time)
db.add(new_meal)
db.flush() # Flush to get the new meal ID
# Link the food to the new meal
meal_food = MealFood(meal_id=new_meal.id, food_id=food_id, quantity=grams)
db.add(meal_food)
# Create tracked meal entry
tracked_meal = TrackedMeal( tracked_meal = TrackedMeal(
tracked_day_id=tracked_day.id, tracked_day_id=tracked_day.id,
meal_id=new_meal.id, meal_id=None,
meal_time=meal_time meal_time=meal_time,
name=food_item.name
) )
db.add(tracked_meal) db.add(tracked_meal)
db.flush() # Flush to get the tracked_meal ID
# Link the food directly to the tracked meal via TrackedMealFood
new_entry = TrackedMealFood(
tracked_meal_id=tracked_meal.id,
food_id=food_id,
quantity=grams,
is_override=False,
is_deleted=False
)
db.add(new_entry)
# Mark day as modified # Mark day as modified
tracked_day.is_modified = True tracked_day.is_modified = True
+29 -6
View File
@@ -18,7 +18,7 @@ from sqlalchemy.orm import sessionmaker, Session, relationship, declarative_base
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import List, Optional from typing import List, Optional, Union
from datetime import date, datetime from datetime import date, datetime
import os import os
import logging import logging
@@ -148,8 +148,9 @@ class TrackedMeal(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
tracked_day_id = Column(Integer, ForeignKey("tracked_days.id")) tracked_day_id = Column(Integer, ForeignKey("tracked_days.id"))
meal_id = Column(Integer, ForeignKey("meals.id")) meal_id = Column(Integer, ForeignKey("meals.id"), nullable=True)
meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2 meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2
name = Column(String, nullable=True) # For single food items or custom names
tracked_day = relationship("TrackedDay", back_populates="tracked_meals") tracked_day = relationship("TrackedDay", back_populates="tracked_meals")
meal = relationship("Meal") meal = relationship("Meal")
@@ -170,10 +171,30 @@ class TrackedMealFood(Base):
tracked_meal = relationship("TrackedMeal", back_populates="tracked_foods") tracked_meal = relationship("TrackedMeal", back_populates="tracked_foods")
food = relationship("Food") food = relationship("Food")
class FitbitConfig(Base):
__tablename__ = "fitbit_config"
id = Column(Integer, primary_key=True, index=True)
client_id = Column(String)
client_secret = Column(String)
redirect_uri = Column(String, default="http://localhost:8080/fitbit-callback")
access_token = Column(String, nullable=True)
refresh_token = Column(String, nullable=True)
expires_at = Column(Float, nullable=True) # Timestamp
class WeightLog(Base):
__tablename__ = "weight_logs"
id = Column(Integer, primary_key=True, index=True)
date = Column(Date, index=True)
weight = Column(Float)
source = Column(String, default="fitbit")
fitbit_log_id = Column(String, unique=True, index=True) # To prevent duplicates
# Pydantic models # Pydantic models
class FoodCreate(BaseModel): class FoodCreate(BaseModel):
name: str name: str
serving_size: str serving_size: Union[float, str]
serving_unit: str serving_unit: str
calories: float calories: float
protein: float protein: float
@@ -189,7 +210,7 @@ class FoodCreate(BaseModel):
class FoodResponse(BaseModel): class FoodResponse(BaseModel):
id: int id: int
name: str name: str
serving_size: str serving_size: Union[float, str]
serving_unit: str serving_unit: str
calories: float calories: float
protein: float protein: float
@@ -407,9 +428,11 @@ def calculate_tracked_meal_nutrition(tracked_meal, db: Session):
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0 'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
} }
# 1. Get base foods from the meal # 1. Get base foods from the meal (if it exists)
# access via relationship, assume eager loading or lazy loading # access via relationship, assume eager loading or lazy loading
base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods} base_foods = {}
if tracked_meal.meal:
base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods}
# 2. Get tracked foods (overrides, deletions, additions) # 2. Get tracked foods (overrides, deletions, additions)
tracked_foods = tracked_meal.tracked_foods tracked_foods = tracked_meal.tracked_foods
+151
View File
@@ -0,0 +1,151 @@
import requests
import base64
import datetime
from datetime import date
from sqlalchemy.orm import Session
from app.database import FitbitConfig, WeightLog
def get_config(db: Session) -> FitbitConfig:
config = db.query(FitbitConfig).first()
if not config:
config = FitbitConfig()
db.add(config)
db.commit()
db.refresh(config)
return config
def refresh_tokens(db: Session, config: FitbitConfig):
if not config.refresh_token:
return None
token_url = "https://api.fitbit.com/oauth2/token"
auth_str = f"{config.client_id}:{config.client_secret}"
b64_auth = base64.b64encode(auth_str.encode()).decode()
headers = {
"Authorization": f"Basic {b64_auth}",
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "refresh_token",
"refresh_token": config.refresh_token
}
try:
response = requests.post(token_url, headers=headers, data=data)
if response.status_code == 200:
tokens = response.json()
config.access_token = tokens['access_token']
config.refresh_token = tokens['refresh_token']
# config.expires_at = datetime.datetime.now().timestamp() + tokens['expires_in'] # Optional
db.commit()
return config.access_token
else:
print(f"Failed to refresh token: {response.text}")
return None
except Exception as e:
print(f"Error refreshing token: {e}")
return None
def sync_fitbit_weight(db: Session, scope: str = "30d"):
"""
Synchronizes weight data from Fitbit.
Returns a dictionary with status and message.
"""
config = get_config(db)
if not config.access_token:
return {"status": "error", "message": "Not connected"}
# Helper to fetch with token refresh support
def fetch_weights_range(start_date: date, end_date: date, token: str):
url = f"https://api.fitbit.com/1/user/-/body/log/weight/date/{start_date}/{end_date}.json"
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json"
}
return requests.get(url, headers=headers)
# Determine ranges
ranges = []
today = datetime.date.today()
if scope == "all":
# Start from a reasonable past date, e.g., 2015-01-01
current_start = datetime.date(2015, 1, 1)
while current_start <= today:
current_end = current_start + datetime.timedelta(days=30)
if current_end > today:
current_end = today
ranges.append((current_start, current_end))
current_start = current_end + datetime.timedelta(days=1)
else:
# Default 30 days
start = today - datetime.timedelta(days=30)
ranges.append((start, today))
total_new = 0
errors = []
# Iterate ranges
# We need to manage token state outside the loop to avoid re-refreshing constantly if it fails
current_token = config.access_token
print(f"DEBUG: Starting sync for scope={scope} with {len(ranges)} ranges.")
for start, end in ranges:
print(f"DEBUG: Fetching range {start} to {end}...")
resp = fetch_weights_range(start, end, current_token)
print(f"DEBUG: Response status: {resp.status_code}")
# Handle 401 (Refresh)
if resp.status_code == 401:
print(f"Token expired during sync of {start}-{end}, refreshing...")
new_token = refresh_tokens(db, config)
if new_token:
current_token = new_token
resp = fetch_weights_range(start, end, current_token)
print(f"DEBUG: Retried request status: {resp.status_code}")
else:
errors.append("Token expired and refresh failed.")
break
# Handle 429 (Rate Limit) - Basic handling: stop
if resp.status_code == 429:
errors.append("Rate limit exceeded.")
print("DEBUG: Rate limit exceeded.")
break
if resp.status_code == 200:
data = resp.json()
weights = data.get('weight', [])
print(f"DEBUG: Found {len(weights)} weights in this range.")
for w in weights:
log_id = str(w.get('logId'))
weight_val = float(w.get('weight'))
date_str = w.get('date')
existing = db.query(WeightLog).filter(WeightLog.fitbit_log_id == log_id).first()
if not existing:
log = WeightLog(
date=datetime.date.fromisoformat(date_str),
weight=weight_val,
fitbit_log_id=log_id,
source='fitbit'
)
db.add(log)
total_new += 1
else:
existing.weight = weight_val
db.commit()
else:
print(f"DEBUG: Error response: {resp.text}")
errors.append(f"Error {resp.status_code} for range {start}-{end}: {resp.text}")
print(f"DEBUG: Sync complete. Total new: {total_new}. Errors: {errors}")
if errors:
return {"status": "warning", "message": f"Synced {total_new} records, but encountered errors: {', '.join(errors[:3])}..."}
else:
return {"status": "success", "message": f"Synced {total_new} new records (" + ("All History" if scope == 'all' else "30d") + ")"}
@@ -0,0 +1,5 @@
# Track add_calcium_20260213 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)
@@ -0,0 +1,8 @@
{
"track_id": "add_calcium_20260213",
"type": "feature",
"status": "new",
"created_at": "2026-02-13T00:00:00Z",
"updated_at": "2026-02-13T00:00:00Z",
"description": "Add Calcium bottom row of 'Daily Totals' on the tracker page"
}
@@ -0,0 +1,18 @@
# Implementation Plan - Add Calcium to Tracker Totals
This plan follows the Test-Driven Development (TDD) process as outlined in `conductor/workflow.md`.
## Phase 1: Infrastructure and Red Phase
- [x] Task: Create a failing E2E test for Calcium display
- [ ] Define a new test in `tests/calcium_display.spec.js` that navigates to the tracker and expects a "Calcium" label and a numeric value in the Daily Totals section.
- [ ] Execute the test and confirm it fails (Red Phase).
## Phase 2: Implementation (Green Phase) [checkpoint: 7718a7f]
- [x] Task: Update tracker template to include Calcium
- [ ] Modify `templates/tracker.html` to add a fourth column to the third row of the "Daily Totals" card.
- [ ] Update existing `col-4` classes in that row to `col-3` to accommodate the new column.
- [ ] Bind the display to `day_totals.calcium` with a `0` decimal place filter and "mg" unit.
- [x] Task: Verify implementation
- [ ] Execute the E2E test created in Phase 1 and confirm it passes (Green Phase).
- [ ] Run existing backend tests to ensure no regressions in nutrition calculations.
- [x] Task: Conductor - User Manual Verification 'Implementation' (Protocol in workflow.md)
@@ -0,0 +1,25 @@
# Specification - Add Calcium to Tracker Totals
## Overview
This track adds a Calcium display to the "Daily Totals" section of the tracker page. This allows users to track their calcium intake (in mg) alongside other micronutrients and macronutrients.
## Functional Requirements
- **Display Calcium:** Add a new column to the third row of the "Daily Totals" card on the `tracker.html` page.
- **Unit of Measurement:** Calcium shall be displayed in milligrams (mg).
- **Precision:** Calcium values shall be rounded to the nearest whole number (0 decimal places).
- **Data Source:** Use the `day_totals.calcium` value provided by the backend, which is already correctly calculated in `calculate_day_nutrition_tracked`.
## Non-Functional Requirements
- **UI Consistency:** The new Calcium display should match the style of the existing Sugar, Fiber, and Sodium displays (border, padding, centered text, small muted label).
- **Responsiveness:** The layout should remain functional on various screen sizes. Adding a fourth column to the row may require adjusting column widths (e.g., from `col-4` to `col-3`).
## Acceptance Criteria
- [ ] A "Calcium" box is visible in the "Daily Totals" section of the tracker page.
- [ ] The value displayed matches the sum of calcium from all tracked foods for that day.
- [ ] The unit "mg" is displayed next to or below the value.
- [ ] The layout of the "Daily Totals" card remains clean and balanced.
## Out of Scope
- Adding Calcium to individual food or meal breakdown tables.
- Updating Open Food Facts import logic (unless found to be missing Calcium data during verification).
- Adding Calcium targets or progress bars.
+49
View File
@@ -0,0 +1,49 @@
# Google HTML/CSS Style Guide Summary
This document summarizes key rules and best practices from the Google HTML/CSS Style Guide.
## 1. General Rules
- **Protocol:** Use HTTPS for all embedded resources.
- **Indentation:** Indent by 2 spaces. Do not use tabs.
- **Capitalization:** Use only lowercase for all code (element names, attributes, selectors, properties).
- **Trailing Whitespace:** Remove all trailing whitespace.
- **Encoding:** Use UTF-8 (without a BOM). Specify `<meta charset="utf-8">` in HTML.
## 2. HTML Style Rules
- **Document Type:** Use `<!doctype html>`.
- **HTML Validity:** Use valid HTML.
- **Semantics:** Use HTML elements according to their intended purpose (e.g., use `<p>` for paragraphs, not for spacing).
- **Multimedia Fallback:** Provide `alt` text for images and transcripts/captions for audio/video.
- **Separation of Concerns:** Strictly separate structure (HTML), presentation (CSS), and behavior (JavaScript). Link to CSS and JS from external files.
- **`type` Attributes:** Omit `type` attributes for stylesheets (`<link>`) and scripts (`<script>`).
## 3. HTML Formatting Rules
- **General:** Use a new line for every block, list, or table element, and indent its children.
- **Quotation Marks:** Use double quotation marks (`""`) for attribute values.
## 4. CSS Style Rules
- **CSS Validity:** Use valid CSS.
- **Class Naming:** Use meaningful, generic names. Separate words with a hyphen (`-`).
- **Good:** `.video-player`, `.site-navigation`
- **Bad:** `.vid`, `.red-text`
- **ID Selectors:** Avoid using ID selectors for styling. Prefer class selectors.
- **Shorthand Properties:** Use shorthand properties where possible (e.g., `padding`, `font`).
- **`0` and Units:** Omit units for `0` values (e.g., `margin: 0;`).
- **Leading `0`s:** Always include leading `0`s for decimal values (e.g., `font-size: 0.8em;`).
- **Hexadecimal Notation:** Use 3-character hex notation where possible (e.g., `#fff`).
- **`!important`:** Avoid using `!important`.
## 5. CSS Formatting Rules
- **Declaration Order:** Alphabetize declarations within a rule.
- **Indentation:** Indent all block content.
- **Semicolons:** Use a semicolon after every declaration.
- **Spacing:**
- Use a space after a property name's colon (`font-weight: bold;`).
- Use a space between the last selector and the opening brace (`.foo {`).
- Start a new line for each selector and declaration.
- **Rule Separation:** Separate rules with a new line.
- **Quotation Marks:** Use single quotes (`''`) for attribute selectors and property values (e.g., `[type='text']`).
**BE CONSISTENT.** When editing code, match the existing style.
*Source: [Google HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html)*
+51
View File
@@ -0,0 +1,51 @@
# Google JavaScript Style Guide Summary
This document summarizes key rules and best practices from the Google JavaScript Style Guide.
## 1. Source File Basics
- **File Naming:** All lowercase, with underscores (`_`) or dashes (`-`). Extension must be `.js`.
- **File Encoding:** UTF-8.
- **Whitespace:** Use only ASCII horizontal spaces (0x20). Tabs are forbidden for indentation.
## 2. Source File Structure
- New files should be ES modules (`import`/`export`).
- **Exports:** Use named exports (`export {MyClass};`). **Do not use default exports.**
- **Imports:** Do not use line-wrapped imports. The `.js` extension in import paths is mandatory.
## 3. Formatting
- **Braces:** Required for all control structures (`if`, `for`, `while`, etc.), even single-line blocks. Use K&R style ("Egyptian brackets").
- **Indentation:** +2 spaces for each new block.
- **Semicolons:** Every statement must be terminated with a semicolon.
- **Column Limit:** 80 characters.
- **Line-wrapping:** Indent continuation lines at least +4 spaces.
- **Whitespace:** Use single blank lines between methods. No trailing whitespace.
## 4. Language Features
- **Variable Declarations:** Use `const` by default, `let` if reassignment is needed. **`var` is forbidden.**
- **Array Literals:** Use trailing commas. Do not use the `Array` constructor.
- **Object Literals:** Use trailing commas and shorthand properties. Do not use the `Object` constructor.
- **Classes:** Do not use JavaScript getter/setter properties (`get name()`). Provide ordinary methods instead.
- **Functions:** Prefer arrow functions for nested functions to preserve `this` context.
- **String Literals:** Use single quotes (`'`). Use template literals (`` ` ``) for multi-line strings or complex interpolation.
- **Control Structures:** Prefer `for-of` loops. `for-in` loops should only be used on dict-style objects.
- **`this`:** Only use `this` in class constructors, methods, or in arrow functions defined within them.
- **Equality Checks:** Always use identity operators (`===` / `!==`).
## 5. Disallowed Features
- `with` keyword.
- `eval()` or `Function(...string)`.
- Automatic Semicolon Insertion.
- Modifying builtin objects (`Array.prototype.foo = ...`).
## 6. Naming
- **Classes:** `UpperCamelCase`.
- **Methods & Functions:** `lowerCamelCase`.
- **Constants:** `CONSTANT_CASE` (all uppercase with underscores).
- **Non-constant Fields & Variables:** `lowerCamelCase`.
## 7. JSDoc
- JSDoc is used on all classes, fields, and methods.
- Use `@param`, `@return`, `@override`, `@deprecated`.
- Type annotations are enclosed in braces (e.g., `/** @param {string} userName */`).
*Source: [Google JavaScript Style Guide](https://google.github.io/styleguide/jsguide.html)*
+37
View File
@@ -0,0 +1,37 @@
# Google Python Style Guide Summary
This document summarizes key rules and best practices from the Google Python Style Guide.
## 1. Python Language Rules
- **Linting:** Run `pylint` on your code to catch bugs and style issues.
- **Imports:** Use `import x` for packages/modules. Use `from x import y` only when `y` is a submodule.
- **Exceptions:** Use built-in exception classes. Do not use bare `except:` clauses.
- **Global State:** Avoid mutable global state. Module-level constants are okay and should be `ALL_CAPS_WITH_UNDERSCORES`.
- **Comprehensions:** Use for simple cases. Avoid for complex logic where a full loop is more readable.
- **Default Argument Values:** Do not use mutable objects (like `[]` or `{}`) as default values.
- **True/False Evaluations:** Use implicit false (e.g., `if not my_list:`). Use `if foo is None:` to check for `None`.
- **Type Annotations:** Strongly encouraged for all public APIs.
## 2. Python Style Rules
- **Line Length:** Maximum 80 characters.
- **Indentation:** 4 spaces per indentation level. Never use tabs.
- **Blank Lines:** Two blank lines between top-level definitions (classes, functions). One blank line between method definitions.
- **Whitespace:** Avoid extraneous whitespace. Surround binary operators with single spaces.
- **Docstrings:** Use `"""triple double quotes"""`. Every public module, function, class, and method must have a docstring.
- **Format:** Start with a one-line summary. Include `Args:`, `Returns:`, and `Raises:` sections.
- **Strings:** Use f-strings for formatting. Be consistent with single (`'`) or double (`"`) quotes.
- **`TODO` Comments:** Use `TODO(username): Fix this.` format.
- **Imports Formatting:** Imports should be on separate lines and grouped: standard library, third-party, and your own application's imports.
## 3. Naming
- **General:** `snake_case` for modules, functions, methods, and variables.
- **Classes:** `PascalCase`.
- **Constants:** `ALL_CAPS_WITH_UNDERSCORES`.
- **Internal Use:** Use a single leading underscore (`_internal_variable`) for internal module/class members.
## 4. Main
- All executable files should have a `main()` function that contains the main logic, called from a `if __name__ == '__main__':` block.
**BE CONSISTENT.** When editing code, match the existing style.
*Source: [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html)*
+14
View File
@@ -0,0 +1,14 @@
# Project Context
## Definition
- [Product Definition](./product.md)
- [Product Guidelines](./product-guidelines.md)
- [Tech Stack](./tech-stack.md)
## Workflow
- [Workflow](./workflow.md)
- [Code Style Guides](./code_styleguides/)
## Management
- [Tracks Registry](./tracks.md)
- [Tracks Directory](./tracks/)
+16
View File
@@ -0,0 +1,16 @@
# Product Guidelines - FoodPlanner
## Visual Identity & Tone
- **Minimalist and Focused:** The interface should be clean and distraction-free, highlighting one primary task at a time to reduce cognitive load.
- **Data Clarity:** Prioritize a high-contrast, readable layout that makes nutritional values easy to scan.
## User Interface Principles
- **Page-Specific Nutritional Awareness:**
- **Global Context:** Use a subtle, persistent summary (e.g., a sticky header) to keep total macros/calories visible for continuous goal tracking.
- **Local Context:** On list-heavy pages (like Food Search or Meal Building), integrate nutritional data directly with the items for immediate decision-making.
- **Task-Oriented "Empty States":** When a user encounters an empty view (no foods, no plans), provide clear, actionable instructions and buttons within that space to guide them to the next step.
## Interaction & Behavior
- **Immediate & Reactive:** Every user action (adjusting quantities, adding items) must trigger an instant UI update to the relevant nutritional totals.
- **Unambiguous Validation:** Use Modal Alerts for errors that require immediate attention or could result in data loss, ensuring critical issues are never overlooked.
- **Low Friction:** Design interactions to minimize the number of clicks required for common tasks like portion adjustments or food selection.
+27
View File
@@ -0,0 +1,27 @@
# Initial Concept\nAn existing Meal Planner application built with FastAPI, SQLAlchemy, and Jinja2 for managing foods, meals, and nutritional plans.
# Product Definition - FoodPlanner
## Vision
A performance-oriented meal planning application designed for health-conscious households (specifically 2-person units) to hit precise nutritional and macronutrient targets. The application prioritizes speed, data reliability, and structured planning over complex external integrations.
## Target Users
- **Macro-Trackers:** Individuals who meticulously track their caloric and macronutrient intake.
- **2-Person Households:** Users who need to coordinate meal plans for a small, consistent group.
## Core Goals
- **Nutritional Precision:** Enable users to reach specific daily and weekly macro/calorie goals through structured 2-week planning.
- **Efficiency:** Reduce the friction of meal prep by providing tools to save, reuse, and template successful plans and meals.
- **Reliability:** Provide a fast, local-first experience with robust data storage (SQLite/PostgreSQL).
## Key Features
- **Nutritional Totaling:** Automated, real-time calculation of macros and calories for individual foods, combined meals, and full daily/weekly plans.
- **Detailed Daily Planner:** A granular view of each day to visualize meal distribution and ensure macro balance across the day.
- **Meals Library:** A centralized repository to create and save custom meals from individual food components.
- **Template System:** Save and apply successful daily or weekly structures to future plans to minimize repetitive data entry.
- **Smart Searching & Sorting:** Real-time, case-insensitive searching and alphabetical sorting in food and meal selection modals for low-friction data entry.
- **Open Food Facts Integration:** Rapidly expand the local food database by importing data directly from the Open Food Facts API.
## Technical Philosophy
- **Local-First & Fast:** The UI must be highly responsive, prioritizing a smooth user experience for frequent data entry.
- **Structured Data:** Use a relational database to ensure data integrity across foods, meals, and plans.
+1
View File
@@ -0,0 +1 @@
{"last_successful_step": "3.3_initial_track_generated"}
+23
View File
@@ -0,0 +1,23 @@
# Tech Stack - FoodPlanner
## Core Backend
- **Language:** Python 3.7+
- **Web Framework:** FastAPI (Asynchronous, high-performance web framework)
- **Data Validation:** Pydantic (Used for schema definition and request/response validation)
## Data Layer
- **ORM:** SQLAlchemy (Using 2.0+ patterns for database interactions)
- **Database:** Support for SQLite (local development) and PostgreSQL (production)
- **Migrations:** Alembic (Handles database schema evolution)
## Frontend & UI
- **Templating:** Jinja2 (Server-side rendering for HTML templates)
- **Frontend Logic:** Minimal JavaScript for reactive UI updates and modal management
## External Integrations
- **Food Data:** Open Food Facts API (For importing nutritional information)
- **AI/Extraction:** OpenAI API (Used for extracting food data from unstructured text)
## Quality Assurance
- **E2E Testing:** Playwright (For browser-based integration tests)
- **Unit/Integration Testing:** pytest (For backend logic and API testing)
+13
View File
@@ -0,0 +1,13 @@
# Project Tracks
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
---
- [x] **Track: Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes'**
*Link: [./tracks/meal_tracker_refactor_20250223/](./tracks/meal_tracker_refactor_20250223/)*
---
- [x] **Track: Add real-time search and alphabetical sorting to the Add Meal and Add Food modals on the tracker page.**
*Link: [./tracks/meal_food_search_20260310/](./tracks/meal_food_search_20260310/)*
@@ -0,0 +1,5 @@
# Track add_calcium_20260213 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)
@@ -0,0 +1,8 @@
{
"track_id": "add_calcium_20260213",
"type": "feature",
"status": "new",
"created_at": "2026-02-13T00:00:00Z",
"updated_at": "2026-02-13T00:00:00Z",
"description": "Add Calcium bottom row of 'Daily Totals' on the tracker page"
}
@@ -0,0 +1,18 @@
# Implementation Plan - Add Calcium to Tracker Totals
This plan follows the Test-Driven Development (TDD) process as outlined in `conductor/workflow.md`.
## Phase 1: Infrastructure and Red Phase
- [x] Task: Create a failing E2E test for Calcium display
- [ ] Define a new test in `tests/calcium_display.spec.js` that navigates to the tracker and expects a "Calcium" label and a numeric value in the Daily Totals section.
- [ ] Execute the test and confirm it fails (Red Phase).
## Phase 2: Implementation (Green Phase) [checkpoint: 7718a7f]
- [x] Task: Update tracker template to include Calcium
- [ ] Modify `templates/tracker.html` to add a fourth column to the third row of the "Daily Totals" card.
- [ ] Update existing `col-4` classes in that row to `col-3` to accommodate the new column.
- [ ] Bind the display to `day_totals.calcium` with a `0` decimal place filter and "mg" unit.
- [x] Task: Verify implementation
- [ ] Execute the E2E test created in Phase 1 and confirm it passes (Green Phase).
- [ ] Run existing backend tests to ensure no regressions in nutrition calculations.
- [x] Task: Conductor - User Manual Verification 'Implementation' (Protocol in workflow.md)
@@ -0,0 +1,25 @@
# Specification - Add Calcium to Tracker Totals
## Overview
This track adds a Calcium display to the "Daily Totals" section of the tracker page. This allows users to track their calcium intake (in mg) alongside other micronutrients and macronutrients.
## Functional Requirements
- **Display Calcium:** Add a new column to the third row of the "Daily Totals" card on the `tracker.html` page.
- **Unit of Measurement:** Calcium shall be displayed in milligrams (mg).
- **Precision:** Calcium values shall be rounded to the nearest whole number (0 decimal places).
- **Data Source:** Use the `day_totals.calcium` value provided by the backend, which is already correctly calculated in `calculate_day_nutrition_tracked`.
## Non-Functional Requirements
- **UI Consistency:** The new Calcium display should match the style of the existing Sugar, Fiber, and Sodium displays (border, padding, centered text, small muted label).
- **Responsiveness:** The layout should remain functional on various screen sizes. Adding a fourth column to the row may require adjusting column widths (e.g., from `col-4` to `col-3`).
## Acceptance Criteria
- [ ] A "Calcium" box is visible in the "Daily Totals" section of the tracker page.
- [ ] The value displayed matches the sum of calcium from all tracked foods for that day.
- [ ] The unit "mg" is displayed next to or below the value.
- [ ] The layout of the "Daily Totals" card remains clean and balanced.
## Out of Scope
- Adding Calcium to individual food or meal breakdown tables.
- Updating Open Food Facts import logic (unless found to be missing Calcium data during verification).
- Adding Calcium targets or progress bars.
@@ -0,0 +1,5 @@
# Track meal_food_search_20260310 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)
@@ -0,0 +1,8 @@
{
"track_id": "meal_food_search_20260310",
"type": "feature",
"status": "new",
"created_at": "2026-03-10T12:00:00Z",
"updated_at": "2026-03-10T12:00:00Z",
"description": "Add real-time search and alphabetical sorting to the Add Meal and Add Food modals on the tracker page."
}
@@ -0,0 +1,32 @@
# Implementation Plan: Meal/Food Search & Sorting (Track: meal_food_search_20260310)
## Phase 1: Preparation and Testing
- [x] Task: Create a new Playwright test file `tests/meal_food_search.spec.js` to verify searching and sorting in modals.
- [x] Task: Write failing E2E tests for:
- [x] Modal opening and initial alphabetical sorting of Meals.
- [x] Real-time filtering in the "Add Meal" modal.
- [x] Modal opening and initial alphabetical sorting of Foods.
- [x] Real-time filtering (by name and brand) in the "Add Food" modal.
- [x] Task: Run the tests and confirm they fail.
- [x] Task: Conductor - User Manual Verification 'Phase 1: Preparation and Testing' (Protocol in workflow.md)
## Phase 2: Implement Sorting & Searching in "Add Meal" Modal
- [x] Task: Update `templates/modals/add_meal.html` to add a search input field above the meal list.
- [x] Task: Add a unique `data-testid` to the search input and individual list items for test reliability.
- [x] Task: Update the backend route `app/api/routes/tracker.py` to sort the `meals` list alphabetically before passing it to the template.
- [x] Task: Implement client-side JavaScript in `templates/modals/add_meal.html` to filter the meal list in real-time as the user types.
- [x] Task: Verify Phase 2 with E2E tests.
- [x] Task: Conductor - User Manual Verification 'Phase 2: Implement Sorting & Searching in Add Meal Modal' (Protocol in workflow.md)
## Phase 3: Implement Sorting & Searching in "Add Food" Modal
- [x] Task: Update `templates/modals/add_single_food.html` (or the relevant "Add Food" modal) to add a search input field above the food list.
- [x] Task: Add a unique `data-testid` to the search input and food list items.
- [x] Task: Update the backend route `app/api/routes/tracker.py` to sort the `foods` list alphabetically before passing it to the template.
- [x] Task: Implement client-side JavaScript in the "Add Food" modal to filter by both `name` and `brand` in real-time.
- [x] Task: Verify Phase 3 with E2E tests.
- [x] Task: Conductor - User Manual Verification 'Phase 3: Implement Sorting & Searching in Add Food Modal' (Protocol in workflow.md)
## Phase 4: Final Verification and Cleanup
- [x] Task: Perform a final run of all tests (E2E and Backend).
- [x] Task: Ensure code coverage for any new logic (if applicable) is >80%.
- [x] Task: Conductor - User Manual Verification 'Phase 4: Final Verification and Cleanup' (Protocol in workflow.md)
@@ -0,0 +1,32 @@
# Specification: Meal/Food Search & Sorting (Track: meal_food_search_20260310)
## Overview
Enhance the user experience on the Tracker page by implementing real-time searching and alphabetical sorting for the "Add Meal" and "Add Food" modals. This will allow users to quickly locate specific items in their potentially large database of foods and meals.
## Functional Requirements
1. **Alphabetical Sorting**:
- The list of available meals in the "Add Meal" modal must be sorted alphabetically (A-Z) by name.
- The list of available foods in the "Add Food" modal must be sorted alphabetically (A-Z) by name.
2. **Real-time Search Filter**:
- A search bar (text input) must be added above the lists in both "Add Meal" and "Add Food" modals.
- As the user types in the search bar, the list must filter in real-time.
- The filter should be case-insensitive.
3. **Search Scope**:
- For **Meals**: The search should match against the `name` field.
- For **Foods**: The search should match against both the `name` and `brand` fields.
## Non-Functional Requirements
- **Performance**: Filtering should be near-instantaneous on the client-side for a smooth user experience.
- **Maintainability**: Use standard Bootstrap and Vanilla JavaScript patterns consistent with the existing codebase.
## Acceptance Criteria
- [ ] Open the "Add Meal" modal on the Tracker page; meals are sorted A-Z.
- [ ] Type in the search bar in the "Add Meal" modal; the list filters to show only matching meals.
- [ ] Open the "Add Food" modal on the Tracker page; foods are sorted A-Z.
- [ ] Type a food name or brand in the search bar in the "Add Food" modal; the list filters correctly.
- [ ] Clearing the search bar restores the full (sorted) list.
## Out of Scope
- Server-side searching (filtering will be done on the already-loaded client-side list).
- Advanced fuzzy matching (initially, simple substring matching is sufficient).
- Searching for other tabs like "Foods" or "Meals" (this track is specific to the Tracker page modals).
@@ -0,0 +1,5 @@
# Track meal_tracker_refactor_20250223 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)
@@ -0,0 +1,8 @@
{
"track_id": "meal_tracker_refactor_20250223",
"type": "refactor",
"status": "new",
"created_at": "2025-02-23T12:00:00Z",
"updated_at": "2025-02-23T12:00:00Z",
"description": "Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes', resolving database pollution and improving system structure."
}
@@ -0,0 +1,28 @@
# Implementation Plan - Meal Tracker Refactoring
This plan outlines the steps for refactoring the meal tracking system to decouple "Journal Logs" from "Cookbook Recipes," resolving database pollution and improving system structure.
## Phase 1: Preparation & Schema Updates [checkpoint: 326a82e]
- [x] Task: Create a new branch for the refactoring track.
- [x] Task: Add the 'name' column to the 'TrackedMeal' table and make 'meal_id' nullable in 'app/database.py'.
- [x] Task: Create and run an Alembic migration for the schema changes.
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Preparation & Schema Updates' (Protocol in workflow.md)
## Phase 2: Logic & Calculation Updates [checkpoint: cc6b4ca]
- [ ] Task: Write failing unit tests for 'calculate_tracked_meal_nutrition' with 'meal_id=None'.
- [ ] Task: Implement support for 'meal_id=None' in 'calculate_tracked_meal_nutrition' within 'app/database.py'.
- [ ] Task: Write failing unit tests for the refactored 'tracker_add_food' endpoint.
- [ ] Task: Refactor the 'tracker_add_food' route in 'app/api/routes/tracker.py' to use the new 'TrackedMeal' structure.
- [ ] Task: Conductor - User Manual Verification 'Phase 2: Logic & Calculation Updates' (Protocol in workflow.md)
## Phase 3: UI & Cookbook Refinement [checkpoint: b834e89]
- [ ] Task: Update the 'tracker.html' template to display 'TrackedMeal.name' for template-less logs.
- [ ] Task: Update the Meals page in 'app/api/routes/meals.py' to filter out 'single_food' and 'snapshot' types.
- [ ] Task: Write failing E2E tests for the new tracking workflow.
- [ ] Task: Conductor - User Manual Verification 'Phase 3: UI & Cookbook Refinement' (Protocol in workflow.md)
## Phase 4: Database Migration & Cleanup [checkpoint: 5c73ce9]
- [x] Task: Create a Python migration script for cleaning up existing 'single_food' entries.
- [x] Task: Run the migration script on the development PostgreSQL database.
- [x] Task: Verify the database state and ensure no orphans remain.
- [x] Task: Conductor - User Manual Verification 'Phase 4: Database Migration & Cleanup' (Protocol in workflow.md)
@@ -0,0 +1,28 @@
# Specification - Meal Tracker Refactoring
**Overview:**
Refactor the meal tracking system to decouple "Journal Logs" from "Cookbook Recipes". Currently, adding a single food item via the tracker incorrectly creates a permanent 'Meal' record of type 'single_food', leading to database pollution and duplicate entries in the Meals library.
**Functional Requirements:**
- **TrackedMeal Schema Update:** Add a 'name' column to the 'TrackedMeal' model to store the display name of a logged meal or a single food item.
- **Nullable meal_id:** Modify 'TrackedMeal.meal_id' to be nullable, allowing "template-less" logs.
- **Refactored Tracker Logic:** Update the 'tracker_add_food' route to log single items directly as a 'TrackedMeal' with 'meal_id=NULL' and the 'name' set to the food item's name.
- **Nutrition Calculation:** Update nutrition calculation logic to handle 'TrackedMeal' entries without a parent 'Meal' template.
- **Tracker UI Update:** Ensure the tracker page displays 'TrackedMeal.name' for these logs and maintains the seamless visual style of existing entries.
- **Cookbook Cleanup (One-time Migration):** Migrate existing 'single_food' meals to the new format and purge the redundant records from the 'meals' and 'meal_foods' tables.
- **Cookbook Filtering:** Update the Meals page to exclude 'single_food' and 'snapshot' meal types from view.
**Non-Functional Requirements:**
- **Database Integrity:** Ensure all existing logs remain accurate and correctly linked to their food items during migration.
- **Performance:** The tracker page should remain fast and responsive with the new logic.
**Acceptance Criteria:**
- [ ] Adding a single food to the tracker does **not** create a new entry in the 'meals' table.
- [ ] Existing 'single_food' duplicates are removed from the 'meals' library.
- [ ] The Meals page only shows "Cookbook Recipes" (e.g., proper combined meals).
- [ ] The Tracker page correctly displays names and calculates nutrition for all logs (both template-based and template-less).
- [ ] "Save as New Meal" remains available for all log entries, including single foods.
**Out of Scope:**
- Refactoring the entire meal planning system beyond the tracker/cookbook separation.
- Changes to the external Open Food Facts integration.
+169
View File
@@ -0,0 +1,169 @@
# Project Workflow
## Guiding Principles
1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
3. **Test-Driven Development:** Write unit tests before implementing functionality
4. **High Code Coverage:** Aim for >80% code coverage for all modules
5. **User Experience First:** Every decision should prioritize user experience
6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
## Task Workflow
All tasks follow a strict lifecycle:
### Standard Task Workflow
1. **Select Task:** Choose the next available task from `plan.md` in sequential order
2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
3. **Write Failing Tests (Red Phase):**
- Create a new test file for the feature or bug fix.
- Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
- **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
4. **Implement to Pass Tests (Green Phase):**
- Write the minimum amount of application code necessary to make the failing tests pass.
- Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
5. **Refactor (Optional but Recommended):**
- With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
- Rerun tests to ensure they still pass after refactoring.
6. **Verify Coverage:** Run coverage reports using the project's chosen tools (e.g., `pytest --cov=app`). Target: >80% coverage for new code.
7. **Document Deviations:** If implementation differs from tech stack:
- **STOP** implementation
- Update `tech-stack.md` with new design
- Add dated note explaining the change
- Resume implementation
8. **Record Completion in Plan:** Update `plan.md`, find the line for the completed task, and update its status from `[~]` to `[x]`.
*Note: Committing code and plan updates is deferred until the entire phase is complete.*
### Phase Completion Verification and Checkpointing Protocol
**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
2. **Ensure Test Coverage for Phase Changes:**
- **Step 2.1: Determine Phase Scope:** Identify files changed since the last phase checkpoint.
- **Step 2.2: List Changed Files:** Execute `git diff --name-only <previous_checkpoint_sha> HEAD` (or since first commit if no previous checkpoint).
- **Step 2.3: Verify and Create Tests:** Ensure all new code has corresponding tests following project conventions.
3. **Execute Automated Tests with Proactive Debugging:**
- Announce and run the automated test suite (e.g., `pytest` and `playwright test`).
- If tests fail, debug and fix (maximum two attempts before seeking guidance).
4. **Propose a Detailed, Actionable Manual Verification Plan:**
- Analyze `product.md`, `product-guidelines.md`, and `plan.md` to define user-facing goals.
- Present a step-by-step verification plan with expected outcomes.
5. **Await Explicit User Feedback:**
- Pause for the user to confirm: "Does this meet your expectations?"
6. **Create Phase Checkpoint Commit:**
- Stage all code changes and the updated `plan.md`.
- Perform a single commit for the entire phase with a message like `feat(phase): Complete <Phase Name>`.
7. **Attach Consolidated Task Summary using Git Notes:**
- **Step 7.1: Draft Note Content:** Create a detailed summary for *all* tasks completed in this phase, including verification results.
- **Step 7.2: Attach Note:** Use `git notes add -m "<summary>" <checkpoint_commit_hash>`.
8. **Get and Record Phase Checkpoint SHA:**
- Obtain the hash of the checkpoint commit and update the phase heading in `plan.md` with `[checkpoint: <sha>]`.
9. **Commit Plan Update:**
- Stage `plan.md` and commit with `conductor(plan): Mark phase '<PHASE NAME>' as complete`.
10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created.
## Quality Gates
Before marking any task complete, verify:
- [ ] All tests pass
- [ ] Code coverage meets requirements (>80%)
- [ ] Code follows project's code style guidelines
- [ ] All public functions/methods are documented
- [ ] Type safety is enforced (Pydantic models, type hints)
- [ ] No linting or static analysis errors
- [ ] Documentation updated if needed
- [ ] No security vulnerabilities introduced
## Development Commands
### Setup
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
npm install
```
### Daily Development
```bash
# Start FastAPI server
uvicorn main:app --reload
# Run Backend Tests
pytest
# Run E2E Tests
npx playwright test
```
### Before Committing
```bash
# Run all checks
pytest && npx playwright test
```
## Testing Requirements
### Unit Testing
- Every module must have corresponding tests in `tests/`.
- Use `pytest` fixtures for database and app setup.
- Mock external APIs (Open Food Facts, OpenAI).
### Integration Testing
- Test complete API flows using `httpx`.
- Verify database state after operations.
### E2E Testing
- Use Playwright to test critical user journeys (Creating Plans, Adding Foods).
## Commit Guidelines
### Message Format
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
### Types
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation only
- `style`: Formatting, missing semicolons, etc.
- `refactor`: Code change that neither fixes a bug nor adds a feature
- `test`: Adding missing tests
- `chore`: Maintenance tasks
## Definition of Done
A task is complete when:
1. All code implemented to specification
2. Unit tests written and passing
3. Code coverage meets project requirements
4. Documentation complete (if applicable)
5. Code passes all configured linting and static analysis checks
6. Implementation notes recorded for the phase summary
7. Phase changes committed and summarized with Git Notes
+7 -1
View File
@@ -5,6 +5,12 @@ services:
- "8999:8999" - "8999:8999"
environment: environment:
- DATABASE_URL=sqlite:////app/data/meal_planner.db - DATABASE_URL=sqlite:////app/data/meal_planner.db
#- DATABASE_URL=postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner_dev
- PYTHONUNBUFFERED=1
volumes: volumes:
- ./alembic:/app/alembic - ./alembic:/app/alembic
- ./data:/app/data - ./data:/app/data
- ./backups:/app/backups
- ./app:/app/app
- ./templates:/app/templates
- ./main.py:/app/main.py
+59
View File
@@ -0,0 +1,59 @@
variable "container_version" {
default = "dev"
}
job "foodplanner-dev" {
datacenters = ["dc1"]
type = "service"
group "app" {
count = 1
network {
port "http" {
to = 8999
}
}
service {
name = "foodplanner-dev"
port = "http"
check {
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
task "app" {
driver = "docker"
config {
image = "ghcr.io/sstent/foodplanner:${var.container_version}"
ports = ["http"]
}
env {
DATABASE_URL = "postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner_dev"
}
resources {
cpu = 500
memory = 1024
}
# Restart policy
restart {
attempts = 3
interval = "10m"
delay = "15s"
mode = "fail"
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
variable "container_version" {
default = "latest"
}
job "foodplanner" {
datacenters = ["dc1"]
type = "service"
group "app" {
count = 1
network {
port "http" {
to = 8999
}
}
service {
name = "foodplanner"
port = "http"
check {
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
task "app" {
driver = "docker"
config {
image = "ghcr.io/sstent/foodplanner:${var.container_version}"
ports = ["http"]
}
env {
DATABASE_URL = "postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner"
}
resources {
cpu = 500
memory = 1024
}
# Restart policy
restart {
attempts = 3
interval = "10m"
delay = "15s"
mode = "fail"
}
}
}
}
-99
View File
@@ -1,99 +0,0 @@
job "foodplanner" {
datacenters = ["dc1"]
type = "service"
group "app" {
count = 1
network {
port "http" {
to = 8999
}
}
service {
name = "foodplanner"
port = "http"
check {
type = "http"
path = "/"
interval = "10s"
timeout = "2s"
}
}
# Prestart restore task
task "restore" {
driver = "docker"
lifecycle {
hook = "prestart"
sidecar = false
}
config {
# image = "litestream/litestream:latest"
image = "litestream/litestream:0.3"
args = [
"restore",
# "-if-replica-exists",
#"-if-db-not-exists",
"-o", "/alloc/tmp/meal_planner.db",
"sftp://root:odroid@192.168.4.63/mnt/Shares/litestream/foodplanner.db"
]
volumes = [
"/opt/nomad/data:/data"
]
}
}
task "app" {
driver = "docker"
config {
image = "ghcr.io/sstent/foodplanner:main"
ports = ["http"]
# Mount the SQLite database file to persist data
# Adjust the source path as needed for your environment
volumes = [
"/mnt/Public/configs/FoodPlanner_backups:/app/backups/",
]
}
env {
DATABASE_PATH = "/alloc/tmp"
DATABASE_URL = "sqlite:////alloc/tmp/meal_planner.db"
}
resources {
cpu = 500
memory = 1024
}
# Restart policy
restart {
attempts = 3
interval = "10m"
delay = "15s"
mode = "fail"
}
}
# Litestream sidecar for continuous replication
task "litestream" {
driver = "docker"
lifecycle {
hook = "poststart" # runs after main task starts
sidecar = true
}
config {
# image = "litestream/litestream:0.5.0-test.10"
image = "litestream/litestream:0.3"
args = [
"replicate",
"/alloc/tmp/meal_planner.db",
"sftp://root:odroid@192.168.4.63/mnt/Shares/litestream/foodplanner.db"
]
}
}
}
}
+14
View File
@@ -46,6 +46,7 @@ async def lifespan(app: FastAPI):
# Schedule the backup job - temporarily disabled for debugging # Schedule the backup job - temporarily disabled for debugging
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
scheduler.add_job(scheduled_backup, 'cron', hour=0) scheduler.add_job(scheduled_backup, 'cron', hour=0)
scheduler.add_job(scheduled_fitbit_sync, 'interval', hours=1)
scheduler.start() scheduler.start()
logging.info("Scheduled backup job started.") logging.info("Scheduled backup job started.")
yield yield
@@ -152,6 +153,19 @@ def scheduled_backup():
backup_path = os.path.join(backup_dir, f"meal_planner_{timestamp}.db") backup_path = os.path.join(backup_dir, f"meal_planner_{timestamp}.db")
backup_database(db_path, backup_path) backup_database(db_path, backup_path)
def scheduled_fitbit_sync():
"""Sync Fitbit weight data."""
logging.info("DEBUG: Starting scheduled Fitbit sync...")
db = SessionLocal()
from app.services.fitbit_service import sync_fitbit_weight
try:
result = sync_fitbit_weight(db, scope="30d")
logging.info(f"Scheduled Fitbit result: {result}")
except Exception as e:
logging.error(f"Scheduled Fitbit sync failed: {e}")
finally:
db.close()
def test_sqlite_connection(db_path): def test_sqlite_connection(db_path):
"""Test if we can create and write to SQLite database file""" """Test if we can create and write to SQLite database file"""
Binary file not shown.
Binary file not shown.
+150
View File
@@ -0,0 +1,150 @@
import os
import sys
import logging
from sqlalchemy import create_engine, text, inspect
from sqlalchemy.orm import sessionmaker
# Import models to ensure simple table discovery if needed,
# though we will mostly work with raw tables or inspection.
from app.database import Base, Food, Meal, MealFood, Plan, Template, TemplateMeal, WeeklyMenu, WeeklyMenuDay, TrackedDay, TrackedMeal, TrackedMealFood, FitbitConfig, WeightLog
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def migrate():
import argparse
parser = argparse.ArgumentParser(description='Migrate data from SQLite to PostgreSQL')
parser.add_argument('--sqlite-path', help='Path to source SQLite database file', default=os.getenv('SQLITE_PATH', '/app/data/meal_planner.db'))
parser.add_argument('--pg-url', help='PostgreSQL connection URL', default=os.getenv('PG_DATABASE_URL'))
args = parser.parse_args()
# Configuration
# Source: SQLite
sqlite_path = args.sqlite_path
sqlite_url = f"sqlite:///{sqlite_path}"
# Destination: Postgres
if args.pg_url:
pg_url = args.pg_url
else:
# update this if running externally
pg_user = os.getenv('POSTGRES_USER', 'user')
pg_password = os.getenv('POSTGRES_PASSWORD', 'password')
pg_host = os.getenv('POSTGRES_HOST', 'postgres')
pg_db = os.getenv('POSTGRES_DB', 'meal_planner')
pg_url = f"postgresql://{pg_user}:{pg_password}@{pg_host}/{pg_db}"
logger.info(f"Source SQLite: {sqlite_url}")
logger.info(f"Destination Postgres: {pg_url}")
# Create Engines
try:
sqlite_engine = create_engine(sqlite_url)
pg_engine = create_engine(pg_url)
# Test connections
with sqlite_engine.connect() as conn:
pass
logger.info("Connected to SQLite.")
with pg_engine.connect() as conn:
pass
logger.info("Connected to Postgres.")
except Exception as e:
logger.error(f"Failed to connect to databases: {e}")
return
# Create tables in Postgres if they don't exist
# Using the Base metadata from the app
logger.info("Creating tables in Postgres...")
Base.metadata.drop_all(pg_engine) # Clean start to avoid conflicts
Base.metadata.create_all(pg_engine)
logger.info("Tables created.")
# Define table order to respect Foreign Keys
tables_ordered = [
'foods',
'meals',
'meal_foods',
'templates',
'template_meals',
'weekly_menus',
'weekly_menu_days',
'plans',
'tracked_days',
'tracked_meals',
'tracked_meal_foods',
'fitbit_config',
'weight_logs'
]
# Migration Loop
with sqlite_engine.connect() as sqlite_conn, pg_engine.connect() as pg_conn:
for table_name in tables_ordered:
logger.info(f"Migrating table: {table_name}")
# Read from SQLite
try:
# Use raw SQL to get all data, handling potential missing tables gracefully if app changed
result = sqlite_conn.execute(text(f"SELECT * FROM {table_name}"))
rows = result.fetchall()
keys = result.keys()
if not rows:
logger.info(f" No data in {table_name}, skipping.")
continue
# Insert into Postgres
# We simply create a list of dicts
data = [dict(zip(keys, row)) for row in rows]
# Setup insert statement
# We use SQLAlchemy core to make it db-agnostic enough
table_obj = Base.metadata.tables[table_name]
pg_conn.execute(table_obj.insert(), data)
pg_conn.commit()
logger.info(f" Migrated {len(rows)} rows.")
# Reset Sequence for Serial ID columns
# Postgres sequences usually named table_id_seq
if 'id' in keys:
# Find max id
max_id = max(row[0] for row in rows) # Assuming 'id' is first or we can look it up.
# Safer:
max_id_val = 0
for d in data:
if d['id'] > max_id_val:
max_id_val = d['id']
if max_id_val > 0:
seq_name = f"{table_name}_id_seq"
# Check if sequence exists (it should for Serial)
try:
pg_conn.execute(text(f"SELECT setval('{seq_name}', {max_id_val})"))
pg_conn.commit()
logger.info(f" Sequence {seq_name} reset to {max_id_val}")
except Exception as seq_err:
logger.warn(f" Could not reset sequence {seq_name} (might not exist): {seq_err}")
pg_conn.rollback()
except Exception as e:
# Check for "no such table" specific error which is common if a feature isn't used
if "no such table" in str(e):
logger.warning(f" Table {table_name} not found in source SQLite. Skipping.")
continue
logger.error(f"Error migrating {table_name}: {e}")
pg_conn.rollback()
# Decide whether to stop or continue. Stopping is safer.
return
logger.info("Migration completed successfully.")
if __name__ == "__main__":
migrate()
+1 -1
View File
@@ -3,7 +3,7 @@ starlette==0.37.2
anyio==4.4.0 anyio==4.4.0
uvicorn[standard]==0.24.0 uvicorn[standard]==0.24.0
sqlalchemy>=2.0.24 sqlalchemy>=2.0.24
#psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-multipart>=0.0.7 python-multipart>=0.0.7
jinja2==3.1.2 jinja2==3.1.2
openfoodfacts>=0.2.0 openfoodfacts>=0.2.0
+73
View File
@@ -0,0 +1,73 @@
{% extends "admin/index.html" %}
{% block admin_content %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">System Configuration Status</h5>
</div>
<div class="card-body">
<p class="text-muted">Current system environment and database connection details.</p>
<table class="table table-striped table-bordered">
<thead class="table-light">
<tr>
<th style="width: 30%">Setting</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Database Type</strong></td>
<td>
{% if 'sqlite' in config.database_url %}
<span class="badge bg-secondary">SQLite</span>
{% elif 'postgresql' in config.database_url %}
<span class="badge bg-primary">PostgreSQL</span>
{% else %}
<span class="badge bg-warning text-dark">{{ config.database_type }}</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Connection URL</strong></td>
<td><code>{{ config.database_url_masked }}</code></td>
</tr>
<tr>
<td><strong>Database Host/Path</strong></td>
<td>{{ config.database_host }}</td>
</tr>
<tr>
<td><strong>Environment</strong></td>
<td>
{% if config.debug %}
<span class="badge bg-warning text-dark">Debug Mode</span>
{% else %}
<span class="badge bg-success">Production</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
<div class="alert alert-info mt-3">
<i class="bi bi-info-circle"></i>
{% if 'sqlite' in config.database_url %}
Running in portable SQLite mode. To switch to PostgreSQL, please refer to the migration guide.
{% else %}
Running in PostgreSQL mode. Database is hosted at <strong>{{ config.database_host }}</strong>.
{% endif %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Activate the correct tab
const tabLink = document.getElementById('config-status-tab');
if (tabLink) {
tabLink.classList.add('active');
tabLink.setAttribute('aria-selected', 'true');
}
});
</script>
{% endblock %}
+179
View File
@@ -0,0 +1,179 @@
{% extends "admin/index.html" %}
{% block admin_content %}
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Fitbit Connection</h5>
{% if is_connected %}
<span class="badge bg-success">Connected</span>
{% else %}
<span class="badge bg-secondary">Disconnected</span>
{% endif %}
</div>
<div class="card-body">
<!-- Configuration Form -->
<form action="/admin/fitbit/config" method="post" class="mb-4">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Client ID</label>
<input type="text" class="form-control" name="client_id" value="{{ config.client_id or '' }}"
required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Client Secret</label>
<input type="text" class="form-control" name="client_secret"
value="{{ config.client_secret or '' }}" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Redirect URI</label>
<input type="text" class="form-control" name="redirect_uri"
value="{{ config.redirect_uri or 'http://localhost:8080/fitbit-callback' }}" required>
</div>
</div>
<button type="submit" class="btn btn-outline-primary btn-sm">Update Configuration</button>
</form>
<hr>
{% if not is_connected %}
<div class="alert alert-info">
<strong>Connect to Fitbit:</strong>
<ol>
<li>Click "Get Authorization URL" below.</li>
<li>Visit the URL in your browser and authorize the app.</li>
<li>You will be redirected to a URL (likely failing to load). Copy the entire URL.</li>
<li>Paste it in the box below and click "Complete Connection".</li>
</ol>
</div>
<div class="mb-3">
<button id="get-auth-url-btn" class="btn btn-primary">Get Authorization URL</button>
<div id="auth-url-container" class="mt-2" style="display:none;">
<textarea class="form-control" rows="2" readonly id="auth-url-display"></textarea>
<a href="#" target="_blank" id="auth-link" class="btn btn-sm btn-link">Open Link</a>
</div>
</div>
<form action="/admin/fitbit/auth/exchange" method="post">
<div class="input-group">
<input type="text" class="form-control" name="code_input"
placeholder="Paste full redirected URL or code here..." required>
<button class="btn btn-success" type="submit">Complete Connection</button>
</div>
</form>
{% else %}
<div class="d-flex align-items-center gap-3">
<button class="btn btn-primary sync-btn" data-scope="30d">
<i class="bi bi-arrow-repeat"></i> Sync Last 30 Days
</button>
<button class="btn btn-secondary sync-btn" data-scope="all">
<i class="bi bi-clock-history"></i> Sync All History
</button>
<span id="sync-status" class="text-muted"></span>
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Recent Weight Logs</h5>
</div>
<div class="card-body">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Date</th>
<th>Weight (kg)</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.date }}</td>
<td>{{ log.weight }}</td>
<td>{{ log.source }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-center text-muted">No logs found. Sync to import data.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Auth URL handler
const authBtn = document.getElementById('get-auth-url-btn');
if (authBtn) {
authBtn.addEventListener('click', async () => {
try {
const response = await fetch('/admin/fitbit/auth_url');
const data = await response.json();
if (data.status === 'success') {
const container = document.getElementById('auth-url-container');
const display = document.getElementById('auth-url-display');
const link = document.getElementById('auth-link');
display.value = data.url;
link.href = data.url;
container.style.display = 'block';
} else {
alert('Error: ' + data.message);
}
} catch (e) {
alert('Request failed: ' + e);
}
});
}
// Sync handler
const syncBtns = document.querySelectorAll('.sync-btn');
syncBtns.forEach(btn => {
btn.addEventListener('click', async () => {
const scope = btn.dataset.scope;
const statusFn = document.getElementById('sync-status');
// Disable all sync buttons
syncBtns.forEach(b => b.disabled = true);
statusFn.textContent = scope === 'all' ? 'Syncing history (this may take a while)...' : 'Syncing...';
statusFn.className = 'text-muted';
try {
const formData = new FormData();
formData.append('scope', scope);
const response = await fetch('/admin/fitbit/sync', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.status === 'success' || data.status === 'warning') {
statusFn.textContent = data.message;
statusFn.className = data.status === 'warning' ? 'text-warning' : 'text-success';
setTimeout(() => location.reload(), 2000); // Reload to show data
} else {
statusFn.textContent = 'Error: ' + data.message;
statusFn.className = 'text-danger';
}
} catch (e) {
statusFn.textContent = 'Failed: ' + e;
statusFn.className = 'text-danger';
} finally {
syncBtns.forEach(b => b.disabled = false);
}
});
});
});
</script>
{% endblock %}
+6
View File
@@ -13,6 +13,12 @@
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a class="nav-link" id="llm-config-tab" href="/admin/llm_config">LLM Config</a> <a class="nav-link" id="llm-config-tab" href="/admin/llm_config">LLM Config</a>
</li> </li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="fitbit-tab" href="/admin/fitbit">Fitbit</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link" id="config-status-tab" href="/admin/config-status">Config Status</a>
</li>
</ul> </ul>
<div class="tab-content mt-3"> <div class="tab-content mt-3">
+79 -8
View File
@@ -100,30 +100,82 @@
resizeChart(); resizeChart();
chart = new Chart(ctx, { chart = new Chart(ctx, {
type: 'bar', // Switch to bar chart type: 'bar',
data: { data: {
labels: labels, labels: labels,
datasets: [ datasets: [
{
type: 'line',
label: 'Weight (lbs)',
data: data.map(item => item.weight_lbs),
borderColor: '#0d6efd', // Bootstrap primary (Blue)
backgroundColor: '#0d6efd',
borderWidth: 2,
pointRadius: function (context) {
const index = context.dataIndex;
const item = data[index]; // Access data array from outer scope
// Show dot if it's a real weight measurement
if (item.weight_is_real) return 4;
// "Or the first point if no datapoints in the view"
// Check if ANY point in the view is real
const anyReal = data.some(d => d.weight_is_real);
if (!anyReal) {
// Make sure we only show ONE dot (the first one / oldest date)
// Data is sorted by date ascending in frontend (index 0 is oldest)
if (index === 0 && item.weight_lbs !== null) return 4;
}
return 0; // Hide dot for inferred points
},
yAxisID: 'y1',
datalabels: {
display: true,
align: 'top',
formatter: function (value, context) {
// Only show label if radius > 0
const index = context.dataIndex;
const item = data[index];
// Same logic as pointRadius
let show = false;
if (item.weight_is_real) show = true;
else {
const anyReal = data.some(d => d.weight_is_real);
if (!anyReal && index === 0 && item.weight_lbs !== null) show = true;
}
return show ? (value ? value + ' lbs' : '') : '';
},
color: '#0d6efd',
font: { weight: 'bold' }
},
spanGaps: true
},
{ {
label: 'Net Carbs', label: 'Net Carbs',
data: netCarbsCals, data: netCarbsCals,
backgroundColor: 'rgba(255, 193, 7, 0.8)', // Bootstrap warning (Yellow) backgroundColor: 'rgba(255, 193, 7, 0.8)', // Bootstrap warning (Yellow)
borderColor: '#ffc107', borderColor: '#ffc107',
borderWidth: 1 borderWidth: 1,
yAxisID: 'y'
}, },
{ {
label: 'Fat', label: 'Fat',
data: fatCals, data: fatCals,
backgroundColor: 'rgba(220, 53, 69, 0.8)', // Bootstrap danger (Red) backgroundColor: 'rgba(220, 53, 69, 0.8)', // Bootstrap danger (Red)
borderColor: '#dc3545', borderColor: '#dc3545',
borderWidth: 1 borderWidth: 1,
yAxisID: 'y'
}, },
{ {
label: 'Protein', label: 'Protein',
data: proteinCals, data: proteinCals,
backgroundColor: 'rgba(25, 135, 84, 0.8)', // Bootstrap success (Green) backgroundColor: 'rgba(25, 135, 84, 0.8)', // Bootstrap success (Green)
borderColor: '#198754', borderColor: '#198754',
borderWidth: 1 borderWidth: 1,
yAxisID: 'y'
} }
] ]
}, },
@@ -133,14 +185,26 @@
scales: { scales: {
y: { y: {
beginAtZero: true, beginAtZero: true,
stacked: true, // Enable stacking for Y axis stacked: true,
title: { title: {
display: true, display: true,
text: 'Calories' text: 'Calories'
} }
}, },
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Weight (lbs)'
},
grid: {
drawOnChartArea: false // only want the grid lines for one axis to show up
}
},
x: { x: {
stacked: true, // Enable stacking for X axis stacked: true,
title: { title: {
display: true, display: true,
text: 'Date' text: 'Date'
@@ -156,8 +220,11 @@
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
if (context.dataset.type === 'line') {
return label + context.parsed.y + ' lbs';
}
const dayData = data[context.dataIndex]; const dayData = data[context.dataIndex];
const macroKey = MACRO_KEYS[context.datasetIndex]; const macroKey = MACRO_KEYS[context.datasetIndex - 1]; // Offset by 1 due to weight dataset
const grams = dayData[macroKey]; const grams = dayData[macroKey];
label += Math.round(context.parsed.y) + ' cals (' + Math.round(grams) + 'g)'; label += Math.round(context.parsed.y) + ' cals (' + Math.round(grams) + 'g)';
} }
@@ -172,6 +239,8 @@
size: 11 size: 11
}, },
display: function (context) { display: function (context) {
if (context.dataset.type === 'line') return false; // Handled separately
const dayData = data[context.dataIndex]; const dayData = data[context.dataIndex];
const pC = dayData.protein * 4; const pC = dayData.protein * 4;
const fC = dayData.fat * 9; const fC = dayData.fat * 9;
@@ -182,6 +251,8 @@
return calcTotal > 0 && (value / calcTotal) > 0.05; return calcTotal > 0 && (value / calcTotal) > 0.05;
}, },
formatter: function (value, context) { formatter: function (value, context) {
if (context.dataset.type === 'line') return '';
const dayData = data[context.dataIndex]; const dayData = data[context.dataIndex];
const pC = dayData.protein * 4; const pC = dayData.protein * 4;
const fC = dayData.fat * 9; const fC = dayData.fat * 9;
@@ -190,7 +261,7 @@
const totalCals = calcTotal || 1; const totalCals = calcTotal || 1;
const percent = Math.round((value / totalCals) * 100); const percent = Math.round((value / totalCals) * 100);
const macroKey = MACRO_KEYS[context.datasetIndex]; const macroKey = MACRO_KEYS[context.datasetIndex - 1]; // Offset by 1
const grams = Math.round(dayData[macroKey]); const grams = Math.round(dayData[macroKey]);
return grams + 'g\n' + percent + '%'; return grams + 'g\n' + percent + '%';
+33 -3
View File
@@ -13,10 +13,11 @@
<input type="hidden" name="date" value="{{ current_date.isoformat() }}"> <input type="hidden" name="date" value="{{ current_date.isoformat() }}">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Select Meal</label> <label class="form-label">Select Meal</label>
<select class="form-control" name="meal_id" required> <input type="text" class="form-control mb-2" id="mealSearchInput" placeholder="Search meals..." data-testid="meal-search-input">
<select class="form-control" name="meal_id" id="mealSelect" required size="10">
<option value="">Choose meal...</option> <option value="">Choose meal...</option>
{% for meal in meals %} {% for meal in meals %}
<option value="{{ meal.id }}">{{ meal.name }}</option> <option value="{{ meal.id }}" data-testid="meal-option">{{ meal.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -28,4 +29,33 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script>
document.getElementById('mealSearchInput').addEventListener('input', function() {
const searchText = this.value.toLowerCase();
const select = document.getElementById('mealSelect');
const options = select.options;
for (let i = 0; i < options.length; i++) {
const option = options[i];
if (option.value === "") continue; // Skip "Choose meal..."
const text = option.text.toLowerCase();
if (text.includes(searchText)) {
option.style.display = "";
} else {
option.style.display = "none";
}
}
});
// Reset search when modal is shown
document.getElementById('addMealModal').addEventListener('show.bs.modal', function () {
document.getElementById('mealSearchInput').value = '';
const options = document.getElementById('mealSelect').options;
for (let i = 0; i < options.length; i++) {
options[i].style.display = "";
}
});
</script>
+33 -2
View File
@@ -12,10 +12,11 @@
<input type="hidden" name="date" value="{{ current_date.isoformat() }}"> <input type="hidden" name="date" value="{{ current_date.isoformat() }}">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Select Food</label> <label class="form-label">Select Food</label>
<select class="form-control" name="food_id" required onchange="updateServingSizeNote(this)"> <input type="text" class="form-control mb-2" id="foodSearchInput" placeholder="Search foods by name or brand..." data-testid="food-search-input">
<select class="form-control" name="food_id" id="foodSelect" required onchange="updateServingSizeNote(this)" size="10">
<option value="">Choose food...</option> <option value="">Choose food...</option>
{% for food in foods %} {% for food in foods %}
<option value="{{ food.id }}" data-serving-size="{{ food.serving_size }}">{{ food.name }}</option> <option value="{{ food.id }}" data-serving-size="{{ food.serving_size }}" data-brand="{{ food.brand }}" data-testid="food-option">{{ food.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -43,6 +44,36 @@
servingSizeNote.textContent = ''; servingSizeNote.textContent = '';
} }
} }
document.getElementById('foodSearchInput').addEventListener('input', function() {
const searchText = this.value.toLowerCase();
const select = document.getElementById('foodSelect');
const options = select.options;
for (let i = 0; i < options.length; i++) {
const option = options[i];
if (option.value === "") continue; // Skip "Choose food..."
const text = option.text.toLowerCase();
const brand = (option.getAttribute('data-brand') || "").toLowerCase();
if (text.includes(searchText) || brand.includes(searchText)) {
option.style.display = "";
} else {
option.style.display = "none";
}
}
});
// Reset search when modal is shown
document.getElementById('addSingleFoodModal').addEventListener('show.bs.modal', function () {
document.getElementById('foodSearchInput').value = '';
const options = document.getElementById('foodSelect').options;
for (let i = 0; i < options.length; i++) {
options[i].style.display = "";
}
document.getElementById('servingSizeNote').textContent = '';
});
</script> </script>
</div> </div>
</div> </div>
+118 -49
View File
@@ -70,15 +70,15 @@
{% for tracked_meal in meals_for_time %} {% for tracked_meal in meals_for_time %}
{# 1. Create stable slugs #} {# 1. Create stable slugs #}
{% set meal_time_slug = meal_time|slugify %} {% set meal_time_slug = meal_time|slugify %}
{% set meal_name_safe = tracked_meal.meal.name|slugify %} {% set display_meal_name = (tracked_meal.name or tracked_meal.meal.name) if (tracked_meal.name or tracked_meal.meal) else "Unnamed Meal" %}
{% set meal_name_safe = display_meal_name|slugify %}
{# 2. Construct the core Unique Meal ID for non-ambiguous locating #} {# 2. Construct the core Unique Meal ID for non-ambiguous locating #}
{% set unique_meal_id = meal_time_slug + '-' + meal_name_safe + '-' + loop.index|string %} {% set unique_meal_id = meal_time_slug + '-' + meal_name_safe + '-' + loop.index|string %}
<div class="mb-3 p-3 bg-light rounded" data-testid="meal-card-{{ unique_meal_id }}"> <div class="mb-3 p-3 bg-light rounded" data-testid="meal-card-{{ unique_meal_id }}">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<div> <div>
<strong data-testid="meal-name-{{ unique_meal_id }}">{{ tracked_meal.meal.name <strong data-testid="meal-name-{{ unique_meal_id }}">{{ display_meal_name }}</strong>
}}</strong>
</div> </div>
<div> <div>
<button class="btn btn-sm btn-outline-secondary me-1" <button class="btn btn-sm btn-outline-secondary me-1"
@@ -117,6 +117,7 @@
<tr> <tr>
<th style="width: 40%">Food</th> <th style="width: 40%">Food</th>
<th class="text-end">Carbs</th> <th class="text-end">Carbs</th>
<th class="text-end">Fiber</th>
<th class="text-end">Net Carbs</th> <th class="text-end">Net Carbs</th>
<th class="text-end">Fat</th> <th class="text-end">Fat</th>
<th class="text-end">Protein</th> <th class="text-end">Protein</th>
@@ -125,6 +126,7 @@
</thead> </thead>
<tbody> <tbody>
{# Display base meal foods, applying overrides #} {# Display base meal foods, applying overrides #}
{% if tracked_meal.meal %}
{% for meal_food in tracked_meal.meal.meal_foods %} {% for meal_food in tracked_meal.meal.meal_foods %}
{% if meal_food.food_id not in deleted_food_ids and meal_food.food_id not in {% if meal_food.food_id not in deleted_food_ids and meal_food.food_id not in
overrides.keys() %} overrides.keys() %}
@@ -153,6 +155,7 @@
}})</span> }})</span>
</td> </td>
<td class="text-end">{{ "%.1f"|format(row_carbs) }}g</td> <td class="text-end">{{ "%.1f"|format(row_carbs) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_carbs - row_fiber) }}g</td> <td class="text-end">{{ "%.1f"|format(row_carbs - row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fat) }}g</td> <td class="text-end">{{ "%.1f"|format(row_fat) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_protein) }}g</td> <td class="text-end">{{ "%.1f"|format(row_protein) }}g</td>
@@ -160,6 +163,7 @@
</tr> </tr>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %}
{# Display overridden/new foods #} {# Display overridden/new foods #}
{% for food_id, tmf in overrides.items() %} {% for food_id, tmf in overrides.items() %}
@@ -190,6 +194,7 @@
<span class="text-muted ms-1">({{ qty|round(1) }} g)</span> <span class="text-muted ms-1">({{ qty|round(1) }} g)</span>
</td> </td>
<td class="text-end">{{ "%.1f"|format(row_carbs) }}g</td> <td class="text-end">{{ "%.1f"|format(row_carbs) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_carbs - row_fiber) }}g</td> <td class="text-end">{{ "%.1f"|format(row_carbs - row_fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_fat) }}g</td> <td class="text-end">{{ "%.1f"|format(row_fat) }}g</td>
<td class="text-end">{{ "%.1f"|format(row_protein) }}g</td> <td class="text-end">{{ "%.1f"|format(row_protein) }}g</td>
@@ -206,6 +211,7 @@
<tr class="table-secondary fw-bold"> <tr class="table-secondary fw-bold">
<td>Total</td> <td>Total</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.carbs) }}g</td> <td class="text-end">{{ "%.1f"|format(meal_totals.carbs) }}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.fiber) }}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.carbs - meal_totals.fiber) <td class="text-end">{{ "%.1f"|format(meal_totals.carbs - meal_totals.fiber)
}}g</td> }}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.fat) }}g</td> <td class="text-end">{{ "%.1f"|format(meal_totals.fat) }}g</td>
@@ -231,60 +237,123 @@
<!-- Right Column - Nutrition Totals --> <!-- Right Column - Nutrition Totals -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card sticky-top" style="top: 20px;"> <div class="sticky-top" style="top: 20px;">
<div class="card-header"> <!-- Daily Totals Card -->
<h5 class="mb-0">Daily Totals</h5> <div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Daily Totals</h5>
</div>
<div class="card-body">
<div class="row text-center">
<!-- Top Row: Calories & Net Carbs -->
<div class="col-6 mb-2">
<div class="border rounded p-1">
<strong class="h4 text-primary">{{ "%.0f"|format(day_totals.calories) }}</strong>
<div class="small text-muted">Calories</div>
</div>
</div>
<div class="col-6 mb-2">
<div class="border rounded p-1">
<strong class="h4 text-warning">{{ "%.1f"|format(day_totals.net_carbs) }}g</strong>
<div class="small text-muted">Net Carbs</div>
</div>
</div>
<!-- Second Row: Macros -->
<div class="col-4 mb-2">
<div class="border rounded p-1">
<strong class="h4 d-block text-primary mb-0">{{ "%.1f"|format(day_totals.carbs)
}}g</strong>
<div class="small text-muted lh-1">Carbs</div>
<div class="small text-muted lh-1">{{ day_totals.carbs_pct }}%</div>
</div>
</div>
<div class="col-4 mb-2">
<div class="border rounded p-1">
<strong class="h4 d-block text-danger mb-0">{{ "%.1f"|format(day_totals.fat)
}}g</strong>
<div class="small text-muted lh-1">Fat</div>
<div class="small text-muted lh-1">{{ day_totals.fat_pct }}%</div>
</div>
</div>
<div class="col-4 mb-2">
<div class="border rounded p-1">
<strong class="h4 d-block text-success mb-0">{{ "%.1f"|format(day_totals.protein)
}}g</strong>
<div class="small text-muted lh-1">Protein</div>
<div class="small text-muted lh-1">{{ day_totals.protein_pct }}%</div>
</div>
</div>
<!-- Third Row: Micros & Sugar -->
<div class="col-3 mb-2">
<div class="border rounded p-1">
<strong>{{ "%.0f"|format(day_totals.sugar) }}g</strong>
<div class="small text-muted">Sugar</div>
</div>
</div>
<div class="col-3 mb-2">
<div class="border rounded p-1">
<strong>{{ "%.1f"|format(day_totals.fiber) }}g</strong>
<div class="small text-muted">Fiber</div>
</div>
</div>
<div class="col-3 mb-2">
<div class="border rounded p-1">
<strong>{{ "%.0f"|format(day_totals.sodium) }}mg</strong>
<div class="small text-muted">Sodium</div>
</div>
</div>
<div class="col-3 mb-2">
<div class="border rounded p-1">
<strong data-testid="daily-calcium-value">{{ "%.0f"|format(day_totals.calcium) }}mg</strong>
<div class="small text-muted">Calcium</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="card-body">
<div class="row text-center"> <!-- Macro Balance Card -->
<div class="col-6 mb-3"> <div class="card">
<div class="border rounded p-2"> <div class="card-header">
<strong class="h4 text-primary">{{ "%.0f"|format(day_totals.calories) }}</strong> <h5 class="mb-0">Macro Balance</h5>
<div class="small text-muted">Calories</div> </div>
<div class="card-body">
{% if day_totals.calories > 0 %}
<!-- Labels Row -->
<div class="d-flex w-100 mb-1 small fw-bold">
<div class="text-center" style="width: {{ day_totals.carbs_pct }}%; color: #0d6efd;">Carbs</div>
<div class="text-center text-danger" style="width: {{ day_totals.fat_pct }}%;">Fat</div>
<div class="text-center text-success" style="width: {{ day_totals.protein_pct }}%;">Protein
</div> </div>
</div> </div>
<div class="col-6 mb-3">
<div class="border rounded p-2"> <!-- Stacked Progress Bar -->
<strong class="h4 text-success">{{ "%.1f"|format(day_totals.protein) }}g</strong> <div class="progress" style="height: 25px;">
<div class="small text-muted">Protein ({{ day_totals.protein_pct }}%)</div> <div class="progress-bar" role="progressbar" style="width: {{ day_totals.carbs_pct }}%"
</div> aria-valuenow="{{ day_totals.carbs_pct }}" aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ day_totals.fat_pct }}%"
aria-valuenow="{{ day_totals.fat_pct }}" aria-valuemin="0" aria-valuemax="100"></div>
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ day_totals.protein_pct }}%" aria-valuenow="{{ day_totals.protein_pct }}"
aria-valuemin="0" aria-valuemax="100"></div>
</div> </div>
<div class="col-6 mb-3">
<div class="border rounded p-2"> <!-- Values Row -->
<strong>{{ "%.1f"|format(day_totals.carbs) }}g</strong> <div class="d-flex w-100 mt-1 small text-muted">
<div class="small text-muted">Carbs ({{ day_totals.carbs_pct }}%)</div> <div class="text-center" style="width: {{ day_totals.carbs_pct }}%;">{{ day_totals.carbs_pct }}%
</div> </div>
</div> <div class="text-center" style="width: {{ day_totals.fat_pct }}%;">{{ day_totals.fat_pct }}%
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-danger">{{ "%.1f"|format(day_totals.fat) }}g</strong>
<div class="small text-muted">Fat ({{ day_totals.fat_pct }}%)</div>
</div> </div>
<div class="text-center" style="width: {{ day_totals.protein_pct }}%;">{{ day_totals.protein_pct
}}%</div>
</div> </div>
<div class="col-6 mb-3"> {% else %}
<div class="border rounded p-2"> <div class="text-center text-muted my-3">
<strong>{{ "%.1f"|format(day_totals.fiber) }}g</strong> <small>No meals tracked yet</small>
<div class="small text-muted">Fiber</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong class="h4 text-warning">{{ "%.1f"|format(day_totals.net_carbs) }}g</strong>
<div class="small text-muted">Net Carbs</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.0f"|format(day_totals.sugar) }}g</strong>
<div class="small text-muted">Sugar</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="border rounded p-2">
<strong>{{ "%.0f"|format(day_totals.sodium) }}mg</strong>
<div class="small text-muted">Sodium</div>
</div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
+15
View File
@@ -0,0 +1,15 @@
const { test, expect } = require('@playwright/test');
test('should display Calcium in Daily Totals section', async ({ page }) => {
// Navigate to tracker page
await page.goto('/tracker');
// Check for Daily Totals card
const dailyTotalsCard = page.locator('.card:has-text("Daily Totals")');
await expect(dailyTotalsCard).toBeVisible();
// Check for Calcium label and value using test-id
const calciumValue = page.getByTestId('daily-calcium-value');
await expect(calciumValue).toBeVisible();
await expect(calciumValue).toContainText(/^[0-9]+mg$/);
});
+88
View File
@@ -0,0 +1,88 @@
const { test, expect } = require('@playwright/test');
test.describe('Meal and Food Search & Sorting', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/tracker');
// Ensure we're in a known state
page.on('dialog', dialog => dialog.accept());
const resetBtn = page.getByRole('button', { name: 'Reset Page' });
if (await resetBtn.isVisible()) {
await resetBtn.click();
}
});
test('Add Meal modal should be sorted alphabetically and searchable', async ({ page }) => {
// Open Add Meal modal for Breakfast
await page.locator('[data-testid="add-meal-breakfast"]').click();
// Check for search input
const searchInput = page.locator('[data-testid="meal-search-input"]');
await expect(searchInput).toBeVisible();
// Check alphabetical sorting
const mealOptions = page.locator('[data-testid="meal-option"]');
const count = await mealOptions.count();
let prevText = "";
for (let i = 0; i < count; i++) {
const text = await mealOptions.nth(i).innerText();
if (text.trim() === "" || text.includes("Choose meal...")) continue;
if (prevText !== "") {
console.log(`Comparing: "${prevText}" to "${text}"`);
const cmp = text.localeCompare(prevText, 'en', { sensitivity: 'base' });
if (cmp < 0) {
console.error(`Sort order failure: "${prevText}" should come after "${text}" (cmp: ${cmp})`);
}
expect(cmp).toBeGreaterThanOrEqual(0);
}
prevText = text;
}
// Test real-time filtering
await searchInput.fill('Protein');
const filteredOptions = page.locator('[data-testid="meal-option"]:visible');
const filteredCount = await filteredOptions.count();
for (let i = 0; i < filteredCount; i++) {
const text = await filteredOptions.nth(i).innerText();
expect(text.toLowerCase()).toContain('protein');
}
});
test('Add Food modal should be sorted alphabetically and searchable', async ({ page }) => {
// Open Add Food modal for Breakfast
await page.locator('[data-testid="add-food-breakfast"]').click();
// Check for search input
const searchInput = page.locator('[data-testid="food-search-input"]');
await expect(searchInput).toBeVisible();
// Check alphabetical sorting
const foodOptions = page.locator('[data-testid="food-option"]');
const count = await foodOptions.count();
let prevText = "";
for (let i = 0; i < count; i++) {
const text = await foodOptions.nth(i).innerText();
if (text.trim() === "" || text.includes("Choose food...")) continue;
if (prevText !== "") {
console.log(`Comparing: "${prevText}" to "${text}"`);
const cmp = text.localeCompare(prevText, 'en', { sensitivity: 'base' });
if (cmp < 0) {
console.error(`Sort order failure: "${prevText}" should come after "${text}" (cmp: ${cmp})`);
}
expect(cmp).toBeGreaterThanOrEqual(0);
}
prevText = text;
}
// Test real-time filtering
await searchInput.fill('Organic');
const filteredOptions = page.locator('[data-testid="food-option"]:visible');
const filteredCount = await filteredOptions.count();
for (let i = 0; i < filteredCount; i++) {
const text = (await filteredOptions.nth(i).innerText()).toLowerCase();
const brand = (await filteredOptions.nth(i).getAttribute('data-brand') || "").toLowerCase();
expect(text.includes('organic') || brand.includes('organic')).toBeTruthy();
}
});
});
+12 -15
View File
@@ -98,20 +98,9 @@ def test_add_food_quantity_saved_correctly(test_client: TestClient, test_engine)
query_session = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)() query_session = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)()
try: try:
# Find the created Meal # Verify NO new Meal was created
created_meal = query_session.query(Meal).order_by(Meal.id.desc()).first() meals = query_session.query(Meal).all()
assert created_meal is not None assert len(meals) == 0
assert created_meal.name == "Test Food"
assert created_meal.meal_type == "single_food"
# Find the MealFood
meal_food = query_session.query(MealFood).filter(MealFood.meal_id == created_meal.id).first()
assert meal_food is not None
assert meal_food.food_id == food.id
# This assertion fails because the backend used data.get("grams", 1.0), so quantity=1.0 instead of 50.0
# After the fix changing to data.get("quantity", 1.0), it will pass
assert meal_food.quantity == 50.0, f"Expected quantity 50.0, but got {meal_food.quantity}"
# Also verify TrackedDay and TrackedMeal were created # Also verify TrackedDay and TrackedMeal were created
tracked_day = query_session.query(TrackedDay).filter( tracked_day = query_session.query(TrackedDay).filter(
@@ -123,8 +112,16 @@ def test_add_food_quantity_saved_correctly(test_client: TestClient, test_engine)
tracked_meal = query_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first() tracked_meal = query_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first()
assert tracked_meal is not None assert tracked_meal is not None
assert tracked_meal.meal_id == created_meal.id assert tracked_meal.meal_id is None
assert tracked_meal.name == "Test Food"
assert tracked_meal.meal_time == "Snack 1" assert tracked_meal.meal_time == "Snack 1"
# Find the TrackedMealFood
from app.database import TrackedMealFood
tmf = query_session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first()
assert tmf is not None
assert tmf.food_id == food.id
assert tmf.quantity == 50.0
finally: finally:
query_session.close() query_session.close()
+6 -3
View File
@@ -140,10 +140,13 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g):
assert response.json()["status"] == "success" assert response.json()["status"] == "success"
# Verify the tracked meal food quantity # Verify the tracked meal food quantity
tracked_meal = session.query(Meal).filter(Meal.name == sample_food_100g.name).first() tracked_meal = session.query(TrackedMeal).filter(TrackedMeal.name == sample_food_100g.name).first()
assert tracked_meal is not None assert tracked_meal is not None
meal_food = session.query(MealFood).filter(MealFood.meal_id == tracked_meal.id).first() assert tracked_meal.meal_id is None
assert meal_food.quantity == grams
tmf = session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first()
assert tmf is not None
assert tmf.quantity == grams
def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g, sample_food_50g): def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g, sample_food_50g):
"""Test updating tracked meal foods with grams input""" """Test updating tracked meal foods with grams input"""
+113
View File
@@ -0,0 +1,113 @@
import pytest
from app.database import Food, TrackedMeal, TrackedMealFood, calculate_tracked_meal_nutrition
from sqlalchemy.orm import Session
def test_calculate_tracked_meal_nutrition_no_meal_template(db_session: Session):
"""Test nutrition calculation for a tracked meal with no parent meal template (meal_id=None)"""
# Create a food
food = Food(
name="Test Food",
serving_size=100.0,
serving_unit="g",
calories=100.0,
protein=10.0,
carbs=20.0,
fat=5.0,
fiber=5.0,
sugar=10.0,
sodium=100.0,
calcium=50.0
)
db_session.add(food)
db_session.commit()
db_session.refresh(food)
# Create a tracked meal without a template
tracked_meal = TrackedMeal(
meal_id=None,
meal_time="Snack",
name="Single Food Log"
)
db_session.add(tracked_meal)
db_session.commit()
db_session.refresh(tracked_meal)
# Add a tracked food entry to it
tracked_food = TrackedMealFood(
tracked_meal_id=tracked_meal.id,
food_id=food.id,
quantity=200.0, # 2 servings
is_override=False,
is_deleted=False
)
db_session.add(tracked_food)
db_session.commit()
db_session.refresh(tracked_food)
# Calculate nutrition
nutrition = calculate_tracked_meal_nutrition(tracked_meal, db_session)
# Assertions
assert nutrition['calories'] == 200.0
assert nutrition['protein'] == 20.0
assert nutrition['carbs'] == 40.0
assert nutrition['fat'] == 10.0
assert nutrition['fiber'] == 10.0
assert nutrition['sugar'] == 20.0
assert nutrition['sodium'] == 200.0
assert nutrition['calcium'] == 100.0
assert nutrition['net_carbs'] == 30.0
assert nutrition['protein_pct'] == 40.0 # (20 * 4) / 200 = 80 / 200 = 40%
assert nutrition['carbs_pct'] == 80.0 # (40 * 4) / 200 = 160 / 200 = 80%
assert nutrition['fat_pct'] == 45.0 # (10 * 9) / 200 = 90 / 200 = 45%
def test_tracker_add_food_api_no_new_meal(client, db_session: Session):
"""Test /tracker/add_food endpoint to ensure it doesn't create redundant Meal templates"""
# Create a food
food = Food(
name="API Test Food",
serving_size=100.0,
serving_unit="g",
calories=100.0,
protein=10.0,
carbs=20.0,
fat=5.0
)
db_session.add(food)
db_session.commit()
db_session.refresh(food)
from app.database import Meal
initial_meal_count = db_session.query(Meal).count()
# Call the API
response = client.post("/tracker/add_food", json={
"person": "Sarah",
"date": "2025-02-24",
"food_id": food.id,
"quantity": 150.0,
"meal_time": "Snack"
})
assert response.status_code == 200
assert response.json()["status"] == "success"
# Verify NO new Meal was created
assert db_session.query(Meal).count() == initial_meal_count
# Verify TrackedMeal exists with meal_id=None and correct name
from app.database import TrackedMeal, TrackedDay
tracked_day = db_session.query(TrackedDay).filter(TrackedDay.date == "2025-02-24").first()
assert tracked_day is not None
tracked_meal = db_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first()
assert tracked_meal is not None
assert tracked_meal.meal_id is None
assert tracked_meal.name == "API Test Food"
# Verify TrackedMealFood exists
from app.database import TrackedMealFood
tmf = db_session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first()
assert tmf is not None
assert tmf.food_id == food.id
assert tmf.quantity == 150.0
+16 -13
View File
@@ -384,12 +384,13 @@ class TestTrackerAddFood:
assert len(tracked_meals) == 1 assert len(tracked_meals) == 1
tracked_meal = tracked_meals[0] tracked_meal = tracked_meals[0]
assert tracked_meal.meal.name == sample_food.name # The meal name should be the food name assert tracked_meal.name == sample_food.name # The meal name should be the food name
assert tracked_meal.meal_id is None
# Verify the food is in the tracked meal's foods # Verify the food is in the tracked meal's foods
assert len(tracked_meal.meal.meal_foods) == 1 assert len(tracked_meal.tracked_foods) == 1
assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id assert tracked_meal.tracked_foods[0].food_id == sample_food.id
assert tracked_meal.meal.meal_foods[0].quantity == 100.0 assert tracked_meal.tracked_foods[0].quantity == 100.0
def test_add_food_to_tracker_with_meal_time(self, client, sample_food, db_session): def test_add_food_to_tracker_with_meal_time(self, client, sample_food, db_session):
@@ -418,11 +419,12 @@ class TestTrackerAddFood:
assert len(tracked_meals) == 1 assert len(tracked_meals) == 1
tracked_meal = tracked_meals[0] tracked_meal = tracked_meals[0]
assert tracked_meal.meal.name == sample_food.name assert tracked_meal.name == sample_food.name
assert tracked_meal.meal_id is None
assert len(tracked_meal.meal.meal_foods) == 1 assert len(tracked_meal.tracked_foods) == 1
assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id assert tracked_meal.tracked_foods[0].food_id == sample_food.id
assert tracked_meal.meal.meal_foods[0].quantity == 150.0 assert tracked_meal.tracked_foods[0].quantity == 150.0
def test_add_food_quantity_is_correctly_converted_to_servings(self, client, db_session): def test_add_food_quantity_is_correctly_converted_to_servings(self, client, db_session):
""" """
@@ -464,12 +466,13 @@ class TestTrackerAddFood:
assert len(tracked_meals) == 1 assert len(tracked_meals) == 1
tracked_meal = tracked_meals[0] tracked_meal = tracked_meals[0]
assert tracked_meal.meal.name == food.name assert tracked_meal.name == food.name
assert tracked_meal.meal_id is None
# Verify the food is in the tracked meal's foods and quantity is in servings # Verify the food is in the tracked meal's foods
assert len(tracked_meal.meal.meal_foods) == 1 assert len(tracked_meal.tracked_foods) == 1
assert tracked_meal.meal.meal_foods[0].food_id == food.id assert tracked_meal.tracked_foods[0].food_id == food.id
assert tracked_meal.meal.meal_foods[0].quantity == grams_to_add assert tracked_meal.tracked_foods[0].quantity == grams_to_add
# Verify nutrition calculation # Verify nutrition calculation
day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session) day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session)
+32
View File
@@ -0,0 +1,32 @@
const { test, expect } = require('@playwright/test');
test('add single food to tracker and verify it is not in meals page', async ({ page }) => {
await page.goto('/tracker');
// Add single food to breakfast
await page.locator('[data-testid="add-food-breakfast"]').click();
// Select a food (Verification Beans)
await page.locator('#addSingleFoodModal select[name="food_id"]').selectOption({ label: 'Verification Beans' });
await page.locator('#addSingleFoodModal input[name="quantity"]').fill('200');
await page.getByRole('button', { name: 'Add Food', exact: true }).click();
// Verify it appears in the tracker
// The name should be just the food name
const mealNameLocator = page.locator('[data-testid^="meal-name-breakfast-verification-beans"]');
await expect(mealNameLocator).toBeVisible();
await expect(mealNameLocator).toHaveText('Verification Beans');
// Verify it contains the food with correct quantity
const foodRowLocator = page.locator('[data-testid^="food-row-breakfast-verification-beans"][data-testid$="verification-beans"]');
await expect(foodRowLocator).toBeVisible();
await expect(foodRowLocator).toContainText('Verification Beans');
await expect(foodRowLocator).toContainText('200.0 g');
// Navigate to Meals page
await page.goto('/meals');
// Verify 'Verification Beans' is NOT in the meals list as a meal name
// It might be in the ingredients dropdown, but shouldn't be a <strong> heading in a card
const mealCardHeading = page.locator('.card-title:has-text("Verification Beans")');
await expect(mealCardHeading).not.toBeVisible();
});