Source side 4 min read · Published · Verified

How to count upcoming events in an ICS file

What “upcoming” should mean for an ICS file, why grep gives the wrong number, and a tested script that expands recurring events over the next 30 days.

Short answer

Counting BEGIN:VEVENT lines counts event definitions, not upcoming events. A correct count needs a fixed window (for example now to 30 days ahead), a rule for events already in progress, all-day dates read in the right time zone, cancelled events left out, and recurring events expanded into their individual dates. For one-off events the free checker does this; for recurring events use a calendar library, such as the script below.

“How many events are coming up?” sounds like a one-line command. It isn’t, because an ICS file stores event definitions, and one definition can mean a single evening, a three-day festival, or a class every Monday for ever. This guide explains what a fair count has to decide, then gives a short script that does it.

If you only need the answer for one file, the ICS event checker does it in your browser. Read on if you need to count recurring events too, or to count in a script.

Why grep gets it wrong

grep -c '^BEGIN:VEVENT' feed.ics
Counts definitions, including past, cancelled and recurring ones.

For the recurring.ics fixture this prints 2: a weekly class that started in January, and a one-off concert. Over the 30 days from 2 March 2026 the real number of upcoming events is 6: five Monday classes and the concert. For multiple-upcoming.ics it prints 5, but only 3 are in the window; one is past and one is too far ahead.

Define the window

Pick a start and an end and write them down. The checker uses now to now plus 30 × 24 hours, and treats the end as exclusive: an event starting exactly at the end doesn’t count, one starting a second before does. Any window works, as long as the website comparison uses the same one.

Decide about events in progress

An event that started an hour ago and finishes tonight is still on. The checker counts an event when it starts before the window ends and hasn’t finished yet. An event that ends exactly now doesn’t count.

The end comes from DTEND, or DTSTART plus DURATION. RFC 5545 gives an all-day event with no end a length of one day; a timed event with no end has no length.

Read all-day dates in the right zone

An all-day event (DTSTART;VALUE=DATE:20260320) is a date, not an instant. “20 March” starts at midnight in some time zone, and the choice can move it across the window edge. Use the calendar’s declared zone (X-WR-TIMEZONE) if it has one, otherwise the audience’s zone. The same applies to “floating” times with no zone; see ICS event on the wrong day or time.

Leave out cancelled events

An event with STATUS:CANCELLED is still in the file, but it isn’t happening. Don’t count it.

Expand recurring events

A definition with RRULE (a repeating rule) or RDATE (extra dates) produces many occurrences. EXDATE removes some, and a separate definition with a matching UID and a RECURRENCE-ID moves or changes one. Expanding these by hand is error-prone; use a library that implements RFC 5545 recurrence.

The free checker deliberately doesn’t expand them. When a recurring series could fall in the window, it marks the result Incomplete count and shows a minimum, such as “1+” for recurring.ics, rather than a guessed total.

A script that counts properly

This Node.js script uses ical.js, the open-source parser the checker is built on, to apply every rule above, including recurring events.

import { readFileSync } from 'node:fs';
import ICAL from 'ical.js';

const DAY = 24 * 60 * 60 * 1000;
const now = process.argv[3] ? new Date(process.argv[3]) : new Date();
const end = new Date(now.getTime() + 30 * DAY);

const calendar = new ICAL.Component(ICAL.parse(readFileSync(process.argv[2], 'utf8')));
for (const tz of calendar.getAllSubcomponents('vtimezone')) ICAL.TimezoneService.register(tz);

// Keep moved or changed occurrences (RECURRENCE-ID) with their series.
const series = new Map();
const exceptions = [];
for (const vevent of calendar.getAllSubcomponents('vevent')) {
  const event = new ICAL.Event(vevent);
  if (event.isRecurrenceException()) exceptions.push(event);
  else series.set(event.uid, event);
}

const found = [];
const consider = (start, finish, summary, status) => {
  const s = start.toJSDate();
  const e = finish ? finish.toJSDate() : start.isDate ? new Date(s.getTime() + DAY) : s;
  // Upcoming = starts before the window ends and hasn't finished yet.
  if (status !== 'CANCELLED' && s < end && (s >= now || e > now)) found.push({ s, summary });
};

for (const ex of exceptions) {
  const parent = series.get(ex.uid);
  if (parent) parent.relateException(ex);
  else consider(ex.startDate, ex.endDate, ex.summary, ex.component.getFirstPropertyValue('status'));
}

for (const event of series.values()) {
  const status = event.component.getFirstPropertyValue('status');
  if (!event.isRecurring()) {
    consider(event.startDate, event.endDate, event.summary, status);
    continue;
  }
  const it = event.iterator();
  for (let next = it.next(); next && next.toJSDate() < end; next = it.next()) {
    const o = event.getOccurrenceDetails(next);
    const oStatus = o.item.component.getFirstPropertyValue('status') ?? status;
    consider(o.startDate, o.endDate, o.item.summary, oStatus);
  }
}

found.sort((a, b) => a.s - b.s);
for (const f of found) console.log(f.s.toISOString(), f.summary);
console.log(`${found.length} upcoming between ${now.toISOString()} and ${end.toISOString()}`);
count-upcoming.mjs. Install with npm install ical.js, then run node count-upcoming.mjs feed.ics. An optional second argument sets “now”, for repeatable tests.
$ node count-upcoming.mjs recurring.ics 2026-03-02T09:00:00Z
2026-03-02T18:00:00.000Z Weekly class (started in January)
2026-03-09T18:00:00.000Z Weekly class (started in January)
2026-03-16T18:00:00.000Z Weekly class (started in January)
2026-03-18T19:00:00.000Z One-off concert
2026-03-23T18:00:00.000Z Weekly class (started in January)
2026-03-30T18:00:00.000Z Weekly class (started in January)
6 upcoming between 2026-03-02T09:00:00.000Z and 2026-04-01T09:00:00.000Z
Real output for recurring.ics with “now” fixed at 2 March 2026, 09:00 UTC. The checker reports 1+ for the same file.

We ran the script against the checker’s test fixtures. For every file without recurring events it gave the same count as the checker: 3 for multiple-upcoming.ics, 3 for boundary.ics (including the event in progress and excluding the one at the window end), 1 for cancelled.ics, and 2 for all-day.ics. In a test series with one date removed by EXDATE and one moved by RECURRENCE-ID, it dropped the removed date and listed the moved one on its new day.

What a count does and doesn’t tell you

A count says the source has events in the window. It says nothing about whether the website shows them. For that, compare the two over the same dates: see ICS feed vs website: which side is empty?. A count of zero means the source has nothing upcoming; ICS file has no upcoming events covers why.

Limitations of this guide

  • The script reads floating times and all-day dates in the computer’s own time zone, and doesn’t resolve TZID names that have no VTIMEZONE definition in the file. The checker handles both more carefully; use the script mainly for recurring events.
  • It walks each series in date order up to the end of the window. An occurrence moved into the window from a date after it would be missed.
  • It doesn’t enforce size limits. Run it only on files you trust, and on a copy if the file is large.

Sources

  1. RFC 5545: Internet Calendaring and Scheduling Core Object Specification (iCalendar) · IETF , accessed 7 Nov 2026
  2. ical.js · ical.js project (GitHub) , accessed 7 Nov 2026

Maintaining calendars for several clients? We’re recruiting five agencies to pilot automated checks Pilot