CalEmdar — plan
Written 2026-10-20, before any code. Built autonomously at Jonathan's request. Every decision below was made without asking; the reasons are written next to each one so they can be revisited.
1. What it is
An ad-free, shared calendar for Emily (and Jonathan). It's the third app in the hirota.us "Em" family, after Emify (music.hirota.us) and QuizEm (quiz.hirota.us).
- Lives at https://cal.hirota.us. That follows the music./quiz. naming.
- Installs to the home screen on iPhone from Safari's Share menu (Add to Home Screen).
- Installs to the Dock on the Mac from Safari's File menu (Add to Dock), or from Chrome.
- Feels native on both screens:
- iPhone: bottom sheets, safe areas, and tap targets of at least 44pt.
- Mac: a sidebar, keyboard shortcuts, and drag with the mouse.
- Sign-in: one shared 6-digit PIN, checked on the server (same pattern as QuizEm, but 6 digits, because a calendar shows where people will physically be). Once signed in, a device stays signed in for 90 days of use.
2. Research: what people hate about calendar apps, and what CalEmdar does about it
Sources: Apple Community and MacRumors threads, App Store and G2 reviews of Fantastical, Cozi and TimeTree reviews, iOS 26 complaint coverage (Yahoo Tech, MacMagazine), the HEY Calendar wishlist, unstar.app's 2026 calendar-app roundup, and iCloud calendar-spam coverage (BleepingComputer, iMore).
| # | Complaint (who) | CalEmdar's answer |
|---|---|---|
| 1 | Month view shows dots, not titles (Fantastical's top complaint) | Month cells show real event titles. On iPhone, tapping a day lists its events under the grid, like the old iOS "month + list" layout that iOS 26 users miss. |
| 2 | Natural-language entry is weak (Apple) or paywalled (Fantastical) | A free quick-add bar that understands typed sentences, for example "Lunch with Sam Fri 1pm at Hilo Bay Cafe", "Yoga every Tue and Thu 6pm" or "Mom's birthday Mar 5 every year". A live preview shows what it understood before you save, so a misread time is caught. |
| 3 | No undo; a deleted event is gone (iPhone Calendar has no undo; restoring means rolling back all of iCloud) | Every create, edit, move and delete shows an Undo toast (also ⌘Z on the Mac). Deleted events go to Recently Deleted for 30 days. |
| 4 | "This and all future events" is confusing, and editing one repeat sometimes duplicates the series (Outlook, Google) | Editing or deleting a repeating event asks in plain words: "Only this one (Tue, Oct 21)", "This and later ones", or "Every one". Each choice is stored as a proper exception or split. Nothing is ever duplicated, and every choice can be undone. |
| 5 | Repeat options are too narrow, e.g. no "second Tuesday of every month" (Google) | Repeats cover daily, weekly on chosen days, monthly by date or by "2nd Tuesday" / "last Friday", and yearly. Each has an interval and an end (never, on a date, or after N times). |
| 6 | Reminders fail silently (Google after iOS updates; push permissions reset) | Server-sent Web Push reminders, with more than one alert per event and default alerts per calendar. Settings has a "Send a test" button and shows whether notifications are working on this device. A reminder missed during a short Pi outage is still sent within a small window. |
| 7 | Overlapping events are unreadable or untappable | Day and week views pack overlapping events side by side in columns. On iPhone, a crowded slot collapses into a "+N more" chip that opens a list. |
| 8 | Time zones: all-day events shift a day when traveling; "3pm Pacific" lands at local time (Apple, Fantastical) | All-day events are stored as plain dates and never shift. Timed events keep their time zone, so a weekly 9am meeting stays at 9am across daylight-saving changes. Quick-add understands "Pacific", "HST" and similar. An optional second time zone column covers the Hawaii ↔ mainland case. |
| 9 | Sync is slow between devices (Apple, Outlook) | The Pi is the single source of truth. Changes show instantly on the device that made them. Other devices refresh when the app comes to the front and every 30 s while it's open. |
| 10 | Two people edit the same event and one edit is lost (shared calendars) | Every event carries a version number. If it changed on another device in the meantime, the app says so and offers "Keep mine" or "Use theirs". It never silently overwrites. |
| 11 | Ads and surprise paywalls (Cozi audio ads, Fantastical moving features to a subscription) | No ads and no tiers. It's ours. |
| 12 | Widgets don't show today or tomorrow (Apple) | A PWA can't make iOS widgets. Instead, CalEmdar publishes a private subscribe link: added to Apple Calendar, its events appear in the native widgets, on Apple Watch, in Siri and in CarPlay. The app itself opens on an "Up next" summary. |
| 13 | Spam calendar invites (iCloud) | There's no invite inbox, so there's no spam. |
| 14 | The app ignores the default view on launch (iOS 26 bug) | It reopens on the last view, or always on Today if that setting is on. |
| 15 | No week numbers, weekends can't be hidden, the week always starts on Sunday | All three are settings. |
| 16 | No year view that shows only the big things (HEY wishlist) | A Year view with 12 mini-months, plus a list of starred events and birthdays. |
| 17 | Retyping the location for repeated events; no copy/duplicate | Locations and titles autocomplete from past events. Choosing a past title also fills in its calendar, length and location. There's a Duplicate action. |
| 18 | Dragging events is clumsy (Google) | Long-press and drag to move or resize on touch; plain drag with the mouse. Snaps to 15 minutes, and every drag can be undone. |
| 19 | Tiny text in month view; days mashed together (Google mobile) | Larger type, a text-size setting, and a month grid that drops to compact chips before it ever becomes illegible. |
| 20 | Search is broken or slow (iOS 26) | Instant search across titles, locations and notes, past and future, including repeating events. |
| 21 | Not enough colors (Google) | 16 accessible calendar colors, plus an optional per-event color. Color is never the only signal: every calendar also has a name and a mark. |
| 22 | Birthdays are just events | Birthday events can store a birth year, and then show the age ("Mom turns 60"). |
| 23 | Overlaps go unnoticed when booking | The editor warns "Overlaps with Dentist (2:00–3:00)" before you save. |
3. Scope
v1 (this build):
- Views: Month (with the day list), Day, 3-Day on iPhone, Week on the Mac, List (agenda) and Year.
- Editing: a full event editor, natural-language quick add with live preview, recurrence with the three edit scopes, Undo and Recently Deleted, drag to move and resize, and duplicate.
- Calendars: multiple calendars, each with a color, a show/hide toggle and default alerts. Seeded with Emily, Jonathan, Together and Birthdays, all renamable.
- Built-in holidays: US federal holidays and optional Hawaii state holidays. These are computed on the device and need no network.
- Reminders: Web Push to installed devices. Each device chooses which calendars alert it.
- Feeds and files:
- A private ICS subscribe link that Apple, Google or Outlook can subscribe to. It can be regenerated.
- Subscribe to outside ICS calendars (school, work, a Google "secret address") as read-only.
- Import and export .ics files.
- Offline: the app opens offline and shows cached events, read-only, with a banner. Unsaved typing is never lost.
- Settings: listed in §5.
- Look and accessibility: light and dark themes that follow the system unless overridden, keyboard shortcuts on the Mac, and accessibility built in from the start.
Deliberately left out of v1, and why:
- Two-way iCloud or Google sync (CalDAV): it would mean storing an iCloud app-specific password on the Pi, and a sync bug could corrupt Emily's real iCloud calendar. The one-way subscribe link (out) plus ICS subscriptions (in) give most of the value with none of that risk.
- Travel time and "leave by": it needs a paid maps API and her location.
- Invites and RSVPs: there are only two of us, and an invite inbox is exactly where spam comes from.
- AI features: quick add covers the useful part without an API bill.
4. Architecture
iPhone / Mac (installed PWA) ──HTTPS──> Cloudflare ──> NPM (cal.hirota.us) ──> calemdar:3000 (Express)
│
MySQL `calemdar` DB (own user)
│
Web Push (Apple/FCM) <── reminder loop (every 30 s)
outside ICS feeds <── subscription refresher (SSRF-guarded)
- Server: Node 20 and Express 4, written as ES modules. It reuses QuizEm's proven scaffold:
- fail-loud secrets;
- MySQL-backed sessions;
- a constant-time, rate-limited PIN check;
- default-deny routing;
asyncRouterwrapping so a rejected handler never hangs a request;- Web Push with an allowlist of push hosts.
- Client: vanilla ES modules with no bundler and no framework, and hand-written CSS built on design tokens. Rendering goes through an escaping
htmltagged template, so any interpolated value is escaped by default. - Shared core: pure, DOM-free modules in
src/public/js/core/, imported by the browser and by the Node server alike, and unit tested withnode --test: tz.js: time-zone math built onIntlonly (wall time ↔ UTC in any IANA zone, DST-safe).rrule.js: parses, builds and expands the recurrence subset (RFC 5545 RRULE: FREQ, INTERVAL, COUNT, UNTIL, BYDAY with ordinals, BYMONTHDAY, BYMONTH, BYSETPOS = ±1).occurrences.js: expands events plus exceptions into the instances inside a date range.layout.js: column packing for day and week views, and chip allocation in month cells.nlp.js: the quick-add parser.ics.js: parses and serializes ICS (folding, TZID, VALUE=DATE, RRULE, EXDATE, RECURRENCE-ID).holidays.js: US federal and Hawaii holidays.- Server-side expansion:
GET /api/occurrences?from&toreturns ready-to-draw instances, so the reminders, the feed and the UI all share one source of truth. The client caches the ranges it fetched (IndexedDB) for offline use, and prefetches neighbouring months. - Sync: a single global
revcounter goes up on every write. The client pollsGET /api/rev(a tiny response) every 30 s while visible, plus onvisibilitychange, and refetches only when the counter moved. Writes are applied optimistically and rolled back on error. - Service worker: network-first with cache fallback, applied to the shell, assets and API GETs. This is the lesson from Emify's sticky-cache bugs. Asset URLs carry a
?v=<content hash>.
Data model (MySQL)
calendars- Columns:
id,name,color,kind(local/subscription),source_url,default_alerts(JSON array of minutes),sort_order,last_fetched_at,last_error,created_at,deleted_at. - The visible/hidden state lives per device.
events- Identity and placement:
id,uid(ICS UID),calendar_id(FK). - Text:
title,location,notes,url. - Timing:
all_day, thenstart_at/end_at(UTC DATETIME, timed events) orstart_date/end_date(DATE, end exclusive, all-day events), andtzid. - Repeating:
rrule,exdates(JSON). - Extras:
alerts(JSON; NULL means use the calendar's default),color,starred,birth_year. - Housekeeping:
version,created_at,updated_at,deleted_at(soft delete). event_overrides- Columns:
event_id(FK, cascade),recurrence_id(the occurrence's original start),cancelled, and nullable copies of every editable field (NULL means inherit from the series). UNIQUE(event_id, recurrence_id).subscription_cache:calendar_id,events_json,fetched_at. Parsed outside events, capped at 5,000 per feed.push_subscriptions:endpoint_hash(unique),endpoint,p256dh,auth,calendar_ids(JSON, NULL = all),tz,label,last_ok_at,last_error.sent_alerts:(event_id, occurrence_start, minutes), primary key, so each reminder is sent exactly once. Old rows are pruned.settings: key/value:rev, the feed token, and so on.- Upgrades: additive only, through
information_schemachecks like QuizEm's. Never delete-and-reinsert.
API (JSON; every mutating request needs Content-Type: application/json and a same-origin Origin)
- Session:
POST /login(form),GET /logout,GET /health(public). Everything else requires a session. - Calendars:
GET/POST /api/calendars,PATCH/DELETE /api/calendars/:id,POST /api/calendars/:id/refresh. - Reading and finding events:
GET /api/occurrences?from=&to=;GET /api/events/:id;GET /api/search?q=;GET /api/suggest?q=(past titles and locations).- Changing events:
POST /api/events;PATCH /api/events/:id, with{scope: this|following|all, occurrence, version, ...fields};DELETE /api/events/:id, with{scope, occurrence}.- Every change responds with an undo token, and
POST /api/undotakes it. - Recently deleted:
GET /api/trash,POST /api/trash/:id/restore. - Sync:
GET /api/rev. - Files:
POST /api/import(ICS text, with a preview step),GET /api/export.ics. - Settings:
GET/PATCH /api/settings,POST /api/feed/rotate. - Push:
/api/push/{key,status,subscribe,unsubscribe,test}. - Feed:
GET /feed/:token.icsis public but token-gated (32 random bytes, constant-time compare, regenerable).
5. UX design
Look: the hirota.us family, with Emify/QuizEm pink as the accent.
- Light theme: a warm off-white "paper" background.
- Dark theme: near-black with a faint warm tint, matching Emify.
- Month and day headings are set in a characterful serif so it reads as a calendar, not a dashboard. The interface itself uses the system font (SF Pro on Apple devices) so it feels native.
- Numbers are tabular so columns line up.
- Today is a pink filled circle, and a "now" line runs across the timeline.
iPhone layout:
- Top bar: the month title (tap it for a month picker), Today, Search, and a view switcher (Month · 3-Day · Day · List).
- Navigation: swipe left and right to change period.
- Bottom: a quick-add bar ("Add… e.g. 'Coffee w/ Kai tomorrow 9am'") and a + button for the full editor.
- Event details: a bottom sheet; swipe down or tap outside to dismiss.
- Editor: a full-height sheet using the native date and time wheels, with 16px+ inputs so iOS never zooms.
Mac layout (≥ 900 px):
- Sidebar: mini month, calendar list with color checkboxes, and "Up next".
- Toolbar: ‹ Today ›, the period title, a segmented control (Day · Week · Month · Year · List), search, and + New.
- Keyboard shortcuts:
- views:
T(Today),D/W/M/Y/L; - navigation: arrows or
J/K(previous/next); - editing:
N(new),⌘Z(undo),Delete(delete),Esc(close); /(search).
States, on every view:
- Loading: a skeleton.
- Empty: a friendly prompt pointing to the next action.
- Error: a plain-language toast with Retry.
- Offline: a banner saying changes are paused.
- Success: confirmed by an Undo toast.
Settings:
- Calendars: add, rename, color, default alerts, delete (with a count of events lost and a confirm).
- Week: week starts on Sunday/Monday, week numbers, hide weekends.
- Opening: open to Today vs last view, default event length.
- Display: 12/24-hour (follows the device by default), text size, theme, second time zone.
- Holidays: US / Hawaii.
- Notifications: on/off, which calendars, test, status.
- Subscribe link: copy, Add to Apple Calendar (
webcal://), regenerate. - Outside calendars: add by URL, refresh, remove.
- Files: import / export .ics.
- Recently Deleted.
- Sign out.
6. Security (an app that knows where people will be)
- PIN and sessions:
- The PIN is checked server-side on every protected route. Sessions are stored in MySQL; the cookie is
httpOnly,secure,SameSite=Lax. - Rate limits: 10 failures per 15 min per IP, plus a global cap (30 failures per hour across all IPs), so a 6-digit PIN can't be brute-forced even from many addresses.
- CSRF: SameSite=Lax, plus a JSON-only body on every mutation, plus an
Origincheck. A cross-site form can't send JSON without a CORS preflight, which we never allow. - Missing secrets fail loud:
SESSION_SECRETandPASSCODEare required, with no defaults. - Headers:
- a strict CSP (
script-src 'self', no inline script); - X-Frame-Options DENY;
- nosniff;
- Referrer-Policy;
- HSTS through NPM.
- Output and input:
- Every user string is escaped by the
htmltemplate, and feed and ICS output is escaped per RFC 5545. - Size caps: title 200, location 300, notes 10k, body 256 kB (import 5 MB), range queries ≤ 400 days, search LIMIT 100.
- Outside-calendar fetcher (SSRF):
- Only https and http (
webcal://is treated as https). - The host's DNS is resolved, and private, loopback, link-local, CGNAT and Docker ranges are blocked, including after redirects (max 3).
- 10 s timeout, 5 MB cap, at most 10 subscriptions, refreshed at most every 30 min.
- Push: endpoints are allowlisted to Apple, Google, Mozilla and Microsoft push hosts, so the Pi can't be made to call arbitrary hosts.
- Logs: they never contain the feed token, cookies or event contents. CR/LF are stripped from anything user-supplied.
- Container: a non-root Docker
USER, a healthcheck, andnpm auditclean.
7. Testing and verification
- Unit (
node --test): - tz math across DST jumps (Los Angeles and Hawaii, which has no DST);
- RRULE edge cases: the 31st in short months, Feb 29 yearly, "last Friday", BYDAY with an interval, UNTIL vs COUNT, EXDATE, overrides, and a this-and-later split preserving history;
- an NLP corpus of 80+ phrases;
- an ICS round-trip and real-world feed quirks;
- column packing;
- the SSRF validator;
- reminder due-ness and dedupe.
- API integration: the real Express app against a throwaway
calemdar_testdatabase. Covers auth default-deny, CRUD, all three recurrence scopes plus undo, version conflicts, trash restore, feed token rejection, and the import preview. - End to end in real WebKit (Playwright): iPhone 15 and Mac Safari viewports.
- Flows: create via quick add, edit a repeat (each scope), undo, drag, search, offline banner, settings.
- Screenshots of every view in light and dark, inspected by me before calling anything done.
- Accessibility pass:
- labels on all inputs and names on icon buttons;
- visible focus, 4.5:1 contrast, reduced motion respected;
- keyboard reachability on the Mac.
- After deploy:
- curl
/healththrough the proxy; - a content-check that the served JS matches the commit;
- confirm the unauthenticated
/api/*is denied; - check that the feed token works and a wrong token is rejected.
8. Deployment
- Code and CI:
- private GitHub repo
Jonathan-Mullet/calemdar; - build-only CI (tests, then an arm64 image to GHCR), with no self-hosted runner job;
- deploy with
~/docker/deploy.sh calemdar <url> <grep>. - Pi setup:
- a
calemdarcompose service with a healthcheck, running non-root; - env
CALEMDAR_SESSION_SECRET,CALEMDAR_PASSCODE,CALEMDAR_DB_PASSand VAPID keys in~/docker/.env; - its own DB and user;
calemdaradded to~/docker/backup-db.sh.- Routing: an NPM proxy host for
cal.hirota.us→calemdar:3000(cert reused like quiz and music), and a Cloudflare CNAMEcal.hirota.us→tesla.jonathan-mullet.com(proxied). It's deliberately public, like QuizEm: Emily uses it from her phone away from home, and the server-side PIN is the gate.
9. Build order
- Scaffold the repo, server skeleton, DB and auth (copied from QuizEm's proven pieces).
- Write the core modules test-first: tz, rrule, occurrences, layout, nlp, ics, holidays.
- Build the API and DB, with integration tests.
- Build the UI shell (tokens, layout, router, store/sync) and the Month, Day/3-Day/Week, List and Year views.
- Add the editor, quick add, recurrence scopes, undo/trash, drag, duplicate and the conflict warning.
- Add reminders/push, the subscribe feed, outside subscriptions, holidays, import/export and settings.
- Polish: accessibility, keyboard, offline, a WebKit visual QA loop, light/dark.
- Infra and deploy: verify the served bytes, write memory notes, and send Jonathan the summary.
Progress is tracked in docs/STATUS.md.