Build a habit tracker with streak counting
Log daily checkmarks per habit and compute the current streak in a custom column.
Published Nov 22, 2025
Northwind runs a few small team rituals — a morning standup, a weekly retro, a Friday demo — and the studio likes to know they actually happen. A simple grid in a Sheet does the logging: one row per habit, one column per day, a tick in the cell when the ritual happened. What the grid does not give you is the number people actually care about: the streak.
This script reads that grid, walks each habit’s tick marks from the most recent
day backwards, and counts how many days in a row it has been kept. It writes the
result into a streak column so the current run is visible at a glance — the
small bit of friction that keeps a ritual alive.
What you’ll need
- A Google Sheet where the first sheet holds the habit grid: column A is the
habit name, and one column per day with a real Date value in the header
row (not text — the script relies on the headers being
Dateobjects). - A column with the header
streak— anywhere in the sheet. The script writes the computed streak there. - Ticked days marked with a
✓character or a checkbox (TRUE). Anything else in a day cell counts as a miss.
The script
// The spreadsheet that holds the habit grid.
const HABITS_SHEET_ID = '1abcHabitsSheetId';
// What counts as "kept" for a given day.
const TICK = '✓';
/**
* Reads the habit grid, counts each habit's current run of consecutive
* kept days, and writes the result into the "streak" column.
*/
function computeStreaks() {
const sheet = SpreadsheetApp.openById(HABITS_SHEET_ID).getSheets()[0];
const [header, ...rows] = sheet.getDataRange().getValues();
// Bail out early if there are no habits to score.
if (!rows.length) {
Logger.log('No habit rows found — nothing to do.');
return;
}
// 1. Find every column whose header is a real Date — these are the days.
const dateCols = header
.map((h, i) => (h instanceof Date ? i : -1))
.filter((i) => i >= 0);
// 2. Sort day columns newest-first so we can count backwards from today.
const sortedDateCols = dateCols.sort((a, b) => header[b] - header[a]);
// 3. Locate the column the streak is written into.
const streakCol = header.indexOf('streak');
if (streakCol === -1) {
Logger.log('No "streak" column found — add one and re-run.');
return;
}
// 4. For each habit, walk days newest-first and stop at the first miss.
const updates = rows.map((r) => {
let streak = 0;
for (const idx of sortedDateCols) {
if (r[idx] === TICK || r[idx] === true) streak++;
else break;
}
return [streak];
});
// 5. Write the streak counts back in one batched call.
sheet.getRange(2, streakCol + 1, updates.length, 1).setValues(updates);
Logger.log('Updated streaks for ' + updates.length + ' habits.');
}
How it works
computeStreaksopens the habits spreadsheet and splits the grid into a header row and the habit rows. If there are no habit rows it logs and stops.- It scans the header for cells that are real
Dateobjects — those are the day columns. Text-formatted dates are ignored, which is the one gotcha to watch for. - It sorts the day columns newest-first by comparing the header dates, so the count always starts from the most recent day.
- It finds the
streakcolumn by header name and stops if it is missing. - For each habit it walks the day columns from newest to oldest, adding one for every tick and breaking the loop the moment it hits a miss — that break is what makes it a current streak rather than a total.
- It writes all the streak values back in a single
setValuescall, which is far faster than updating cells one at a time.
Example run
Say the grid looks like this, with today being 22 Nov:
| Habit | 20 Nov | 21 Nov | 22 Nov | streak |
|---|---|---|---|---|
| Standup | ✓ | ✓ | ✓ | |
| Retro | ✓ | ✓ | ||
| Demo | ✓ | ✓ |
After a run, the streak column is filled in:
| Habit | 20 Nov | 21 Nov | 22 Nov | streak |
|---|---|---|---|---|
| Standup | ✓ | ✓ | ✓ | 3 |
| Retro | ✓ | ✓ | 2 | |
| Demo | ✓ | ✓ | 1 |
Demo was kept today and yesterday-but-one, but the gap on 21 Nov resets the count — its current streak is 1, not 2.
Trigger it
Run this once a day, after the day’s cells have been filled in:
- In the Apps Script editor open Triggers (the clock icon).
- Add Trigger → choose
computeStreaks, event source Time-driven, a Day timer set to late evening. - Save and approve the authorisation prompt.
To update streaks the instant a tick is added, swap the time trigger for an
onEdit installable trigger on the habits spreadsheet instead.
Watch out for
- The day headers must be real
Datevalues. If a header reads as text the column is skipped and that day silently never counts. Format the header row as dates to be safe. - A streak is broken by anything that is not a
✓or aTRUEcheckbox — an empty cell, anx, a typo. Decide on one tick convention and stick to it. - Days in the future, if you pre-fill the grid, sort to the front and will be counted. Only tick cells once the day has actually happened.
- The
streakvalue is a snapshot from the last run. It does not “decay” overnight on its own — the count is only correct as of the most recent run, so keep the daily trigger in place. - Reordering or inserting day columns is fine; the script keys off header dates,
not column positions. Renaming the
streakcolumn is not — update the constant to match.
Related
Build an OKR tracker with progress rollups
Aggregate Northwind's key-result progress into objective-level scores automatically.
Updated Jan 14, 2026
Build a recurring-task generator
Spawn new Northwind task rows on a daily or weekly cadence from a Recurring sheet.
Updated Jan 10, 2026
Build a multi-sheet search-and-jump tool
Find a value across every tab of a workbook and click through to the cell that contains it.
Updated Jan 7, 2026
Auto-rebuild grouped summaries on edit
Refresh pivot-style rollups the instant data changes — no manual recompute.
Updated Jan 4, 2026
Build a data-quality scorecard
Grade any sheet on completeness, validity, and freshness — surface gaps as a single score.
Updated Dec 31, 2025