Generate a timesheet from calendar events
Turn each week into billable hours, grouped by client tag in the event title.
Published Jul 24, 2025
Filling in a weekly timesheet at Northwind is the kind of admin nobody enjoys. By Friday afternoon people are reconstructing their week from memory, guessing which meetings were for which client, and rounding to the nearest hour because the truth is fuzzy. The data is right there on the calendar — it just needs to be tallied.
This script reads the past seven days of events, groups them by a client tag
in the title (the convention is [Acme] Design review), and appends one row
per client to a Timesheets sheet. Friday afternoons get their hour back, and
the numbers are honest because they came from the calendar.
What you’ll need
- The convention: tag any billable event with the client in square brackets at
the start of the title, e.g.
[Acme] Design review. Untagged events get bucketed underuntaggedso you can see how much time leaked out. - A
TimesheetsGoogle Sheet for the output. The script appends rows; it does not clear, so you get a running log of weeks. - The script runs against
CalendarApp.getDefaultCalendar()— the calendar of whoever owns the script. Each person runs their own copy.
The script
// Sheet that holds the running timesheet log.
const TIMESHEET_SHEET = '1abcTimesheetsId';
// How many days back to scan. Seven gives a "past week" view.
const WINDOW_DAYS = 7;
/**
* Reads the past week of calendar events, groups them by [tag] in the
* title, and appends one row per client to the Timesheets sheet.
*/
function buildTimesheet() {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - WINDOW_DAYS);
const events = CalendarApp.getDefaultCalendar().getEvents(start, end);
if (!events.length) {
Logger.log('No events in the window — nothing to log.');
return;
}
// 1. Tally hours per [tag]. Anything without a leading bracket is
// bucketed under "untagged" so we can see admin overflow.
const totals = new Map();
for (const event of events) {
const tag = (event.getTitle().match(/^\[([^\]]+)\]/) || [, 'untagged'])[1];
const hours = (event.getEndTime() - event.getStartTime()) / 3600_000;
totals.set(tag, (totals.get(tag) || 0) + hours);
}
// 2. Sort by hours descending — biggest clients at the top of the
// weekly entry — and format the date once for every row.
const weekStart = Utilities.formatDate(start, 'GMT', 'yyyy-MM-dd');
const rows = [...totals.entries()]
.sort((a, b) => b[1] - a[1])
.map(([client, hours]) => [weekStart, client, hours.toFixed(2)]);
// 3. Append to the running log. Headers, if any, are left untouched.
const sheet = SpreadsheetApp.openById(TIMESHEET_SHEET).getSheets()[0];
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, 3).setValues(rows);
Logger.log('Logged ' + rows.length + ' client rows for week starting ' + weekStart + '.');
}
How it works
buildTimesheetbuilds a seven-day window ending now and asks the default calendar for every event in that range.- If there are no events it logs and returns early — no point appending an empty row.
- For each event it pulls the tag out of the title with a small regex:
[Acme] Design reviewbecomesAcme. Anything without a leading[tag]falls through tountagged. - It adds the event’s duration (end minus start, divided into hours) to a
Mapkeyed by tag. TheMapmakes the running total trivial. - It sorts clients by hours descending, formats the week-start date once,
and produces a row of
[weekStart, client, hours]per tag. - It appends the rows to the existing log — no clearing — so you build up a history of weeks over time.
Example run
Say last week’s calendar contains, among other things, these tagged events:
[Acme] Design review— 1 hour[Acme] Standup— 0.5 hours x 4[Globex] Workshop— 3 hours[Globex] Demo prep— 1.5 hoursInternal: team lunch— 1 hour (no tag)
After buildTimesheet runs, the Timesheets sheet has these new rows
appended:
| Week of | Client | Hours |
|---|---|---|
| 2025-07-18 | Acme | 3.00 |
| 2025-07-18 | Globex | 4.50 |
| 2025-07-18 | untagged | 1.00 |
The untagged row is the useful one. It is the time that did not bill, and it
shows you whether you are leaking too much of the week into admin.
Trigger it
Run it Friday afternoon so the week is fresh in memory if anything looks off:
- In the Apps Script editor, open Triggers (clock icon).
- Add a trigger for
buildTimesheet, event source Time-driven, type Week timer, day Friday, time 4pm to 5pm. - Approve the authorisation prompt the first time.
Watch out for
- The tag convention is everything. Without
[Client] ...at the front of the title, time goes intountagged. A short doc on the team wiki saves a surprising amount of grief. - All-day events count as 24 hours each. Strip them out with a
e.isAllDayEvent()filter if you do not want them in the totals. - Declined meetings still appear in
getEvents. Filter one.getMyStatus() !== CalendarApp.GuestStatus.NOif you want to count only meetings you actually attended. - The window is a calendar week, not a working week. The script reads the last seven days from “now”; events from the previous Saturday and Sunday are included. Run it Friday afternoon, not Monday, and the boundary lines up.
- Hours are in decimal (3.50, not 3:30) because spreadsheets sum decimals
cleanly. If you need duration formatting for an invoice, do it in the
spreadsheet with
TEXT(A1/24, "[h]:mm").
Related
Send a feedback survey after each event
Email attendees a survey link automatically after Northwind workshops or trainings.
Updated Oct 24, 2025
Build a team-capacity view from calendars
Show how booked the Northwind team is this week — meeting hours per person.
Updated Oct 20, 2025
Flag meetings that could have been emails
Detect short, agendaless, oversized meetings — the smell of bad calendar hygiene.
Updated Oct 12, 2025
Send tiered deadline countdown reminders
Email Northwind teammates at 7, 3, and 1 days out from a Sheet of upcoming deadlines.
Updated Sep 30, 2025
Archive past events to a log sheet
Keep a searchable Northwind meeting history — every event logged with title, attendees, duration.
Updated Sep 26, 2025