Candidates on CogniPrep can now subscribe to their
application dates from Apple Calendar, Outlook or anything else that takes a
calendar URL. That meant writing iCalendar, and I did not take a dependency,
because the feed publishes exactly one shape of event and the format's traps are
short enough to list:
* Hand-rolled rather than a dependency because the feed only ever publishes one
* shape of event, and the format's sharp edges are few and well defined:
* - lines end in CRLF, not LF;
* - content lines longer than 75 octets are folded onto continuation lines
* that start with a space, and the fold must not split a UTF-8 character;
* - backslash, semicolon, comma and newline in text values are escaped.
* Each of those is covered by a test, since a calendar app that meets a
* malformed feed tends to drop the whole subscription silently.
164 lines of writer, 92 lines of tests, zero imports. Here is what each of those
traps actually costs.
Folding is measured in octets, and the continuation line pays for its own space
RFC 5545 says a content line is at most 75 octets. Not characters. An employer
name with an accent in it, or a title in a non-Latin script, blows that limit
sooner than a character count suggests, and a split in the middle of a UTF-8
sequence produces bytes no client can read:
export function foldLine(line: string): string {
const encoder = new TextEncoder();
const physical: string[] = [];
let current = '';
let currentOctets = 0;
// The first line holds 75 octets; each continuation line spends one on its
// leading space, leaving 74 for content.
let limit = MAX_LINE_OCTETS;
for (const char of line) {
const octets = encoder.encode(char).length;
if (currentOctets + octets > limit) {
physical.push(current);
current = '';
currentOctets = 0;
limit = MAX_LINE_OCTETS - 1;
}
current += char;
currentOctets += octets;
}
physical.push(current);
return physical.join(`${CRLF} `);
}
Two details carry the correctness. for (const char of line) iterates code
points, not UTF-16 units, so the loop cannot cut an emoji or an accented
character in half. And limit drops from 75 to 74 after the first physical
line, because the leading space on a continuation is itself one of the octets the
spec counts. Get that wrong and nothing breaks visibly; you just emit lines one
octet over, and some client somewhere decides your whole calendar is invalid.
An all-day event ends the day after it ends
The classic one. DTEND is exclusive for VALUE=DATE events, so a date on 3
November has to be written as starting on the 3rd and ending on the 4th:
if ('date' in event.start) {
// DTEND is exclusive for all-day events, so a one-day event ends the next day.
lines.push(
`DTSTART;VALUE=DATE:${formatDate(event.start.date)}`,
`DTEND;VALUE=DATE:${formatDate(nextDay(event.start.date))}`
);
}
Omit it or set it to the same day and clients disagree with each other about
whether the event exists, which is worse than either answer.
Timed events get a one-hour block, and the comment is there so nobody later
mistakes it for information we have:
/**
* How long a timed entry lasts. We only ever learn a start time, so this is a
* placeholder long enough to see in a day view, not a claim about the sitting.
*/
const TIMED_ENTRY_MINUTES = 60;
The UID is the entire update story
A subscription feed has no diff and no events API. The client re-fetches the
whole thing and reconciles by UID. So the UID has to be derived from the row,
not from the fetch:
uid: `${entry.id}@cogniprep.app`,
The id is the database row for a date, and ${applicationId}-practice-${day} for
a practice day. Edit the date and the same UID comes back with a new
LAST-MODIFIED, so the event moves. Generate a fresh UID per fetch, which is the
easy mistake, and every poll silently adds a duplicate to somebody's calendar
until they unsubscribe in disgust.
DTSTAMP is the opposite case. It is the moment the feed was generated, which
would make every response byte-different and every test a snapshot of the clock,
so the whole calendar takes now as an input:
/** The DTSTAMP for every event. Injected so output is deterministic in tests. */
now: Date;
Two names for the same property, because clients never agree
The calendar header writes both the modern and the legacy spellings:
// NAME and REFRESH-INTERVAL are the RFC 7986 properties; the X-WR and
// X-PUBLISHED-TTL pair are the older names Apple and Outlook still read.
`NAME:${escapeText(calendar.name)}`,
`X-WR-CALNAME:${escapeText(calendar.name)}`,
...
`REFRESH-INTERVAL;VALUE=DURATION:${refresh}`,
`X-PUBLISHED-TTL:${refresh}`,
And the type that holds the value is honest about how much good it does:
/**
* How often clients are asked to re-fetch, in minutes. A hint only: Apple and
* Outlook may honour it, Google ignores it.
*/
refreshMinutes: number;
We ask for 60 minutes. Google polls on whatever schedule it likes. That single
fact is why the product has a second, pushed integration for people who live in
Google Calendar, and why the feed route is cheap to serve rather than clever.
TRANSP does the other half of the politeness, and it is decided per kind of
date rather than per source:
/* a deadline is a date in the diary rather than a
* meeting, so it shows as free; an interview or assessment day is busy. */
application_deadline: { label: 'Application deadline', busy: false },
online_test_due: { label: 'Online tests due', busy: false },
video_interview: { label: 'Video interview due', busy: false },
assessment_centre: { label: 'Assessment centre', busy: true },
final_interview: { label: 'Final interview', busy: true },
custom: { label: 'Other date', busy: false },
Two of the six block time. Four are deadlines, which belong in the diary without
making you look unavailable, and practice days are TRANSPARENT for the same
reason: a plan with practice on half the month should not make somebody look
booked solid to their colleagues.
One builder, two calendars, and a switch for people who share a screen
Both the feed and the Google sync render from the same neutral entry list, for a
reason worth stating out loud:
* Keeping one builder means the two calendars can never disagree
* about what an event is called, when it is, or which ones exist.
That builder also holds the feature I did not anticipate needing and now think is
the most important one in the whole integration:
/** Leave employer names out of titles, for candidates who share a calendar. */
hideEmployerNames: boolean;
With it on, the event is Online tests due instead of
Online tests due: <Employer>, and the employer is left out of the description
too. Plenty of people share a calendar with a partner, or have a work laptop
with their personal calendar on it, and "which companies am I interviewing at"
is not a detail to leak by default on a feature whose whole job is to put your
job hunt in front of you.
Closing an application removes its dates from every calendar on the next fetch
without deleting anything from the page, which is the same one-way-door
reasoning: the calendar is a view, the plan is the record.
The route around it
The feed is fetched by calendar apps, which carry no cookies, so the URL is the
credential and the route is deliberately unauthenticated. Two consequences
shaped it.
First, the rate limit is per feed, not per IP:
* Deliberately not behind withApiHandler. Its global limit is per IP, and the
* feeds of many candidates are fetched from the same few Google and iCloud
* servers, so a per-IP cap would cut off everyone subscribed through them at
* once. This route limits per feed instead.
30 requests a minute per feed, which is generous for a person with several
devices and still not a door you can lean on.
Second, every failure mode answers the same way. An unknown link, a malformed
one and one that has been reset all get a plain 404 with no body to interpret.
You can run that one yourself:
curl -i https://cogniprep.app/api/calendar/feed/zzzzzzzzzzzz.ics
# HTTP/2 404
# cache-control: no-store
# x-robots-tag: noindex, nofollow
HEAD behaves identically, because some clients probe a subscription before
subscribing to it, and that probe must not count as a fetch. A successful
response carries Content-Type: text/calendar; charset=utf-8 and
Cache-Control: private, max-age=300, the five minutes being a compromise
between several devices polling at once and an edit showing up promptly.
See it for yourself: run the curl -i above and watch the 404 that tells you
nothing, then read
section 12 of our privacy policy,
which documents what the subscription stores (a hash of the link, your two
display choices, and when a calendar app last downloaded it) and what it does
not. If you want the feed itself, it is on the applications page after you
sign up.
Top comments (0)