U.S. Economic Recovery Dashboard
Wrangle Harvard economic data and deploy a live Streamlit dashboard.
Introduction
30 Second Summary
When the world shut down in 2020, some households bounced back quickly while others struggled for years. The data behind that uneven recovery tells a powerful story about income inequality in America.
In this project, you will build an interactive dashboard that visualizes how consumer spending and employment recovered differently across U.S. states and income groups using real Harvard economic data.
What You'll Build
You'll wrangle multiple CSV datasets from the Opportunity Insights Economic Tracker, perform exploratory analysis in Google Colab, and deploy a live Streamlit dashboard to a public URL.
By the end of this project, you'll have:
- A documented analysis notebook that wrangles real economic data across multiple CSV files with full exploratory analysis.
- A live interactive dashboard deployed at a public URL with charts showing spending trends, income quartile comparisons, and employment recovery timelines.
- A polished GitHub repository with a professional README documenting your business question, methodology, findings, and recommendations.
- Secret Mission: Add a state-by-state economic resilience scoring system that ranks states and lets users adjust weights dynamically.
Are there any prerequisites?
You should be comfortable with Python and pandas basics (importing libraries, working with DataFrames, filtering data). You'll also need a GitHub account and access to Google Colab.
Set Up Your Environment and Data
Before you can analyze economic recovery patterns, you need two things: a place to store your project code and a workspace to explore the data.
In this step, you'll create a GitHub repository, open a Google Colab notebook, and load real economic data from Harvard's Opportunity Insights Economic Tracker.
In this step, get ready to:
- Create a public GitHub repository for the project.
- Load the Opportunity Insights Economic Tracker data in Google Colab.
- Verify the data loaded correctly with the expected columns and date ranges.
Create your GitHub repository
Your GitHub repository stores every file for this project. You will push your dashboard code here later so that Streamlit Community Cloud can deploy it.
- Go to github.com and sign in to your account.
- Click the + icon in the top-right corner.
- Select New repository.
- Enter us-economic-recovery-dashboard as the repository name.
- Set the visibility to Public.
- Check the box for Add a README file.
- Click Create repository.
Why does this need to be public?
Streamlit Community Cloud can only deploy apps from public repositories on the free tier. Making it public also means recruiters can view your code directly from the dashboard link.
Set up your Colab notebook and load the data
Google Colab gives you a free Python environment in the browser. You will use it for exploratory analysis before building the dashboard.
- Go to colab.research.google.com and sign in with your Google account.
- Click New notebook.
- Click the notebook title at the top left (it says Untitled0.ipynb) and rename it to analysis.ipynb.
- Press Enter to confirm the new name.
Colab comes with pandas pre-installed, but you need two additional libraries for charting and building the dashboard.
- Install Streamlit and Plotly by running this command in the first cell:
!pip install streamlit plotly
What does this command do?
The ! prefix tells Colab to run the command as a terminal instruction rather than Python code. pip install downloads and installs both libraries into the notebook's runtime environment.
- Run the cell by clicking the play button or pressing Shift+Enter.
Seeing a dependency error?
Colab occasionally shows pip resolver warnings during install. As long as the final output does not say ERROR, the install succeeded. If you see a real error, try restarting the runtime via Runtime > Restart runtime and run the cell again.
Still stuck? Help me fix my pip install error in Google Colab.
Now you will load three CSV files directly from the Opportunity Insights Economic Tracker repository on GitHub. This dataset tracks U.S. consumer spending and employment as percentage changes relative to a January 2020 baseline.
- In a new cell, load the Economic Tracker data by running this code:
import pandas as pd
# Base URL for the Economic Tracker raw CSV files
base_url = "https://raw.githubusercontent.com/OpportunityInsights/EconomicTracker/main/data/"
# Load consumer spending data (daily, by state)
spending = pd.read_csv(base_url + "Affinity%20-%20State%20-%20Daily.csv")
# Load employment data (weekly, by state)
employment = pd.read_csv(base_url + "Employment%20-%20State%20-%20Weekly.csv")
# Load geographic crosswalk (state FIPS codes to state names)
geo = pd.read_csv(base_url + "GeoIDs%20-%20State.csv")
What does this code do?
Each pd.read_csv() call downloads a CSV file from the Economic Tracker's public GitHub repository and loads it into a DataFrame. The %20 in the URLs is URL encoding for spaces in the file names.
- spending contains daily consumer spending changes by state from anonymized credit card data.
- employment contains weekly employment changes by state from payroll processors.
- geo maps numeric state FIPS codes to human-readable state names.
- Run the cell. It may take a few seconds to download the files.
Seeing a connection or HTTP error?
If you see HTTPError or a timeout, the GitHub raw content servers may be temporarily slow. Wait 30 seconds and run the cell again. Make sure the URLs in your code match exactly, including the %20 encoding for spaces.
Need more help? Help me debug my data loading error in Colab.
Verify the data loaded correctly
Before moving on, confirm that all three DataFrames have the expected shapes and columns.
- In a new cell, check the shapes, columns, and date ranges by running this code:
# Check spending data
print("Spending shape:", spending.shape)
print("Spending columns:", spending.columns.tolist()[:10])
print()
# Check employment data
print("Employment shape:", employment.shape)
print("Employment columns:", employment.columns.tolist()[:10])
print()
# Check geographic crosswalk
print("Geo shape:", geo.shape)
print("Geo columns:", geo.columns.tolist())
print()
# Check date ranges
print("Spending years:", spending["year"].min(), "to", spending["year"].max())
print("Employment years:", employment["year"].min(), "to", employment["year"].max())
What should I see?
Your output should confirm:
- The spending DataFrame has columns including statefips, year, month, day, and spending metrics like spend_all.
- The employment DataFrame has columns including statefips, year, month, day_endofweek, and emp.
- The geo DataFrame has columns including statefips and statename.
- The year range covers at least 2020 through 2023.
- Run the cell and confirm your output matches the expected columns and date ranges.
Columns don't match?
If you see different column names, double-check that your pd.read_csv URLs point to the correct files. The spending file must be Affinity%20-%20State%20-%20Daily.csv and the employment file must be Employment%20-%20State%20-%20Weekly.csv.
Still not matching? Help me check why my DataFrames have unexpected columns.
✔️ Awesome, I've got everything!
Great. Make sure your notebook is saved (Colab auto-saves to Google Drive).
ⓧ I'd like to double check the full code
Here is all the code you should have across your three cells in analysis.ipynb:
# Cell 1: Install libraries
!pip install streamlit plotly
# Cell 2: Load data
import pandas as pd
# Base URL for the Economic Tracker raw CSV files
base_url = "https://raw.githubusercontent.com/OpportunityInsights/EconomicTracker/main/data/"
# Load consumer spending data (daily, by state)
spending = pd.read_csv(base_url + "Affinity%20-%20State%20-%20Daily.csv")
# Load employment data (weekly, by state)
employment = pd.read_csv(base_url + "Employment%20-%20State%20-%20Weekly.csv")
# Load geographic crosswalk (state FIPS codes to state names)
geo = pd.read_csv(base_url + "GeoIDs%20-%20State.csv")
# Cell 3: Verify data
print("Spending shape:", spending.shape)
print("Spending columns:", spending.columns.tolist()[:10])
print()
print("Employment shape:", employment.shape)
print("Employment columns:", employment.columns.tolist()[:10])
print()
print("Geo shape:", geo.shape)
print("Geo columns:", geo.columns.tolist())
print()
print("Spending years:", spending["year"].min(), "to", spending["year"].max())
print("Employment years:", employment["year"].min(), "to", employment["year"].max())
Your data is loaded and verified. Next up, you'll wrangle these raw DataFrames into clean, analysis-ready tables by creating proper date columns, filtering to the recovery period, and merging everything together.
Wrangle and Clean the Data
You now have three raw DataFrames loaded into your notebook. The next challenge is turning that raw data into something you can actually analyze.
Real-world datasets are messy. The Economic Tracker files use different date formats, have missing values encoded as dots or empty strings, and operate at different time frequencies (daily vs. weekly). In this step, you will clean both datasets, align their time frequencies, and merge them into a single analysis-ready DataFrame.
In this step, get ready to:
- Clean and filter the consumer spending data to 2020-2023.
- Clean and filter the employment data to the same period.
- Merge both datasets with state names and align time frequencies.
Clean and prepare the consumer spending data
The spending DataFrame has separate year, month, and day columns instead of a single date column. You need a proper datetime for filtering and time-series analysis.
- In your analysis.ipynb notebook, create a new code cell and add the following code to clean the spending data:
# Create a proper datetime column from year, month, and day
spending["date"] = pd.to_datetime(
spending[["year", "month", "day"]].rename(columns={"day": "day"})
)
# Filter to the 2020-2023 recovery period
spending = spending[(spending["date"] >= "2020-01-01") & (spending["date"] <= "2023-12-31")]
# Select key columns for analysis
spending = spending[["statefips", "date", "spend_all", "spend_all_q1", "spend_all_q2", "spend_all_q3", "spend_all_q4"]]
print(f"Spending data: {spending.shape[0]} rows, {spending.shape[1]} columns")
print(f"Date range: {spending['date'].min()} to {spending['date'].max()}")
spending.head()
What does this code do?
This cell transforms the raw spending data into a clean, analysis-ready format.
- pd.to_datetime() combines the separate year/month/day columns into a single datetime column called date.
- The boolean filter keeps only rows from January 2020 through December 2023, the recovery period you are analyzing.
- Selecting specific columns drops dozens of unused spending categories, keeping only spend_all (total spending) and spend_all_q1 through spend_all_q4 (spending by income quartile).
- Run the cell by pressing Shift+Enter.
- You should see a row count, a date range spanning 2020 to 2023, and a preview of the first five rows with statefips, date, and spending columns.
Seeing a KeyError?
If you get a KeyError on year, month, or day, make sure you ran the data-loading cell from Step 1 first. Colab cells must run in order.
If you see a column-name mismatch, run spending.columns.tolist() to check the exact column names in your DataFrame.
Help me debug my spending data cleaning code.
Clean and prepare the employment data
The employment data uses a different date format. Instead of separate day columns, it has a day_endofweek column representing the last day of each reporting week. This requires a different datetime construction approach.
- Create a new code cell below and add this code to clean the employment data:
# Create a datetime column from year, month, and day_endofweek
employment["date"] = pd.to_datetime(
employment["year"].astype(str) + "-" + employment["month"].astype(str) + "-" + employment["day_endofweek"].astype(str)
)
# Filter to the same 2020-2023 period
employment = employment[(employment["date"] >= "2020-01-01") & (employment["date"] <= "2023-12-31")]
# Select key columns for analysis
employment = employment[["statefips", "date", "emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]]
print(f"Employment data: {employment.shape[0]} rows, {employment.shape[1]} columns")
print(f"Date range: {employment['date'].min()} to {employment['date'].max()}")
employment.head()
Why is the datetime construction different here?
The spending data had clean integer columns (year, month, day) that pd.to_datetime() can parse directly from a DataFrame. The employment data uses day_endofweek instead of day, so you construct a date string by concatenating the parts with hyphens.
Both approaches produce the same result: a proper datetime column named date that pandas can use for filtering and grouping.
The emp column represents overall employment change, while emp_incq1 through emp_incq4 break it down by income quartile (Q1 = lowest income, Q4 = highest).
All values are percentage changes relative to a January 2020 baseline, not absolute numbers.
- Run the cell with Shift+Enter.
- You should see the employment row count, a date range covering 2020-2023, and a preview with statefips, date, and employment columns.
Getting a ValueError on the date parsing?
If you see a ValueError about date parsing, some rows may have invalid day values. Check for non-numeric entries by running employment["day_endofweek"].unique() and look for anything that is not a number.
Help me fix my employment date column.
Merge datasets and resample to weekly frequency
Right now, spending is daily data and employment is weekly. To combine them, you need to align their frequencies. You will resample spending to weekly averages, then merge both datasets together with the GeoIDs crosswalk for human-readable state names.
- Create a new code cell and add this code to resample and merge:
# Resample spending from daily to weekly to align with employment data
spending_weekly = spending.groupby([pd.Grouper(key="date", freq="W"), "statefips"]).agg(
spend_all=("spend_all", "mean"),
spend_all_q1=("spend_all_q1", "mean"),
spend_all_q2=("spend_all_q2", "mean"),
spend_all_q3=("spend_all_q3", "mean"),
spend_all_q4=("spend_all_q4", "mean"),
).reset_index()
# Merge spending and employment on date and state
merged = spending_weekly.merge(
employment,
on=["date", "statefips"],
how="inner"
)
# Add human-readable state names from the GeoIDs crosswalk
merged = merged.merge(
geo[["statefips", "statename"]],
on="statefips",
how="left"
)
# Convert columns to numeric, coercing '.' and empty strings to NaN
numeric_cols = ["spend_all", "spend_all_q1", "spend_all_q2", "spend_all_q3", "spend_all_q4",
"emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]
for col in numeric_cols:
merged[col] = pd.to_numeric(merged[col], errors="coerce")
print(f"Merged data: {merged.shape[0]} rows, {merged.shape[1]} columns")
print(f"Date range: {merged['date'].min()} to {merged['date'].max()}")
print(f"Null state names: {merged['statename'].isna().sum()}")
print(f"States included: {merged['statename'].nunique()}")
merged.head()
What does this code do?
This cell performs three key operations to produce your final analysis DataFrame.
- pd.Grouper(key="date", freq="W") resamples daily spending into weekly averages per state. This aligns the spending frequency with the employment data.
- The first merge() joins weekly spending with employment on both date and statefips. Using how="inner" keeps only weeks where both datasets have data.
- The second merge() adds the statename column from the GeoIDs crosswalk so you can display state names instead of numeric codes.
- pd.to_numeric(..., errors="coerce") handles the Economic Tracker's missing value encoding (dots and empty strings) by converting them to NaN values that pandas can work with.
- Run the cell with Shift+Enter.
- You should see a merged DataFrame with a row count, date range spanning 2020-2023, zero null state names, and a count of states included.
Seeing zero rows after the merge?
If your merged DataFrame has 0 rows, the date columns may not align between the two datasets. Weekly grouping rounds dates to Sunday boundaries, so the employment dates need to fall on those same boundaries.
Run spending_weekly["date"].head() and employment["date"].head() to compare the date values. If they do not overlap, check that both filtering steps use the same 2020-2023 range.
Help me debug my merge that produces zero rows.
✔️ Awesome, I've got everything!
Great. Double check you have run all three cells in order and your merged DataFrame shows no null state names.
ⓧ I'd like to double check the full code
Here is all the data wrangling code for this step. Each block should be in its own cell in your notebook:
# Cell 1: Clean spending data
spending["date"] = pd.to_datetime(
spending[["year", "month", "day"]].rename(columns={"day": "day"})
)
spending = spending[(spending["date"] >= "2020-01-01") & (spending["date"] <= "2023-12-31")]
spending = spending[["statefips", "date", "spend_all", "spend_all_q1", "spend_all_q2", "spend_all_q3", "spend_all_q4"]]
print(f"Spending data: {spending.shape[0]} rows, {spending.shape[1]} columns")
print(f"Date range: {spending['date'].min()} to {spending['date'].max()}")
spending.head()
# Cell 2: Clean employment data
employment["date"] = pd.to_datetime(
employment["year"].astype(str) + "-" + employment["month"].astype(str) + "-" + employment["day_endofweek"].astype(str)
)
employment = employment[(employment["date"] >= "2020-01-01") & (employment["date"] <= "2023-12-31")]
employment = employment[["statefips", "date", "emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]]
print(f"Employment data: {employment.shape[0]} rows, {employment.shape[1]} columns")
print(f"Date range: {employment['date'].min()} to {employment['date'].max()}")
employment.head()
# Cell 3: Resample, merge, and handle missing values
spending_weekly = spending.groupby([pd.Grouper(key="date", freq="W"), "statefips"]).agg(
spend_all=("spend_all", "mean"),
spend_all_q1=("spend_all_q1", "mean"),
spend_all_q2=("spend_all_q2", "mean"),
spend_all_q3=("spend_all_q3", "mean"),
spend_all_q4=("spend_all_q4", "mean"),
).reset_index()
merged = spending_weekly.merge(
employment,
on=["date", "statefips"],
how="inner"
)
merged = merged.merge(
geo[["statefips", "statename"]],
on="statefips",
how="left"
)
numeric_cols = ["spend_all", "spend_all_q1", "spend_all_q2", "spend_all_q3", "spend_all_q4",
"emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]
for col in numeric_cols:
merged[col] = pd.to_numeric(merged[col], errors="coerce")
print(f"Merged data: {merged.shape[0]} rows, {merged.shape[1]} columns")
print(f"Date range: {merged['date'].min()} to {merged['date'].max()}")
print(f"Null state names: {merged['statename'].isna().sum()}")
print(f"States included: {merged['statename'].nunique()}")
merged.head()
Your data is clean, merged, and ready for analysis. Next up, you will dig into the numbers and uncover how different states and income groups recovered at different speeds.
Analyze Recovery Patterns
Your cleaned dataset is ready. Now comes the core of any data analysis project: extracting meaningful insights from the numbers.
In this step, you will calculate recovery metrics for each state and build interactive visualizations that tell a story about economic inequality. These charts will form the foundation of your Streamlit dashboard in the next step.
In this step, get ready to:
- Calculate spending recovery, employment recovery timelines, and income inequality gaps per state.
- Create three interactive Plotly visualizations exploring the data.
- Document your findings with a clear business narrative.
Calculate key recovery metrics per state
Before building any charts, you need to define what "recovery" means in concrete, measurable terms. The Economic Tracker data measures everything as percentage changes relative to a January 2020 baseline.
You will calculate three metrics: how much spending bounced back, how quickly employment returned to normal, and how differently high-income and low-income groups experienced the recovery.
- In your analysis.ipynb notebook, create a new code cell and add the following to import Plotly and calculate state-level recovery metrics:
import plotly.express as px
import plotly.graph_objects as go
# Calculate average spending recovery per state (2021-2022 period)
recovery_period = merged[(merged["date"] >= "2021-01-01") & (merged["date"] <= "2022-12-31")]
state_metrics = recovery_period.groupby("statename").agg(
avg_spend_recovery=("spend_all", "mean"),
avg_emp_recovery=("emp", "mean"),
q4_recovery=("spend_all_q4", "mean"),
q1_recovery=("spend_all_q1", "mean"),
).reset_index()
# Calculate income inequality gap (Q4 highest-income minus Q1 lowest-income recovery)
state_metrics["income_inequality_gap"] = state_metrics["q4_recovery"] - state_metrics["q1_recovery"]
print(state_metrics[["statename", "avg_spend_recovery", "avg_emp_recovery", "income_inequality_gap"]].head(10))
print(f"\nTotal states analyzed: {len(state_metrics)}")
What does this code do?
This cell filters your data to the 2021-2022 recovery period and computes averages per state.
- avg_spend_recovery: the mean of spend_all during 2021-2022. A positive value means spending was above the January 2020 baseline.
- income_inequality_gap: the difference between Q4 (highest-income) and Q1 (lowest-income) spending recovery. A larger gap means wealthier households recovered much faster.
- Run the cell by pressing Shift+Enter.
- You should see a table with 10 states and their recovery metrics, plus a total count of all states analyzed.
- Create another new code cell below and add this code to calculate how long each state took to recover employment:
# Calculate employment recovery timeline (weeks to return to pre-pandemic baseline)
# Baseline is 0% change (the January 2020 reference point)
emp_by_state_week = merged.groupby(["statename", "date"])["emp"].mean().reset_index()
emp_by_state_week["recovered"] = emp_by_state_week["emp"] >= 0
def weeks_to_recover(group):
"""Find how many weeks it took for employment to first reach baseline."""
recovered_rows = group[group["recovered"]]
if recovered_rows.empty:
return None
first_recovery_date = recovered_rows["date"].min()
pandemic_start = pd.Timestamp("2020-03-15")
return (first_recovery_date - pandemic_start).days // 7
# Apply the function to each state
recovery_timeline = emp_by_state_week.groupby("statename").apply(weeks_to_recover).reset_index()
recovery_timeline.columns = ["statename", "weeks_to_recover"]
# Merge into state metrics
state_metrics = state_metrics.merge(recovery_timeline, on="statename", how="left")
print(state_metrics[["statename", "weeks_to_recover"]].sort_values("weeks_to_recover").head(10))
print(f"\nStates that never fully recovered: {state_metrics['weeks_to_recover'].isna().sum()}")
What does this code do?
This calculates the employment recovery timeline for each state. Since the data values represent percentage changes from the January 2020 baseline, a value of 0 means employment has returned to pre-pandemic levels.
The weeks_to_recover function finds the first week each state's employment reached or exceeded zero, then counts the weeks from the pandemic start (March 15, 2020). States that never reached baseline show as None.
- Run the cell. You should see the 10 fastest-recovering states ranked by weeks, plus a count of states that never fully recovered.
Seeing an error about apply?
If you get a FutureWarning about groupby.apply, this is safe to ignore. The code still works correctly. Help me understand the groupby apply warning.
Create interactive visualizations with Plotly
With your metrics calculated, it is time to build the three visualizations that will tell the story of unequal recovery. Each chart answers a different question about how the pandemic's economic impact played out.
- Create a new code cell and add this line chart showing national spending recovery split by income quartile:
# Line chart: National spending recovery over time, split by income quartile
national = merged.groupby("date").agg(
Q1=("spend_all_q1", "mean"),
Q2=("spend_all_q2", "mean"),
Q3=("spend_all_q3", "mean"),
Q4=("spend_all_q4", "mean"),
).reset_index()
fig_quartile = go.Figure()
for quartile in ["Q1", "Q2", "Q3", "Q4"]:
fig_quartile.add_trace(go.Scatter(
x=national["date"],
y=national[quartile],
mode="lines",
name=f"Income {quartile}"
))
fig_quartile.update_layout(
title="National Spending Recovery by Income Quartile",
yaxis_title="Spending Change (relative to Jan 2020)",
xaxis_title="Date",
hovermode="x unified"
)
fig_quartile.show()
What does this chart show?
This line chart shows how spending recovered at the national level, broken out by income group. Q1 is the lowest-income quartile and Q4 is the highest.
You should see the lines diverge significantly. Higher-income groups typically recovered spending faster and even exceeded their pre-pandemic baseline, while lower-income groups lagged behind.
- Run the cell. You should see an interactive line chart with four colored lines representing each income quartile over time.
- Hover over the chart to see exact values at any date. Notice how the Q4 line recovers faster than Q1.
- Create a new code cell and add this bar chart ranking states by spending recovery:
# Bar chart: Top 20 states ranked by overall spending recovery speed
state_recovery_sorted = state_metrics.sort_values("avg_spend_recovery", ascending=True).tail(20)
fig_states = px.bar(
state_recovery_sorted,
x="avg_spend_recovery",
y="statename",
orientation="h",
title="Top 20 States by Average Spending Recovery",
labels={"avg_spend_recovery": "Avg Spending Change", "statename": "State"}
)
fig_states.show()
What does this chart show?
This horizontal bar chart ranks the top 20 states by their average spending change during the recovery period. States with longer bars had consumer spending that bounced back the most relative to their January 2020 baseline.
- Run the cell. You should see a horizontal bar chart with 20 states listed vertically and bars extending to the right.
- Create one more code cell and add this scatter plot showing the relationship between spending and employment recovery:
# Scatter plot: Spending recovery vs employment recovery per state
fig_scatter = px.scatter(
state_metrics,
x="avg_spend_recovery",
y="avg_emp_recovery",
text="statename",
title="Spending Recovery vs Employment Recovery",
labels={"avg_spend_recovery": "Avg Spending Recovery", "avg_emp_recovery": "Avg Employment Recovery"}
)
fig_scatter.update_traces(textposition="top center", textfont_size=8)
fig_scatter.show()
What does this chart show?
This scatter plot reveals whether states that recovered spending also recovered jobs. Each dot is a state. States in the upper-right recovered strongly on both fronts. States in the lower-left struggled across the board.
Look for outliers where spending recovered but employment did not, or vice versa. These states often have structural factors (tourism dependence, energy sector concentration) that explain the gap.
- Run the cell. You should see a scatter plot with state names labeling each point.
Charts not rendering?
Make sure you ran the import cell at the top of this step first. Plotly renders automatically in Google Colab without additional configuration. If the chart area appears blank, try clicking Runtime > Restart and run all to re-execute every cell in order. Help me debug why my Plotly charts are not showing in Google Colab.
Document your findings with a business narrative
Visualizations alone do not make a portfolio piece stand out. Hiring managers want to see that you can interpret data and communicate insights clearly. You will now add markdown cells that tell the story behind your charts.
- Create a new markdown cell above your first chart (the income quartile line chart) and add a section heading and narrative context:
## Key Finding 1: Income Quartile Recovery Gap
The spending recovery after the 2020 downturn was deeply unequal across income groups.
The highest-income quartile (Q4) recovered to and exceeded baseline spending levels
faster than the lowest-income quartile (Q1), which experienced a prolonged period of
reduced spending.
This gap persisted throughout 2021-2022, suggesting structural barriers to recovery
for lower-income households rather than a temporary shock.
- Create another markdown cell between the bar chart and scatter plot:
## Key Finding 2: State-Level Recovery Varies Widely
Not all states recovered at the same pace. States with diversified economies and
higher remote-work adoption tended to recover consumer spending faster. States
heavily dependent on tourism, hospitality, or energy sectors showed slower recovery
trajectories.
The top-recovering states consistently outperformed the national average by a
significant margin, while lagging states remained below baseline for extended periods.
- Create a final markdown cell after the scatter plot summarizing the spending-vs-employment relationship:
## Key Finding 3: Spending and Employment Don't Always Move Together
The scatter plot reveals that spending recovery and employment recovery are
correlated but not identical. Some states saw consumer spending rebound while
employment remained depressed, likely due to government transfer payments and
changes in spending patterns (e.g., goods vs. services).
Other states recovered employment but saw muted spending growth, possibly
reflecting jobs returning at lower wages or in different sectors than pre-pandemic.
**Recommendation:** Policymakers and businesses should track both metrics
together rather than relying on a single indicator to gauge economic health.
Why write findings in the notebook?
A notebook with only code and charts looks unfinished. Adding markdown cells with specific numbers and interpretations shows a hiring manager that you can think critically about data, not just manipulate it.
As you run your cells and see the actual numbers, update the narrative with specifics from your output (e.g., exact quartile recovery percentages or the number of weeks to recovery for specific states).
- Review your three markdown cells and update them with specific numbers from your chart outputs. For example, note which quartile crossed baseline first and approximately when.
- Save your notebook by pressing Ctrl+S (or Cmd+S on macOS).
Make your numbers specific
Replace any placeholder language in your markdown with actual values from your output. A finding like "Q4 exceeded baseline by X% while Q1 remained Y% below" is far more compelling to a hiring manager than vague statements about gaps.
✔️ Awesome, I've got everything!
Great. Make sure your notebook is saved with all three Plotly visualizations rendering and at least three markdown cells documenting your findings with specific numbers.
ⓧ I'd like to double check the full code
Here are all the analysis cells you should have added in this step:
import plotly.express as px
import plotly.graph_objects as go
# Calculate average spending recovery per state (2021-2022 period)
recovery_period = merged[(merged["date"] >= "2021-01-01") & (merged["date"] <= "2022-12-31")]
state_metrics = recovery_period.groupby("statename").agg(
avg_spend_recovery=("spend_all", "mean"),
avg_emp_recovery=("emp", "mean"),
q4_recovery=("spend_all_q4", "mean"),
q1_recovery=("spend_all_q1", "mean"),
).reset_index()
# Calculate income inequality gap (Q4 highest-income minus Q1 lowest-income recovery)
state_metrics["income_inequality_gap"] = state_metrics["q4_recovery"] - state_metrics["q1_recovery"]
print(state_metrics[["statename", "avg_spend_recovery", "avg_emp_recovery", "income_inequality_gap"]].head(10))
print(f"\nTotal states analyzed: {len(state_metrics)}")
# Calculate employment recovery timeline (weeks to return to pre-pandemic baseline)
# Baseline is 0% change (the January 2020 reference point)
emp_by_state_week = merged.groupby(["statename", "date"])["emp"].mean().reset_index()
emp_by_state_week["recovered"] = emp_by_state_week["emp"] >= 0
def weeks_to_recover(group):
"""Find how many weeks it took for employment to first reach baseline."""
recovered_rows = group[group["recovered"]]
if recovered_rows.empty:
return None
first_recovery_date = recovered_rows["date"].min()
pandemic_start = pd.Timestamp("2020-03-15")
return (first_recovery_date - pandemic_start).days // 7
# Apply the function to each state
recovery_timeline = emp_by_state_week.groupby("statename").apply(weeks_to_recover).reset_index()
recovery_timeline.columns = ["statename", "weeks_to_recover"]
# Merge into state metrics
state_metrics = state_metrics.merge(recovery_timeline, on="statename", how="left")
print(state_metrics[["statename", "weeks_to_recover"]].sort_values("weeks_to_recover").head(10))
print(f"\nStates that never fully recovered: {state_metrics['weeks_to_recover'].isna().sum()}")
# Line chart: National spending recovery over time, split by income quartile
national = merged.groupby("date").agg(
Q1=("spend_all_q1", "mean"),
Q2=("spend_all_q2", "mean"),
Q3=("spend_all_q3", "mean"),
Q4=("spend_all_q4", "mean"),
).reset_index()
fig_quartile = go.Figure()
for quartile in ["Q1", "Q2", "Q3", "Q4"]:
fig_quartile.add_trace(go.Scatter(
x=national["date"],
y=national[quartile],
mode="lines",
name=f"Income {quartile}"
))
fig_quartile.update_layout(
title="National Spending Recovery by Income Quartile",
yaxis_title="Spending Change (relative to Jan 2020)",
xaxis_title="Date",
hovermode="x unified"
)
fig_quartile.show()
# Bar chart: Top 20 states ranked by overall spending recovery speed
state_recovery_sorted = state_metrics.sort_values("avg_spend_recovery", ascending=True).tail(20)
fig_states = px.bar(
state_recovery_sorted,
x="avg_spend_recovery",
y="statename",
orientation="h",
title="Top 20 States by Average Spending Recovery",
labels={"avg_spend_recovery": "Avg Spending Change", "statename": "State"}
)
fig_states.show()
# Scatter plot: Spending recovery vs employment recovery per state
fig_scatter = px.scatter(
state_metrics,
x="avg_spend_recovery",
y="avg_emp_recovery",
text="statename",
title="Spending Recovery vs Employment Recovery",
labels={"avg_spend_recovery": "Avg Spending Recovery", "avg_emp_recovery": "Avg Employment Recovery"}
)
fig_scatter.update_traces(textposition="top center", textfont_size=8)
fig_scatter.show()
Your analysis is complete. You now have concrete metrics and compelling visualizations ready to power an interactive dashboard. Next up, you will turn these charts into a live Streamlit app that anyone can explore.
Build the Streamlit Dashboard
Your analysis notebook now tells a clear story about how spending and employment recovered unevenly across states and income groups. The next step is turning those insights into something a hiring manager can click and explore.
In this step, you will build an interactive Streamlit dashboard that lets users filter by state and date range, see live KPI metrics, and explore the same Plotly visualizations from your notebook. Streamlit turns a plain Python script into a web app with zero frontend code.
In this step, get ready to:
- Create app.py with a data loading function and app structure.
- Build interactive sidebar filters, KPI metrics, and three Plotly charts.
- Add a requirements.txt with pinned dependency versions.
Create the app file and data loading function
Your Streamlit app lives in a single Python file that loads data, builds widgets, and renders charts. You will create this file in your us-economic-recovery-dashboard repository.
- Navigate to your us-economic-recovery-dashboard folder in the terminal by running:
cd ~/Desktop/us-economic-recovery-dashboard
- Install the project dependencies locally by running:
pip install streamlit pandas plotly
Why install locally if you already installed in Colab?
Google Colab runs on a remote server with its own Python environment. To preview your Streamlit dashboard on your own machine, you need the same packages installed in your local Python environment.
- Create a new file called app.py in the root of your us-economic-recovery-dashboard folder.
- Add the app initialization code to the top of app.py by copying the following:
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
# Configure the page layout and title
st.set_page_config(page_title="U.S. Economic Recovery Dashboard", layout="wide")
# Display the main heading and description
st.title("U.S. Economic Recovery Dashboard")
st.markdown("Analyzing how consumer spending and employment recovered unevenly across states and income groups (2020-2023)")
What does this code do?
st.set_page_config sets the browser tab title and switches to a wide layout so charts have room to breathe.
st.title and st.markdown render the dashboard heading and a subtitle explaining what the viewer is looking at.
- Save app.py and test your app by running:
streamlit run app.py
A browser tab opens at http://localhost:8501 showing your title and subtitle. Keep this terminal running throughout the step.
How does Streamlit work?
Streamlit runs your entire Python script top-to-bottom every time you save or a user interacts with a widget. You can click Always rerun in the top-right corner to auto-refresh the app whenever you save the file.
App not opening?
If you see a "command not found" error, make sure you ran pip install streamlit successfully in the previous step.
If the browser tab doesn't open automatically, navigate to http://localhost:8501 manually.
help me debug my Streamlit app not launching
Now add the data loading function that fetches the Economic Tracker CSVs, cleans them, and merges them into a single DataFrame. This is the same pipeline you built in Colab, packaged into a cached function.
- Add the following load_data() function below the st.markdown line in app.py:
@st.cache_data
def load_data():
# Base URL for raw CSV files from the Economic Tracker repo
base_url = "https://raw.githubusercontent.com/OpportunityInsights/EconomicTracker/main/data/"
# Load spending, employment, and geographic crosswalk files
spending = pd.read_csv(base_url + "Affinity%20-%20State%20-%20Daily.csv")
employment = pd.read_csv(base_url + "Employment%20-%20State%20-%20Weekly.csv")
geo = pd.read_csv(base_url + "GeoIDs%20-%20State.csv")
# Create a proper datetime column for spending data
spending["date"] = pd.to_datetime(
spending[["year", "month", "day"]].rename(columns={"day": "day"})
)
spending = spending[(spending["date"] >= "2020-01-01") & (spending["date"] <= "2023-12-31")]
# Create a proper datetime column for employment data
employment["date"] = pd.to_datetime(
employment["year"].astype(str) + "-" + employment["month"].astype(str) + "-" + employment["day_endofweek"].astype(str)
)
employment = employment[(employment["date"] >= "2020-01-01") & (employment["date"] <= "2023-12-31")]
# Resample daily spending to weekly to align with employment frequency
spending_weekly = spending.groupby([pd.Grouper(key="date", freq="W"), "statefips"]).agg(
spend_all=("spend_all", "mean"),
spend_all_q1=("spend_all_q1", "mean"),
spend_all_q2=("spend_all_q2", "mean"),
spend_all_q3=("spend_all_q3", "mean"),
spend_all_q4=("spend_all_q4", "mean"),
).reset_index()
# Merge spending with employment on date and state
merged = spending_weekly.merge(
employment[["date", "statefips", "emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]],
on=["date", "statefips"],
how="inner"
)
# Add human-readable state names from the crosswalk file
merged = merged.merge(
geo[["statefips", "statename"]],
on="statefips",
how="left"
)
# Coerce all metric columns to numeric (handles '.' or empty strings)
numeric_cols = ["spend_all", "spend_all_q1", "spend_all_q2", "spend_all_q3", "spend_all_q4",
"emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]
for col in numeric_cols:
merged[col] = pd.to_numeric(merged[col], errors="coerce")
return merged
data = load_data()
What does this code do?
The @st.cache_data decorator tells Streamlit to run load_data() only once and cache the result. Without caching, the app would re-download three CSVs on every interaction.
Inside the function, spending data is resampled from daily to weekly using pd.Grouper(key="date", freq="W") so it aligns with the weekly employment data. The merge on statefips and date combines both datasets, and the GeoIDs crosswalk adds readable state names.
- Save app.py and check your browser.
The app still shows just the title and subtitle. The data loads in the background on first load (you may see a brief spinner in the top-right corner).
Seeing a red error message?
If you see a ModuleNotFoundError, double-check that you installed all three packages with pip install streamlit pandas plotly.
If you see a network-related error, confirm you have internet access. The function fetches CSVs from GitHub on first run.
help me fix my Streamlit data loading error
Build the interactive filters and charts
With data loaded, you can now add the interactive pieces. Streamlit sidebar widgets let users filter by state and date range, and the main area displays KPI metrics and charts that respond to those filters.
- Add the sidebar filters below the data = load_data() line in app.py:
# Sidebar filter controls
st.sidebar.header("Filters")
# State selection multiselect with defaults
states = sorted(data["statename"].dropna().unique())
selected_states = st.sidebar.multiselect(
"Select States",
options=states,
default=["California", "Texas", "New York", "Florida"]
)
# Date range slider spanning the full dataset
date_min = data["date"].min().to_pydatetime()
date_max = data["date"].max().to_pydatetime()
date_range = st.sidebar.slider(
"Date Range",
min_value=date_min,
max_value=date_max,
value=(date_min, date_max)
)
# Apply filters to the data
filtered = data[
(data["statename"].isin(selected_states)) &
(data["date"] >= pd.Timestamp(date_range[0])) &
(data["date"] <= pd.Timestamp(date_range[1]))
]
What does this code do?
st.sidebar.multiselect creates a dropdown where users can pick multiple states. The default parameter pre-selects four large states so the dashboard is never empty on load.
st.sidebar.slider creates a draggable date range. The filtered DataFrame updates instantly whenever a user changes either widget, because Streamlit reruns the script from top to bottom.
- Save app.py and check your browser.
You should see a sidebar on the left with a state multiselect (four states pre-selected) and a date range slider.
Sidebar not appearing?
Make sure you typed st.sidebar.header and not just st.header. The .sidebar prefix moves the widget to the left panel.
help me fix my Streamlit sidebar
- Add the KPI metrics row below the filtered DataFrame in app.py:
# Top-level KPI metrics that respond to the filters
col1, col2, col3 = st.columns(3)
with col1:
avg_spend = filtered["spend_all"].mean()
st.metric("Avg Spending Change", f"{avg_spend:.1%}" if pd.notna(avg_spend) else "N/A")
with col2:
avg_emp = filtered["emp"].mean()
st.metric("Avg Employment Change", f"{avg_emp:.1%}" if pd.notna(avg_emp) else "N/A")
with col3:
q4_avg = filtered["spend_all_q4"].mean()
q1_avg = filtered["spend_all_q1"].mean()
gap = (q4_avg - q1_avg) if (pd.notna(q4_avg) and pd.notna(q1_avg)) else None
st.metric("Income Gap (Q4 - Q1)", f"{gap:.1%}" if gap is not None else "N/A")
What does this code do?
st.columns(3) creates three side-by-side columns. Each column uses st.metric to display a single headline number.
The Income Gap metric shows the difference between the highest-income quartile (Q4) spending recovery and the lowest (Q1). A positive gap means wealthier households recovered faster.
- Save app.py and check your browser.
Three metric cards now appear below the title showing spending change, employment change, and income gap values. Try changing the state selection in the sidebar to see the numbers update.
- Add the income quartile line chart below the KPI row in app.py:
# Line chart: national spending recovery split by income quartile
st.subheader("Spending Recovery by Income Quartile")
national = data.groupby("date").agg(
Q1=("spend_all_q1", "mean"),
Q2=("spend_all_q2", "mean"),
Q3=("spend_all_q3", "mean"),
Q4=("spend_all_q4", "mean"),
).reset_index()
fig_quartile = go.Figure()
for quartile in ["Q1", "Q2", "Q3", "Q4"]:
fig_quartile.add_trace(go.Scatter(
x=national["date"],
y=national[quartile],
mode="lines",
name=f"Income {quartile}"
))
fig_quartile.update_layout(
yaxis_title="Spending Change (relative to Jan 2020)",
xaxis_title="Date",
hovermode="x unified"
)
st.plotly_chart(fig_quartile, use_container_width=True)
What does this code do?
This chart aggregates spending recovery nationally (across all states) and plots four lines. Each line represents an income quartile, from Q1 (lowest earners) to Q4 (highest earners).
The hovermode="x unified" setting shows all four values in a single tooltip when hovering over any date. This makes the income gap immediately visible at any point in time.
- Save app.py and check your browser.
You should see a line chart with four coloured lines showing how different income groups recovered at different speeds. Hover over the chart to see the unified tooltip.
- Add the state-by-state bar chart below the line chart in app.py:
# Horizontal bar chart: top 20 states by average spending recovery
st.subheader("State-by-State Recovery Comparison")
state_recovery = data.groupby("statename").agg(
avg_spend_recovery=("spend_all", "mean"),
avg_emp_recovery=("emp", "mean"),
).reset_index().dropna()
state_recovery_sorted = state_recovery.sort_values("avg_spend_recovery", ascending=True).tail(20)
fig_states = px.bar(
state_recovery_sorted,
x="avg_spend_recovery",
y="statename",
orientation="h",
title="Top 20 States by Average Spending Recovery",
labels={"avg_spend_recovery": "Avg Spending Change", "statename": "State"}
)
st.plotly_chart(fig_states, use_container_width=True)
What does this code do?
This section computes the average spending and employment recovery per state across the full dataset. It sorts states by spending recovery and shows only the top 20 as a horizontal bar chart.
The state_recovery DataFrame is reused by the scatter plot below, which is why it is computed here rather than inside a function.
- Save app.py and check your browser.
A horizontal bar chart now ranks the top 20 states by their average spending recovery.
- Add the scatter plot and data source footer to finish app.py:
# Scatter plot: spending recovery vs employment recovery per state
st.subheader("Spending vs Employment Recovery by State")
fig_scatter = px.scatter(
state_recovery,
x="avg_spend_recovery",
y="avg_emp_recovery",
text="statename",
title="Spending Recovery vs Employment Recovery",
labels={"avg_spend_recovery": "Avg Spending Recovery", "avg_emp_recovery": "Avg Employment Recovery"}
)
fig_scatter.update_traces(textposition="top center", textfont_size=8)
st.plotly_chart(fig_scatter, use_container_width=True)
# Footer with data source attribution
st.markdown("---")
st.markdown("**Data Source:** [Opportunity Insights Economic Tracker](https://tracktherecovery.org/) | Harvard University")
st.markdown("**Methodology:** Spending data from anonymized credit card transactions. Employment data from payroll processors. All values represent percentage changes relative to January 2020 baseline.")
What does this code do?
The scatter plot shows each state as a point, with spending recovery on the x-axis and employment recovery on the y-axis. States in the upper-right recovered strongly on both measures.
The footer credits the Opportunity Insights Economic Tracker and explains the methodology. Data attribution is essential for credibility in a portfolio project.
- Save app.py and check your browser.
You should now see all three charts (line, bar, scatter) plus the data source footer at the bottom. Try adjusting the state selection and date range in the sidebar to confirm the KPI metrics update dynamically.
Charts not rendering?
Check for indentation errors in your Python file. Streamlit will show a red traceback at the top of the page if there is a syntax issue.
If charts appear but show no data, confirm that data = load_data() runs without error. Look for warnings in your terminal output.
help me fix my Plotly charts in Streamlit
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that all three charts display correctly with interactive filters.
ⓧ I'd like to double check the full code
Here is the complete app.py file:
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
st.set_page_config(page_title="U.S. Economic Recovery Dashboard", layout="wide")
st.title("U.S. Economic Recovery Dashboard")
st.markdown("Analyzing how consumer spending and employment recovered unevenly across states and income groups (2020-2023)")
@st.cache_data
def load_data():
base_url = "https://raw.githubusercontent.com/OpportunityInsights/EconomicTracker/main/data/"
spending = pd.read_csv(base_url + "Affinity%20-%20State%20-%20Daily.csv")
employment = pd.read_csv(base_url + "Employment%20-%20State%20-%20Weekly.csv")
geo = pd.read_csv(base_url + "GeoIDs%20-%20State.csv")
spending["date"] = pd.to_datetime(
spending[["year", "month", "day"]].rename(columns={"day": "day"})
)
spending = spending[(spending["date"] >= "2020-01-01") & (spending["date"] <= "2023-12-31")]
employment["date"] = pd.to_datetime(
employment["year"].astype(str) + "-" + employment["month"].astype(str) + "-" + employment["day_endofweek"].astype(str)
)
employment = employment[(employment["date"] >= "2020-01-01") & (employment["date"] <= "2023-12-31")]
spending_weekly = spending.groupby([pd.Grouper(key="date", freq="W"), "statefips"]).agg(
spend_all=("spend_all", "mean"),
spend_all_q1=("spend_all_q1", "mean"),
spend_all_q2=("spend_all_q2", "mean"),
spend_all_q3=("spend_all_q3", "mean"),
spend_all_q4=("spend_all_q4", "mean"),
).reset_index()
merged = spending_weekly.merge(
employment[["date", "statefips", "emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]],
on=["date", "statefips"],
how="inner"
)
merged = merged.merge(
geo[["statefips", "statename"]],
on="statefips",
how="left"
)
numeric_cols = ["spend_all", "spend_all_q1", "spend_all_q2", "spend_all_q3", "spend_all_q4",
"emp", "emp_incq1", "emp_incq2", "emp_incq3", "emp_incq4"]
for col in numeric_cols:
merged[col] = pd.to_numeric(merged[col], errors="coerce")
return merged
data = load_data()
st.sidebar.header("Filters")
states = sorted(data["statename"].dropna().unique())
selected_states = st.sidebar.multiselect(
"Select States",
options=states,
default=["California", "Texas", "New York", "Florida"]
)
date_min = data["date"].min().to_pydatetime()
date_max = data["date"].max().to_pydatetime()
date_range = st.sidebar.slider(
"Date Range",
min_value=date_min,
max_value=date_max,
value=(date_min, date_max)
)
filtered = data[
(data["statename"].isin(selected_states)) &
(data["date"] >= pd.Timestamp(date_range[0])) &
(data["date"] <= pd.Timestamp(date_range[1]))
]
col1, col2, col3 = st.columns(3)
with col1:
avg_spend = filtered["spend_all"].mean()
st.metric("Avg Spending Change", f"{avg_spend:.1%}" if pd.notna(avg_spend) else "N/A")
with col2:
avg_emp = filtered["emp"].mean()
st.metric("Avg Employment Change", f"{avg_emp:.1%}" if pd.notna(avg_emp) else "N/A")
with col3:
q4_avg = filtered["spend_all_q4"].mean()
q1_avg = filtered["spend_all_q1"].mean()
gap = (q4_avg - q1_avg) if (pd.notna(q4_avg) and pd.notna(q1_avg)) else None
st.metric("Income Gap (Q4 - Q1)", f"{gap:.1%}" if gap is not None else "N/A")
st.subheader("Spending Recovery by Income Quartile")
national = data.groupby("date").agg(
Q1=("spend_all_q1", "mean"),
Q2=("spend_all_q2", "mean"),
Q3=("spend_all_q3", "mean"),
Q4=("spend_all_q4", "mean"),
).reset_index()
fig_quartile = go.Figure()
for quartile in ["Q1", "Q2", "Q3", "Q4"]:
fig_quartile.add_trace(go.Scatter(
x=national["date"],
y=national[quartile],
mode="lines",
name=f"Income {quartile}"
))
fig_quartile.update_layout(
yaxis_title="Spending Change (relative to Jan 2020)",
xaxis_title="Date",
hovermode="x unified"
)
st.plotly_chart(fig_quartile, use_container_width=True)
st.subheader("State-by-State Recovery Comparison")
state_recovery = data.groupby("statename").agg(
avg_spend_recovery=("spend_all", "mean"),
avg_emp_recovery=("emp", "mean"),
).reset_index().dropna()
state_recovery_sorted = state_recovery.sort_values("avg_spend_recovery", ascending=True).tail(20)
fig_states = px.bar(
state_recovery_sorted,
x="avg_spend_recovery",
y="statename",
orientation="h",
title="Top 20 States by Average Spending Recovery",
labels={"avg_spend_recovery": "Avg Spending Change", "statename": "State"}
)
st.plotly_chart(fig_states, use_container_width=True)
st.subheader("Spending vs Employment Recovery by State")
fig_scatter = px.scatter(
state_recovery,
x="avg_spend_recovery",
y="avg_emp_recovery",
text="statename",
title="Spending Recovery vs Employment Recovery",
labels={"avg_spend_recovery": "Avg Spending Recovery", "avg_emp_recovery": "Avg Employment Recovery"}
)
fig_scatter.update_traces(textposition="top center", textfont_size=8)
st.plotly_chart(fig_scatter, use_container_width=True)
st.markdown("---")
st.markdown("**Data Source:** [Opportunity Insights Economic Tracker](https://tracktherecovery.org/) | Harvard University")
st.markdown("**Methodology:** Spending data from anonymized credit card transactions. Employment data from payroll processors. All values represent percentage changes relative to January 2020 baseline.")
Add the requirements file
Streamlit Community Cloud reads a requirements.txt file to know which packages to install when deploying your app. Pinning exact versions ensures your dashboard works the same way in production as it does locally.
- Create a file called requirements.txt in the root of your us-economic-recovery-dashboard folder with the following contents:
streamlit==1.58.0
pandas==2.2.3
plotly==6.8.0
Why pin versions?
Without pinned versions, the deployment platform installs the latest available packages. A breaking change in any dependency could take your live dashboard offline. Pinning to specific versions like streamlit==1.58.0 guarantees consistent behaviour.
- Save requirements.txt.
- Confirm both files exist by running:
ls
You should see app.py, requirements.txt, and README.md in the output.
- Commit and push both files to GitHub by running:
git add app.py requirements.txt
git commit -m "Add Streamlit dashboard app and requirements"
git push origin main
Push rejected?
If git push is rejected, run git pull origin main first to sync any changes (like the README) created on GitHub. Then push again.
help me push my code to GitHub
Your interactive dashboard is live locally and committed to GitHub. Next up, you will deploy it to Streamlit Community Cloud so anyone can access it from a public URL.
Deploy to Streamlit Community Cloud
Your dashboard works locally, but a hiring manager will never run your code on their own machine. A live URL they can click is what separates a portfolio notebook from a shipped data product.
In this step, you will push a professional README to your repository and deploy the dashboard to Streamlit Community Cloud. The result is a public .streamlit.app URL that anyone can visit.
In this step, get ready to:
- Push a professional README.md documenting your business question, methodology, and findings.
- Deploy the app on Streamlit Community Cloud.
- Verify the live dashboard works at a public URL.
Add a professional README to your repository
A strong README is what turns a repository from "some code" into a portfolio piece. It tells a reader the business question you investigated, how you approached it, and what you found.
- In your us-economic-recovery-dashboard repository on GitHub, create a new file called README.md.
- You can do this by clicking Add file then Create new file from the repository's main page.
- Name the file README.md and paste the following template, replacing the placeholder sections with your own findings from the analysis notebook:
# U.S. Economic Recovery Dashboard
An interactive dashboard analyzing how consumer spending and employment recovered unevenly across U.S. states and income groups (2020-2023), using the Opportunity Insights Economic Tracker dataset from Harvard University.
## Live Dashboard
[View the live dashboard](https://your-app-name.streamlit.app)
## Business Question
How did economic recovery from the 2020 downturn vary across U.S. states and income groups? Which populations recovered fastest, and where do structural gaps remain?
## Methodology
- **Data Source:** Opportunity Insights Economic Tracker (Harvard University)
- **Spending Data:** Anonymized credit card transactions representing percentage changes relative to January 2020 baseline
- **Employment Data:** Payroll processor records representing percentage changes relative to January 2020 baseline
- **Analysis Period:** 2020-2023
- **Tools:** Python, pandas, Plotly, Streamlit
## Key Findings
1. Higher-income quartiles (Q3, Q4) recovered consumer spending faster than lower-income quartiles (Q1, Q2).
2. Employment recovery lagged spending recovery across most states.
3. States with diverse economies tended to recover more evenly across income groups.
## Repository Structure
- `app.py` - Streamlit dashboard application
- `analysis.ipynb` - Full exploratory data analysis notebook (Google Colab)
- `requirements.txt` - Python dependencies with pinned versions
## Data Attribution
Data from the [Opportunity Insights Economic Tracker](https://tracktherecovery.org/).
Citation: Chetty, Friedman, Hendren, Stepner, and the Opportunity Insights Team (2020).
What makes this README portfolio-ready?
Hiring managers scan repositories in under 30 seconds. A clear business question, methodology section, and key findings summary lets them immediately see your analytical thinking without running any code.
The live dashboard link at the top gives them one click to see your work in action.
- Replace the placeholder findings (items 1-3 under Key Findings) with specific numbers from your own analysis notebook.
- Scroll to the bottom of the page and click Commit changes.
- Enter a meaningful commit message like Add professional README with methodology and findings.
- Click Commit changes to confirm.
- Also upload your analysis.ipynb notebook to the repository using Add file then Upload files.
- Download analysis.ipynb from Google Colab first by clicking File then Download then Download .ipynb.
- Commit this upload with a message like Add analysis notebook.
Your repository should now contain four files: app.py, requirements.txt, README.md, and analysis.ipynb.
Don't see all four files?
Make sure you committed each file to the main branch. If you accidentally created a new branch, switch back to main from the branch dropdown and repeat the upload.
help me figure out why my files aren't showing in my GitHub repository
Deploy on Streamlit Community Cloud
Streamlit Community Cloud connects directly to your GitHub repository. Any time you push changes, the deployed app updates automatically.
- Go to share.streamlit.io in your browser.
- Sign in with your GitHub account (the same one that owns your repository).
- Click Create app in the upper-right corner of your workspace.
- When asked if you already have an app, click Yup, I have an app.
- Fill in the fields:
- Click Deploy.
What happens after you click Deploy?
Streamlit Community Cloud clones your repository, installs the packages listed in requirements.txt, and runs app.py. You can watch the build logs on the right side of the screen.
Most apps deploy within a few minutes. Your app loads real data from the Opportunity Insights GitHub repository on each run, so the first load may take slightly longer as it fetches the CSV files.
Wait for the build to complete. You will see your dashboard appear at a public .streamlit.app URL once deployment finishes.
Seeing a build error?
Check the logs on the right side of the deployment screen. The most common issue is a typo in requirements.txt or a missing dependency.
Make sure your requirements.txt contains exactly these three lines:
streamlit==1.58.0
pandas==2.2.3
plotly==6.8.0
help me debug my Streamlit Community Cloud deployment error
Verify the live dashboard and update your README
A deployed app is only useful if it actually works for visitors. You need to test that all the interactive elements render correctly at the public URL.
- Open your live dashboard URL (it will look like https://your-app-name.streamlit.app).
- Test the sidebar state filter by selecting and deselecting states.
- Drag the date range slider and confirm the charts update.
- Hover over each chart to verify interactive tooltips appear.
- Scroll down and confirm all three chart sections are visible: the income quartile line chart, the state bar chart, and the scatter plot.
You should see a fully interactive dashboard with KPI metrics at the top, a spending recovery line chart by income quartile, a horizontal bar chart of top states, and a scatter plot of spending vs. employment recovery.
- Copy your live dashboard URL from the browser address bar.
- Return to your README.md on GitHub and click the pencil icon to edit it.
- Replace https://your-app-name.streamlit.app in the Live Dashboard link with your actual URL.
- Commit the change with a message like Add live dashboard URL to README.
What if the app goes to sleep?
Streamlit Community Cloud apps sleep after 7 days of inactivity. They automatically wake up when someone visits the URL again. There is no cost for keeping your app deployed on the free tier.
Charts not rendering or filters not responding?
Try clicking Manage app in the lower-right corner of your deployed app, then select Reboot app from the menu. This forces a fresh restart and clears cached data.
If the issue persists, check that app.py in your GitHub repository matches the version that was working locally.
help me troubleshoot my Streamlit dashboard not rendering charts correctly after deployment
Secret mission
Add an Economic Resilience Scoring System
Your dashboard shows recovery patterns, but which states actually bounced back the strongest overall? Build a composite Economic Resilience Score that combines spending, employment, and income equality into a single ranked leaderboard. Add adjustable weight sliders so users can explore how different priorities change the rankings.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on free services, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Streamlit Community Cloud app (free tier, no cost).
- GitHub repository us-economic-recovery-dashboard (free).
- Google Colab notebook analysis.ipynb (free, stored in Google Drive).
Keep everything running
No action needed. Choose this if you want your dashboard to stay live as a portfolio piece.
- Your Streamlit Community Cloud app stays live indefinitely on the free tier. Apps sleep after 7 days of inactivity but automatically wake when someone visits the URL.
- Your GitHub repository remains public and accessible to hiring managers.
- Your Google Colab notebook stays in your Google Drive until you delete it.
Pause - I'll come back to this later
Nothing to pause. All services are free and stateless. Your Streamlit app sleeps automatically after 7 days of inactivity and wakes when visited.
- No running processes to stop.
- Your app, repository, and notebook will all be waiting for you when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean everything up.
- Delete your Streamlit Community Cloud app: go to share.streamlit.io, click the overflow menu icon next to your app, click Delete, enter the required confirmation string, and click Delete again.
- Delete your GitHub repository: go to your us-economic-recovery-dashboard repository on GitHub, click Settings, scroll to the bottom under Danger Zone, and click Delete this repository. Confirm by typing the repository name when prompted.
- Delete your Google Colab notebook: open Google Drive, find analysis.ipynb, right-click it, and select Move to trash.
That's a Wrap!
That's a Wrap!
Nice work! You just took messy, real-world economic data from Harvard's Opportunity Insights team and turned it into a live, interactive dashboard that anyone can visit.
You've learned how to:
- Build an end-to-end data analysis pipeline that wrangles multiple CSV files with different schemas and temporal granularities into clean, merged insights.
- Create interactive Plotly visualizations that tell a data story about U.S. economic recovery patterns across states and income groups.
- Deploy a live Streamlit dashboard to a public URL backed by a professional GitHub repository with portfolio-ready documentation.
- Secret Mission: Added a dynamic Economic Resilience Scoring system with adjustable weight sliders that let users explore how different priorities change state rankings.
Ready to quiz yourself?