Case Study 2: Maya Tracks Time and Finds Her Budget Breakers

Chapter 5 — Loops and Iteration: Automating Repetitive Tasks Python for Business for Beginners


Background

Maya Reyes has been freelancing for three years. She has a system — call it organized chaos. Time entries go into a text file. Invoices come from a template she fills in manually. Project budgets live in her head, cross-referenced against a sticky note on her monitor.

It works. Barely.

The problem is that Maya bills by the project. Each project has a fixed or estimated budget, and Maya tracks her time against it. When she goes over budget, she absorbs the cost (or has an uncomfortable conversation with the client). In a good month she catches overruns early. In a bad month she catches them on the invoice — after the work is done.

She needs a way to process her monthly time log, calculate her total billable hours, and flag every project where she has exceeded the agreed budget. Fast. Before she sits down to invoice.


The Data

Maya's time log for November looks like this — each entry records the project, date, and hours worked:

# Maya's November time log
# Each entry: (project_code, date_worked, hours_billed)
time_log = [
    ("APEX-001", "2024-11-01", 3.5),
    ("CHEN-004", "2024-11-01", 2.0),
    ("APEX-001", "2024-11-04", 4.0),
    ("RIVER-007", "2024-11-04", 1.5),
    ("CHEN-004", "2024-11-05", 3.0),
    ("APEX-001", "2024-11-06", 5.5),
    ("RIVER-007", "2024-11-07", 2.5),
    ("MOSS-002", "2024-11-08", 6.0),
    ("APEX-001", "2024-11-11", 3.0),
    ("CHEN-004", "2024-11-11", 4.5),
    ("MOSS-002", "2024-11-12", 3.5),
    ("RIVER-007", "2024-11-13", 2.0),
    ("APEX-001", "2024-11-14", 2.5),
    ("PARK-009", "2024-11-15", 5.0),
    ("CHEN-004", "2024-11-18", 3.0),
    ("MOSS-002", "2024-11-18", 4.0),
    ("RIVER-007", "2024-11-19", 1.0),
    ("PARK-009", "2024-11-20", 6.5),
    ("APEX-001", "2024-11-21", 4.0),
    ("MOSS-002", "2024-11-22", 2.5),
    ("CHEN-004", "2024-11-25", 2.0),
    ("PARK-009", "2024-11-25", 3.0),
    ("RIVER-007", "2024-11-26", 3.5),
    ("APEX-001", "2024-11-27", 2.0),
    ("PARK-009", "2024-11-27", 4.0),
]

# Project budgets: total hours agreed with client
project_budgets = {
    "APEX-001": 20.0,    # Website redesign — capped at 20 hours
    "CHEN-004": 15.0,    # Financial analysis — capped at 15 hours
    "RIVER-007": 10.0,   # Process documentation — capped at 10 hours
    "MOSS-002": 12.0,    # Competitive analysis — capped at 12 hours
    "PARK-009": 16.0,    # Operational review — capped at 16 hours
}

# Maya's billing rate
HOURLY_RATE = 175.00

Step 1 — Calculate Total Hours with a while Loop

Maya's first question is simple: how many total hours did she bill in November?

She could use a for loop, and that would be perfectly reasonable. But she wants to demonstrate a while loop pattern that she'll reuse elsewhere: process entries one at a time using an index counter, stopping when the log is exhausted.

# while loop with an index counter
# This is the 'process until done' pattern.

total_hours = 0.0
entry_index = 0

while entry_index < len(time_log):
    project, date, hours = time_log[entry_index]
    total_hours += hours
    entry_index += 1

print(f"Total hours billed in November: {total_hours:.1f}")
print(f"Entries processed: {entry_index}")
print(f"Gross billings (before expenses): ${total_hours * HOURLY_RATE:,.2f}")

Output:

Total hours billed in November: 79.5
Entries processed: 25
Gross billings (before expenses): $13,912.50

The while loop here is doing exactly what a for loop would do — but the index-based approach is useful when you need to look at multiple entries at once (for example, to compare today's entry with yesterday's) or when you need to skip ahead based on a condition.


Step 2 — Early Exit with break

Maya's accountant asked for something specific: the date on which Maya first crossed 40 billable hours in November. This is a classic early-exit pattern — you loop through the entries in order and stop the moment you hit your target.

MINIMUM_HOURS_THRESHOLD = 40.0

running_hours = 0.0
threshold_date = None

for project, date, hours in time_log:
    running_hours += hours

    if running_hours >= MINIMUM_HOURS_THRESHOLD:
        threshold_date = date
        break   # Stop — we found what we needed. No point continuing.

if threshold_date:
    print(f"\nMaya crossed {MINIMUM_HOURS_THRESHOLD} billable hours on: {threshold_date}")
    print(f"  Hours at that point: {running_hours:.1f}")
else:
    print(f"\nMaya never reached {MINIMUM_HOURS_THRESHOLD} hours this month.")

