Back to blog
June 18, 2026 · 5 min ·

Your calendar is a database. I made mine track my office days.

Hybrid policies want you in the office some percentage of the time. I almost built an app for that. Then I remembered I already had a synced, notifying, queryable datastore.

My employer runs the hybrid thing: be in the office 50% of the working days each quarter. Reasonable on paper. Quietly annoying in practice, because 50% of a quarter is not a number anyone carries around in their head.

I opened the attendance dashboard one evening and I was sitting a bit below the target. After a small, dignified panic, my brain did the worst thing a developer brain does. It said: I’ll build an app.

A database for my office days. A bit of auth. A cron job. A dashboard with a progress ring, because obviously a progress ring. An Agent, even, (since that is the word we are all contractually required to say in 2026). I got as far as thinking about the repo name before I stopped and asked the only question that matters: what is the smallest thing that solves this?

The answer was open in another tab. My calendar.

Your calendar is already the database

Look at what Google Calendar actually is. It is a datastore. Rows (events), with fields (title, time, status). It syncs to every device you own, it sends notifications, and it will run your code on a schedule for free. You did not build any of that. You have been treating it as a glorified to-do list when it is closer to Postgres with an excellent notification layer bolted on.

So the model gets very small:

  • Each planned office day is a row.
  • Whether I went is a boolean.
  • The whole feature is one query: count my office days this quarter.

No new database. No hosting. The write API is dragging things around with a mouse.

Book the intent

First, put the plan in. 50% of a quarter is about 2.5 days a week once you strip out weekends and bank holidays. So I dropped two recurring events onto the calendar: Office day on Tuesday and Wednesday every week, and Office day every other Thursday to lift the average to 2.5.

The titles are doing real work here, and that is the one trick worth internalising: everything keys off the event title starting with Office day. That prefix is my table name. Nothing else about the event is sacred.

Log attendance without logging anything

This is the part I like.

These are events I own, which means there is no RSVP. You cannot accept or decline your own calendar event. So the only honest signal left is presence:

  • The block is still there on a past day, I went.
  • I deleted it, I did not.
  • I moved it to another day, it counts on the new day, because the query reads where the event actually sits.

No form. No “log my day” button that I will press exactly twice and then never again. The act of tidying my calendar is the act of recording attendance. On a Friday I delete the blocks I bailed on, and the data is now true.

That is the whole logging layer. A habit I already half-have, pointed at a purpose.

The fifteen-line agent

The last piece is something to read the calendar and tell me where I stand. This is a Google Apps Script, the most underrated thing Google ships. Native access to Calendar and Gmail, a built-in weekly timer, zero servers.

const CONFIG = {
  RECIPIENT: 'you@example.com',
  EVENT_PREFIX: 'Office day',   // the title every counted event starts with
  TARGET: 33,                   // 50% of this quarter's working days
  QUARTER_START: '2026-07-01',
};

function officeDaysSoFar() {
  const cal = CalendarApp.getDefaultCalendar();
  return cal.getEvents(new Date(CONFIG.QUARTER_START), new Date())
    .filter(e => e.getTitle().indexOf(CONFIG.EVENT_PREFIX) === 0)
    .filter(e => e.getMyStatus() !== CalendarApp.GuestStatus.NO)
    .length;
}

function runWeeklyCheck() {
  const done = officeDaysSoFar();
  const left = Math.max(0, CONFIG.TARGET - done);
  MailApp.sendEmail(
    CONFIG.RECIPIENT,
    `Attendance: ${done}/${CONFIG.TARGET}`,
    `Office days this quarter: ${done} of ${CONFIG.TARGET}\nStill to do: ${left}`
  );
}

function installWeeklyTrigger() {
  ScriptApp.newTrigger('runWeeklyCheck')
    .timeBased().onWeekDay(ScriptApp.WeekDay.FRIDAY).atHour(17).create();
}

Run installWeeklyTrigger once, click through the “Google hasn’t verified this app” screen (it is your own code asking to read your own calendar), and that is it. Every Friday at 5 it emails me the count. Read the table, do the subtraction, send the mail.

