Case Study 37-2: Maya Builds the Client-Facing Project Status Portal

The Arc

Maya Reyes has been in this book since Chapter 1. Let's trace the full arc before we reach the milestone.

Chapter 1: Maya is tracking her consulting projects in a spreadsheet. She has eight active clients, invoices in Word documents, and a persistent fear that she is forgetting to charge for time.

Chapter 9: She moves her project data from a spreadsheet into a structured CSV (maya_projects.csv) and writes a Python script to read and summarize it. For the first time, she can see all her projects in one view without scrolling through tabs.

Chapter 16: She automates her invoicing. A Python script reads her projects CSV, computes hours times rate, generates a formatted Excel invoice, and saves it to a dated folder. She recovers approximately six hours per month she was spending on manual invoice preparation.

Chapter 19: She adds email automation. The invoicing script now emails each invoice directly to the client on the 1st of the month, with the invoice attached. Clients get invoices on time, every month, without Maya touching anything.

Chapter 22: She sets up a scheduled task to run the full pipeline automatically — data aggregation, invoice generation, email delivery — every first-of-month at 7 a.m. Her invoicing process now runs while she sleeps.

Chapter 23: She migrates from CSV files to a SQLite database (maya_projects.db). Her data has grown to include time entries, client contacts, milestone records, and invoice history. SQL queries make cross-referencing this data significantly more powerful than anything she could do with pandas on a CSV.

Chapter 36: She builds automated PDF status reports — one per client, generated on demand or on schedule, showing project progress, hours consumed, and budget remaining. She emails these to clients who ask for them.

Chapter 37 (now): She wraps everything into a web application. Clients can log in with a project code and see their own status page — hours, budget, milestones — in real time, without asking Maya for a status email.

This is Maya's version of what every software product company calls a "client portal." She built it herself. In Python.


The Business Problem

Every active client wanted status updates. They all had different preferences:

  • Natalie from Hartwell Financial wanted weekly status emails
  • Kevin from GreenBridge Logistics wanted to be able to check any time he felt anxious about the project
  • The team at Coastal Property Management wanted something they could share with their own leadership

Maya was spending three to four hours per week answering status emails, composing updates, and attaching the same PDF reports she was already generating automatically. The irony was not lost on her: she had automated the report generation, but she was still manually distributing the information.

The insight was simple: give clients a URL they can bookmark and check themselves. The status data is already in her database. The reports are already being generated. A Flask application was the missing layer between "data that exists" and "data that clients can see."


The Design