Output:

Maya crossed 40.0 billable hours on: 2024-11-14
  Hours at that point: 41.0

Without break, this loop would keep running through all 25 entries even after the answer was found. That matters when the log is 2,500 entries instead of 25.


Step 3 — Hours Per Project with a Dictionary Accumulator

To find budget overruns, Maya needs to know how many hours she spent on each project. She builds a dictionary accumulator inside a for loop:

hours_by_project = {}

for project, date, hours in time_log:
    if project not in hours_by_project:
        hours_by_project[project] = 0.0
    hours_by_project[project] += hours

print("\nHours by project:")
for project, total in hours_by_project.items():
    print(f"  {project}: {total:.1f} hours")

Output:

Hours by project:
  APEX-001: 24.5 hours
  CHEN-004: 14.5 hours
  RIVER-007: 10.5 hours
  MOSS-002: 16.0 hours
  PARK-009: 18.5 hours

Notice that Maya does not have a list of project codes ahead of time — they emerge from the data itself. The if project not in hours_by_project check ensures the first time she sees a project, she creates its entry at zero before adding to it.


Step 4 — Finding Over-Budget Projects with a List Comprehension

Now for the main event. Maya wants a list of every project where her actual hours exceed the budgeted hours. She uses a list comprehension — a concise way to build a list by filtering and transforming another sequence.

# List comprehension: build a list of over-budget project summaries
over_budget_projects = [
    {
        "project": project,
        "budgeted_hours": project_budgets[project],
        "actual_hours": hours_by_project[project],
        "overage_hours": hours_by_project[project] - project_budgets[project],
        "overage_cost": (hours_by_project[project] - project_budgets[project]) * HOURLY_RATE,
    }
    for project, actual_hours in hours_by_project.items()
    if actual_hours > project_budgets.get(project, float("inf"))
]

# Sort by overage hours, worst first
over_budget_projects.sort(key=lambda p: p["overage_hours"], reverse=True)

print(f"\nProjects over budget: {len(over_budget_projects)}")

The list comprehension reads: "For each project and its actual hours in the hours dictionary, if the actual hours exceed the budgeted hours, create a summary dictionary." The if clause at the end is the filter — projects within budget are excluded from the result entirely.

Breaking it down for readability, the equivalent for loop version would be:

# Equivalent for loop (same result, more lines)
over_budget_projects_v2 = []

for project, actual_hours in hours_by_project.items():
    budget = project_budgets.get(project, float("inf"))
    if actual_hours > budget:
        overage_hours = actual_hours - budget
        over_budget_projects_v2.append({
            "project": project,
            "budgeted_hours": budget,
            "actual_hours": actual_hours,
            "overage_hours": overage_hours,
            "overage_cost": overage_hours * HOURLY_RATE,
        })

Both versions produce identical results. The list comprehension is more concise; the for loop is more readable for beginners. Use whichever you find clearer — correctness is more important than brevity.


Step 5 — The Full Report

def generate_maya_billing_report(time_log, project_budgets, hourly_rate):
    """
    Process Maya's time log and generate a monthly billing summary.

    Identifies total hours, gross billings, and any projects that have
    exceeded their agreed budget (which requires a client conversation
    before the invoice goes out).

    Parameters
    ----------
    time_log        : list of (project_code, date_string, hours) tuples
    project_budgets : dict mapping project codes to budgeted hours
    hourly_rate     : Maya's hourly billing rate
    """

    # --- Compute hours per project ---
    hours_by_project = {}
    for project, date, hours in time_log:
        if project not in hours_by_project:
            hours_by_project[project] = 0.0
        hours_by_project[project] += hours

    total_hours = sum(hours_by_project.values())
    gross_billings = total_hours * hourly_rate

    # --- Find over-budget projects via list comprehension ---
    over_budget = [
        {
            "project": project,
            "budgeted": project_budgets.get(project, 0),
            "actual": actual,
            "overage": actual - project_budgets.get(project, 0),
            "cost_at_risk": (actual - project_budgets.get(project, 0)) * hourly_rate,
        }
        for project, actual in hours_by_project.items()
        if actual > project_budgets.get(project, float("inf"))
    ]
    over_budget.sort(key=lambda p: p["overage"], reverse=True)

    at_risk_dollars = sum(p["cost_at_risk"] for p in over_budget)

    # --- Find 40-hour threshold crossing date (while + break pattern) ---
    running = 0.0
    threshold_date = None
    for project, date, hours in time_log:
        running += hours
        if running >= 40.0:
            threshold_date = date
            break

    # --- Print report ---
    print("=" * 60)
    print("  MAYA REYES CONSULTING — NOVEMBER BILLING SUMMARY")
    print("=" * 60)
    print(f"\n  Total Hours Billed:   {total_hours:>8.1f} hrs")
    print(f"  Billing Rate:         ${hourly_rate:>7,.2f}/hr")
    print(f"  Gross Billings:       ${gross_billings:>10,.2f}")
    if threshold_date:
        print(f"  Crossed 40 hrs on:    {threshold_date}")

    print("\nPROJECT BREAKDOWN")
    print("-" * 60)
    print(f"  {'Project':<12} {'Budget':>8} {'Actual':>8} {'Status':<18} {'Notes'}")
    print("-" * 60)

    for project in sorted(hours_by_project):
        actual = hours_by_project[project]
        budget = project_budgets.get(project, 0)
        remaining = budget - actual
        utilization = (actual / budget * 100) if budget > 0 else 0

        if actual > budget:
            status = "OVER BUDGET"
            notes = f"-{abs(remaining):.1f} hrs (${abs(remaining) * hourly_rate:,.0f})"
        elif utilization >= 90:
            status = "Near limit"
            notes = f"{remaining:.1f} hrs remaining"
        else:
            status = "On track"
            notes = f"{remaining:.1f} hrs remaining"

        print(
            f"  {project:<12} {budget:>7.1f}h {actual:>7.1f}h  {status:<18} {notes}"
        )

    if over_budget:
        print(f"\nOVER-BUDGET PROJECTS REQUIRING CLIENT DISCUSSION")
        print("-" * 60)
        for p in over_budget:
            print(
                f"  {p['project']}: {p['overage']:.1f} hrs over budget  "
                f"= ${p['cost_at_risk']:,.2f} at risk"
            )
        print("-" * 60)
        print(f"  Total at-risk revenue: ${at_risk_dollars:,.2f}")
        print(
            "\n  ACTION: Contact these clients before issuing invoices."
        )
    else:
        print("\n  All projects are within budget. Invoice as normal.")

    print("=" * 60)


