Case Study 14-2: Maya Visualizes Her Consulting Business

The Situation

At the end of Q3, Maya Reyes sits down for her quarterly self-review. She has the data from Case Study 13-2: project records, client summaries, revenue by type, quarterly trends. In Chapter 13, she found the numbers. Now she wants to see them.

Maya has three stakeholders for this visualization work: 1. Herself — she needs operational charts to spot trends and make decisions 2. Her accountant — who wants a clean revenue breakdown for tax planning 3. Potential clients — to whom she sometimes shows a sanitized "portfolio overview"

Today she builds the first two. The third she will tackle when she has more time for design polish.

Her questions: 1. How has my revenue changed month by month? 2. Where is my revenue coming from (retainer vs. project)? 3. Am I billing enough hours, and is my utilization improving? 4. Are my invoices being paid on time?


The Data

import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

plt.rcParams.update({
    "font.family": "sans-serif",
    "font.size": 9,
    "axes.titlesize": 11,
    "axes.titleweight": "bold",
    "axes.labelsize": 9,
    "xtick.labelsize": 8,
    "ytick.labelsize": 8,
})

BLUE   = "#2563EB"
GREEN  = "#16A34A"
AMBER  = "#D97706"
RED    = "#DC2626"
STEEL  = "#64748B"
PURPLE = "#7C3AED"

# Monthly project data
monthly_data = pd.DataFrame({
    "month": pd.date_range("2024-01-01", periods=9, freq="MS"),
    "month_label": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep"],
    "retainer_revenue": [9200, 9200, 9200, 15100, 15100, 15100, 22300, 22300, 22300],
    "project_revenue":  [4500, 7800, 3300, 5600, 9100, 4200, 3900, 8700, 5600],
    "hours_billed":     [48, 72, 42, 68, 88, 54, 72, 90, 62],
    "target_hours":     [80, 80, 80, 80, 80, 80, 80, 80, 80],
})
monthly_data["total_revenue"] = (
    monthly_data["retainer_revenue"] + monthly_data["project_revenue"]
)
monthly_data["utilization_pct"] = (
    monthly_data["hours_billed"] / monthly_data["target_hours"] * 100
).round(1)
monthly_data["rolling_3m"] = monthly_data["total_revenue"].rolling(window=3).mean()

# Client type breakdown
client_type = pd.DataFrame({
    "client_type": ["Retainer", "Project-Based"],
    "revenue":     [69200, 52700],
    "margin_pct":  [47.3, 46.8],
    "client_count":[3, 5],
})

# Invoice aging (as of September 30)
invoices = pd.DataFrame({
    "invoice_id": [f"INV-{i:03d}" for i in range(1, 13)],
    "client": [
        "Harborview Capital", "Redwood Analytics", "Harborview Capital",
        "Crestline Foods",    "Bayshore Logistics", "Pinnacle Health",
        "Harborview Capital", "Redwood Analytics",  "Crestline Foods",
        "Westport Media",     "Bayshore Logistics",  "Ironwood Mfg",
    ],
    "amount": [8190, 4900, 14700, 5800, 5580, 4840, 9870, 8140, 6240, 3465, 5115, 12000],
    "status": [
        "Paid", "Paid", "Paid", "Paid", "Paid", "Paid",
        "Paid", "Outstanding", "Outstanding", "Outstanding",
        "Overdue", "Overdue",
    ],
    "days_outstanding": [0, 0, 0, 0, 0, 0, 0, 12, 18, 25, 35, 42],
})

# Quarterly revenue summary
quarterly = pd.DataFrame({
    "quarter": ["Q1", "Q2", "Q3"],
    "retainer": [27600, 45300, 66900],
    "project":  [15600, 18900, 18200],
})
quarterly["total"] = quarterly["retainer"] + quarterly["project"]
quarterly["qoq_growth"] = quarterly["total"].pct_change() * 100

Step 1: Revenue by Client Type — Horizontal Bar Chart

fig1, ax = plt.subplots(figsize=(8, 4))

