Live at overlap.runtianwu.com (moved from overlap.runtian.uk; old links still work) · by Micropeptide · MIT License
Try it without inviting anyone: the sample poll has seven answers already and runs entirely in your browser. Open it as a guest or as the organizer.
Watch the demo: Overlap: Find a Time That Works for Everyone (12 minutes, on YouTube), made for Hyperbloom October 2026.
See exactly who can make it. One link, no accounts: everyone marks their free times, each person gets their own color, and Overlap ranks the best times for you. Then the organizer sets the final time, and everyone's page becomes the plan, with a calendar invite.
- You see who, not just how many. Each person gets their own colored lane, so you can read the group's week at a glance. For big groups, a heatmap is one click away.
- It finds the answer for you. Best times ranks the options above the grid, starting with the times everyone can make, then the closest matches, with names on each one.
- Answers have shades. Besides Available, people can mark times as Preferred or If needed, and the ranking uses both.
- It finishes the job. The final time can be one slot, something that runs over several days, or several separate sessions, and every guest gets one calendar invite (.ics, Google Calendar or Outlook) for all of them.
- The organizer stays in control. Change the dates, times, note for guests, place or call link, meeting length and closing date after publishing; choose whether guests see each other's answers and whether answers can change; remove an answer, export to CSV, close and reopen, duplicate, or delete.
- Private by default. No accounts, ads, cookies or tracking. Passwords are optional and never leave the browser, and email is opt-in.
- Nobody needs an account, not even the organizer. Email is optional.
- One guest link to share, plus a private link or optional organizer password to manage the poll from any device. Replace issues a new private link if it leaks.
- Share the guest link by copying it, from the phone's share sheet, as a ready-made invitation message, or as a QR code (made in the browser) for a screen, slide or paper.
- Specific dates or days of the week, in each person's own time zone, with the organizer's zone shown alongside. Select a range picks a long stretch of dates in two clicks and can skip weekends.
- Mark times by dragging, Shift-clicking a range, the keyboard (arrows, Space, Shift), or two taps on a phone. There's an eraser and undo, and you can show everyone else's times faintly behind yours.
- Private edit links, copyable any time: nobody can overwrite your answer by typing your name. Guests can add an optional password to get back in from another device.
- Optional email, confirmed first and at most once every 30 minutes, with one-click unsubscribe: organizers get their private link and a note when people respond; guests get their edit link and hear when the time is set.
- In 20 languages, chosen from the browser's settings or the footer (and remembered): English, Español, Français, Deutsch, Italiano, Português, Nederlands, Polski, Русский, Українська, Türkçe, العربية, हिन्दी, 简体中文, 繁體中文, 日本語, 한국어, Tiếng Việt, Bahasa Indonesia, ไทย. Emails come in the language they were asked in. See docs/I18N.md.
- Works with a keyboard and screen readers, never relies on color alone, and follows light/dark mode and reduced motion. See Accessibility.
- Polls stay until the organizer deletes them; guests can delete their own answers any time.
The full list of ideas considered, built and rejected is in
docs/IMPROVEMENTS.md. Nothing is deleted automatically
unless you set RETENTION_DAYS (see Configuration).
You need Node.js 22.13 or newer (it uses the built-in node:sqlite). There
are no runtime dependencies to install.
npm startOpen http://127.0.0.1:3000. Data goes in ./data/overlap.db.
npm run dev does the same with auto-restart when files change.
npm install # dev tools only: Playwright and axe-core
npm test # unit + API tests (node:test), about 1 second
npm run test:e2e # browser tests on desktop Chrome and a Pixel 7 profileIf Playwright says the browser is missing, run npx playwright install chromium once.
| What | Where |
|---|---|
| Time zone conversion, DST gaps and repeats, cross-zone layout, weekly reference weeks | test/time.test.js |
| Overlap ranking, meeting length, "if needed", preferred, unanswered times, duplicate partials | test/overlap.test.js |
| Calendar files: escaping, folding, weekly repeats, VTIMEZONE | test/ics.test.js |
Create poll, validation, guest submit/edit/delete, name collisions, guest keys can't manage, link rotation, hidden results, close/finalize/reopen, .ics, expiry, security headers |
test/api.test.js |
| Full organizer and guest flows by mouse, keyboard and touch; weekly polls; Tokyo and London viewers; no sideways scrolling on phones | e2e/organizer, guest, weekly, timezones |
| Preferred/erase/undo, notes, drafts, shortcuts, location, deadlines, CSV, duplicate, read-only closed polls, cross-midnight labels, drag auto-scroll | e2e/features.spec.js |
| Auto-refresh safety: during drags, after saves, keeping focus, filters and undo; link detection | e2e/refresh.spec.js |
| axe-core WCAG 2.1 AA scan of every main screen, light and dark | e2e/a11y.spec.js |
All settings are environment variables. Every one is optional.
| Variable | Default | Meaning |
|---|---|---|
PORT |
3000 |
Port to listen on |
HOST |
127.0.0.1 |
Interface to bind. Use 0.0.0.0 in a container |
DATA_DIR |
./data |
Where overlap.db lives |
RETENTION_DAYS |
0 |
0 keeps polls until the organizer deletes them. A positive number turns on automatic deletion that many days after a poll's last date (weekly polls: after its last change). The privacy page, footer and organizer page follow this setting |
TRUST_PROXY |
0 |
Number of reverse proxies in front of Overlap (usually 1). Rate limits then use the address that proxy appended to X-Forwarded-For, which clients can't fake |
PUBLIC_URL |
from request | Base URL used in calendar invites, e.g. https://overlap.example.com |
RATE_LIMIT_CREATE |
30 |
New polls per connection per hour |
RATE_LIMIT_WRITE |
300 |
Other changes per connection per 10 minutes |
RATE_LIMIT_READ |
1200 |
Page data requests per connection per 10 minutes |
RATE_LIMIT_EMAIL |
10 |
Emails a connection may ask for per hour (also capped at 5 per address and 40 per poll per day) |
RESEND_API_KEY |
none | Turns on email, together with EMAIL_FROM. A Resend key with sending access only |
EMAIL_FROM |
none | Sender, e.g. Overlap <overlap@example.com>, on a domain verified in Resend |
EMAIL_OUTBOX |
none | Development: write each email as JSON into this folder instead of sending it |
Overlap refuses to start if a number setting isn't a sensible whole number, rather than silently running without limits.
The live copy runs on Cloudflare: one Worker serves the pages, another the
API, with a D1 database. See docs/DEPLOY-CLOUDFLARE.md. The same
browser tests run against that setup with npm run test:e2e:cloudflare.
To host it yourself on a server instead: Overlap is one Node process and one SQLite file. Any small VPS (1 vCPU, 512 MB) is plenty. Serve it over HTTPS. Links carry keys, and only HTTPS protects them in transit.
docker compose up -dThis builds the image, keeps data in the overlap-data volume, and listens on
127.0.0.1:3000. Put an HTTPS proxy in front: deploy/Caddyfile is a three-line
Caddy config that fetches certificates automatically. (The Dockerfile is
straightforward but wasn't built on the machine Overlap was developed on, because
Docker wasn't installed there. Check it builds before you rely on it.)
sudo useradd --system overlap
sudo mkdir -p /opt/overlap /var/lib/overlap && sudo chown overlap /var/lib/overlap
sudo cp -r package.json server shared public /opt/overlap/
sudo cp deploy/overlap.service /etc/systemd/system/
sudo systemctl enable --now overlapThen run Caddy (or nginx) in front with deploy/Caddyfile.
Deploy the Dockerfile, attach a persistent volume at /data, set
TRUST_PROXY=1 and PUBLIC_URL. Without a persistent volume, polls vanish on
every redeploy.
The whole state is DATA_DIR/overlap.db (plus -wal/-shm files while running).
For a consistent copy while the server runs:
npm run backupThis writes backups/overlap-<timestamp>.db (pass a directory to change it). Backups
keep deleted polls until the backup itself is deleted, so keep them short-lived,
or say so on the privacy page.
Copy the new files over the old ones and restart. The schema is created with
CREATE TABLE IF NOT EXISTS. If you change it later, add a migration in
server/store.js.
public/ browser app: plain ES modules, no build step
js/views/ home (create), guest, manage (organizer), privacy, about
js/components/ grid (the heart), date picker, poll form, best times, results
js/config.js where the API lives ('' = same server; rewritten by build-pages)
js/lib/sample.js the sample poll (/p/example, /m/example), answered entirely in the browser
shared/ used by BOTH browser and server
time.js wall clock ↔ UTC with Intl only; slot generation; viewer layout
overlap.js per-slot tallies and ranked "best time" windows
ics.js calendar invites (with VTIMEZONE for weekly repeats)
qr.js QR codes for the guest link
server/ runs anywhere (Node or Cloudflare Workers)…
api.js every API route, independent of the platform
store-core.js storage logic over a tiny async SQL driver; hashed keys
validate.js input checks with human error messages
ratelimit.js in-memory request limits
server/ …plus the Node-only parts
index.js config, startup, optional hourly expiry sweep
app.js static files, security headers, Node HTTP adapter
store.js node:sqlite driver (secure delete, WAL checkpoints)
worker/ Cloudflare Workers adapter (D1 driver, CORS, optional cron) + D1 migrations
scripts/ build-pages (static build), build-moved (old-address page), serve-pages (local stand-in), backup
Time model. A poll is "these dates, from 9:00 to 17:00, in Europe/London, in 30-minute steps". The server expands it into slots: UTC instants for each step on the organizer's wall clock. Responses store slot instants, so an answer means the same moment for everyone. The browser groups slots into columns (local dates) and rows (local times) in whatever zone the viewer picks. On a spring-forward day, times that don't exist are skipped. On a fall-back day, a repeated wall time is offered once (its first occurrence).
Weekly polls. The chosen weekdays are pinned to one Monday-to-Sunday
reference week near the poll's creation, and slots are generated in that week
exactly as for dates, so ranking and time zones need no special cases. Only the
labels change ("Mon" rather than "Mon, Oct 5"). The reference week avoids a
daylight saving change in the organizer's zone. Editing the days keeps the same
week, so existing answers keep their meaning. A weekly final time becomes a
repeating calendar event (RRULE:FREQ=WEEKLY) pinned to the organizer's zone,
so it stays at the same local time when clocks change.
Identity without accounts. Creating a poll returns a random 192-bit key. The
private link is /m/<poll>#k=<key>. Answering returns a separate key, and the
edit link is /p/<poll>#r=<key>. The part after # never reaches the server
in a page request; the browser sends the key only in an Authorization header
to the API. The database stores SHA-256 hashes of keys, never the keys themselves.
Keys are also kept in the browser's localStorage, so returning on the same
device just works.
Optional passwords. The browser turns a password into a key with
PBKDF2-SHA-256 (210,000 rounds, salted with overlap/v1/<role>/<poll id>), and
the server stores a SHA-256 hash of that key, as it does for link keys. An
organizer password is accepted as Authorization: Bearer <key> alongside the
private link. A guest signs in with POST /sign-in {name, password: key} and
then sends Authorization: Password <response id>:<key>. After 30 wrong
passwords in an hour a poll refuses all passwords (right or wrong) until the
hour is up; the count lives in the database (throttle), so it holds across
Worker instances. Links are never locked out.
Optional email. PUT …/email {email, updates, link} sends one email: the
private link (only a key the browser already holds, checked against its hash)
and/or a button to confirm updates. Nothing else is sent until confirmed. While
a poll has a confirmed subscriber, changes are noted in poll_events (kind,
time, response id; no names), and every few minutes (Node timer, Worker cron)
sendDueDigests sends each subscriber one digest after changes settle for 2
minutes, at most every 30 minutes, never about their own doing. Update emails
never contain private links. Addresses are deleted on unsubscribe, response
deletion or poll deletion; a link-only request stores none.
"Unanswered" vs "unavailable". Each response records which slots existed when it was saved. If the organizer later adds dates, those new slots show as "hasn't seen this time" (crosshatched) rather than "not available".
API (JSON). Organizer endpoints need Authorization: Bearer <private key>.
| Method | Path | Who |
|---|---|---|
POST |
/api/polls |
anyone: kind: "dates" with dates, or kind: "weekly" with weekdays (0 = Sunday … 6 = Saturday) |
GET |
/api/polls/:id |
anyone with the id (responses omitted if hidden) |
GET |
/api/polls/:id/manage |
organizer |
PATCH |
/api/polls/:id |
organizer: any poll field (incl. location, closesOn, allowEdits), status: open|closed, final: {start,end}|null (start must be one of the poll's slots) |
DELETE |
/api/polls/:id |
organizer |
POST |
/api/polls/:id/private-link |
organizer: replace key |
POST |
/api/polls/:id/responses |
guest (poll must be open): name, available, preferred, ifNeeded (lists of slot instants), optional note |
GET |
/api/polls/:id/my-response |
guest, with edit key |
PUT |
/api/polls/:id/responses/:rid |
that guest only, while the poll is open and allowEdits is on |
DELETE |
/api/polls/:id/responses/:rid |
that guest or the organizer |
GET |
/api/polls/:id/invite.ics |
anyone, once a final time is set |
POST |
/api/polls/:id/sign-in |
guest: name and password (a derived key) |
GET PUT DELETE |
/api/polls/:id/email |
organizer: email status, send link / start updates, stop |
GET PUT DELETE |
/api/polls/:id/responses/:rid/email |
that guest: the same |
POST |
/api/email/confirm |
anyone with a confirmation key ({token}) |
POST |
/api/email/unsubscribe |
anyone with an unsubscribe key ({token}, or ?t= for one-click) |
GET |
/api/config |
retention period, limits, and whether email is on |
The in-app privacy page (/privacy, source in public/js/views/privacy.js)
is the user-facing statement. Every claim on it matches the code above:
- Stored: poll details (including an optional place and closing date), display names, marked times, optional guest notes, timestamps, key hashes, optional password-key hashes, per-poll wrong-password counts, and (only for people who ask for updates) email addresses plus a 30-day record of what changed. Nothing else.
- Email goes through Resend only when someone asks for an email.
- Deleted data is overwritten on disk (
secure_deleteplus a write-ahead-log checkpoint), not just unlinked. - The browser keeps private links, the last name typed, form settings and
unsubmitted marks in its own
localStorage. The server never sees it. - No cookies, analytics, ads, third-party scripts, fonts or CDNs. Fonts are self-hosted.
A strict Content-Security-Policy (
default-src 'self') enforces this in the browser. - No request logging. IP addresses are held in memory for rate limiting, for at most an hour, and are never written to disk. Your proxy or host may log them, so turn that off if you want the same guarantee.
- Not claimed: encryption at rest, or anonymity. Anyone with the guest link can see names and times unless the organizer picks "Only me".
If you change behavior, update the privacy page in the same change.
- Visible focus on everything (3px outline, or an inset ring on grid cells).
- The grid is an ARIA
gridwith a roving tab stop. Each cell is labelled with its day, time and state. - Keyboard: arrows move, Space or Enter marks, holding Shift with the arrows marks a block (the starting cell included; going back un-marks, and letting go of Shift makes it one undo step), Home and End jump within a row. The date picker works the same way for a run of days.
- State is never shown by color alone: "if needed" is striped, "hasn't seen this time" is crosshatched, and "everyone" carries a check mark. Forced-colors (Windows High Contrast) mode is handled.
prefers-reduced-motionandprefers-color-scheme: darkare respected.- An axe-core scan runs in the e2e suite.
Overlap is made by Micropeptide and released
under the MIT License. The bundled fonts (Bricolage Grotesque, Atkinson
Hyperlegible Next) are under the SIL Open Font License; see public/fonts/OFL-*.txt.
- Anyone with a guest link can answer. There is no way to stop someone with the link from adding a made-up name. The organizer can remove responses.
- Edit links are bearer keys. Lose the link and clear your browser storage, and without a password or an emailed copy, that response can only be removed by the organizer. Overlap can't recover it.
- Passwords are only as strong as people make them. Online guessing is capped per poll, but someone with a copy of the database could guess weak ones offline.
- Weekly polls across time zones convert using the offsets in effect when the poll was created. If the organizer's and a guest's zones change clocks on different dates (e.g. the US and Europe, for a few weeks each spring and autumn), a guest may see times shifted by an hour during those weeks. The organizer's own times never shift.
- Switching a poll between dates and days of the week is allowed, but people who already answered need to answer again, since none of the new times match theirs.
- Overnight ranges (e.g. 10 pm to 2 am) need to be set up as two dates.
- Fall-back repeated hour: on the night clocks go back in the organizer's zone, the repeated hour is offered once.
- Phones: there's no drag-to-select on touch, so that scrolling stays reliable. Instead, "Select a range" fills everything between two taps, alongside "Whole day", "Clear" and "Copy to every day".
- Calendar links for Google and Outlook.com are ordinary links. Clicking one
sends the event's details to that company. The
.icsdownload doesn't. - Rate limits are per process and reset on restart. Run a single instance.
- Clicking a heatmap cell as the organizer opens "Choose the final time" for that slot. It's cancellable, but it can surprise people who are just exploring.