# Run the report
generate_maya_billing_report(time_log, project_budgets, HOURLY_RATE)

Output:

============================================================
  MAYA REYES CONSULTING — NOVEMBER BILLING SUMMARY
============================================================

  Total Hours Billed:       79.5 hrs
  Billing Rate:           $175.00/hr
  Gross Billings:       $13,912.50
  Crossed 40 hrs on:    2024-11-14

PROJECT BREAKDOWN
------------------------------------------------------------
  Project      Budget  Actual  Status             Notes
------------------------------------------------------------
  APEX-001     20.0h   24.5h  OVER BUDGET        -4.5 hrs ($788)
  CHEN-004     15.0h   14.5h  Near limit         0.5 hrs remaining
  MOSS-002     12.0h   16.0h  OVER BUDGET        -4.0 hrs ($700)
  PARK-009     16.0h   18.5h  OVER BUDGET        -2.5 hrs ($438)
  RIVER-007    10.0h   10.5h  OVER BUDGET        -0.5 hrs ($88)

OVER-BUDGET PROJECTS REQUIRING CLIENT DISCUSSION
------------------------------------------------------------
  APEX-001: 4.5 hrs over budget  = $787.50 at risk
  MOSS-002: 4.0 hrs over budget  = $700.00 at risk
  PARK-009: 2.5 hrs over budget  = $437.50 at risk
  RIVER-007: 0.5 hrs over budget  = $87.50 at risk
------------------------------------------------------------
  Total at-risk revenue: $2,012.50

  ACTION: Contact these clients before issuing invoices.
============================================================

What Maya Did (and What Comes Next)

Maya looks at the output and winces. Four out of five projects are over budget. Two of them significantly. She has known APEX-001 was getting long, but seeing $788 at-risk in print is different from knowing it vaguely.

She picks up her phone and calls the APEX-001 client before she writes the invoice. It goes well — the client acknowledges the project ran longer than scoped and approves the overage. The others are smaller conversations.

The script did not prevent the overruns. But it found them in seconds rather than the forty-five minutes of cross-referencing it would have taken manually. And it gave Maya data to walk into those conversations with: not "I think I went a little over," but "I'm 4.5 hours over, which represents $787.50."

That is a different conversation.


Key Concepts Demonstrated

Concept Where Used
while loop with index counter Step 1: total hours calculation
break for early exit Step 2: crossing 40-hour threshold
Dictionary accumulator in for loop Step 3: hours by project
List comprehension with if filter Step 4: over-budget projects
sort() with key=lambda Ranking over-budget projects by overage
dict.get() with default Safe budget lookup for unknown projects
float("inf") Ensuring all projects are "within budget" if no budget is set

Challenge Extension

  1. Add a continue clause to skip any time_log entries where hours == 0 (sometimes Maya logs a session but records zero hours for an interrupted meeting).
  2. Extend the list comprehension to also include projects that are within 1 hour of their budget (flagged as "Warning" rather than "Over").
  3. Calculate the most productive day of the week for Maya by parsing the date strings and grouping hours by weekday.
  4. Use a while loop to simulate what day Maya will cross her income target of $12,000 if she continues billing at her current daily rate.