bars = ax.barh(
    client_type["client_type"],
    client_type["revenue"],
    color=[BLUE, STEEL],
    edgecolor="white",
    height=0.5,
)

for bar in bars:
    width = bar.get_width()
    ax.text(
        width + 1500,
        bar.get_y() + bar.get_height() / 2.0,
        f"${width:,.0f}",
        va="center",
        fontsize=10,
        fontweight="bold",
    )

total_rev = client_type["revenue"].sum()
for i, (_, row) in enumerate(client_type.iterrows()):
    share = row["revenue"] / total_rev * 100
    ax.text(
        row["revenue"] - 2000,
        i,
        f"{share:.0f}% of total",
        va="center",
        ha="right",
        fontsize=8,
        color="white",
        fontweight="bold",
    )

ax.set_title("YTD Revenue by Client Type (Jan–Sep 2024)", pad=10)
ax.set_xlabel("Revenue (USD)")
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.set_xlim(0, client_type["revenue"].max() * 1.20)
ax.grid(axis="x", linestyle="--", alpha=0.4)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

plt.tight_layout()
plt.savefig("maya_client_type_revenue.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.close()
print("Saved: maya_client_type_revenue.png")

Maya looks at this chart for a long moment. Retainer revenue ($69K) is larger than project-based ($52K), which is good — it means the predictable base is growing. But 57% vs 43% is closer than she wants. Her goal for next year is 70/30.


Step 2: Monthly Revenue Stack — Tracking Growth Over Time

fig2, ax = plt.subplots(figsize=(11, 5))

# Stacked bars: retainer (blue) + project (amber)
ax.bar(
    monthly_data["month_label"],
    monthly_data["retainer_revenue"],
    color=BLUE,
    edgecolor="white",
    label="Retainer",
    width=0.65,
)
ax.bar(
    monthly_data["month_label"],
    monthly_data["project_revenue"],
    bottom=monthly_data["retainer_revenue"],
    color=AMBER,
    edgecolor="white",
    label="Project-Based",
    width=0.65,
)

# Overlay 3-month rolling average line
ax.plot(
    monthly_data["month_label"],
    monthly_data["rolling_3m"],
    color=STEEL,
    linewidth=2,
    linestyle="--",
    marker="none",
    label="3-Month Avg",
    zorder=4,
)

# Annotate each month total
for _, row in monthly_data.iterrows():
    ax.text(
        row["month_label"],
        row["total_revenue"] + 400,
        f"${row['total_revenue']/1000:.0f}K",
        ha="center",
        va="bottom",
        fontsize=7.5,
        color="#374151",
    )

ax.set_title("Maya Reyes Consulting — Monthly Revenue Jan–Sep 2024", pad=10)
ax.set_xlabel("Month")
ax.set_ylabel("Revenue (USD)")
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.legend(loc="upper left")
ax.set_ylim(0, monthly_data["total_revenue"].max() * 1.22)
ax.grid(axis="y", linestyle="--", alpha=0.4)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

plt.tight_layout()
plt.savefig("maya_monthly_revenue_stacked.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.close()
print("Saved: maya_monthly_revenue_stacked.png")

The stacked bars tell a clear story: retainer revenue (blue) is growing because Maya added new retainer clients in April and July. Project revenue (amber) fluctuates. The rolling average line shows the underlying upward trend through the noise.


Step 3: Utilization Over Time — Line Chart with Target Reference

Utilization is Maya's most important operational metric. It measures how much of her available working time she is billing. Below 60% means she needs more clients; above 90% means she is at risk of burnout and has no capacity for business development.

fig3, ax = plt.subplots(figsize=(10, 5))

# Color-code points by utilization level
point_colors = []
for util in monthly_data["utilization_pct"]:
    if util >= 85:
        point_colors.append(AMBER)    # Warning: approaching capacity
    elif util >= 60:
        point_colors.append(GREEN)    # Healthy range
    else:
        point_colors.append(RED)      # Below sustainable

ax.plot(
    monthly_data["month_label"],
    monthly_data["utilization_pct"],
    color=BLUE,
    linewidth=2.5,
    zorder=2,
)

# Plot each point in its color
for i, (month, util, color) in enumerate(zip(
    monthly_data["month_label"],
    monthly_data["utilization_pct"],
    point_colors,
)):
    ax.scatter(month, util, color=color, s=80, zorder=3, edgecolors="white", linewidths=1.5)
    ax.text(month, util + 2, f"{util:.0f}%", ha="center", va="bottom", fontsize=8)

# Reference band: healthy utilization range 60%–85%
ax.axhspan(60, 85, alpha=0.08, color=GREEN, label="Healthy range (60–85%)")
ax.axhline(60, color=GREEN, linewidth=1, linestyle=":", alpha=0.6)
ax.axhline(85, color=AMBER, linewidth=1, linestyle=":", alpha=0.6)
ax.axhline(100, color=RED, linewidth=1.5, linestyle="--", alpha=0.5, label="Full capacity")

ax.set_title("Monthly Billing Utilization Jan–Sep 2024", pad=10)
ax.set_xlabel("Month")
ax.set_ylabel("Utilization %")
ax.set_ylim(0, 115)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0f}%"))
ax.legend(loc="upper left")
ax.grid(axis="y", linestyle="--", alpha=0.3)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

plt.tight_layout()
plt.savefig("maya_utilization_line.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.close()
print("Saved: maya_utilization_line.png")

This chart worries Maya. January, March, and June are red (below 60%). July and August are amber (above 85%), which contributed to feeling burned out in August. She needs more consistent workload.


Step 4: Invoice Aging — Horizontal Bar Chart with Status Colors

# Sort by days outstanding for visual clarity
invoices_sorted = invoices.sort_values("days_outstanding", ascending=True).reset_index(drop=True)

STATUS_COLORS = {
    "Paid": "#86EFAC",         # Light green
    "Outstanding": AMBER,
    "Overdue": RED,
}

bar_colors = [STATUS_COLORS[s] for s in invoices_sorted["status"]]

fig4, ax = plt.subplots(figsize=(9, 6))

bars = ax.barh(
    range(len(invoices_sorted)),
    invoices_sorted["amount"],
    color=bar_colors,
    edgecolor="white",
    height=0.7,
)

# Y-axis labels: client + invoice ID
ax.set_yticks(range(len(invoices_sorted)))
ax.set_yticklabels(
    [f"{row['client'][:20]}\n{row['invoice_id']}" for _, row in invoices_sorted.iterrows()],
    fontsize=7.5,
)

# Data labels: amount and days
for bar, (_, row) in zip(bars, invoices_sorted.iterrows()):
    width = bar.get_width()
    label = f"${width:,.0f}"
    if row["days_outstanding"] > 0:
        label += f"  ({row['days_outstanding']} days)"
    ax.text(
        width + 100,
        bar.get_y() + bar.get_height() / 2.0,
        label,
        va="center",
        fontsize=7.5,
    )

# Custom legend
from matplotlib.patches import Patch
legend_elements = [
    Patch(facecolor="#86EFAC", label="Paid"),
    Patch(facecolor=AMBER,     label="Outstanding"),
    Patch(facecolor=RED,       label="Overdue (>30 days)"),
]
ax.legend(handles=legend_elements, loc="lower right")

ax.set_title("Invoice Status as of Sep 30, 2024", pad=10)
ax.set_xlabel("Invoice Amount (USD)")
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.set_xlim(0, invoices_sorted["amount"].max() * 1.35)
ax.grid(axis="x", linestyle="--", alpha=0.3)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

plt.tight_layout()
plt.savefig("maya_invoice_aging.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.close()
print("Saved: maya_invoice_aging.png")

Two invoices are overdue: Bayshore Logistics (35 days) and Ironwood Manufacturing (42 days). Ironwood is a $12,000 invoice — that is a meaningful cash flow concern. Maya adds both to her follow-up call list.


Step 5: Quarterly Revenue with Growth Annotation

fig5, ax = plt.subplots(figsize=(7, 5))

x = range(len(quarterly["quarter"]))
width = 0.45

ax.bar([i - width/2 for i in x], quarterly["retainer"],
       width=width, label="Retainer", color=BLUE,  edgecolor="white")
ax.bar([i + width/2 for i in x], quarterly["project"],
       width=width, label="Project",  color=AMBER, edgecolor="white")

# QoQ growth annotation
for i, (_, row) in enumerate(quarterly.iterrows()):
    if pd.notna(row["qoq_growth"]):
        color = GREEN if row["qoq_growth"] > 0 else RED
        ax.annotate(
            f"+{row['qoq_growth']:.0f}% QoQ" if row["qoq_growth"] > 0
            else f"{row['qoq_growth']:.0f}% QoQ",
            xy=(i, row["total"] + 1000),
            ha="center",
            fontsize=8.5,
            fontweight="bold",
            color=color,
        )

ax.set_title("Quarterly Revenue: Retainer vs. Project 2024", pad=10)
ax.set_xlabel("Quarter")
ax.set_ylabel("Revenue (USD)")
ax.set_xticks(list(x))
ax.set_xticklabels(quarterly["quarter"])
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax.legend()
ax.grid(axis="y", linestyle="--", alpha=0.4)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

plt.tight_layout()
plt.savefig("maya_quarterly_comparison.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.close()
print("Saved: maya_quarterly_comparison.png")

Step 6: Final Summary Figure — All Charts Composed

fig_summary, axes = plt.subplots(2, 2, figsize=(14, 9))
fig_summary.patch.set_facecolor("white")
fig_summary.suptitle(
    "Maya Reyes Consulting — Q3 2024 Business Review",
    fontsize=14,
    fontweight="bold",
    y=0.98,
)

ax_stack = axes[0][0]
ax_util  = axes[0][1]
ax_type  = axes[1][0]
ax_aging = axes[1][1]

# ── Replot each chart into the composite figure ─────────────────────────────

# Panel 1: Monthly stacked revenue
ax_stack.bar(monthly_data["month_label"], monthly_data["retainer_revenue"],
             color=BLUE, edgecolor="white", label="Retainer", width=0.65)
ax_stack.bar(monthly_data["month_label"], monthly_data["project_revenue"],
             bottom=monthly_data["retainer_revenue"],
             color=AMBER, edgecolor="white", label="Project", width=0.65)
ax_stack.set_title("Monthly Revenue by Type")
ax_stack.set_ylabel("Revenue (USD)")
ax_stack.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax_stack.legend(loc="upper left", fontsize=8)
ax_stack.grid(axis="y", linestyle="--", alpha=0.3)
ax_stack.spines["top"].set_visible(False)
ax_stack.spines["right"].set_visible(False)

# Panel 2: Utilization
ax_util.plot(monthly_data["month_label"], monthly_data["utilization_pct"],
             color=BLUE, linewidth=2.5, marker="o", markersize=6,
             markerfacecolor="white", markeredgewidth=2)
ax_util.axhspan(60, 85, alpha=0.08, color=GREEN)
ax_util.axhline(60, color=GREEN, linewidth=1, linestyle=":")
ax_util.axhline(85, color=AMBER, linewidth=1, linestyle=":")
ax_util.set_title("Monthly Utilization %")
ax_util.set_ylabel("Utilization %")
ax_util.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0f}%"))
ax_util.set_ylim(0, 115)
ax_util.grid(axis="y", linestyle="--", alpha=0.3)
ax_util.spines["top"].set_visible(False)
ax_util.spines["right"].set_visible(False)

# Panel 3: Revenue by client type
type_colors = [BLUE, STEEL]
ax_type.barh(client_type["client_type"], client_type["revenue"],
             color=type_colors, edgecolor="white", height=0.5)
ax_type.set_title("Revenue by Client Type (YTD)")
ax_type.set_xlabel("Revenue (USD)")
ax_type.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax_type.grid(axis="x", linestyle="--", alpha=0.3)
ax_type.spines["top"].set_visible(False)
ax_type.spines["right"].set_visible(False)

# Panel 4: Invoice aging (outstanding and overdue only)
outstanding = invoices[invoices["status"] != "Paid"].sort_values("days_outstanding")
status_colors_mini = [STATUS_COLORS[s] for s in outstanding["status"]]
ax_aging.barh(
    range(len(outstanding)),
    outstanding["amount"],
    color=status_colors_mini,
    edgecolor="white",
    height=0.6,
)
ax_aging.set_yticks(range(len(outstanding)))
ax_aging.set_yticklabels(
    [f"{row['client'][:18]}" for _, row in outstanding.iterrows()],
    fontsize=7.5,
)
ax_aging.set_title("Open Invoices (Outstanding & Overdue)")
ax_aging.set_xlabel("Amount (USD)")
ax_aging.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
ax_aging.grid(axis="x", linestyle="--", alpha=0.3)
ax_aging.spines["top"].set_visible(False)
ax_aging.spines["right"].set_visible(False)

plt.tight_layout(rect=[0, 0.02, 1, 0.96])
fig_summary.text(0.5, 0.005, "Maya Reyes Consulting  |  Private & Confidential",
                 ha="center", fontsize=7.5, color="#94A3B8")

plt.savefig("maya_business_review.png", dpi=150, bbox_inches="tight", facecolor="white")
plt.close()
print("Saved: maya_business_review.png")

What Maya Discovered From the Charts

The visualization process surfaced insights that the numbers alone had not made obvious:

Utilization is more volatile than she realized. Looking at the monthly data in a table, she knew February and August were high. Seeing the utilization line chart made it visually clear that the pattern was zigzag — feast-and-famine rather than steady. She needs to smooth her pipeline.

The Ironwood Manufacturing invoice is a real problem. In the data table, $12,000 at 42 days was a number. In the invoice aging chart, it is a long red bar that stands out immediately. She calls their accounts payable department that afternoon.

Retainer revenue stair-stepping is reassuring. The stacked bar chart shows three distinct jumps (January, April, July) as Maya added retainer clients. The visual pattern confirms her strategy is working.

The summary figure is worth more than five individual charts. Having all four panels on one page lets Maya tell a coherent story: "Revenue is growing, utilization is uneven, retainer business is healthy, invoice collection needs attention."


Techniques Used in This Case Study

Chart Type Panel Business Question Answered
Horizontal bar Client type revenue Where is my revenue coming from?
Stacked bar + line Monthly revenue How is my business growing, and what drives it?
Line with reference band Utilization Am I working at a sustainable pace?
Horizontal bar (colored) Invoice aging Which invoices need immediate follow-up?
Grouped bar Quarterly comparison How does each quarter compare?

Discussion Questions

  1. Maya uses ax.axhspan(60, 85, alpha=0.08, color=GREEN) to shade the "healthy utilization" range. How would you add text labels inside the shaded band (e.g., "Healthy range") at the center of the band?

  2. The invoice aging chart shows only outstanding and overdue invoices in the summary panel. How would you modify it to also show a "Total Paid" figure as context — perhaps as a subtitle or annotation?

  3. Maya wants to project her Q4 revenue. If retainer revenue stays flat and project revenue follows the average of Q1–Q3, what Q4 total would you forecast? Add this as a dashed bar on the quarterly chart.

  4. The utilization line shows Jan–Sep. Maya's target is 70% average utilization for the full year. Given the first 9 months, what average utilization does she need in Oct, Nov, and Dec to hit that target? Add an ax.axhline() showing this required pace.

  5. Maya's accountant wants a pie chart showing revenue by client type, arguing that clients find percentages more intuitive than bar lengths. Build both the pie chart and the horizontal bar chart, and write a two-sentence explanation of which you recommend for Maya's client-facing portfolio and why.