That snippet is the whole idea. The version I actually run adds three things: a per-week count, a small text progress bar, and a warning when the pace needed to recover stops being realistic. If you want the exact setup I have, here it is end to end. Change RECIPIENT, set QUARTERS to your own quarters and 50% targets, then run installWeeklyTrigger once. Same Friday email, same keep-or-delete workflow.

const CONFIG = {
  RECIPIENT: 'you@example.com',
  CALENDAR_ID: 'primary',
  EVENT_PREFIX: 'Office day',   // title every counted event starts with
  MAX_PER_WEEK: 4,              // warn if recovering needs more than this a week
  QUIET_WHEN_ON_TRACK: false,   // false = email every week; true = only when behind
  TRIGGER_HOUR: 17,
};

// Edit these to your own quarters and 50% targets.
// joiner: true skips a quarter you started partway through, so it will not
// nag you about a number that is no longer reachable.
const QUARTERS = [
  { id: 'Q3', label: 'Q3 (Jul to Sep)', start: '2026-07-01', end: '2026-09-30', target: 33, joiner: false },
  { id: 'Q4', label: 'Q4 (Oct to Dec)', start: '2026-10-01', end: '2026-12-31', target: 32, joiner: false },
];

// Days that should not count as working days. Swap in your own.
const BANK_HOLIDAYS = ['2026-08-31', '2026-12-25', '2026-12-28'];

function ymd(d) {
  return Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy-MM-dd');
}
function dateOnly(s) {
  const p = s.split('-');
  return new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
}
function isWorkingDay(d) {
  const dow = d.getDay();
  if (dow === 0 || dow === 6) return false;
  return BANK_HOLIDAYS.indexOf(ymd(d)) === -1;
}
function countWorkingDays(start, end) {
  let n = 0;
  const cur = new Date(start.getTime());
  while (cur <= end) {
    if (isWorkingDay(cur)) n++;
    cur.setDate(cur.getDate() + 1);
  }
  return n;
}
function bar(pct) {
  let f = Math.max(0, Math.min(10, Math.round(pct / 10)));
  let b = '';
  for (let i = 0; i < 10; i++) b += i < f ? '#' : '-';
  return b;
}

function computeStatus() {
  const now = new Date();
  const todayStr = ymd(now);
  const today = dateOnly(todayStr);
  const q = QUARTERS.find(x => todayStr >= x.start && todayStr <= x.end);
  if (!q) return null;

  const qStart = dateOnly(q.start);
  const qEnd = dateOnly(q.end);
  const cal = CONFIG.CALENDAR_ID === 'primary'
    ? CalendarApp.getDefaultCalendar()
    : CalendarApp.getCalendarById(CONFIG.CALENDAR_ID);

  const endOfToday = new Date(today.getTime());
  endOfToday.setHours(23, 59, 59);

  const dow = today.getDay();
  const weekStart = new Date(today.getTime());
  weekStart.setDate(today.getDate() + (dow === 0 ? -6 : 1 - dow));
  weekStart.setHours(0, 0, 0, 0);

  let attended = 0, thisWeek = 0;
  cal.getEvents(qStart, endOfToday).forEach(e => {
    if ((e.getTitle() || '').indexOf(CONFIG.EVENT_PREFIX) !== 0) return;
    if (e.getMyStatus() === CalendarApp.GuestStatus.NO) return; // deleted or declined = did not go
    attended++;
    if (e.getStartTime() >= weekStart) thisWeek++;
  });

  const totalWD = countWorkingDays(qStart, qEnd);
  const elapsedWD = countWorkingDays(qStart, today < qEnd ? today : qEnd);
  const expected = totalWD ? Math.round(q.target * elapsedWD / totalWD) : 0;
  const remaining = Math.max(0, q.target - attended);
  const pct = Math.round(attended / q.target * 100);
  const weeksLeft = Math.max(1, Math.ceil((qEnd - today) / (7 * 864e5)));
  const perWeek = Math.round(remaining / weeksLeft * 10) / 10;
  const behind = attended < expected;
  const atRisk = perWeek > CONFIG.MAX_PER_WEEK;

  return { q, attended, thisWeek, expected, remaining, pct, weeksLeft, perWeek,
           behind, atRisk, onTrack: !behind && !atRisk,
           weekEnding: Utilities.formatDate(now, Session.getScriptTimeZone(), 'd MMM yyyy') };
}

