adding macro details to tracker, changing charts to stacked bar chart of macros

This commit is contained in:
2026-01-06 06:49:43 -08:00
parent e91611d441
commit 70b45ede71
5 changed files with 339 additions and 106 deletions

View File

@@ -44,7 +44,10 @@ async def get_charts_data(
day_totals = calculate_day_nutrition_tracked(tracked_meals, db) day_totals = calculate_day_nutrition_tracked(tracked_meals, db)
chart_data.append({ chart_data.append({
"date": tracked_day.date.isoformat(), "date": tracked_day.date.isoformat(),
"calories": round(day_totals.get("calories", 0), 2) "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)
}) })
return chart_data return chart_data

View File

@@ -3,9 +3,10 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import List, Optional, Union from typing import List, Optional, Union
import logging
# Import from the database module # Import from the database module
from app.database import get_db, Meal, Template, TemplateMeal, TrackedDay, TrackedMeal, calculate_meal_nutrition, MealFood, TrackedMealFood, Food, calculate_day_nutrition_tracked from app.database import get_db, Meal, Template, TemplateMeal, TrackedDay, TrackedMeal, calculate_meal_nutrition, MealFood, TrackedMealFood, Food, calculate_day_nutrition_tracked, Plan
from main import templates from main import templates
router = APIRouter() router = APIRouter()
@@ -39,6 +40,29 @@ async def tracker_page(request: Request, person: str = "Sarah", date: str = None
db.commit() db.commit()
db.refresh(tracked_day) db.refresh(tracked_day)
# Check if we need to sync from Plan (if no tracked meals exist)
existing_meals_count = db.query(TrackedMeal).filter(
TrackedMeal.tracked_day_id == tracked_day.id
).count()
if existing_meals_count == 0:
# Look for planned meals
planned_meals = db.query(Plan).filter(
Plan.person == person,
Plan.date == current_date
).all()
if planned_meals:
logging.info(f"Syncing {len(planned_meals)} planned meals to tracker for {person} on {current_date}")
for plan in planned_meals:
tracked_meal = TrackedMeal(
tracked_day_id=tracked_day.id,
meal_id=plan.meal_id,
meal_time=plan.meal_time
)
db.add(tracked_meal)
db.commit()
# Get tracked meals for this day with eager loading of meal foods # Get tracked meals for this day with eager loading of meal foods
tracked_meals = db.query(TrackedMeal).options( tracked_meals = db.query(TrackedMeal).options(
joinedload(TrackedMeal.meal) joinedload(TrackedMeal.meal)

View File

@@ -40,8 +40,10 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
<script> <script>
let chart; let chart;
const MACRO_KEYS = ['net_carbs', 'fat', 'protein'];
function resizeChart() { function resizeChart() {
const container = document.getElementById('chartContainer'); const container = document.getElementById('chartContainer');
@@ -51,7 +53,12 @@
} }
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// Register the plugin
if (typeof ChartDataLabels !== 'undefined') {
Chart.register(ChartDataLabels);
}
const daysSelect = document.getElementById('daysSelect'); const daysSelect = document.getElementById('daysSelect');
const loadBtn = document.getElementById('loadChartBtn'); const loadBtn = document.getElementById('loadChartBtn');
const ctx = document.getElementById('caloriesChart').getContext('2d'); const ctx = document.getElementById('caloriesChart').getContext('2d');
@@ -63,7 +70,7 @@
// Default load for 7 days // Default load for 7 days
loadChart(7); loadChart(7);
loadBtn.addEventListener('click', function() { loadBtn.addEventListener('click', function () {
const selectedDays = parseInt(daysSelect.value); const selectedDays = parseInt(daysSelect.value);
loadChart(selectedDays); loadChart(selectedDays);
}); });
@@ -76,7 +83,14 @@
data.sort((a, b) => new Date(a.date) - new Date(b.date)); data.sort((a, b) => new Date(a.date) - new Date(b.date));
const labels = data.map(item => item.date); const labels = data.map(item => item.date);
const calories = data.map(item => item.calories);
// Calculate calories from macros
// Protein: 4 cals/g
// Fat: 9 cals/g
// Net Carbs: 4 cals/g
const proteinCals = data.map(item => item.protein * 4);
const fatCals = data.map(item => item.fat * 9);
const netCarbsCals = data.map(item => item.net_carbs * 4);
if (chart) { if (chart) {
chart.destroy(); chart.destroy();
@@ -86,17 +100,32 @@
resizeChart(); resizeChart();
chart = new Chart(ctx, { chart = new Chart(ctx, {
type: 'line', type: 'bar', // Switch to bar chart
data: { data: {
labels: labels, labels: labels,
datasets: [{ datasets: [
label: 'Calories', {
data: calories, label: 'Net Carbs',
borderColor: 'rgb(75, 192, 192)', data: netCarbsCals,
backgroundColor: 'rgba(75, 192, 192, 0.2)', backgroundColor: 'rgba(255, 193, 7, 0.8)', // Bootstrap warning (Yellow)
tension: 0.1, borderColor: '#ffc107',
fill: true borderWidth: 1
}] },
{
label: 'Fat',
data: fatCals,
backgroundColor: 'rgba(220, 53, 69, 0.8)', // Bootstrap danger (Red)
borderColor: '#dc3545',
borderWidth: 1
},
{
label: 'Protein',
data: proteinCals,
backgroundColor: 'rgba(25, 135, 84, 0.8)', // Bootstrap success (Green)
borderColor: '#198754',
borderWidth: 1
}
]
}, },
options: { options: {
responsive: true, responsive: true,
@@ -104,17 +133,69 @@
scales: { scales: {
y: { y: {
beginAtZero: true, beginAtZero: true,
stacked: true, // Enable stacking for Y axis
title: { title: {
display: true, display: true,
text: 'Calories' text: 'Calories'
} }
}, },
x: { x: {
stacked: true, // Enable stacking for X axis
title: { title: {
display: true, display: true,
text: 'Date' text: 'Date'
} }
} }
},
plugins: {
tooltip: {
callbacks: {
label: function (context) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
const dayData = data[context.dataIndex];
const macroKey = MACRO_KEYS[context.datasetIndex];
const grams = dayData[macroKey];
label += Math.round(context.parsed.y) + ' cals (' + Math.round(grams) + 'g)';
}
return label;
}
}
},
datalabels: {
color: 'white',
font: {
weight: 'bold',
size: 11
},
display: function (context) {
const dayData = data[context.dataIndex];
const pC = dayData.protein * 4;
const fC = dayData.fat * 9;
const ncC = dayData.net_carbs * 4;
const calcTotal = pC + fC + ncC;
const value = context.dataset.data[context.dataIndex];
return calcTotal > 0 && (value / calcTotal) > 0.05;
},
formatter: function (value, context) {
const dayData = data[context.dataIndex];
const pC = dayData.protein * 4;
const fC = dayData.fat * 9;
const ncC = dayData.net_carbs * 4;
const calcTotal = pC + fC + ncC;
const totalCals = calcTotal || 1;
const percent = Math.round((value / totalCals) * 100);
const macroKey = MACRO_KEYS[context.datasetIndex];
const grams = Math.round(dayData[macroKey]);
return grams + 'g\n' + percent + '%';
}
}
} }
} }
}); });

View File

@@ -4,7 +4,8 @@
<div class="col-md-8"> <div class="col-md-8">
<!-- Date Navigation --> <!-- Date Navigation -->
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<button class="btn btn-outline-secondary" onclick="navigateDate('{{ prev_date }}')" data-testid="navigate-yesterday"> <button class="btn btn-outline-secondary" onclick="navigateDate('{{ prev_date }}')"
data-testid="navigate-yesterday">
<i class="bi bi-chevron-left"></i> Yesterday <i class="bi bi-chevron-left"></i> Yesterday
</button> </button>
<div class="text-center"> <div class="text-center">
@@ -15,7 +16,8 @@
<span class="badge bg-success">As Planned</span> <span class="badge bg-success">As Planned</span>
{% endif %} {% endif %}
</div> </div>
<button class="btn btn-outline-secondary" onclick="navigateDate('{{ next_date }}')" data-testid="navigate-tomorrow"> <button class="btn btn-outline-secondary" onclick="navigateDate('{{ next_date }}')"
data-testid="navigate-tomorrow">
Tomorrow <i class="bi bi-chevron-right"></i> Tomorrow <i class="bi bi-chevron-right"></i>
</button> </button>
</div> </div>
@@ -45,10 +47,12 @@
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ meal_time }}</h5> <h5 class="mb-0">{{ meal_time }}</h5>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-success" onclick="addMealToTime('{{ meal_time }}')" data-testid="add-meal-{{ meal_time|slugify }}"> <button class="btn btn-sm btn-outline-success" onclick="addMealToTime('{{ meal_time }}')"
data-testid="add-meal-{{ meal_time|slugify }}">
<i class="bi bi-plus"></i> Add Meal <i class="bi bi-plus"></i> Add Meal
</button> </button>
<button class="btn btn-sm btn-info text-white" onclick="addSingleFoodToTime('{{ meal_time }}')" data-testid="add-food-{{ meal_time|slugify }}"> <button class="btn btn-sm btn-info text-white" onclick="addSingleFoodToTime('{{ meal_time }}')"
data-testid="add-food-{{ meal_time|slugify }}">
<i class="bi bi-plus-circle"></i> Add Food <i class="bi bi-plus-circle"></i> Add Food
</button> </button>
</div> </div>
@@ -63,23 +67,28 @@
{% endfor %} {% endfor %}
{% if meals_for_time %} {% if meals_for_time %}
{% 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 meal_name_safe = tracked_meal.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> <strong data-testid="meal-name-{{ unique_meal_id }}">{{ tracked_meal.meal.name
}}</strong>
</div> </div>
<div> <div>
<button class="btn btn-sm btn-outline-secondary me-1" onclick="editTrackedMeal('{{ tracked_meal.id }}')" title="Edit Meal" data-testid="edit-meal-{{ unique_meal_id }}"> <button class="btn btn-sm btn-outline-secondary me-1"
onclick="editTrackedMeal('{{ tracked_meal.id }}')" title="Edit Meal"
data-testid="edit-meal-{{ unique_meal_id }}">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-outline-danger" onclick="removeMeal('{{ tracked_meal.id }}')" data-testid="delete-meal-{{ unique_meal_id }}"> <button class="btn btn-sm btn-outline-danger"
onclick="removeMeal('{{ tracked_meal.id }}')"
data-testid="delete-meal-{{ unique_meal_id }}">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@@ -87,7 +96,6 @@
<!-- Food Breakdown --> <!-- Food Breakdown -->
<div class="ms-3"> <div class="ms-3">
<div class="row row-cols-1 row-cols-sm-2">
{% set overrides = {} %} {% set overrides = {} %}
{% set all_override_ids = [] %} {% set all_override_ids = [] %}
{% set deleted_food_ids = [] %} {% set deleted_food_ids = [] %}
@@ -100,41 +108,114 @@
{% set _ = all_override_ids.append(tmf.food_id) %} {% set _ = all_override_ids.append(tmf.food_id) %}
{% endfor %} {% endfor %}
{% set displayed_food_ids = [] %} {% set meal_totals = {'carbs': 0, 'fiber': 0, 'fat': 0, 'protein': 0, 'calories': 0} %}
{% set state = {'has_foods': False} %}
<div class="table-responsive">
<table class="table table-sm table-striped small mb-0">
<thead>
<tr>
<th style="width: 40%">Food</th>
<th class="text-end">Carbs</th>
<th class="text-end">Net Carbs</th>
<th class="text-end">Fat</th>
<th class="text-end">Protein</th>
<th class="text-end">Cals</th>
</tr>
</thead>
<tbody>
{# Display base meal foods, applying overrides #} {# Display base meal foods, applying overrides #}
{% for meal_food in tracked_meal.meal.meal_foods %} {% for meal_food in tracked_meal.meal.meal_foods %}
{# Only show base meal food if it's not deleted and there's no active override for it #} {% 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() %}
<div class="col"> {% set _ = state.update({'has_foods': True}) %}
{% set food_name_safe = meal_food.food.name|slugify %} {% set food = meal_food.food %}
<div class="d-flex justify-content-between small text-muted" data-testid="food-display-{{ unique_meal_id }}-{{ food_name_safe }}-{{ loop.index }}"> {% set qty = meal_food.quantity %}
<span>• {{ meal_food.food.name }}</span> {% set mult = qty / food.serving_size if food.serving_size > 0 else 0 %}
<span class="text-end">{{ meal_food.quantity }} {{ meal_food.food.serving_unit }}</span> {% set row_carbs = (food.carbs or 0) * mult %}
</div> {% set row_fiber = (food.fiber or 0) * mult %}
</div> {% set row_fat = (food.fat or 0) * mult %}
{% else %} {% set row_protein = (food.protein or 0) * mult %}
<!-- DEBUG: meal_food {{ meal_food.food_id }} - {{ meal_food.food.name }} - in deleted_food_ids: {{ meal_food.food_id in deleted_food_ids }}, in overrides: {{ meal_food.food_id in overrides.keys() }} --> {% set row_cals = (food.calories or 0) * mult %}
{# Accumulate Totals #}
{% set _ = meal_totals.update({'carbs': meal_totals.carbs + row_carbs}) %}
{% set _ = meal_totals.update({'fiber': meal_totals.fiber + row_fiber}) %}
{% set _ = meal_totals.update({'fat': meal_totals.fat + row_fat}) %}
{% set _ = meal_totals.update({'protein': meal_totals.protein + row_protein}) %}
{% set _ = meal_totals.update({'calories': meal_totals.calories + row_cals}) %}
{% set food_name_safe = food.name|slugify %}
<tr data-testid="food-row-{{ unique_meal_id }}-{{ food_name_safe }}">
<td>
{{ food.name }}
<span class="text-muted ms-1">({{ qty|round(1) }} {{ food.serving_unit
}})</span>
</td>
<td class="text-end">{{ "%.1f"|format(row_carbs) }}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_protein) }}g</td>
<td class="text-end fw-bold">{{ "%.0f"|format(row_cals) }}</td>
</tr>
{% endif %} {% endif %}
{% set _ = displayed_food_ids.append(meal_food.food_id) %}
{% endfor %} {% endfor %}
{# Display overridden and new foods #} {# Display overridden/new foods #}
{% for food_id, tmf in overrides.items() %} {% for food_id, tmf in overrides.items() %}
{% set food_name_safe = tmf.food.name|slugify %} {% set _ = state.update({'has_foods': True}) %}
<div class="col"> {% set food = tmf.food %}
<div class="d-flex justify-content-between small text-muted" data-testid="food-display-{{ unique_meal_id }}-{{ food_name_safe }}-{{ loop.index }}"> {% set qty = tmf.quantity %}
<span>• {{ tmf.food.name }}</span> {# Overrides are always in grams #}
<span class="text-end">{{ tmf.quantity }} g</span> {% set mult = qty / food.serving_size if food.serving_size > 0 else 0 %}
</div> {% set row_carbs = (food.carbs or 0) * mult %}
</div> {% set row_fiber = (food.fiber or 0) * mult %}
{% set row_fat = (food.fat or 0) * mult %}
{% set row_protein = (food.protein or 0) * mult %}
{% set row_cals = (food.calories or 0) * mult %}
{# Accumulate Totals #}
{% set _ = meal_totals.update({'carbs': meal_totals.carbs + row_carbs}) %}
{% set _ = meal_totals.update({'fiber': meal_totals.fiber + row_fiber}) %}
{% set _ = meal_totals.update({'fat': meal_totals.fat + row_fat}) %}
{% set _ = meal_totals.update({'protein': meal_totals.protein + row_protein}) %}
{% set _ = meal_totals.update({'calories': meal_totals.calories + row_cals}) %}
{% set food_name_safe = food.name|slugify %}
<tr class="table-info"
data-testid="food-row-{{ unique_meal_id }}-{{ food_name_safe }}">
<td>
{{ food.name }} <i class="bi bi-pencil-fill x-small text-muted"
title="Custom Quantity"></i>
<span class="text-muted ms-1">({{ qty|round(1) }} g)</span>
</td>
<td class="text-end">{{ "%.1f"|format(row_carbs) }}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_protein) }}g</td>
<td class="text-end fw-bold">{{ "%.0f"|format(row_cals) }}</td>
</tr>
{% endfor %} {% endfor %}
</div>
{% if not tracked_meal.meal.meal_foods %} {% if not state.has_foods %}
<div class="col-12"> <tr>
<div class="small text-muted">No foods in this meal</div> <td colspan="6" class="text-center text-muted">No foods in this meal</td>
</div> </tr>
{% else %}
{# Summary Row #}
<tr class="table-secondary fw-bold">
<td>Total</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.carbs) }}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.carbs - meal_totals.fiber)
}}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.fat) }}g</td>
<td class="text-end">{{ "%.1f"|format(meal_totals.protein) }}g</td>
<td class="text-end">{{ "%.0f"|format(meal_totals.calories) }}</td>
</tr>
{% endif %} {% endif %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

44
tests/test_charts_api.py Normal file
View File

@@ -0,0 +1,44 @@
import pytest
from datetime import date, timedelta
from app.database import TrackedDay, TrackedMeal, TrackedMealFood, Meal, MealFood, Food
class TestChartsData:
"""Test charts data API"""
def test_charts_api_returns_macros(self, client, sample_meal, db_session):
"""Test that /api/charts returns protein, fat, and net_carbs"""
# Create a tracked day with data
tracked_day = TrackedDay(person="Sarah", date=date.today(), is_modified=True)
db_session.add(tracked_day)
db_session.commit()
db_session.refresh(tracked_day)
# Add meal
tracked_meal = TrackedMeal(
tracked_day_id=tracked_day.id,
meal_id=sample_meal.id,
meal_time="Breakfast"
)
db_session.add(tracked_meal)
db_session.commit()
# Fetch chart data
response = client.get("/api/charts?person=Sarah&days=7")
assert response.status_code == 200
data = response.json()
assert len(data) > 0
# Check fields
item = data[0]
assert "date" in item
assert "calories" in item
assert "protein" in item
assert "fat" in item
assert "net_carbs" in item
# Check values (protein > 0 based on sample_meal)
assert item["protein"] >= 0
assert item["fat"] >= 0
assert item["net_carbs"] >= 0