Maya made a deliberate simplicity decision: no user accounts, no password registration, no "forgot password" flow. Clients log in with a project code — a unique identifier that Maya already assigned to every project in her database (format: CLIENT-YEAR-###, e.g., HART-2024-001).

The project code serves dual purposes: it identifies the project and it authenticates the client. If you have the code, you can see your project. If you do not, you cannot.

This is appropriate because: - Maya's project status data is not highly sensitive (it is not financial account data or medical records) - Each client can only see their own project — the code is scoped to one project record - If a code is compromised, Maya can invalidate it by updating the database - The system is much simpler to explain and maintain than a full authentication system

Maya wrote this requirement explicitly into her project notes: "If a future client requires truly confidential data access or has compliance requirements around access control, I will need to implement proper auth or use a third-party auth service. This implementation is appropriate for the current client base."


The Database Schema

Maya's SQLite database (maya_projects.db) has been evolving since Chapter 23. For the client portal, the relevant tables:

-- Projects table (the core entity)
CREATE TABLE projects (
    id INTEGER PRIMARY KEY,
    project_code TEXT UNIQUE NOT NULL,  -- e.g., HART-2024-001
    client_name TEXT NOT NULL,
    project_name TEXT NOT NULL,
    status TEXT NOT NULL,               -- Active, On-Hold, Completed
    phase TEXT,                         -- Discovery, Design, Implementation, etc.
    start_date TEXT,
    target_end_date TEXT,
    hourly_rate REAL NOT NULL,
    hours_budgeted REAL NOT NULL,
    fixed_fee REAL,                     -- NULL if hourly, value if fixed-fee
    notes TEXT
);

-- Time entries table
CREATE TABLE time_entries (
    id INTEGER PRIMARY KEY,
    project_id INTEGER REFERENCES projects(id),
    entry_date TEXT NOT NULL,
    hours REAL NOT NULL,
    description TEXT,
    billable INTEGER NOT NULL DEFAULT 1
);

-- Milestones table
CREATE TABLE milestones (
    id INTEGER PRIMARY KEY,
    project_id INTEGER REFERENCES projects(id),
    milestone_name TEXT NOT NULL,
    target_date TEXT,
    completed INTEGER NOT NULL DEFAULT 0,
    completed_date TEXT
);

The Application Code

Maya's client portal is structured as a minimal Flask application — deliberately lean, since she maintains it herself.

"""
maya_client_portal/app.py
Client-facing project status portal for Maya Reyes Consulting.

Clients log in with their project code to view their project's
current status, hours, budget, and milestones.
"""

import os
import sqlite3
from pathlib import Path

from dotenv import load_dotenv
from flask import Flask, g, redirect, render_template, request, session, url_for

load_dotenv()

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY")

DATABASE = Path(__file__).parent / "data" / "maya_projects.db"


# --- Database helpers ---

def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(DATABASE)
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(error):
    db = g.pop("db", None)
    if db is not None:
        db.close()


# --- Data access functions ---

def get_project_by_code(project_code: str) -> sqlite3.Row | None:
    """Look up a project by its unique project code.

    Returns the project Row if found, None otherwise.
    Used for authentication — having the code proves you're the client.
    """
    db = get_db()
    return db.execute(
        "SELECT * FROM projects WHERE project_code = ?",
        (project_code.upper(),),
    ).fetchone()


def get_time_summary(project_id: int) -> dict:
    """Compute hours logged and budget metrics for a project."""
    db = get_db()

    # Total billable hours logged
    result = db.execute(
        "SELECT COALESCE(SUM(hours), 0) as total_hours "
        "FROM time_entries "
        "WHERE project_id = ? AND billable = 1",
        (project_id,),
    ).fetchone()
    hours_logged = float(result["total_hours"])

    # Recent time entries (last 5)
    recent_entries = db.execute(
        "SELECT entry_date, hours, description "
        "FROM time_entries "
        "WHERE project_id = ? "
        "ORDER BY entry_date DESC LIMIT 5",
        (project_id,),
    ).fetchall()

    return {
        "hours_logged": hours_logged,
        "recent_entries": recent_entries,
    }


def get_project_milestones(project_id: int) -> list:
    """Return all milestones for a project, ordered by target date."""
    db = get_db()
    return db.execute(
        "SELECT milestone_name, target_date, completed, completed_date "
        "FROM milestones "
        "WHERE project_id = ? "
        "ORDER BY target_date ASC",
        (project_id,),
    ).fetchall()


# --- Routes ---

@app.route("/", methods=["GET", "POST"])
def project_login():
    """Project code entry page.

    GET:  Show the code entry form.
    POST: Look up the project code. On match, store project info
          in the session and redirect to the status page.
          On no match, show an error.
    """
    # Already authenticated — go directly to status
    if "project_code" in session:
        return redirect(url_for("project_status"))

    error = None

    if request.method == "POST":
        project_code = request.form.get("project_code", "").strip().upper()

        if not project_code:
            error = "Please enter your project code."
        else:
            project = get_project_by_code(project_code)
            if project:
                session["project_code"] = project_code
                session["project_id"] = project["id"]
                session["client_name"] = project["client_name"]
                return redirect(url_for("project_status"))
            else:
                error = (
                    "Project code not found. "
                    "Please check your code and try again, "
                    "or contact Maya at hello@mayareyes.consulting."
                )

    return render_template("login.html", error=error)


@app.route("/status")
def project_status():
    """The client-facing project status page.

    Displays project overview, budget/hours, milestones, and
    recent time entries. Requires a valid project code in session.
    """
    if "project_code" not in session:
        return redirect(url_for("project_login"))

    project_id = session["project_id"]
    db = get_db()

    # Re-fetch the project to get fresh data (not cached session data)
    project = db.execute(
        "SELECT * FROM projects WHERE id = ?", (project_id,)
    ).fetchone()

    if not project:
        # Project was deleted — clear session and redirect
        session.clear()
        return redirect(url_for("project_login"))

    time_summary = get_time_summary(project_id)
    milestones = get_project_milestones(project_id)

    # Compute budget metrics
    hours_budgeted = project["hours_budgeted"]
    hours_logged = time_summary["hours_logged"]
    hours_remaining = max(hours_budgeted - hours_logged, 0)
    hours_pct = min(round((hours_logged / hours_budgeted * 100), 1), 100) if hours_budgeted > 0 else 0

    # Budget in dollars
    if project["fixed_fee"]:
        # Fixed-fee project: budget is the fixed fee
        budget_total = project["fixed_fee"]
        budget_consumed = round(hours_logged * project["hourly_rate"], 2)
    else:
        # Hourly project: budget is hours * rate
        budget_total = round(hours_budgeted * project["hourly_rate"], 2)
        budget_consumed = round(hours_logged * project["hourly_rate"], 2)

    budget_remaining = max(budget_total - budget_consumed, 0)
    budget_pct = min(round((budget_consumed / budget_total * 100), 1), 100) if budget_total > 0 else 0

    context = {
        "project": project,
        "milestones": milestones,
        "recent_entries": time_summary["recent_entries"],
        "hours_logged": hours_logged,
        "hours_budgeted": hours_budgeted,
        "hours_remaining": hours_remaining,
        "hours_pct": hours_pct,
        "budget_total": budget_total,
        "budget_consumed": budget_consumed,
        "budget_remaining": budget_remaining,
        "budget_pct": budget_pct,
        "milestones_complete": sum(1 for m in milestones if m["completed"]),
        "milestones_total": len(milestones),
    }

    return render_template("project_status.html", **context)


@app.route("/logout")
def project_logout():
    """Clear the project session and return to the login page."""
    session.clear()
    return redirect(url_for("project_login"))


@app.errorhandler(404)
def not_found(error):
    return render_template("404.html"), 404

The Status Page Template

The client status page is where Maya put the most design effort. Clients are not technical — they need a clear, reassuring, professional presentation of the project's health.

{% extends "base.html" %}

{% block title %}{{ project.project_name }} — Project Status{% endblock %}

{% block content %}

<!-- Project header -->
<div class="project-header">
    <div>
        <p class="client-label">{{ project.client_name }}</p>
        <h1>{{ project.project_name }}</h1>
        <div class="status-badges">
            <span class="badge badge-{{ project.status | lower | replace(' ', '-') }}">
                {{ project.status }}
            </span>
            {% if project.phase %}
            <span class="badge badge-neutral">{{ project.phase }}</span>
            {% endif %}
        </div>
    </div>
    <div class="project-code">
        Project Code: <code>{{ project.project_code }}</code>
    </div>
</div>

<!-- Three summary metrics -->
<div class="metrics-grid">
    <div class="metric-card">
        <span class="metric-label">Hours Logged</span>
        <span class="metric-value">{{ hours_logged }}h</span>
        <span class="metric-subtext">of {{ hours_budgeted }}h budgeted</span>
        <div class="progress-bar-track">
            <div class="progress-bar-fill {% if hours_pct >= 90 %}danger
                {% elif hours_pct >= 75 %}warning{% endif %}"
                 style="width: {{ hours_pct }}%;">
            </div>
        </div>
    </div>

    <div class="metric-card">
        <span class="metric-label">Budget Used</span>
        <span class="metric-value">${{ "{:,.0f}".format(budget_consumed) }}</span>
        <span class="metric-subtext">of ${{ "{:,.0f}".format(budget_total) }} total</span>
        <div class="progress-bar-track">
            <div class="progress-bar-fill {% if budget_pct >= 90 %}danger
                {% elif budget_pct >= 75 %}warning{% endif %}"
                 style="width: {{ budget_pct }}%;">
            </div>
        </div>
    </div>

    <div class="metric-card">
        <span class="metric-label">Milestones</span>
        <span class="metric-value">{{ milestones_complete }}/{{ milestones_total }}</span>
        <span class="metric-subtext">completed</span>
    </div>
</div>

<!-- Milestones -->
{% if milestones %}
<div class="card">
    <div class="card-title">Project Milestones</div>
    <ul class="milestone-list">
        {% for milestone in milestones %}
        <li class="milestone-item {{ 'completed' if milestone.completed else 'pending' }}">
            <span class="milestone-check">{{ '✓' if milestone.completed else '○' }}</span>
            <div>
                <span class="milestone-name">{{ milestone.milestone_name }}</span>
                <span class="milestone-date text-muted text-sm">
                    {% if milestone.completed %}
                        Completed {{ milestone.completed_date }}
                    {% elif milestone.target_date %}
                        Target: {{ milestone.target_date }}
                    {% endif %}
                </span>
            </div>
        </li>
        {% endfor %}
    </ul>
</div>
{% endif %}

<!-- Recent activity -->
{% if recent_entries %}
<div class="card">
    <div class="card-title">Recent Activity (Last 5 Entries)</div>
    <table>
        <thead>
            <tr>
                <th>Date</th>
                <th>Hours</th>
                <th>Description</th>
            </tr>
        </thead>
        <tbody>
            {% for entry in recent_entries %}
            <tr>
                <td>{{ entry.entry_date }}</td>
                <td>{{ entry.hours }}h</td>
                <td>{{ entry.description or '—' }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
</div>
{% endif %}

{% endblock %}

The First Client Session

Kevin from GreenBridge Logistics was the first client Maya gave access to. She chose him deliberately: he was the client most likely to check it frequently, and therefore the most likely to find bugs early.

She sent him an email:

Kevin — I've been working on something new for our clients. I've built a project portal where you can check your project's status, hours, and milestones without having to email me for updates.

Your project page: https://portal.mayareyes.consulting/ Your project code: GRBN-2024-002

Let me know what you think. All feedback welcome.

Kevin's response came twelve minutes later: "This is incredibly useful. I can already see that we've used 67% of our hours — I didn't realize we were tracking that fast. Can we schedule a call to talk about budget?"

Maya had her first "the portal worked exactly as intended" moment. Kevin had information he needed, had acted on it proactively, and the conversation that followed was productive rather than reactive.

Two weeks later, she sent portal codes to all eight active clients.


What the Portal Did for Maya's Business

Reduced status email volume by approximately 80%. Clients who would email "quick question — how are we tracking on hours?" now checked the portal instead.

Changed client conversations. Instead of "can you tell me where we stand," conversations started from "I saw on the portal that we're at 73% of budget — here's what I think we should prioritize." Clients came to calls better prepared.

Created a record of progress. The milestone timeline gave clients a visual sense of what had been accomplished, not just what remained. One client used the portal to show their own leadership team why the project was on track.

Made scope creep visible. When clients could see hours ticking upward in real time, conversations about scope changes became easier. The data was right there, neutral and clear.

Differentiated Maya's practice. None of the other independent consultants her clients compared her to had anything like this. Several clients mentioned it specifically in referrals.


The Honest Assessment

Maya built this in four days of focused work — one day for the database query functions, one day for the Flask routing and authentication, and two days on the templates and design. She had been building Python skills since Chapter 1. For someone starting from this chapter without that background, budget two weeks.

What the portal cannot do: - Send automatic notifications when milestone status changes (that would require a separate scheduler — see Chapter 22) - Handle concurrent users gracefully under load (fine for eight clients; a problem at eight hundred) - Reset project codes automatically (Maya handles this manually, which is acceptable at her scale) - Show historical budget trend charts (she has the data; adding Chart.js to the template would take one afternoon)

What those limitations tell you: building the first version that works beats planning the perfect version that never ships. Maya launched with the data she had and the features her clients actually needed. The wishlist items can follow.


Maya's Python Portfolio: Completed

As of this case study, Maya has built:

  1. Automated invoicing system — Chapter 16 — Generates and emails invoices monthly without manual intervention
  2. Scheduled reporting pipeline — Chapter 22 — Runs data aggregation and PDF generation on schedule
  3. SQLite project database — Chapter 23 — Replaces CSV files with a properly structured relational database
  4. Automated PDF status reports — Chapter 36 — On-demand and scheduled client reports
  5. Client-facing project portal — Chapter 37 — Real-time web access to project status for all clients

She started Chapter 1 tracking projects in a spreadsheet. She ends Chapter 37 having built infrastructure that runs her consulting practice's operational layer almost autonomously. Her tooling looks like something a three-person software startup would be proud of.

Chapter 38 covers the final step: deploying the portal to a real server so it is always available, not just when her laptop is open.


Next: Chapter 38 — Deploying Python to the Cloud. Maya's portal goes live. Acme Corp's dashboard becomes accessible from anywhere. And everything you have built in this book gets a public address.