function statusBody(s) {
  const L = [s.q.label];
  L.push(`Week ending ${s.weekEnding}: ${s.thisWeek} office day(s) logged.`, '');
  L.push(`Progress: ${s.attended} of ${s.q.target} (${s.pct}%)`, `[${bar(s.pct)}]`);
  L.push(`Expected by now: ${s.expected}`);
  L.push(s.onTrack ? 'Status: on track.'
       : s.behind ? `Status: behind by ${s.expected - s.attended}.`
       : 'Status: pace is getting tight.');
  L.push('', `Left to do: ${s.remaining} over about ${s.weeksLeft} weeks, roughly ${s.perWeek} a week.`);
  if (s.atRisk) L.push('', `Heads up: that is over your ${CONFIG.MAX_PER_WEEK} a week ceiling. Front-load now, or talk to whoever owns the target.`);
  return L.join('\n');
}

function runWeeklyCheck() {
  const s = computeStatus();
  if (!s) return;
  if (s.q.joiner) return;                              // joining quarter: do not nag
  if (CONFIG.QUIET_WHEN_ON_TRACK && s.onTrack) return; // stay quiet when on track
  const tag = s.onTrack ? 'on track' : s.atRisk ? 'at risk' : 'behind';
  MailApp.sendEmail(CONFIG.RECIPIENT, `Attendance ${s.q.id}: ${s.attended}/${s.q.target} (${tag})`, statusBody(s));
}

function installWeeklyTrigger() {
  ScriptApp.getProjectTriggers().forEach(t => {
    if (t.getHandlerFunction() === 'runWeeklyCheck') ScriptApp.deleteTrigger(t);
  });
  ScriptApp.newTrigger('runWeeklyCheck')
    .timeBased().onWeekDay(ScriptApp.WeekDay.FRIDAY).atHour(CONFIG.TRIGGER_HOUR).create();
}

function sendTestEmail() {
  const s = computeStatus();
  if (s) MailApp.sendEmail(CONFIG.RECIPIENT, `Attendance test (${s.q.id})`, statusBody(s));
}

Run sendTestEmail once to see a sample land in your inbox, then installWeeklyTrigger to set the Friday timer. After that it runs itself.

The lesson I keep relearning: match the tool to the problem. I was one weekend away from building a service to replicate features the calendar already had. The honest answer to “should I build an agent for this” is usually “you already have four, and one of them is a spreadsheet.”

The pattern, and everywhere else it works

Now delete the word “office” and look at what is left:

  1. Pre-booked events that represent intent.
  2. Keep-or-delete as a zero-effort log.
  3. A scheduled script that counts and reports.

That is not an attendance tracker. That is a habit engine wearing an attendance tracker as a costume. Point the title prefix at anything:

  • Gym. Book Workout three times a week. Delete the ones you skip. Friday’s email tells you if you hit three or lied to yourself.
  • Shipping cadence. Publish once a week, and the script nags the moment the streak breaks.
  • Deep work. Book the focus blocks, keep only the ones you actually defended, get a weekly tally of hours you protected versus hours the calendar ate.
  • Reading, language practice, the instrument gathering dust. Same shape.
  • Hydration or meds, where the email just confirms the week rather than chasing a target.
  • On-call or a shared rota, where you count across a team calendar instead of your own.

To re-aim the whole thing you change three constants: the title prefix, the target, the date range. Everything else is identical. One small script becomes a general-purpose “did I do the thing this many times” machine, and you never opened a terminal.

Start here

You can have a working version before your coffee goes cold.

  1. Drop a recurring event on your calendar for the thing you want to track. Give it a title you would not use for anything else.
  2. Paste the script at script.google.com, set RECIPIENT and EVENT_PREFIX, set a target.
  3. Run installWeeklyTrigger once, approve the permissions, done.

The only discipline it asks of you: delete the events you did not honour, before the email goes out. The calendar believes you completely. Your one job is to make it true.

Your calendar already knows where you have been. The least it can do is keep score.