appscript.dev
Automation Intermediate Sheets

Build a client-facing progress tracker

Show Northwind project status through a web app — clients self-serve their updates.

Published Oct 7, 2025

Northwind clients want to know where their projects stand, and the answer is always the same email: “everything is on track, here is what is next”. Writing those updates by hand is repetitive, and a client who asks twice in a week gets two slightly different stories depending on who replied.

This script gives each client a private status page instead. They open a link, the web app reads their projects straight from the tracking sheet, and they see the current status for themselves — no email, no chasing, and the single source of truth is the same sheet the team already updates.

What you’ll need

  • A Projects Google Sheet with a header row including a clientId column plus whatever status fields you want to show — for example projectName, status, nextMilestone and dueDate.
  • A short, unguessable value in the clientId column for each client — a random string works well, so one client’s link cannot reveal another’s.
  • An HTML file named Progress in the same Apps Script project (see below).
  • The project deployed as a web app so clients can open it.

The script

// The spreadsheet that holds the project status rows.
const PROJECTS_SHEET_ID = '1abcProjectsId';

/**
 * Web app entry point. Reads the client id from the URL, filters the
 * Projects sheet to that client, and renders their status page.
 */
function doGet(e) {
  const clientId = e.parameter.c;

  // Bail out early if the link is missing its client id.
  if (!clientId) return HtmlService.createHtmlOutput('Missing client id');

  // Keep only the rows that belong to this client.
  const projects = readSheet(PROJECTS_SHEET_ID)
    .filter((p) => p.clientId === clientId);

  // Pass the filtered projects into the Progress template and render it.
  const t = HtmlService.createTemplateFromFile('Progress');
  t.projects = projects;
  return t.evaluate();
}

/**
 * Reads a sheet into an array of objects keyed by the header row.
 */
function readSheet(id) {
  const [h, ...rows] = SpreadsheetApp.openById(id).getSheets()[0]
    .getDataRange().getValues();
  return rows.map((r) => Object.fromEntries(h.map((k, i) => [k, r[i]])));
}

The Progress HTML file renders the status list. A minimal version:

<!DOCTYPE html>
<html>
  <body style="font-family: Arial, sans-serif; max-width: 560px; margin: 40px auto;">
    <h2>Your projects</h2>

    <? if (projects.length === 0) { ?>
      <p>No projects found for this link.</p>
    <? } else { ?>
      <? for (const p of projects) { ?>
        <div style="border-bottom: 1px solid #ddd; padding: 12px 0;">
          <strong><?= p.projectName ?></strong>
          <p>Status: <?= p.status ?></p>
          <p>Next: <?= p.nextMilestone ?> (due <?= p.dueDate ?>)</p>
        </div>
      <? } ?>
    <? } ?>
  </body>
</html>

How it works

  1. doGet runs whenever a client opens the web app URL. It reads the c query parameter — the client id, the part after ?c= in the link.
  2. If there is no c, it returns a short message instead of an empty page.
  3. readSheet reads the whole Projects sheet into objects keyed by header name, so each row is easy to work with.
  4. The rows are filtered down to only those whose clientId matches the link, so a client never sees another client’s projects.
  5. The filtered list is passed into the Progress template.
  6. The template loops over the projects and renders each one’s status and next milestone — or a friendly message if the client has no projects.

Example run

The Projects sheet holds rows for several clients:

clientIdprojectNamestatusnextMilestonedueDate
x9k2Brand refreshIn progressFinal artwork2026-06-10
x9k2Site rebuildAwaiting contentHomepage draft2026-06-20
m4p7Print campaignComplete

The client behind x9k2 opens https://script.google.com/.../exec?c=x9k2 and sees just their two projects:

Brand refresh — Status: In progress — Next: Final artwork (due 2026-06-10)

Site rebuild — Status: Awaiting content — Next: Homepage draft (due 2026-06-20)

The m4p7 row never appears on that page.

Run it

This is an on-demand web app, so there is no trigger to set:

  1. In the Apps Script editor, click Deploy > New deployment.
  2. Choose Web app, set Execute as to yourself and Who has access to Anyone so external clients can open the link.
  3. Copy the web app URL and append ?c= plus the client’s id — that full link is what you send each client.

Pair with Build a branded approval interface so clients can also approve deliverables.

Watch out for

  • The clientId is the only thing keeping pages private. Use long, random values, not sequential numbers, or a client could view another’s status by editing the URL.
  • The page is read-only and reflects the sheet exactly. If the team has not updated a row, the client sees stale information — keep the sheet current.
  • Every page load reads the entire Projects sheet. That is fine for hundreds of rows; for a very large sheet, consider caching or splitting by client.
  • “Execute as me” means the web app reads the sheet with your permissions, so clients never need access to the spreadsheet — but be careful what columns the Progress template renders, as anything in a client’s rows is visible.
  • There is no login. Anyone with the link can open it, so treat the URL like a password and send it over a channel the client controls.

Related