πŸ“Š 5.5 Creating Dashboards

In this notebook you’ll build an interactive dashboard for nutrition data with Plotly and ipywidgets, and export a single-file HTML you can share.

You will: - Load and tidy large_food_log.csv (hippo snacks πŸ‰πŸ¦›). - Create interactive controls (Nutrient / Meal / Date range). - Build key visuals: trend lines, stacked bars, treemap, rolling averages. - Show KPIs (totals, daily average, unique days). - Export a shareable dashboard.html.

Why dashboards? Dashboards condense many views into one space so trends pop out fast β€” perfect for busy nutrition teams.
# Colab setup: fetch repo so we can read data
import os
from google.colab import files

MODULE = '05_advanced'
DATASET = 'large_food_log.csv'
BASE_PATH = '/content/data-analysis-projects'
MODULE_PATH = os.path.join(BASE_PATH, 'notebooks', MODULE)
DATASET_PATH = os.path.join(MODULE_PATH, 'data', DATASET)

try:
    print('Attempting to clone repository...')
    if not os.path.exists(BASE_PATH):
        !git clone https://github.com/ggkuhnle/data-analysis-projects.git
    os.chdir(MODULE_PATH)
    if os.path.exists(DATASET_PATH):
        print(f'Dataset found: {DATASET_PATH} βœ…')
    else:
        raise FileNotFoundError('Dataset missing after clone.')
except Exception as e:
    print(f'Cloning failed: {e}')
    print('Falling back to manual upload...')
    os.makedirs('data', exist_ok=True)
    uploaded = files.upload()
    if DATASET in uploaded:
        with open(os.path.join('data', DATASET), 'wb') as f:
            f.write(uploaded[DATASET])
        print(f'Successfully uploaded {DATASET} βœ…')
    else:
        raise FileNotFoundError(f'Upload {DATASET} to continue.')
%pip install -q pandas plotly ipywidgets
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from ipywidgets import interact, widgets, Layout, HBox, VBox
from pathlib import Path
pd.set_option('display.max_columns', 40)
print('Dashboard environment ready.')

πŸ“₯ Load & Tidy

We’ll parse dates, normalise text columns, and add a few helpful features (year / month, day-of-week).

csv_path = Path('data') / 'large_food_log.csv'
df = pd.read_csv(csv_path)

# Basic cleaning
for col in ['ID','Meal','Nutrient']:
    if col in df.columns:
        df[col] = df[col].astype(str).str.strip()
df['Amount'] = pd.to_numeric(df['Amount'], errors='coerce')
df['Date'] = pd.to_datetime(df['Date'], errors='coerce')

# Filter invalid rows
df = df.dropna(subset=['Amount','Date'])
df = df[df['Amount'] >= 0]

# Time features
df['Year']  = df['Date'].dt.year
df['Month'] = df['Date'].dt.to_period('M').astype(str)
df['DOW']   = df['Date'].dt.day_name()

display(df.head())
print('\nRows:', len(df), '| Date range:', df['Date'].min().date(), 'β†’', df['Date'].max().date())

🧰 Helpers: KPI & Slicer Functions

A couple of small utilities we’ll reuse across the dashboard.

def slice_data(df, nutrient=None, meal=None, date_range=None):
    d = df.copy()
    if nutrient and nutrient != 'All':
        d = d[d['Nutrient'] == nutrient]
    if meal and meal != 'All':
        d = d[d['Meal'] == meal]
    if date_range and all(date_range):
        d = d[(d['Date'] >= date_range[0]) & (d['Date'] <= date_range[1])]
    return d

def kpis(d):
    total = d['Amount'].sum()
    n_days = d['Date'].nunique()
    daily_avg = total / n_days if n_days else 0.0
    return {
        'Total amount': total,
        'Unique days': int(n_days),
        'Daily average': daily_avg
    }

πŸŽ›οΈ Interactive Controls

Pick a Nutrient, Meal, and a Date range to drive the visuals below.

nutrients = ['All'] + sorted(df['Nutrient'].dropna().unique().tolist())
meals     = ['All'] + sorted(df['Meal'].dropna().unique().tolist())
start, end = df['Date'].min().date(), df['Date'].max().date()

w_nutrient = widgets.Dropdown(options=nutrients, value='All', description='Nutrient:', layout=Layout(width='250px'))
w_meal     = widgets.Dropdown(options=meals, value='All', description='Meal:', layout=Layout(width='250px'))
w_start    = widgets.DatePicker(description='Start:', value=pd.Timestamp(start).date())
w_end      = widgets.DatePicker(description='End:', value=pd.Timestamp(end).date())

display(HBox([w_nutrient, w_meal, w_start, w_end]))

πŸ“Œ KPIs

Quick headline numbers update with your selections.

