Email a weekly "what changed" report from a Sheet
Diff the Projects sheet week over week and email the team the rows that changed.
Published Apr 14, 2026
A shared sheet is a living document — rows added, rows closed, statuses nudged along — and that constant motion is exactly what makes it hard to follow. Open the Projects sheet on a Friday and you see the current state, but not the story: what is new, what dropped off, what moved. The week’s changes are invisible because nothing keeps the previous version to compare against.
Northwind’s Projects sheet changes daily, and the team wants a Friday email
that answers one question: what changed this week? This script keeps that
question answerable. Each week it saves a snapshot of the sheet to Drive, then
compares the current state against last week’s snapshot and emails the team a
plain diff — rows added, rows removed, and statuses that moved. The first run has
nothing to compare against, so it just stores the baseline and tells the team the
diff starts next week.
What you’ll need
- A Google Sheet with a
Projectstab. Row 1 must hold headers; the script relies on columns namedclient,project, andstatus. - A Drive folder to hold the weekly JSON snapshots, and its folder ID from the URL.
- The team email address that should receive the report.
- The spreadsheet’s ID, from its URL. All three IDs go in the config below.
The script
// The spreadsheet that holds the Projects tab.
const PROJECTS_SHEET_ID = '1abcProjectsSheetId';
// The Drive folder where weekly JSON snapshots are stored.
const SNAPSHOT_FOLDER_ID = '1abcSnapshotsFolderId';
// Who receives the weekly diff email.
const TEAM = '[email protected]';
// Script Property key that points at the most recent snapshot file.
const LAST_SNAPSHOT_KEY = 'LAST_SNAPSHOT_ID';
/**
* Snapshots the Projects sheet, diffs it against last week's snapshot,
* and emails the team what changed. Designed to run on a weekly trigger.
*/
function emailWeeklyDiff() {
// 1. Read the sheet as it is now, and load last week's snapshot.
const current = currentRows();
const previous = lastSnapshot();
// 2. Bail out early if the sheet is empty — nothing to snapshot.
if (current.length === 0) {
Logger.log('Projects sheet is empty — nothing to report.');
return;
}
// 3. Save this week's state before doing anything else, so a later
// failure can't lose the snapshot.
saveSnapshot(current);
// 4. No previous snapshot: this is the first run. Store the baseline
// and tell the team the diff starts next week.
if (!previous) {
GmailApp.sendEmail(
TEAM,
'Weekly Projects snapshot',
'First snapshot stored. Diff will start next week.'
);
return;
}
// 5. Compare the two weeks and email the formatted diff.
const diff = compare(previous, current);
GmailApp.sendEmail(TEAM, 'Projects — week-over-week changes', formatDiff(diff));
Logger.log('Weekly diff emailed to the team.');
}
/**
* Reads the Projects sheet and returns one plain object per row,
* keyed by the header names in row 1.
*/
function currentRows() {
const [headers, ...rows] = SpreadsheetApp.openById(PROJECTS_SHEET_ID)
.getSheets()[0]
.getDataRange()
.getValues();
return rows.map((row) =>
Object.fromEntries(headers.map((key, i) => [key, row[i]]))
);
}
/**
* Loads last week's snapshot from Drive, or null if there isn't one
* yet (the first ever run).
*/
function lastSnapshot() {
const id = PropertiesService.getScriptProperties()
.getProperty(LAST_SNAPSHOT_KEY);
if (!id) return null;
return JSON.parse(DriveApp.getFileById(id).getBlob().getDataAsString());
}
/**
* Writes the current rows to a dated JSON file in the snapshot folder
* and records its ID so next week's run can find it.
*/
function saveSnapshot(rows) {
const stamp = new Date().toISOString().slice(0, 10);
const blob = Utilities.newBlob(
JSON.stringify(rows),
'application/json',
`projects-${stamp}.json`
);
const file = DriveApp.getFolderById(SNAPSHOT_FOLDER_ID).createFile(blob);
PropertiesService.getScriptProperties()
.setProperty(LAST_SNAPSHOT_KEY, file.getId());
}
/**
* Compares two weeks of rows, keyed by client + project, and returns
* the rows added, removed, and changed in status.
*/
function compare(prev, curr) {
const key = (r) => `${r.client}|${r.project}`;
const prevMap = new Map(prev.map((r) => [key(r), r]));
const currMap = new Map(curr.map((r) => [key(r), r]));
const added = curr.filter((r) => !prevMap.has(key(r)));
const removed = prev.filter((r) => !currMap.has(key(r)));
const statusChanged = curr.filter(
(r) => prevMap.has(key(r)) && prevMap.get(key(r)).status !== r.status
);
return { added, removed, statusChanged };
}
/**
* Turns a diff object into a readable plain-text email body. Sections
* with no rows are left out entirely.
*/
function formatDiff(d) {
const section = (label, rows, fn) =>
rows.length
? `\n── ${label} (${rows.length}) ──\n${rows.map(fn).join('\n')}\n`
: '';
return (
[
section('Added', d.added, (r) => `+ ${r.client} / ${r.project}`),
section('Removed', d.removed, (r) => `- ${r.client} / ${r.project}`),
section(
'Status changed',
d.statusChanged,
(r) => `~ ${r.client} / ${r.project}: → ${r.status}`
),
].join('') || 'No changes this week.'
);
}
How it works
emailWeeklyDiffreads the sheet’s current state withcurrentRowsand loads last week’s snapshot withlastSnapshot.- If the sheet is empty it logs a message and stops, so an empty sheet never produces a misleading “everything was removed” report.
- It saves this week’s snapshot before doing anything else. Saving first means that even if a later step fails, next week still has a snapshot to diff against.
- On the very first run there is no previous snapshot. The script stores the baseline and emails a short note that the diff begins next week.
currentRowsreads the whole sheet and turns each row into an object keyed by the header names, so the rest of the code can refer tor.clientandr.statusinstead of column numbers.lastSnapshotlooks up the stored file ID in Script Properties and parses the JSON back into objects, or returnsnullif there is no ID yet.saveSnapshotwrites the rows to a dated JSON file in the Drive folder and records the new file’s ID, so each week’s snapshot is both archived and findable.comparebuilds a lookup keyed byclient|projectfor each week, then finds rows that exist only this week (added), only last week (removed), or in both with a differentstatus(status changed).formatDiffrenders the diff as plain text, dropping any section that has no rows, and falls back to “No changes this week.” when nothing moved.
Example run
Suppose last week’s snapshot and this week’s sheet differ like so: a new project
started for Crane Co, the Belmont rebrand wrapped up and was deleted, and the
Acme website moved from in progress to review. The Friday email reads:
── Added (1) ──
+ Crane Co / Q3 campaign
── Removed (1) ──
- Belmont / Rebrand
── Status changed (1) ──
~ Acme / Website: → review
A week with no edits produces a one-line body: No changes this week. Either
way, the team gets the story instead of having to spot it themselves.
Trigger it
This is a weekly report — run it at the end of the working week:
- In the Apps Script editor, open Triggers (the clock icon).
- Click Add Trigger.
- Choose
emailWeeklyDiff, event source Time-driven, type Week timer, set the day to Friday, and pick the 5pm–6pm slot. - Save, and approve the authorisation prompt the first time it runs.
Watch out for
- The diff is keyed on
client|project. If either value is edited between snapshots, the row reads as one removed and one added rather than a single change. Treat those two fields as stable identifiers. - Only
statuschanges are tracked. A change to a deadline, an owner, or a note will not appear in the report — extendcompareif you need to watch other columns. - The snapshot is keyed off a single Script Property. If that property is cleared or the snapshot file is deleted, the next run treats itself as a first run and the week’s diff is lost.
- Snapshots accumulate in the Drive folder, one JSON file per week. They are small, but tidy the folder occasionally or add a step to delete old files.
- The script depends on the header names
client,project, andstatusin row 1. Rename a column in the sheet and the matching part of the diff silently stops working. - The comparison is only as frequent as the trigger. If the weekly run is skipped, the next run diffs against an older snapshot, so the report covers more than one week without saying so.
Related
Send meeting follow-ups with the notes attached
After a Calendar event ends, email attendees the linked notes Doc automatically.
Updated May 19, 2026
Embed inline charts in a status email
Render a Sheets chart as an image inside the email body, not as an attachment.
Updated May 12, 2026
Send HTML email from a Google Doc template
Use a styled Doc as the source for branded, on-brand HTML email — no design tool needed.
Updated May 5, 2026
Parse bank-alert emails into an expense ledger
Convert transaction alerts from Northwind's bank into categorised spend rows automatically.
Updated Apr 28, 2026
Generate a printable address book from contacts
Export Northwind's Google Contacts to a formatted Doc you can actually print.
Updated Apr 21, 2026