Render a heatmap calendar from daily values
Build a GitHub-style activity grid in cells from a column of dated counts.
Published Aug 9, 2025
Northwind logs every client touch-point — a call, a meeting, a delivered piece of work — against the day it happened. Over a year that is a long, flat column of dates and counts that tells you almost nothing at a glance. You cannot see the busy stretches, the quiet weeks, or whether things are trending up.
This script turns that column into a GitHub-style heatmap: a 7-row by 53-column grid where each cell is one day, coloured from pale grey for nothing to deep green for a busy day. A year of activity becomes one block you can read in seconds. The script writes the grid and applies a colour gradient with conditional formatting, so the heatmap re-paints itself every time it runs.
What you’ll need
- An
Activitysheet with two columns and a header row:dateandcount. One row per day; days with no activity can simply be absent. - A
Heatmaptab where the grid is drawn. The script clears and rewrites it on every run, so it does not matter what is in it. - The spreadsheet IDs for the activity data and the dashboard. They are pulled into named constants at the top of the script.
The script
// The spreadsheet holding the raw "Activity" log (date, count).
const ACTIVITY_SHEET_ID = '1abcActivitySheetId';
// The spreadsheet whose "Heatmap" tab the grid is drawn on.
const DASHBOARD_SHEET_ID = '1abcDashboardSheetId';
// Name of the tab that receives the grid.
const HEATMAP_TAB = 'Heatmap';
// Grid shape: 7 days down, 53 weeks across — enough to cover a full year
// plus the partial weeks at each end.
const ROWS = 7;
const COLS = 53;
// The colour gradient. Empty days are grey; the midpoint count and above
// climb from light to dark green.
const COLOUR_LOW = '#ebedf0';
const COLOUR_MID = '#40c463';
const COLOUR_HIGH = '#216e39';
const MID_COUNT = '5';
/**
* Reads the daily activity log and paints a year-long heatmap grid on
* the Heatmap tab, coloured by a conditional-formatting gradient.
*/
function buildHeatmap() {
// 1. Load the activity counts, keyed by ISO date string.
const data = readActivity();
if (!data.size) {
Logger.log('No activity rows found — nothing to draw.');
return;
}
// 2. Work out the grid's start: 364 days back, then rewound to the
// Sunday of that week so every column is a clean Sun-Sat block.
const today = new Date();
today.setHours(0, 0, 0, 0);
const startSunday = new Date(today);
startSunday.setDate(today.getDate() - 364);
startSunday.setDate(startSunday.getDate() - startSunday.getDay());
// 3. Fill the grid column by column (week) and row by row (weekday).
const grid = Array.from({ length: ROWS }, () => Array(COLS).fill(''));
for (let col = 0; col < COLS; col++) {
for (let row = 0; row < ROWS; row++) {
const day = new Date(startSunday);
day.setDate(day.getDate() + col * 7 + row);
// Leave future days blank — the grid stops at today.
if (day > today) continue;
const iso = day.toISOString().slice(0, 10);
grid[row][col] = data.get(iso) || 0;
}
}
// 4. Clear the Heatmap tab and write the grid in one block.
const heat = SpreadsheetApp.openById(DASHBOARD_SHEET_ID)
.getSheetByName(HEATMAP_TAB);
heat.clear();
const range = heat.getRange(1, 1, ROWS, COLS);
range.setValues(grid);
// 5. Apply a three-point colour gradient over the whole grid.
const rule = SpreadsheetApp.newConditionalFormatRule()
.setGradientMinpoint(COLOUR_LOW)
.setGradientMidpoint(
COLOUR_MID,
SpreadsheetApp.InterpolationType.NUMBER,
MID_COUNT
)
.setGradientMaxpoint(COLOUR_HIGH)
.setRanges([range])
.build();
heat.setConditionalFormatRules([rule]);
Logger.log('Heatmap repainted for the year ending ' +
today.toISOString().slice(0, 10));
}
/**
* Reads the Activity sheet and returns a Map of ISO date string -> count,
* so the grid builder can look up any day in constant time.
*/
function readActivity() {
const [header, ...rows] = SpreadsheetApp.openById(ACTIVITY_SHEET_ID)
.getSheets()[0]
.getDataRange()
.getValues();
return new Map(
rows.map((row) => {
// Dates may come back as Date objects or as strings — normalise
// both to the YYYY-MM-DD form used as the grid key.
const key = row[0] instanceof Date
? row[0].toISOString().slice(0, 10)
: String(row[0]);
return [key, row[1]];
})
);
}
How it works
buildHeatmapcallsreadActivity, which loads the log and returns aMapof ISO date string to count. If the log is empty it logs a message and stops.- It finds the grid’s start date: 364 days before today, then rewound to that week’s Sunday. That guarantees every column is a clean Sunday-to-Saturday week, the same as a GitHub contribution graph.
- It walks the grid column by column. Each column is a week, each row a
weekday. For every cell it computes the actual date, looks it up in the map,
and writes the count — or
0if there was no activity. Days after today are left blank. - It clears the
Heatmaptab and writes the whole 7×53 grid in onesetValuescall, which is far faster than touching cells one at a time. - It builds a conditional-formatting rule with a three-point gradient — grey at
the low end, green at the
MID_COUNTmidpoint, dark green at the top — and applies it over the entire grid. The colours recompute automatically as the numbers change.
Example run
Say the Activity sheet contains rows like these:
| date | count |
|---|---|
| 2025-08-04 | 3 |
| 2025-08-05 | 8 |
| 2025-08-06 | 1 |
| 2025-08-08 | 12 |
After a run, the Heatmap tab holds a 7×53 grid of numbers. Each cell is one
day, and the conditional-formatting gradient paints them:
- Days with
0show pale grey (#ebedf0). - A day with
3shows a light green, below the midpoint. - A day with
8or12shows deep green (#216e39).
Read top to bottom for the days of a week, left to right for the weeks of the year. Busy stretches stand out as dark bands; quiet spells stay grey.
Trigger it
This is a scheduled job — the heatmap should reflect yesterday’s activity each morning:
- In the Apps Script editor, open Triggers (the clock icon).
- Click Add Trigger.
- Choose
buildHeatmap, event source Time-driven, a Day timer, and the 2am to 3am slot. - Save. The grid now repaints itself overnight.
Watch out for
- The grid is fixed at 7×53. A leap year, or the partial weeks at each end, can push a year just past 53 columns — a day or two may fall off the left edge. For an exact fit, compute the column count from the real start and end dates.
toISOStringreturns a UTC date. If your spreadsheet’s timezone is far from UTC, a count logged late in the evening could land on the wrong day in the grid. For most uses the drift is harmless, but be aware of it near midnight.- The
Heatmaptab is cleared on every run. Do not keep notes, labels, or other formulas on it — put month labels or a legend on a separate tab. - The midpoint count (
MID_COUNT) is fixed at 5. If a typical busy day at Northwind is 20 touch-points, the whole grid will look dark green. Tune the midpoint to your real data so the gradient stays meaningful. - Days entirely absent from the log are drawn as
0. The grid cannot tell “no activity” apart from “not recorded” — make sure the log is complete before you read quiet weeks as genuinely quiet.
Related
Build a linear-projection forecasting model
Extend a Northwind revenue trend into the next quarter with a simple linear regression.
Updated Aug 30, 2025
Build a subscription churn dashboard
Track active, churned, and reactivated accounts month over month from a Subscriptions sheet.
Updated Aug 27, 2025
Build a cohort-retention analysis tab
Compute month-over-month retention curves from a signups log — built monthly cohorts, automatic.
Updated Aug 23, 2025
Build a budget-vs-actual variance tracker
Compute and colour-code variances between budgeted and actual spend per project category.
Updated Aug 20, 2025
Generate a project burndown chart
Visualise remaining work against an ideal line, refreshed nightly from the Tasks sheet.
Updated Aug 16, 2025