def render_kpis(nutrient, meal, start_date, end_date):
    d = slice_data(df, nutrient, meal, (pd.to_datetime(start_date), pd.to_datetime(end_date)))
    stats = kpis(d)
    fig = go.Figure()
    fig.add_trace(go.Indicator(mode='number', value=stats['Total amount'], title={'text':'Total amount'}))
    fig.add_trace(go.Indicator(mode='number', value=stats['Daily average'], title={'text':'Daily average'}, domain={'x':[0.35,0.65], 'y':[0,1]}))
    fig.add_trace(go.Indicator(mode='number', value=stats['Unique days'], title={'text':'Unique days'}, domain={'x':[0.7,1], 'y':[0,1]}))
    fig.update_layout(grid={'rows':1,'columns':3}, height=200, margin=dict(l=10,r=10,t=10,b=10))
    fig.show()

render_kpis(w_nutrient.value, w_meal.value, w_start.value, w_end.value)

def _on_change(change):
    render_kpis(w_nutrient.value, w_meal.value, w_start.value, w_end.value)

for w in [w_nutrient, w_meal, w_start, w_end]:
    w.observe(_on_change, names='value')

πŸ“ˆ Trend (Daily + Rolling 7)

Daily totals with a 7-day rolling mean. Facets by Nutrient when β€˜All’ is selected.

def trend_figure(nutrient, meal, start_date, end_date):
    d = slice_data(df, nutrient, meal, (pd.to_datetime(start_date), pd.to_datetime(end_date)))
    if d.empty:
        return go.Figure().add_annotation(text='No data in selection', showarrow=False)
    daily = d.groupby(['Date','Nutrient'], as_index=False)['Amount'].sum().rename(columns={'Amount':'Total'})
    daily['Roll7'] = daily.sort_values('Date').groupby('Nutrient')['Total'].transform(lambda s: s.rolling(7, min_periods=1).mean())
    if nutrient == 'All':
        fig = px.line(daily, x='Date', y='Total', color='Nutrient', title='Daily totals by Nutrient')
        for n in daily['Nutrient'].unique():
            dn = daily[daily['Nutrient']==n]
            fig.add_trace(go.Scatter(x=dn['Date'], y=dn['Roll7'], name=f'{n} (7d MA)', mode='lines', line=dict(dash='dash')))
    else:
        fig = px.line(daily, x='Date', y='Total', title=f'Daily totals: {nutrient}')
        fig.add_trace(go.Scatter(x=daily['Date'], y=daily['Roll7'], name='7d MA', mode='lines', line=dict(dash='dash')))
    fig.update_layout(height=420, margin=dict(l=10,r=10,t=40,b=10))
    return fig

fig_trend = trend_figure(w_nutrient.value, w_meal.value, w_start.value, w_end.value)
fig_trend.show()

def _on_change_trend(change):
    trend_figure(w_nutrient.value, w_meal.value, w_start.value, w_end.value).show()

for w in [w_nutrient, w_meal, w_start, w_end]:
    w.observe(_on_change_trend, names='value')

🍱 Stacked Bars (Meal Composition)

Composition by Meal within the selected period (sum of Amount).

def stacked_meal(nutrient, meal, start_date, end_date):
    d = slice_data(df, nutrient, meal, (pd.to_datetime(start_date), pd.to_datetime(end_date)))
    if d.empty:
        return go.Figure().add_annotation(text='No data in selection', showarrow=False)
    comp = d.groupby(['Meal','Nutrient'], as_index=False)['Amount'].sum()
    fig = px.bar(comp, x='Meal', y='Amount', color='Nutrient', title='Meal composition (sum of Amount)', barmode='stack')
    fig.update_layout(height=380, margin=dict(l=10,r=10,t=40,b=10))
    return fig

fig_bars = stacked_meal(w_nutrient.value, w_meal.value, w_start.value, w_end.value)
fig_bars.show()

def _on_change_bars(change):
    stacked_meal(w_nutrient.value, w_meal.value, w_start.value, w_end.value).show()

for w in [w_nutrient, w_meal, w_start, w_end]:
    w.observe(_on_change_bars, names='value')

🌳 Treemap (Who eats what?)

Treemap by ID β†’ Meal β†’ Nutrient reveals heavy hitters and diet mix quickly.

def treemap_fig(nutrient, meal, start_date, end_date):
    d = slice_data(df, nutrient, meal, (pd.to_datetime(start_date), pd.to_datetime(end_date)))
    if d.empty:
        return go.Figure().add_annotation(text='No data in selection', showarrow=False)
    agg = d.groupby(['ID','Meal','Nutrient'], as_index=False)['Amount'].sum()
    fig = px.treemap(agg, path=['ID','Meal','Nutrient'], values='Amount', title='Treemap: totals by ID β†’ Meal β†’ Nutrient')
    fig.update_layout(height=500, margin=dict(l=10,r=10,t=40,b=10))
    return fig

fig_tree = treemap_fig(w_nutrient.value, w_meal.value, w_start.value, w_end.value)
fig_tree.show()

