mirror of
https://github.com/sstent/foodplanner.git
synced 2026-06-06 02:44:33 +00:00
Compare commits
30 Commits
ea45b32450
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e411a8f6c4 | |||
| e68a1f52af | |||
| 7fc17967e0 | |||
| e47af2c839 | |||
| 7099577f92 | |||
| ced3c63f92 | |||
| 5c73ce9caf | |||
| 0e4cb5a5ed | |||
| b834e89a97 | |||
| 00600a76fa | |||
| cc6b4ca145 | |||
| f0430c810b | |||
| 326a82ea5d | |||
| afdf9fa5b7 | |||
| 8d3e91a825 | |||
| 042d8d93cc | |||
| a66a5142b5 | |||
| 7718a7f879 | |||
| 9574994abb | |||
| fe06c40a29 | |||
| af38fcaace | |||
| c222360ca7 | |||
| 4e5e70581c | |||
| 1d9017c1a1 | |||
| 48bd301db6 | |||
| ce6b107560 | |||
| 6972c9b8f9 | |||
| 9fa3380730 | |||
| 09653d7415 | |||
| b48a7675dd |
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
+43
-17
@@ -75,8 +75,8 @@ 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()
|
||||||
|
|
||||||
# Get all templates for template dropdown
|
# Get all templates for template dropdown
|
||||||
templates_list = db.query(Template).all()
|
templates_list = db.query(Template).all()
|
||||||
@@ -138,10 +138,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 +746,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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)*
|
||||||
@@ -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)*
|
||||||
@@ -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)*
|
||||||
@@ -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/)
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 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.
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"last_successful_step": "3.3_initial_track_generated"}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 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/)*
|
||||||
@@ -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_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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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 + '%';
|
||||||
|
|||||||
+118
-49
@@ -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>
|
||||||
|
|||||||
@@ -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$/);
|
||||||
|
});
|
||||||
+12
-15
@@ -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()
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user