Restaurant P&L Dashboard
Build an interactive restaurant P&L dashboard with Python and Streamlit.
Introduction
30 Second Summary
Every restaurant lives or dies by its numbers. Managers track revenue, labor costs, and food costs month after month to spot problems before they spiral.
In this project, you will build an interactive Restaurant P&L Dashboard using Python, Streamlit, and Plotly that displays financial KPIs, monthly trend charts, and a 6-month revenue forecast.
What You'll Build
A fully interactive dashboard that simulates financial data modeled on a fast-casual restaurant, complete with KPI cards, line charts, and forecasting powered by exponential smoothing.
By the end of this project, you'll have:
- A Python data generator that creates 24 months of realistic restaurant P&L data with seasonal revenue patterns.
- An interactive Streamlit dashboard displaying KPI metric cards for Revenue, Labor Cost %, Food Cost %, and Profit Margin with month-over-month deltas.
- A revenue forecasting section using Holt-Winters exponential smoothing from statsmodels that predicts 6 months ahead with confidence intervals.
- Secret Mission: A multi-location comparison feature that lets you toggle between restaurant locations and compare their P&L performance side-by-side.
Are there any prerequisites?
You should be comfortable with basic Python (variables, functions, and loops). No prior experience with Streamlit, Plotly, or financial analysis is needed.
Generate Restaurant P&L Data
Every dashboard needs data behind it. Before you build any visualizations, you need a reliable data source that mimics what a real restaurant's financial reports look like.
In this step, you'll write a Python script that generates 24 months of synthetic P&L (Profit & Loss) data modeled on a fast-casual restaurant. The data includes seasonal revenue patterns, labor costs, food costs, and profit margins.
In this step, get ready to:
- Create data.py with a function that generates 24 months of restaurant financial data.
- Model realistic cost percentages for labor, food, and other expenses.
- Run the script standalone to verify the DataFrame output.
Create the seasonal revenue generator
Restaurants don't earn the same amount every month. Summer brings more foot traffic, December has holiday celebrations, and January is slow. Your data generator needs to capture these patterns.
- In VS Code, create a new file called data.py in your project folder.
- Copy the following code into data.py:
import pandas as pd
import numpy as np
def generate_restaurant_data(
location_name="Downtown",
base_revenue=200000,
months=24,
seed=42,
):
"""Generate realistic monthly restaurant P&L data."""
rng = np.random.default_rng(seed)
dates = pd.date_range(
start="2024-07-01", periods=months, freq="MS"
)
# Seasonal revenue pattern (peaks in summer and December)
seasonal_factors = []
for date in dates:
month = date.month
if month in [6, 7, 8]: # Summer peak
factor = 1.15 + rng.uniform(-0.03, 0.03)
elif month == 12: # Holiday peak
factor = 1.20 + rng.uniform(-0.03, 0.03)
elif month in [1, 2]: # Winter dip
factor = 0.85 + rng.uniform(-0.03, 0.03)
else:
factor = 1.0 + rng.uniform(-0.05, 0.05)
seasonal_factors.append(factor)
revenue = [
base_revenue * factor + rng.normal(0, 5000)
for factor in seasonal_factors
]
What does this code do?
This is the first half of your data generation function. Here's what each section handles:
- rng = np.random.default_rng(seed) creates a reproducible random number generator. Using a fixed seed means you get the same data every time you run the script.
- pd.date_range(..., freq="MS") generates 24 month-start dates starting from July 2024.
- The seasonal_factors loop assigns multipliers to each month. Summer months get a 15% boost, December gets 20%, and January/February dip by 15%.
- The revenue list applies those seasonal factors to the base revenue, plus small random noise for realism.
You won't see a change yet. The next chunk completes the function with cost calculations.
Add cost calculations and build the DataFrame
Fast-casual restaurants like Cava typically run at 25-32% labor cost, 28-33% food cost, and about 15% in other operating expenses. These benchmarks determine whether a location is profitable.
- In data.py, add the following code directly below the revenue list you just wrote (still inside the function, at the same indentation level):
# Labor cost: 25-32% of revenue (higher in slow months)
labor_pct = [
0.28 + rng.uniform(-0.03, 0.04) + (0.02 if sf < 0.95 else 0)
for sf in seasonal_factors
]
labor_cost = [r * lp for r, lp in zip(revenue, labor_pct)]
# Food cost (COGS): 28-33% of revenue
food_pct = [0.30 + rng.uniform(-0.02, 0.03) for _ in range(months)]
food_cost = [r * fp for r, fp in zip(revenue, food_pct)]
# Other operating expenses: ~15% of revenue
other_expenses = [r * (0.15 + rng.uniform(-0.01, 0.01)) for r in revenue]
# Profit = Revenue - Labor - Food - Other
profit = [
r - l - f - o
for r, l, f, o in zip(revenue, labor_cost, food_cost, other_expenses)
]
profit_margin = [p / r * 100 for p, r in zip(profit, revenue)]
df = pd.DataFrame(
{
"date": dates,
"location": location_name,
"revenue": [round(r, 2) for r in revenue],
"labor_cost": [round(l, 2) for l in labor_cost],
"labor_pct": [round(lp * 100, 1) for lp in labor_pct],
"food_cost": [round(f, 2) for f in food_cost],
"food_pct": [round(fp * 100, 1) for fp in food_pct],
"other_expenses": [round(o, 2) for o in other_expenses],
"profit": [round(p, 2) for p in profit],
"profit_margin": [round(pm, 1) for pm in profit_margin],
}
)
return df
What does this code do?
This section calculates each expense category as a percentage of revenue, then assembles everything into a pandas DataFrame.
- labor_pct averages around 28% but bumps up by 2% in slow months (when seasonal_factors < 0.95). This mirrors how fixed staffing costs eat more of revenue during quiet periods.
- food_pct (also called COGS) centers around 30%, varying between 28-33%.
- Profit is calculated by subtracting all costs from revenue. The profit_margin converts that into a percentage.
- The DataFrame rounds values and stores both the dollar amounts and the percentages for easy charting later.
Why generate synthetic data instead of using a real dataset?
Real restaurant financials are confidential. Generating your own data lets you control the patterns and ensures the project is completely self-contained with no external file dependencies.
The benchmarks you coded (Labor 25-32%, Food Cost 28-33%, ~15% other expenses) are based on typical fast-casual restaurant operations. Your dashboard will show patterns that a hiring manager would recognize as realistic.
- Save data.py.
You won't see a visible change yet. The next chunk adds a way to run this file directly and check the output.
Run the data generator to verify output
Python's if __name__ == "__main__" pattern lets you run a file directly for testing while still importing it from other files later.
- At the bottom of data.py (outside the function, with no indentation), add this code:
if __name__ == "__main__":
df = generate_restaurant_data()
print(df.to_string())
print(f"\nAverage Revenue: ${df['revenue'].mean():,.0f}")
print(f"Average Labor %: {df['labor_pct'].mean():.1f}%")
print(f"Average Food Cost %: {df['food_pct'].mean():.1f}%")
print(f"Average Profit Margin: {df['profit_margin'].mean():.1f}%")
What does this code do?
- if __name__ == "__main__" only runs when you execute data.py directly. It won't run when another file imports the generate_restaurant_data function.
- print(df.to_string()) displays the full 24-row DataFrame so you can spot-check the numbers.
- The summary statistics at the end give you a quick sanity check that averages fall within realistic ranges.
- Save data.py.
- Run the script by entering this command in your terminal:
python data.py
- You should see a table of 24 rows with columns for date, location, revenue, labor_cost, labor_pct, food_cost, food_pct, other_expenses, profit, and profit_margin.
- At the bottom, the summary should show averages roughly in these ranges: Revenue around $190K-$210K, Labor around 28-30%, Food Cost around 30-32%, and Profit Margin around 20-27%.
Seeing an error when you run the script?
- If you see IndentationError, make sure the cost calculations and DataFrame code are indented with 4 spaces (inside the function body). The if __name__ block should have zero indentation.
- If you see ModuleNotFoundError, make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- If you see NameError, check that the revenue list and seasonal_factors list are both inside the function before the cost calculations that use them.
Still stuck? Help me debug my data.py script
✔️ Awesome, I've got everything!
Great. Double check you've saved data.py and your output shows 24 rows of financial data.
ⓧ I'd like to double check the full code
Here's the complete data.py file. Compare it against yours:
import pandas as pd
import numpy as np
def generate_restaurant_data(
location_name="Downtown",
base_revenue=200000,
months=24,
seed=42,
):
"""Generate realistic monthly restaurant P&L data."""
rng = np.random.default_rng(seed)
dates = pd.date_range(
start="2024-07-01", periods=months, freq="MS"
)
# Seasonal revenue pattern (peaks in summer and December)
seasonal_factors = []
for date in dates:
month = date.month
if month in [6, 7, 8]: # Summer peak
factor = 1.15 + rng.uniform(-0.03, 0.03)
elif month == 12: # Holiday peak
factor = 1.20 + rng.uniform(-0.03, 0.03)
elif month in [1, 2]: # Winter dip
factor = 0.85 + rng.uniform(-0.03, 0.03)
else:
factor = 1.0 + rng.uniform(-0.05, 0.05)
seasonal_factors.append(factor)
revenue = [
base_revenue * factor + rng.normal(0, 5000)
for factor in seasonal_factors
]
# Labor cost: 25-32% of revenue (higher in slow months)
labor_pct = [
0.28 + rng.uniform(-0.03, 0.04) + (0.02 if sf < 0.95 else 0)
for sf in seasonal_factors
]
labor_cost = [r * lp for r, lp in zip(revenue, labor_pct)]
# Food cost (COGS): 28-33% of revenue
food_pct = [0.30 + rng.uniform(-0.02, 0.03) for _ in range(months)]
food_cost = [r * fp for r, fp in zip(revenue, food_pct)]
# Other operating expenses: ~15% of revenue
other_expenses = [r * (0.15 + rng.uniform(-0.01, 0.01)) for r in revenue]
# Profit = Revenue - Labor - Food - Other
profit = [
r - l - f - o
for r, l, f, o in zip(revenue, labor_cost, food_cost, other_expenses)
]
profit_margin = [p / r * 100 for p, r in zip(profit, revenue)]
df = pd.DataFrame(
{
"date": dates,
"location": location_name,
"revenue": [round(r, 2) for r in revenue],
"labor_cost": [round(l, 2) for l in labor_cost],
"labor_pct": [round(lp * 100, 1) for lp in labor_pct],
"food_cost": [round(f, 2) for f in food_cost],
"food_pct": [round(fp * 100, 1) for fp in food_pct],
"other_expenses": [round(o, 2) for o in other_expenses],
"profit": [round(p, 2) for p in profit],
"profit_margin": [round(pm, 1) for pm in profit_margin],
}
)
return df
if __name__ == "__main__":
df = generate_restaurant_data()
print(df.to_string())
print(f"\nAverage Revenue: ${df['revenue'].mean():,.0f}")
print(f"Average Labor %: {df['labor_pct'].mean():.1f}%")
print(f"Average Food Cost %: {df['food_pct'].mean():.1f}%")
print(f"Average Profit Margin: {df['profit_margin'].mean():.1f}%")
Your data engine is ready. Next up, you'll build the actual dashboard that visualizes these numbers as interactive KPI cards.
Build the KPI Metrics Section
You now have a data generator that produces 24 months of realistic restaurant P&L numbers. The next step is turning that raw data into something a manager can glance at and immediately understand.
In this step, you'll create the main Streamlit app file and build a KPI (Key Performance Indicator) dashboard section. The dashboard will show four metric cards for Revenue, Labor Cost %, Food Cost %, and Profit Margin, each comparing to the previous month.
In this step, get ready to:
- Create the Streamlit app file with page configuration, a title, and a sidebar month selector.
- Display four KPI metric cards with delta indicators.
- Run the app and verify the dashboard renders correctly.
Create the Streamlit app with page config and sidebar
Every Streamlit app starts with a page configuration block that sets the browser tab title, icon, and layout mode. You'll also add a sidebar slider so users can browse different months of data.
- Create a new file called app.py in your project folder (the same folder that contains data.py).
- Add the following code to set up the app shell:
import streamlit as st
import plotly.graph_objects as go
import pandas as pd
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from data import generate_restaurant_data
# Page configuration
st.set_page_config(
page_title="Restaurant P&L Dashboard",
page_icon="\ud83c\udf7d\ufe0f",
layout="wide",
)
st.title("\ud83c\udf7d\ufe0f Restaurant P&L Dashboard")
st.markdown("Monthly financial performance inspired by fast-casual operations")
# Generate data
df = generate_restaurant_data()
# Sidebar controls
st.sidebar.header("Controls")
selected_month_idx = st.sidebar.slider(
"Select Month",
min_value=0,
max_value=len(df) - 1,
value=len(df) - 1,
format="",
)
st.sidebar.write(f"**Showing:** {df.iloc[selected_month_idx]['date'].strftime('%B %Y')}")
What does this code do?
The top section imports all the libraries you installed earlier. You won't use plotly or ExponentialSmoothing until later steps, but importing them now keeps all imports in one place.
The st.set_page_config() call sets the browser tab title and icon, and layout="wide" tells Streamlit to use the full browser width instead of a narrow centered column.
The sidebar slider lets users pick which month's data to inspect. The format="" hides the default numeric tooltip so the formatted month name below the slider is the only label.
- Save app.py.
You won't see anything yet. In the next substep, you'll add KPI cards, then launch the app to see everything together.
Display four KPI metric cards with delta indicators
Restaurant managers track four numbers daily: how much money came in (Revenue), how much went to staff (Labor Cost %), how much went to ingredients (Food Cost %), and what's left over (Profit Margin). Each card will show the current month's value plus the change from the previous month.
- In app.py, add the following code below the sidebar section you just wrote:
# --- KPI METRICS SECTION ---
st.header("Key Performance Indicators")
current = df.iloc[selected_month_idx]
previous = df.iloc[max(0, selected_month_idx - 1)]
col1, col2, col3, col4 = st.columns(4)
with col1:
delta_rev = current["revenue"] - previous["revenue"]
st.metric(
label="Revenue",
value=f"${current['revenue']:,.0f}",
delta=f"${delta_rev:,.0f}",
)
with col2:
delta_labor = current["labor_pct"] - previous["labor_pct"]
st.metric(
label="Labor Cost %",
value=f"{current['labor_pct']:.1f}%",
delta=f"{delta_labor:.1f}%",
delta_color="inverse",
)
with col3:
delta_food = current["food_pct"] - previous["food_pct"]
st.metric(
label="Food Cost %",
value=f"{current['food_pct']:.1f}%",
delta=f"{delta_food:.1f}%",
delta_color="inverse",
)
with col4:
delta_margin = current["profit_margin"] - previous["profit_margin"]
st.metric(
label="Profit Margin",
value=f"{current['profit_margin']:.1f}%",
delta=f"{delta_margin:.1f}%",
)
What does this code do?
The st.columns(4) call creates four equal-width columns so the KPI cards sit side by side.
Each st.metric() card shows a label, a value, and a delta (the difference from the previous month). The delta arrow is green for increases and red for decreases by default.
For Labor Cost % and Food Cost %, delta_color="inverse" flips the colors. An increase in costs is bad (red), and a decrease is good (green).
- Save app.py.
The dashboard is ready to preview. In the next substep, you'll launch it.
Run the app and verify the metrics display
- Launch the Streamlit app by running this command in your terminal:
streamlit run app.py
What should I see?
Streamlit opens a new browser tab automatically. You should see the title at the top, a sidebar slider on the left, and four KPI cards in a row showing Revenue, Labor Cost %, Food Cost %, and Profit Margin.
Try dragging the sidebar slider to different months. The metric values and delta arrows update instantly.
App not loading?
- If you see a ModuleNotFoundError, make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- If you see an ImportError mentioning data, confirm that data.py is in the same folder as app.py.
- If the browser tab doesn't open, check your terminal for a URL (usually http://localhost:8501) and open it manually.
Help me debug my Streamlit app that won't start
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py and your app is running in the browser.
ⓧ I'd like to double check the full code
Here is the complete app.py file at this point:
import streamlit as st
import plotly.graph_objects as go
import pandas as pd
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from data import generate_restaurant_data
# Page configuration
st.set_page_config(
page_title="Restaurant P&L Dashboard",
page_icon="\ud83c\udf7d\ufe0f",
layout="wide",
)
st.title("\ud83c\udf7d\ufe0f Restaurant P&L Dashboard")
st.markdown("Monthly financial performance inspired by fast-casual operations")
# Generate data
df = generate_restaurant_data()
# Sidebar controls
st.sidebar.header("Controls")
selected_month_idx = st.sidebar.slider(
"Select Month",
min_value=0,
max_value=len(df) - 1,
value=len(df) - 1,
format="",
)
st.sidebar.write(f"**Showing:** {df.iloc[selected_month_idx]['date'].strftime('%B %Y')}")
# --- KPI METRICS SECTION ---
st.header("Key Performance Indicators")
current = df.iloc[selected_month_idx]
previous = df.iloc[max(0, selected_month_idx - 1)]
col1, col2, col3, col4 = st.columns(4)
with col1:
delta_rev = current["revenue"] - previous["revenue"]
st.metric(
label="Revenue",
value=f"${current['revenue']:,.0f}",
delta=f"${delta_rev:,.0f}",
)
with col2:
delta_labor = current["labor_pct"] - previous["labor_pct"]
st.metric(
label="Labor Cost %",
value=f"{current['labor_pct']:.1f}%",
delta=f"{delta_labor:.1f}%",
delta_color="inverse",
)
with col3:
delta_food = current["food_pct"] - previous["food_pct"]
st.metric(
label="Food Cost %",
value=f"{current['food_pct']:.1f}%",
delta=f"{delta_food:.1f}%",
delta_color="inverse",
)
with col4:
delta_margin = current["profit_margin"] - previous["profit_margin"]
st.metric(
label="Profit Margin",
value=f"{current['profit_margin']:.1f}%",
delta=f"{delta_margin:.1f}%",
)
Your KPI section is live and responding to the sidebar slider. Next up, you'll add Plotly line charts to visualize how these metrics trend over the full 24 months.
Add Monthly Trend Charts
Your KPI cards give a snapshot of the current month. But restaurant managers need to see patterns over time to make informed decisions.
In this step, you'll build three interactive Plotly charts that visualize Revenue, Labor Cost %, Food Cost %, and Profit Margin across all 24 months of data.
In this step, get ready to:
- Create a Revenue trend line chart with currency formatting.
- Build a combined cost percentage chart comparing Labor Cost % and Food Cost %.
- Add a Profit Margin area chart with a target line at 15%.
Create the Revenue trend line chart
A Revenue line chart shows monthly income over time, making seasonal peaks and dips immediately visible. You'll use plotly.graph_objects to build an interactive chart with hover tooltips, zoom, and pan built in.
- In app.py, scroll to the bottom of the file (after the KPI metrics section).
- Add the Monthly Trends section header and Revenue chart by pasting this code at the bottom:
# --- MONTHLY TRENDS SECTION ---
st.header("Monthly Trends")
# Revenue trend
fig_revenue = go.Figure()
fig_revenue.add_trace(
go.Scatter(
x=df["date"],
y=df["revenue"],
mode="lines+markers",
name="Revenue",
line=dict(color="#2E86AB", width=3),
marker=dict(size=6),
)
)
fig_revenue.update_layout(
title="Monthly Revenue",
xaxis_title="Month",
yaxis_title="Revenue ($)",
yaxis_tickformat="$,.0f",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_revenue, use_container_width=True)
What does this code do?
- go.Figure() creates an empty Plotly chart canvas.
- go.Scatter() with mode="lines+markers" draws a line connecting dots at each data point.
- yaxis_tickformat="$,.0f" formats the y-axis labels as currency (e.g., $200,000).
- st.plotly_chart() renders the Plotly figure inside the Streamlit app.
- Save app.py.
- Switch back to your browser. Streamlit auto-refreshes the page. You should see a "Monthly Trends" header followed by an interactive line chart showing revenue across 24 months.
Don't see the chart?
- Make sure you saved app.py. Streamlit only picks up changes after saving.
- Scroll down below the KPI cards. The chart appears under a new "Monthly Trends" heading.
- Check that the new code is pasted after the closing parenthesis of the last st.metric() block, not inside it.
Why isn't my Plotly chart rendering in Streamlit?
Create the cost percentage chart
Labor and food are the two biggest cost drivers in a restaurant. Plotting them on the same chart makes it easy to compare how they fluctuate relative to each other.
- In app.py, add the cost percentages chart directly below the Revenue chart code by pasting this at the bottom of the file:
# Cost percentages
fig_costs = go.Figure()
fig_costs.add_trace(
go.Scatter(
x=df["date"],
y=df["labor_pct"],
mode="lines+markers",
name="Labor Cost %",
line=dict(color="#E84855", width=2),
)
)
fig_costs.add_trace(
go.Scatter(
x=df["date"],
y=df["food_pct"],
mode="lines+markers",
name="Food Cost %",
line=dict(color="#F9A620", width=2),
)
)
fig_costs.update_layout(
title="Labor Cost % vs Food Cost %",
xaxis_title="Month",
yaxis_title="Percentage (%)",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_costs, use_container_width=True)
What does this code do?
- Two add_trace() calls place both Labor Cost % and Food Cost % lines on the same chart.
- Each trace gets its own color (#E84855 for labor, #F9A620 for food) so they're easy to distinguish.
- The shared y-axis lets you compare the two percentages directly side by side over time.
- Save app.py and switch to your browser.
- Scroll below the Revenue chart. You should see a second chart titled "Labor Cost % vs Food Cost %" with a red line and an orange line.
Only seeing one line?
- Check that you have two separate fig_costs.add_trace() calls. Each trace needs its own go.Scatter() block.
- The legend in the top-right corner of the chart shows which traces are active. Click a legend entry to toggle its visibility.
Help me fix my multi-trace Plotly chart
Add the Profit Margin area chart with a target line
Profit Margin is the bottom-line metric that tells you how much of each revenue dollar actually stays as profit. An area chart filled to the x-axis makes it easy to see when margins are healthy and when they dip below a target threshold.
- In app.py, add the Profit Margin chart at the bottom of the file:
# Profit margin with target line
fig_margin = go.Figure()
fig_margin.add_trace(
go.Scatter(
x=df["date"],
y=df["profit_margin"],
fill="tozeroy",
name="Profit Margin",
line=dict(color="#44AF69", width=2),
fillcolor="rgba(68, 175, 105, 0.2)",
)
)
fig_margin.add_hline(
y=15,
line_dash="dash",
line_color="red",
annotation_text="Target: 15%",
annotation_position="top right",
)
fig_margin.update_layout(
title="Profit Margin Trend",
xaxis_title="Month",
yaxis_title="Margin (%)",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_margin, use_container_width=True)
What does this code do?
- fill="tozeroy" shades the area between the line and the x-axis, turning the line chart into an area chart.
- fillcolor="rgba(68, 175, 105, 0.2)" sets a translucent green fill so the grid lines remain visible underneath.
- add_hline(y=15) draws a horizontal dashed red line at the 15% profit margin target. Any month where the green area dips below this line signals underperformance.
- Save app.py and switch to your browser.
- Scroll to the bottom of the page. You should see a green shaded area chart with a dashed red target line at 15%.
Chart looks empty or the target line is missing?
- Make sure fill="tozeroy" is inside the go.Scatter() call, not in update_layout().
- The add_hline() call goes directly on fig_margin, not inside add_trace().
Help me debug my Profit Margin area chart
Why a target line at 15%?
Fast-casual restaurants typically aim for profit margins between 10-20%. A 15% target represents a healthy benchmark. When the green area dips below the red line, it signals months where the restaurant underperformed relative to this goal.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
Here's the complete app.py file at this point:
import streamlit as st
import plotly.graph_objects as go
import pandas as pd
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from data import generate_restaurant_data
# Page configuration
st.set_page_config(
page_title="Restaurant P&L Dashboard",
page_icon="🍽️",
layout="wide",
)
st.title("🍽️ Restaurant P&L Dashboard")
st.markdown("Monthly financial performance inspired by fast-casual operations")
# Generate data
df = generate_restaurant_data()
# Sidebar controls
st.sidebar.header("Controls")
selected_month_idx = st.sidebar.slider(
"Select Month",
min_value=0,
max_value=len(df) - 1,
value=len(df) - 1,
format="",
)
st.sidebar.write(f"**Showing:** {df.iloc[selected_month_idx]['date'].strftime('%B %Y')}")
# --- KPI METRICS SECTION ---
st.header("Key Performance Indicators")
current = df.iloc[selected_month_idx]
previous = df.iloc[max(0, selected_month_idx - 1)]
col1, col2, col3, col4 = st.columns(4)
with col1:
delta_rev = current["revenue"] - previous["revenue"]
st.metric(
label="Revenue",
value=f"${current['revenue']:,.0f}",
delta=f"${delta_rev:,.0f}",
)
with col2:
delta_labor = current["labor_pct"] - previous["labor_pct"]
st.metric(
label="Labor Cost %",
value=f"{current['labor_pct']:.1f}%",
delta=f"{delta_labor:.1f}%",
delta_color="inverse",
)
with col3:
delta_food = current["food_pct"] - previous["food_pct"]
st.metric(
label="Food Cost %",
value=f"{current['food_pct']:.1f}%",
delta=f"{delta_food:.1f}%",
delta_color="inverse",
)
with col4:
delta_margin = current["profit_margin"] - previous["profit_margin"]
st.metric(
label="Profit Margin",
value=f"{current['profit_margin']:.1f}%",
delta=f"{delta_margin:.1f}%",
)
# --- MONTHLY TRENDS SECTION ---
st.header("Monthly Trends")
# Revenue trend
fig_revenue = go.Figure()
fig_revenue.add_trace(
go.Scatter(
x=df["date"],
y=df["revenue"],
mode="lines+markers",
name="Revenue",
line=dict(color="#2E86AB", width=3),
marker=dict(size=6),
)
)
fig_revenue.update_layout(
title="Monthly Revenue",
xaxis_title="Month",
yaxis_title="Revenue ($)",
yaxis_tickformat="$,.0f",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_revenue, use_container_width=True)
# Cost percentages
fig_costs = go.Figure()
fig_costs.add_trace(
go.Scatter(
x=df["date"],
y=df["labor_pct"],
mode="lines+markers",
name="Labor Cost %",
line=dict(color="#E84855", width=2),
)
)
fig_costs.add_trace(
go.Scatter(
x=df["date"],
y=df["food_pct"],
mode="lines+markers",
name="Food Cost %",
line=dict(color="#F9A620", width=2),
)
)
fig_costs.update_layout(
title="Labor Cost % vs Food Cost %",
xaxis_title="Month",
yaxis_title="Percentage (%)",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_costs, use_container_width=True)
# Profit margin with target line
fig_margin = go.Figure()
fig_margin.add_trace(
go.Scatter(
x=df["date"],
y=df["profit_margin"],
fill="tozeroy",
name="Profit Margin",
line=dict(color="#44AF69", width=2),
fillcolor="rgba(68, 175, 105, 0.2)",
)
)
fig_margin.add_hline(
y=15,
line_dash="dash",
line_color="red",
annotation_text="Target: 15%",
annotation_position="top right",
)
fig_margin.update_layout(
title="Profit Margin Trend",
xaxis_title="Month",
yaxis_title="Margin (%)",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_margin, use_container_width=True)
Your dashboard now tells a complete visual story of the restaurant's financial health over 24 months. Next up, you'll add a forecasting section that predicts future revenue using exponential smoothing.
Implement Revenue Forecasting
Your dashboard already tells a powerful story about the past 24 months of restaurant operations. But restaurant managers need more than history. They need to see where revenue is heading next.
In this step, you will add a forecasting section that uses Holt-Winters exponential smoothing to predict the next 6 months of revenue. The model accounts for both trend and seasonality, which is exactly what restaurant revenue data exhibits.
In this step, get ready to:
- Fit a Holt-Winters forecasting model to your historical revenue data.
- Build a forecast chart showing historical and predicted revenue with confidence intervals.
- Display summary metrics for the 6-month revenue forecast.
Fit the Holt-Winters forecasting model
Holt-Winters exponential smoothing decomposes a time series into trend, seasonality, and noise. With 24 months of data and clear seasonal peaks (summer and December), the model can learn these patterns and project them forward.
- In app.py, scroll to the very bottom of the file (below the st.plotly_chart(fig_margin, use_container_width=True) line).
- Add the forecasting section header and model fitting code:
# --- FORECASTING SECTION ---
st.header("📈 Revenue Forecast (Next 6 Months)")
# Fit Holt-Winters model
revenue_series = df.set_index("date")["revenue"]
model = ExponentialSmoothing(
revenue_series,
trend="add",
seasonal="add",
seasonal_periods=12,
)
fitted_model = model.fit()
What does this code do?
First, revenue_series extracts just the revenue column from your DataFrame with dates as the index. This is the format time series models expect.
Then ExponentialSmoothing creates the model with three key settings: trend="add" captures the upward or downward drift, seasonal="add" captures repeating yearly patterns, and seasonal_periods=12 tells the model that the seasonal cycle repeats every 12 months.
Why Holt-Winters over Prophet?
Facebook's Prophet is popular for forecasting, but it requires a C++ compiler (Stan) which makes installation painful on Windows. The statsmodels library installs cleanly everywhere and Holt-Winters is the textbook approach for monthly data with clear seasonality.
- Save app.py.
- Check your browser. You should see a new section header that reads "Revenue Forecast (Next 6 Months)" below the Profit Margin chart.
Seeing an error about ExponentialSmoothing?
Make sure the import from statsmodels.tsa.holtwinters import ExponentialSmoothing is at the top of your file. It was added in the previous step.
If you see a "module not found" error, confirm your virtual environment is active and that you installed statsmodels in Step 1.
Help me debug my ExponentialSmoothing import error.
Generate the forecast and build the chart
With the model fitted, you can now ask it to predict the next 6 months. You will also compute confidence intervals so the chart shows how uncertain those predictions become over time.
- Below the fitted_model = model.fit() line, add the forecast generation and confidence interval code:
# Generate forecast
forecast_steps = 6
forecast = fitted_model.forecast(steps=forecast_steps)
forecast_dates = pd.date_range(
start=revenue_series.index[-1] + pd.DateOffset(months=1),
periods=forecast_steps,
freq="MS",
)
# Confidence intervals (approximate using residual std)
residuals = fitted_model.resid
std_resid = residuals.std()
upper_bound = forecast + 1.96 * std_resid
lower_bound = forecast - 1.96 * std_resid
What does this code do?
The forecast() method projects revenue forward 6 months. Then forecast_dates creates matching month-start dates for the x-axis of your chart.
For confidence intervals, the code uses the model's residuals (the difference between what the model predicted and actual values). Multiplying the standard deviation by 1.96 gives a 95% confidence band. This is a standard statistical approximation.
- Now add the forecast chart directly below the confidence interval code:
# Forecast chart
fig_forecast = go.Figure()
# Historical
fig_forecast.add_trace(
go.Scatter(
x=revenue_series.index,
y=revenue_series.values,
mode="lines+markers",
name="Historical Revenue",
line=dict(color="#2E86AB", width=2),
)
)
# Forecast
fig_forecast.add_trace(
go.Scatter(
x=forecast_dates,
y=forecast.values,
mode="lines+markers",
name="Forecast",
line=dict(color="#E84855", width=2, dash="dash"),
)
)
# Confidence interval
fig_forecast.add_trace(
go.Scatter(
x=pd.Index(forecast_dates.tolist() + forecast_dates[::-1].tolist()),
y=pd.Index(upper_bound.tolist() + lower_bound[::-1].tolist()),
fill="toself",
fillcolor="rgba(232, 72, 85, 0.1)",
line=dict(color="rgba(255,255,255,0)"),
name="95% Confidence Interval",
)
)
fig_forecast.update_layout(
title="Revenue Forecast with Confidence Intervals",
xaxis_title="Month",
yaxis_title="Revenue ($)",
yaxis_tickformat="$,.0f",
template="plotly_white",
height=450,
)
st.plotly_chart(fig_forecast, use_container_width=True)
What does the confidence interval trace do?
The confidence interval uses a clever Plotly trick. It creates a single trace that goes forward along the upper bound, then backwards along the lower bound, forming a closed shape. The fill="toself" fills that enclosed area with a translucent shade so you can see the uncertainty range.
The invisible line (color="rgba(255,255,255,0)") means only the shaded fill is visible, not an outline.
- Save app.py and check your browser.
- You should see a new chart with a solid blue line for historical revenue, a dashed red line for the forecast, and a light pink shaded area around the forecast representing the 95% confidence interval.
Chart not appearing?
Check that revenue_series is defined before the model fitting code. It must be the line revenue_series = df.set_index("date")["revenue"] placed above the ExponentialSmoothing call.
If you see a blank area where the chart should be, look for a Python traceback in your terminal. A common cause is a missing closing parenthesis in the go.Scatter() calls.
Help me figure out why my forecast chart isn't rendering.
Display forecast summary metrics
A chart alone makes people interpret visually. Adding concrete numbers below it makes the forecast actionable for a restaurant manager.
- Below the st.plotly_chart(fig_forecast, use_container_width=True) line, add the forecast summary metrics:
# Forecast summary
col1, col2, col3 = st.columns(3)
with col1:
st.metric(
"Next Month Forecast",
f"${forecast.iloc[0]:,.0f}",
)
with col2:
avg_forecast = forecast.mean()
st.metric(
"Avg Forecasted Revenue",
f"${avg_forecast:,.0f}",
)
with col3:
growth = (forecast.iloc[-1] - revenue_series.iloc[-1]) / revenue_series.iloc[-1] * 100
st.metric(
"6-Month Growth",
f"{growth:.1f}%",
)
What do these metrics show?
- Next Month Forecast shows the model's prediction for the very next month.
- Avg Forecasted Revenue averages all 6 forecasted months to give an overall expected revenue level.
- 6-Month Growth calculates the percentage change between the last historical month and the final forecasted month.
- Save app.py and check your browser.
- Below the forecast chart, you should see three metric cards showing a dollar amount for the next month forecast, an average forecasted revenue, and a percentage for 6-month growth.
Metrics showing unexpected values?
Make sure the growth calculation uses revenue_series.iloc[-1] (the last historical data point) in the denominator. Using forecast.iloc[-1] in both numerator and denominator would give zero.
My forecast summary metrics look wrong. Help me check the calculations.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and your dashboard is showing the full forecasting section.
ⓧ I'd like to double check the full code
Here is the complete app.py file at this point:
import streamlit as st
import plotly.graph_objects as go
import pandas as pd
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from data import generate_restaurant_data
# Page configuration
st.set_page_config(
page_title="Restaurant P&L Dashboard",
page_icon="🍽️",
layout="wide",
)
st.title("🍽️ Restaurant P&L Dashboard")
st.markdown("Monthly financial performance inspired by fast-casual operations")
# Generate data
df = generate_restaurant_data()
# Sidebar controls
st.sidebar.header("Controls")
selected_month_idx = st.sidebar.slider(
"Select Month",
min_value=0,
max_value=len(df) - 1,
value=len(df) - 1,
format="",
)
st.sidebar.write(f"**Showing:** {df.iloc[selected_month_idx]['date'].strftime('%B %Y')}")
# --- KPI METRICS SECTION ---
st.header("Key Performance Indicators")
current = df.iloc[selected_month_idx]
previous = df.iloc[max(0, selected_month_idx - 1)]
col1, col2, col3, col4 = st.columns(4)
with col1:
delta_rev = current["revenue"] - previous["revenue"]
st.metric(
label="Revenue",
value=f"${current['revenue']:,.0f}",
delta=f"${delta_rev:,.0f}",
)
with col2:
delta_labor = current["labor_pct"] - previous["labor_pct"]
st.metric(
label="Labor Cost %",
value=f"{current['labor_pct']:.1f}%",
delta=f"{delta_labor:.1f}%",
delta_color="inverse",
)
with col3:
delta_food = current["food_pct"] - previous["food_pct"]
st.metric(
label="Food Cost %",
value=f"{current['food_pct']:.1f}%",
delta=f"{delta_food:.1f}%",
delta_color="inverse",
)
with col4:
delta_margin = current["profit_margin"] - previous["profit_margin"]
st.metric(
label="Profit Margin",
value=f"{current['profit_margin']:.1f}%",
delta=f"{delta_margin:.1f}%",
)
# --- MONTHLY TRENDS SECTION ---
st.header("Monthly Trends")
# Revenue trend
fig_revenue = go.Figure()
fig_revenue.add_trace(
go.Scatter(
x=df["date"],
y=df["revenue"],
mode="lines+markers",
name="Revenue",
line=dict(color="#2E86AB", width=3),
marker=dict(size=6),
)
)
fig_revenue.update_layout(
title="Monthly Revenue",
xaxis_title="Month",
yaxis_title="Revenue ($)",
yaxis_tickformat="$,.0f",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_revenue, use_container_width=True)
# Cost percentages
fig_costs = go.Figure()
fig_costs.add_trace(
go.Scatter(
x=df["date"],
y=df["labor_pct"],
mode="lines+markers",
name="Labor Cost %",
line=dict(color="#E84855", width=2),
)
)
fig_costs.add_trace(
go.Scatter(
x=df["date"],
y=df["food_pct"],
mode="lines+markers",
name="Food Cost %",
line=dict(color="#F9A620", width=2),
)
)
fig_costs.update_layout(
title="Labor Cost % vs Food Cost %",
xaxis_title="Month",
yaxis_title="Percentage (%)",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_costs, use_container_width=True)
# Profit margin with target line
fig_margin = go.Figure()
fig_margin.add_trace(
go.Scatter(
x=df["date"],
y=df["profit_margin"],
fill="tozeroy",
name="Profit Margin",
line=dict(color="#44AF69", width=2),
fillcolor="rgba(68, 175, 105, 0.2)",
)
)
fig_margin.add_hline(
y=15,
line_dash="dash",
line_color="red",
annotation_text="Target: 15%",
annotation_position="top right",
)
fig_margin.update_layout(
title="Profit Margin Trend",
xaxis_title="Month",
yaxis_title="Margin (%)",
template="plotly_white",
height=400,
)
st.plotly_chart(fig_margin, use_container_width=True)
# --- FORECASTING SECTION ---
st.header("📈 Revenue Forecast (Next 6 Months)")
# Fit Holt-Winters model
revenue_series = df.set_index("date")["revenue"]
model = ExponentialSmoothing(
revenue_series,
trend="add",
seasonal="add",
seasonal_periods=12,
)
fitted_model = model.fit()
# Generate forecast
forecast_steps = 6
forecast = fitted_model.forecast(steps=forecast_steps)
forecast_dates = pd.date_range(
start=revenue_series.index[-1] + pd.DateOffset(months=1),
periods=forecast_steps,
freq="MS",
)
# Confidence intervals (approximate using residual std)
residuals = fitted_model.resid
std_resid = residuals.std()
upper_bound = forecast + 1.96 * std_resid
lower_bound = forecast - 1.96 * std_resid
# Forecast chart
fig_forecast = go.Figure()
# Historical
fig_forecast.add_trace(
go.Scatter(
x=revenue_series.index,
y=revenue_series.values,
mode="lines+markers",
name="Historical Revenue",
line=dict(color="#2E86AB", width=2),
)
)
# Forecast
fig_forecast.add_trace(
go.Scatter(
x=forecast_dates,
y=forecast.values,
mode="lines+markers",
name="Forecast",
line=dict(color="#E84855", width=2, dash="dash"),
)
)
# Confidence interval
fig_forecast.add_trace(
go.Scatter(
x=pd.Index(forecast_dates.tolist() + forecast_dates[::-1].tolist()),
y=pd.Index(upper_bound.tolist() + lower_bound[::-1].tolist()),
fill="toself",
fillcolor="rgba(232, 72, 85, 0.1)",
line=dict(color="rgba(255,255,255,0)"),
name="95% Confidence Interval",
)
)
fig_forecast.update_layout(
title="Revenue Forecast with Confidence Intervals",
xaxis_title="Month",
yaxis_title="Revenue ($)",
yaxis_tickformat="$,.0f",
template="plotly_white",
height=450,
)
st.plotly_chart(fig_forecast, use_container_width=True)
# Forecast summary
col1, col2, col3 = st.columns(3)
with col1:
st.metric(
"Next Month Forecast",
f"${forecast.iloc[0]:,.0f}",
)
with col2:
avg_forecast = forecast.mean()
st.metric(
"Avg Forecasted Revenue",
f"${avg_forecast:,.0f}",
)
with col3:
growth = (forecast.iloc[-1] - revenue_series.iloc[-1]) / revenue_series.iloc[-1] * 100
st.metric(
"6-Month Growth",
f"{growth:.1f}%",
)
Secret mission
Location Comparison
Your single-location dashboard is great, but real restaurant operators manage multiple locations. Can you extend the dashboard to compare P&L performance across Downtown, Suburban Mall, and University District locations side-by-side?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Running Streamlit server process (launched with streamlit run app.py).
- Project folder on your Desktop containing app.py, data.py, and the venv virtual environment.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your dashboard.
- Your Streamlit server stays running in the terminal. You can view the dashboard at any time by visiting the local URL it shows.
- Your project files remain on your Desktop, ready for future edits.
Pause - I'll come back to this later
Shut down the running Streamlit process to free up your terminal. Your project files stay intact so you can restart later.
- Switch to the terminal where Streamlit is running.
- Press Ctrl+C to stop the server.
- Deactivate your virtual environment by running:
deactivate
When you want to come back, reactivate the virtual environment and run the app again.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes everything you built in this project.
- Switch to the terminal where Streamlit is running.
- Press Ctrl+C to stop the server.
- Deactivate your virtual environment by running:
deactivate
- Navigate to your Desktop and delete the project folder (macOS/Linux):
cd ~/Desktop
rm -rf restaurant-dashboard
- On Windows, run this instead:
cd ~\Desktop
rmdir /s /q restaurant-dashboard
If your project folder has a different name, replace restaurant-dashboard with the actual folder name you used.
Nice Work!
Nice Work!
You just built a professional restaurant P&L dashboard from scratch using Python, Streamlit, and Plotly.
What you learned:
- Built a complete interactive Streamlit dashboard displaying restaurant financial KPIs with st.metric() delta indicators.
- Generated realistic synthetic P&L data modeled on fast-casual restaurant benchmarks using pandas and NumPy, complete with seasonal revenue patterns.
- Created interactive Plotly visualizations for Revenue, Labor Cost %, Food Cost %, and Profit Margin trends over 24 months.
- Implemented Holt-Winters exponential smoothing for 6-month revenue forecasting with confidence intervals using statsmodels.
- Added a multi-location comparison feature for side-by-side P&L analysis with overlay charts and a performance ranking table.
Ready to quiz yourself?