def _on_change_tree(change):
    treemap_fig(w_nutrient.value, w_meal.value, w_start.value, w_end.value).show()

for w in [w_nutrient, w_meal, w_start, w_end]:
    w.observe(_on_change_tree, names='value')

πŸ’Ύ Export Single-File HTML Dashboard

Write a self-contained dashboard.html (no server needed) with KPIs + charts for your current selection.

def build_dashboard_html(nutrient, meal, start_date, end_date, out='dashboard.html'):
    # Build figures with current selection
    kpi_fig = go.Figure()
    d = slice_data(df, nutrient, meal, (pd.to_datetime(start_date), pd.to_datetime(end_date)))
    s = kpis(d)
    kpi_fig.add_trace(go.Indicator(mode='number', value=s['Total amount'], title={'text':'Total amount'}))
    kpi_fig.add_trace(go.Indicator(mode='number', value=s['Daily average'], title={'text':'Daily average'}, domain={'x':[0.35,0.65], 'y':[0,1]}))
    kpi_fig.add_trace(go.Indicator(mode='number', value=s['Unique days'], title={'text':'Unique days'}, domain={'x':[0.7,1], 'y':[0,1]}))
    kpi_fig.update_layout(grid={'rows':1,'columns':3}, height=200, margin=dict(l=10,r=10,t=10,b=10))

    figs = [
        ('KPIs', kpi_fig),
        ('Trend', trend_figure(nutrient, meal, start_date, end_date)),
        ('Meal Composition', stacked_meal(nutrient, meal, start_date, end_date)),
        ('Treemap', treemap_fig(nutrient, meal, start_date, end_date))
    ]
    html_blocks = []
    for title, fig in figs:
        html_blocks.append(f"<h2 style='font-family:sans-serif'>{title}</h2>" + fig.to_html(full_html=False, include_plotlyjs='cdn'))
    template = f"""
    <html>
    <head>
      <meta charset='utf-8'>
      <title>Nutrition Dashboard</title>
      <style>body{{max-width:1200px;margin:20px auto;font-family:sans-serif;}}</style>
    </head>
    <body>
      <h1 style='font-family:sans-serif'>Nutrition Dashboard</h1>
      <p><b>Filters:</b> Nutrient={nutrient} | Meal={meal} | Range={start_date} β†’ {end_date}</p>
      {''.join(html_blocks)}
    </body>
    </html>
    """
    with open(out, 'w', encoding='utf-8') as f:
        f.write(template)
    print('Wrote:', Path(out).resolve())

build_dashboard_html(w_nutrient.value, w_meal.value, w_start.value, w_end.value, out='dashboard.html')

βž• Optional: Quarto Template (Static/Parametric)

If you use Quarto, this writes a minimal .qmd you can render outside Colab to a polished dashboard.

What you get A starter Quarto page with Plotly support, filter parameters, and Python code chunks.
qmd = '''
---
title: "Nutrition Dashboard"
format:
  html:
    theme: cosmo
    toc: false
filters:
  - lightbox
execute:
  echo: false
params:
  nutrient: All
  meal: All
  start: 2024-01-01
  end: 2024-12-31
---

```{python}
import pandas as pd, plotly.express as px
df = pd.read_csv('data/large_food_log.csv')
df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
df = df.dropna(subset=['Amount','Date'])
nutrient = params['nutrient']
meal = params['meal']
start = pd.to_datetime(params['start'])
end   = pd.to_datetime(params['end'])
d = df.copy()
if nutrient!='All': d = d[d['Nutrient']==nutrient]
if meal!='All': d = d[d['Meal']==meal]
d = d[(d['Date']>=start)&(d['Date']<=end)]
daily = d.groupby(['Date','Nutrient'], as_index=False)['Amount'].sum()
fig = px.line(daily, x='Date', y='Amount', color='Nutrient', title='Daily totals')
fig
```
'''
with open('nutrition_dashboard.qmd','w', encoding='utf-8') as f:
    f.write(qmd)
print('Wrote Quarto file:', Path('nutrition_dashboard.qmd').resolve())

🧩 Exercises

  1. Facet trends β€” Change the trend chart to facet by Meal when Nutrient is β€˜All’.

  2. Percent share β€” In the stacked bar, show percent composition per Meal (hint: compute totals per Meal, then divide and use text_auto='0.0%').

  3. Anomaly hint β€” Add a line for 30-day rolling mean and scatter markers when daily total is 50% above the rolling mean.

  4. Save & ship β€” Export different filter combinations as dashboard_*.html and share with your team.

βœ… Conclusion

You built an interactive dashboard with Plotly + ipywidgets, added KPIs and multiple linked views, and exported a portable HTML. You’re ready to package insights for nutrition stakeholders.

More
  • Plotly Express: https://plotly.com/python/
  • ipywidgets: https://ipywidgets.readthedocs.io/
  • Quarto dashboards: https://quarto.org/docs/interactive/