Vibecode Fantastical
track this build5 phases, 15 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Fantastical, create a polished Apple calendar client with natural-language event entry and local sets. The hard boundary is native apple integrations, widgets, sync, proposals, and years of calendar edge cases, plus calendar integrations and reliability.
You are building a lean indie version of Fantastical. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Fantastical · indie build A calendar client that connects to existing accounts over CalDAV, parses a typed sentence into a correctly timed event, renders day, week and month views, handles recurrence and exceptions properly, and writes back so the event appears on every other device the user owns. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a desktop shell | the parsing and the views are where the work is, and both are comfortable here | | Sync | CalDAV with a well-tested client library | the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend | | Time | A library with a full time zone database, never naive dates | an event stored without a zone is an event that moves when the user travels | | Recurrence | A recurrence rule library plus your own exception handling | expanding rules is solved; deciding what an edited occurrence means is not | | Parsing | A deterministic grammar first, with a model only for what it cannot handle | a parser you can debug beats one you can only retrain | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the build and the desktop shell. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A real calendar account with CalDAV access** · free with an existing account - Why: Provider quirks are the substance of Phase 1 and cannot be discovered against a local test server. - Get it: Use an account you already have. Some providers require an application-specific password rather than your main one, so generate that first. - Verify: A client can list calendars and fetch events - [ ] **A second device using the same calendar** · free - Why: Phases 1 and 4 both test propagation and conflicts, which need a genuinely separate client. - Get it: Your phone or another computer with the same account configured in its own calendar application. - Verify: An event created on one appears on the other - [ ] **A time library with a full time zone database** · free - Why: Recurrence across clock changes is the single hardest correctness problem here, and a library without full zone data cannot get it right. - Get it: Choose a library with complete zone support rather than one that relies on the platform, and pin its version so a zone data update is a deliberate upgrade. - Verify: A test event across a daylight saving transition behaves correctly - [ ] **A calendar fixture with five years of events** · free - Why: Phase 5's performance work needs it, and every naive approach feels fine on a week of events. - Get it: Generate it, including several recurring series and some overlapping events. - Verify: The fixture loads and month view can be measured against it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. - native Apple integrations, widgets, sync, proposals, and years of calendar edge cases - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity If one of those is essential to you, that is the reason to keep paying for Fantastical, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fantastical The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a calendar client with natural language event entry that talks to the calendars you already use to replace Fantastical. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not build a calendar server. CalDAV exists, every major provider speaks it or an equivalent, and your events must remain readable by the phone and the watch you are not writing. Sync with what the user already has. Time zones and recurrence are where every calendar application eventually breaks. Treat them as the core of the build rather than as details to handle later, because retrofitting correct recurrence handling means rewriting the event model. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a desktop shell · the parsing and the views are where the work is, and both are comfortable here - Sync: CalDAV with a well-tested client library · the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend - Time: A library with a full time zone database, never naive dates · an event stored without a zone is an event that moves when the user travels - Recurrence: A recurrence rule library plus your own exception handling · expanding rules is solved; deciding what an edited occurrence means is not - Parsing: A deterministic grammar first, with a model only for what it cannot handle · a parser you can debug beats one you can only retrain ### Data model (create this before Phase 1) - accounts(id, name, url, username, credential_ref, sync_token, last_synced_at) - calendars(id, account_id, href, name, color, read_only, ctag) - events(id, calendar_id, uid, etag, summary, location, starts_at_utc, ends_at_utc, timezone, all_day, rrule, rdates, exdates, sequence) - occurrences(event_id, occurrence_start_utc, overridden_event_id) · expanded for the visible range only, never for all time - pending_changes(id, event_id, operation, payload, attempts, last_error) · the outbox that makes offline editing safe - Every timestamp is stored in coordinated universal time alongside its original zone, because a recurring event at nine in the morning stays at nine when the clocks change and a fixed instant does not. ### Phase 1 · Sync that does not lose events Build: Connect to a CalDAV account, discover calendars, and sync events using the collection sync report so only changes come down. Store the etag for each event. Do a full resynchronisation when the server says the token is invalid. Trap: Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. Done when: A real account's calendars and events appear. An event created on the phone appears after a sync. An event deleted elsewhere disappears rather than lingering. Deliberately invalidating the sync token triggers a full resynchronisation rather than an error. Do not build yet: No parsing, no views, no writing back. ### Phase 2 · Time zones and recurrence, done properly Build: Store the zone with every event. Expand recurrence rules only for the visible range. Handle exceptions: a single occurrence edited, an occurrence deleted, and the series edited from a point forward. Test explicitly across a daylight saving transition. Trap: A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. Done when: A weekly event spanning a daylight saving transition occurs at the same local time on both sides, verified against a real calendar. Deleting one occurrence leaves the rest. Editing one occurrence does not alter the series. An all day event does not shift when the machine's zone changes. Do not build yet: No natural language parsing and no writing back yet. ### Phase 3 · Natural language entry Build: Parse a typed sentence into a title, date, time, duration, location, calendar and recurrence with a deterministic grammar. Show the interpreted result before saving, always. Fall back to a model only for sentences the grammar cannot handle. Trap: A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. Done when: Twenty test sentences parse to the expected events, including relative dates, durations, times without minutes and a recurrence. The interpreted result is shown before saving in every case. An ambiguous sentence surfaces the ambiguity rather than picking silently. A sentence with no recognisable date creates an event today rather than failing. Do not build yet: No writing back to the server yet. ### Phase 4 · Writing back, and conflicts Build: Write events back over CalDAV using the stored etag for conditional updates. On an etag mismatch, fetch the server version and present both rather than overwriting. Queue changes made offline and drain them on reconnection. Trap: Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. Done when: An event created here appears on another device. Editing the same event on two devices produces a visible conflict rather than a silent overwrite, tested by editing on both while offline. Changes made offline are queued and applied on reconnection. A rejected write is retried with the fresh etag rather than abandoned. Do not build yet: No views beyond a list yet. ### Phase 5 · Views that are fast at a real calendar's size Build: Day, week and month views with overlapping event layout. Query and expand only the visible range. Test against a calendar with several years of events and several recurring series. Trap: Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. Done when: Month view renders in well under a hundred milliseconds on a calendar with five years of events and twenty recurring series. Overlapping events lay out side by side without covering each other. Navigating between months does not re-expand rules already cached. An all day event spanning several days renders across them. Do not build yet: Distribution and notifications come last. ### Phase 6 · Notifications, invitations and distribution Build: Local notifications for reminders, scheduled from the expanded occurrences. Handle meeting invitations at least well enough not to corrupt them: accept, decline and preserve the parts you do not understand. Sign and distribute. Trap: A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model. Done when: A reminder fires while the application is in the background. An invitation with attendees and a conferencing link round trips without losing either, verified by comparing the raw data before and after. Accepting an invitation updates the attendee status. The signed build opens on another machine without a warning. Do not build yet: Nothing after this. ### Out of scope (and why) - The native integrations behind the paid application: widgets, watch complications, menu bar presence and the system-level polish that makes it feel part of the machine. - Meeting proposals and scheduling links, which are a scheduling product rather than a calendar client. - The years of provider-specific compatibility work behind handling every calendar server's interpretation of the standard. - Weather, travel time and the contextual features that need external services and their bills. ### README must contain - That events are stored with their local time and zone, not only as an instant, and why - That recurrence expands only for the visible range - That the parsed interpretation is always shown before saving - That writes are conditional on the etag and a mismatch raises a conflict - That unknown event properties are preserved on round trip, and which providers were tested ===== AGENTS.md ===== # Agent instructions · Fantastical indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with a desktop shell, CalDAV with a well-tested client library, A library with a full time zone database, never naive dates, A recurrence rule library plus your own exception handling, A deterministic grammar first, with a model only for what it cannot handle. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. ===== BUILD_PLAN.md ===== # Build plan · Fantastical A calendar client that connects to existing accounts over CalDAV, parses a typed sentence into a correctly timed event, renders day, week and month views, handles recurrence and exceptions properly, and writes back so the event appears on every other device the user owns. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Sync that does not lose events Your real calendar appears, and a change made elsewhere shows up. ### Steps 1. Discover calendars Principal discovery then calendar listing. Providers differ here more than the specification suggests, so test against the account you actually use. Files: `src/caldav/discover.ts` 2. Sync incrementally with etags The collection sync report gives changes since a token. Store etags so you can detect conflicts later. Files: `src/caldav/sync.ts` 3. Handle an invalid token Fall back to a full sync. Test it by corrupting the stored token deliberately. Files: `src/caldav/sync.ts` ### Done when - [ ] Real calendars and events appear - [ ] An event created elsewhere appears after a sync - [ ] A deletion elsewhere removes it locally - [ ] An invalidated token triggers a full resynchronisation ### Watch out - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. ## Phase 2 · Time zones and recurrence, done properly Recurring events land at the right local time, including across a clock change. ### Steps 1. Store local time plus zone Both, always. The coordinated universal time value is derived for querying, not authoritative for recurring events. Files: `src/time.ts` 2. Expand only the visible range Expanding an infinite rule for all time is a hang waiting to happen. Expand for the range being displayed and cache it. Files: `src/recurrence.ts` 3. Test across a transition A fixture series spanning a clock change in a zone that observes one. This test catches the bug that otherwise ships. Files: `src/recurrence.test.ts` ### Done when - [ ] A weekly event keeps its local time across a clock change - [ ] Deleting one occurrence leaves the series intact - [ ] Editing one occurrence does not alter the series - [ ] An all day event does not shift with the machine zone ### Watch out - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. ## Phase 3 · Natural language entry Typing a sentence produces the event you meant, and shows you what it understood. ### Steps 1. Write a deterministic grammar Dates, times, durations, recurrence keywords, location prepositions and calendar names. Debuggable and testable without a model. Files: `src/parse/grammar.ts` 2. Build a test corpus of twenty sentences Real sentences you would type, with expected results. This corpus is what stops parser changes regressing. Files: `src/parse/corpus.json` 3. Always show the interpretation A preview of date, time, duration and calendar before saving. Not a confidence-gated preview; always. Files: `src/ui/Preview.tsx` ### Done when - [ ] Twenty corpus sentences parse to expected events - [ ] The interpretation is shown before every save - [ ] An ambiguous sentence surfaces the ambiguity - [ ] A sentence with no date creates an event today ### Watch out - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. ## Phase 4 · Writing back, and conflicts Events you create appear on your phone, and a simultaneous edit elsewhere is not silently lost. ### Steps 1. Write conditionally with the etag The precondition header on every update. A mismatch is information, not a failure to swallow. Files: `src/caldav/write.ts` 2. Present conflicts Both versions side by side with their timestamps. The user chooses; the client does not guess. Files: `src/ui/Conflict.tsx` 3. Queue offline changes An outbox with retries and visible failures, drained on reconnection. Files: `src/outbox.ts` ### Done when - [ ] An event created here appears on another device - [ ] A simultaneous edit produces a visible conflict - [ ] Offline changes are queued and applied on reconnection - [ ] A rejected write retries with a fresh etag ### Watch out - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. ## Phase 5 · Views that are fast at a real calendar's size Day, week and month views render quickly on a calendar with years of history. ### Steps 1. Query and expand by visible range The range drives everything. Nothing outside it is computed. Files: `src/views/range.ts` 2. Lay out overlapping events Column assignment so concurrent events share the width rather than hiding each other. Files: `src/views/layout.ts` 3. Build a large fixture calendar Five years and twenty series. Performance claims against a week of events are meaningless. Files: `src/fixtures/large.ts` ### Done when - [ ] Month view renders under a hundred milliseconds on the large fixture - [ ] Overlapping events lay out side by side - [ ] Navigating months reuses cached expansions - [ ] A multi-day all day event renders across its days ### Watch out - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. ## Not in this build - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. ## After v1, if you want it - Multiple account support with a unified view, which mostly works already if the model stayed neutral - Time zone display for events in another zone, which travellers need constantly - A quick entry window on a global hotkey, since the parser is the valuable part ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local cache of accounts, calendars and events. Rebuildable from the server. DB_PATH=./data/calendar.db # Required. How often to poll for changes. Too frequent is rude to the provider and rarely necessary. SYNC_INTERVAL_SECONDS=300 # Required. How far around the visible range to expand recurrence. Larger costs memory and time. EXPAND_RANGE_DAYS=120 # Required. How far ahead notifications are scheduled. Must exceed your expansion refresh interval or reminders stop. REMINDER_LOOKAHEAD_MINUTES=10080 # Required. Used when an event carries no zone. Should match the machine unless the user overrides it. DEFAULT_TIMEZONE=Europe/London
You are building a lean indie version of Fantastical. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Fantastical · indie build A calendar client that connects to existing accounts over CalDAV, parses a typed sentence into a correctly timed event, renders day, week and month views, handles recurrence and exceptions properly, and writes back so the event appears on every other device the user owns. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a desktop shell | the parsing and the views are where the work is, and both are comfortable here | | Sync | CalDAV with a well-tested client library | the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend | | Time | A library with a full time zone database, never naive dates | an event stored without a zone is an event that moves when the user travels | | Recurrence | A recurrence rule library plus your own exception handling | expanding rules is solved; deciding what an edited occurrence means is not | | Parsing | A deterministic grammar first, with a model only for what it cannot handle | a parser you can debug beats one you can only retrain | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the build and the desktop shell. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A real calendar account with CalDAV access** · free with an existing account - Why: Provider quirks are the substance of Phase 1 and cannot be discovered against a local test server. - Get it: Use an account you already have. Some providers require an application-specific password rather than your main one, so generate that first. - Verify: A client can list calendars and fetch events - [ ] **A second device using the same calendar** · free - Why: Phases 1 and 4 both test propagation and conflicts, which need a genuinely separate client. - Get it: Your phone or another computer with the same account configured in its own calendar application. - Verify: An event created on one appears on the other - [ ] **A time library with a full time zone database** · free - Why: Recurrence across clock changes is the single hardest correctness problem here, and a library without full zone data cannot get it right. - Get it: Choose a library with complete zone support rather than one that relies on the platform, and pin its version so a zone data update is a deliberate upgrade. - Verify: A test event across a daylight saving transition behaves correctly - [ ] **A calendar fixture with five years of events** · free - Why: Phase 5's performance work needs it, and every naive approach feels fine on a week of events. - Get it: Generate it, including several recurring series and some overlapping events. - Verify: The fixture loads and month view can be measured against it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. - native Apple integrations, widgets, sync, proposals, and years of calendar edge cases - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity If one of those is essential to you, that is the reason to keep paying for Fantastical, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fantastical The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a calendar client with natural language event entry that talks to the calendars you already use to replace Fantastical. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not build a calendar server. CalDAV exists, every major provider speaks it or an equivalent, and your events must remain readable by the phone and the watch you are not writing. Sync with what the user already has. Time zones and recurrence are where every calendar application eventually breaks. Treat them as the core of the build rather than as details to handle later, because retrofitting correct recurrence handling means rewriting the event model. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a desktop shell · the parsing and the views are where the work is, and both are comfortable here - Sync: CalDAV with a well-tested client library · the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend - Time: A library with a full time zone database, never naive dates · an event stored without a zone is an event that moves when the user travels - Recurrence: A recurrence rule library plus your own exception handling · expanding rules is solved; deciding what an edited occurrence means is not - Parsing: A deterministic grammar first, with a model only for what it cannot handle · a parser you can debug beats one you can only retrain ### Data model (create this before Phase 1) - accounts(id, name, url, username, credential_ref, sync_token, last_synced_at) - calendars(id, account_id, href, name, color, read_only, ctag) - events(id, calendar_id, uid, etag, summary, location, starts_at_utc, ends_at_utc, timezone, all_day, rrule, rdates, exdates, sequence) - occurrences(event_id, occurrence_start_utc, overridden_event_id) · expanded for the visible range only, never for all time - pending_changes(id, event_id, operation, payload, attempts, last_error) · the outbox that makes offline editing safe - Every timestamp is stored in coordinated universal time alongside its original zone, because a recurring event at nine in the morning stays at nine when the clocks change and a fixed instant does not. ### Phase 1 · Sync that does not lose events Build: Connect to a CalDAV account, discover calendars, and sync events using the collection sync report so only changes come down. Store the etag for each event. Do a full resynchronisation when the server says the token is invalid. Trap: Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. Done when: A real account's calendars and events appear. An event created on the phone appears after a sync. An event deleted elsewhere disappears rather than lingering. Deliberately invalidating the sync token triggers a full resynchronisation rather than an error. Do not build yet: No parsing, no views, no writing back. ### Phase 2 · Time zones and recurrence, done properly Build: Store the zone with every event. Expand recurrence rules only for the visible range. Handle exceptions: a single occurrence edited, an occurrence deleted, and the series edited from a point forward. Test explicitly across a daylight saving transition. Trap: A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. Done when: A weekly event spanning a daylight saving transition occurs at the same local time on both sides, verified against a real calendar. Deleting one occurrence leaves the rest. Editing one occurrence does not alter the series. An all day event does not shift when the machine's zone changes. Do not build yet: No natural language parsing and no writing back yet. ### Phase 3 · Natural language entry Build: Parse a typed sentence into a title, date, time, duration, location, calendar and recurrence with a deterministic grammar. Show the interpreted result before saving, always. Fall back to a model only for sentences the grammar cannot handle. Trap: A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. Done when: Twenty test sentences parse to the expected events, including relative dates, durations, times without minutes and a recurrence. The interpreted result is shown before saving in every case. An ambiguous sentence surfaces the ambiguity rather than picking silently. A sentence with no recognisable date creates an event today rather than failing. Do not build yet: No writing back to the server yet. ### Phase 4 · Writing back, and conflicts Build: Write events back over CalDAV using the stored etag for conditional updates. On an etag mismatch, fetch the server version and present both rather than overwriting. Queue changes made offline and drain them on reconnection. Trap: Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. Done when: An event created here appears on another device. Editing the same event on two devices produces a visible conflict rather than a silent overwrite, tested by editing on both while offline. Changes made offline are queued and applied on reconnection. A rejected write is retried with the fresh etag rather than abandoned. Do not build yet: No views beyond a list yet. ### Phase 5 · Views that are fast at a real calendar's size Build: Day, week and month views with overlapping event layout. Query and expand only the visible range. Test against a calendar with several years of events and several recurring series. Trap: Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. Done when: Month view renders in well under a hundred milliseconds on a calendar with five years of events and twenty recurring series. Overlapping events lay out side by side without covering each other. Navigating between months does not re-expand rules already cached. An all day event spanning several days renders across them. Do not build yet: Distribution and notifications come last. ### Phase 6 · Notifications, invitations and distribution Build: Local notifications for reminders, scheduled from the expanded occurrences. Handle meeting invitations at least well enough not to corrupt them: accept, decline and preserve the parts you do not understand. Sign and distribute. Trap: A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model. Done when: A reminder fires while the application is in the background. An invitation with attendees and a conferencing link round trips without losing either, verified by comparing the raw data before and after. Accepting an invitation updates the attendee status. The signed build opens on another machine without a warning. Do not build yet: Nothing after this. ### Out of scope (and why) - The native integrations behind the paid application: widgets, watch complications, menu bar presence and the system-level polish that makes it feel part of the machine. - Meeting proposals and scheduling links, which are a scheduling product rather than a calendar client. - The years of provider-specific compatibility work behind handling every calendar server's interpretation of the standard. - Weather, travel time and the contextual features that need external services and their bills. ### README must contain - That events are stored with their local time and zone, not only as an instant, and why - That recurrence expands only for the visible range - That the parsed interpretation is always shown before saving - That writes are conditional on the etag and a mismatch raises a conflict - That unknown event properties are preserved on round trip, and which providers were tested ===== AGENTS.md ===== # Agent instructions · Fantastical indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with a desktop shell, CalDAV with a well-tested client library, A library with a full time zone database, never naive dates, A recurrence rule library plus your own exception handling, A deterministic grammar first, with a model only for what it cannot handle. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. ===== BUILD_PLAN.md ===== # Build plan · Fantastical A calendar client that connects to existing accounts over CalDAV, parses a typed sentence into a correctly timed event, renders day, week and month views, handles recurrence and exceptions properly, and writes back so the event appears on every other device the user owns. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Sync that does not lose events Your real calendar appears, and a change made elsewhere shows up. ### Steps 1. Discover calendars Principal discovery then calendar listing. Providers differ here more than the specification suggests, so test against the account you actually use. Files: `src/caldav/discover.ts` 2. Sync incrementally with etags The collection sync report gives changes since a token. Store etags so you can detect conflicts later. Files: `src/caldav/sync.ts` 3. Handle an invalid token Fall back to a full sync. Test it by corrupting the stored token deliberately. Files: `src/caldav/sync.ts` ### Done when - [ ] Real calendars and events appear - [ ] An event created elsewhere appears after a sync - [ ] A deletion elsewhere removes it locally - [ ] An invalidated token triggers a full resynchronisation ### Watch out - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. ## Phase 2 · Time zones and recurrence, done properly Recurring events land at the right local time, including across a clock change. ### Steps 1. Store local time plus zone Both, always. The coordinated universal time value is derived for querying, not authoritative for recurring events. Files: `src/time.ts` 2. Expand only the visible range Expanding an infinite rule for all time is a hang waiting to happen. Expand for the range being displayed and cache it. Files: `src/recurrence.ts` 3. Test across a transition A fixture series spanning a clock change in a zone that observes one. This test catches the bug that otherwise ships. Files: `src/recurrence.test.ts` ### Done when - [ ] A weekly event keeps its local time across a clock change - [ ] Deleting one occurrence leaves the series intact - [ ] Editing one occurrence does not alter the series - [ ] An all day event does not shift with the machine zone ### Watch out - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. ## Phase 3 · Natural language entry Typing a sentence produces the event you meant, and shows you what it understood. ### Steps 1. Write a deterministic grammar Dates, times, durations, recurrence keywords, location prepositions and calendar names. Debuggable and testable without a model. Files: `src/parse/grammar.ts` 2. Build a test corpus of twenty sentences Real sentences you would type, with expected results. This corpus is what stops parser changes regressing. Files: `src/parse/corpus.json` 3. Always show the interpretation A preview of date, time, duration and calendar before saving. Not a confidence-gated preview; always. Files: `src/ui/Preview.tsx` ### Done when - [ ] Twenty corpus sentences parse to expected events - [ ] The interpretation is shown before every save - [ ] An ambiguous sentence surfaces the ambiguity - [ ] A sentence with no date creates an event today ### Watch out - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. ## Phase 4 · Writing back, and conflicts Events you create appear on your phone, and a simultaneous edit elsewhere is not silently lost. ### Steps 1. Write conditionally with the etag The precondition header on every update. A mismatch is information, not a failure to swallow. Files: `src/caldav/write.ts` 2. Present conflicts Both versions side by side with their timestamps. The user chooses; the client does not guess. Files: `src/ui/Conflict.tsx` 3. Queue offline changes An outbox with retries and visible failures, drained on reconnection. Files: `src/outbox.ts` ### Done when - [ ] An event created here appears on another device - [ ] A simultaneous edit produces a visible conflict - [ ] Offline changes are queued and applied on reconnection - [ ] A rejected write retries with a fresh etag ### Watch out - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. ## Phase 5 · Views that are fast at a real calendar's size Day, week and month views render quickly on a calendar with years of history. ### Steps 1. Query and expand by visible range The range drives everything. Nothing outside it is computed. Files: `src/views/range.ts` 2. Lay out overlapping events Column assignment so concurrent events share the width rather than hiding each other. Files: `src/views/layout.ts` 3. Build a large fixture calendar Five years and twenty series. Performance claims against a week of events are meaningless. Files: `src/fixtures/large.ts` ### Done when - [ ] Month view renders under a hundred milliseconds on the large fixture - [ ] Overlapping events lay out side by side - [ ] Navigating months reuses cached expansions - [ ] A multi-day all day event renders across its days ### Watch out - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. ## Not in this build - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. ## After v1, if you want it - Multiple account support with a unified view, which mostly works already if the model stayed neutral - Time zone display for events in another zone, which travellers need constantly - A quick entry window on a global hotkey, since the parser is the valuable part ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local cache of accounts, calendars and events. Rebuildable from the server. DB_PATH=./data/calendar.db # Required. How often to poll for changes. Too frequent is rude to the provider and rarely necessary. SYNC_INTERVAL_SECONDS=300 # Required. How far around the visible range to expand recurrence. Larger costs memory and time. EXPAND_RANGE_DAYS=120 # Required. How far ahead notifications are scheduled. Must exceed your expansion refresh interval or reminders stop. REMINDER_LOOKAHEAD_MINUTES=10080 # Required. Used when an event carries no zone. Should match the machine unless the user overrides it. DEFAULT_TIMEZONE=Europe/London
You are building a production product version of Fantastical. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Fantastical · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Fantastical, create a polished Apple calendar client with natural-language event entry and local sets. The hard boundary is native apple integrations, widgets, sync, proposals, and years of calendar edge cases, plus calendar integrations and reliability. ## Product outcome A calendar client whose recurring events survive clock changes, whose parser always shows its interpretation before saving, whose writes never silently overwrite another device, and which does not corrupt events it only partly understands. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - PostgreSQL - public deploy target - Resend API key - domain with HTTPS ## Explicit non-goals for v1 - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. - native Apple integrations, widgets, sync, proposals, and years of calendar edge cases - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity ## Success criteria - The daylight saving transition test passes - The twenty sentence parse corpus passes - A conditional write conflict is confirmed to surface rather than overwrite - An invitation round trips without losing attendees or conferencing details - Month view performance on the large fixture is measured and has not regressed ===== BRIEF.md ===== # Build brief · Fantastical The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a calendar client with natural language event entry that talks to the calendars you already use to replace Fantastical. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not build a calendar server. CalDAV exists, every major provider speaks it or an equivalent, and your events must remain readable by the phone and the watch you are not writing. Sync with what the user already has. Time zones and recurrence are where every calendar application eventually breaks. Treat them as the core of the build rather than as details to handle later, because retrofitting correct recurrence handling means rewriting the event model. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a desktop shell · the parsing and the views are where the work is, and both are comfortable here - Sync: CalDAV with a well-tested client library · the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend - Time: A library with a full time zone database, never naive dates · an event stored without a zone is an event that moves when the user travels - Recurrence: A recurrence rule library plus your own exception handling · expanding rules is solved; deciding what an edited occurrence means is not - Parsing: A deterministic grammar first, with a model only for what it cannot handle · a parser you can debug beats one you can only retrain ### Data model (create this before Phase 1) - accounts(id, name, url, username, credential_ref, sync_token, last_synced_at) - calendars(id, account_id, href, name, color, read_only, ctag) - events(id, calendar_id, uid, etag, summary, location, starts_at_utc, ends_at_utc, timezone, all_day, rrule, rdates, exdates, sequence) - occurrences(event_id, occurrence_start_utc, overridden_event_id) · expanded for the visible range only, never for all time - pending_changes(id, event_id, operation, payload, attempts, last_error) · the outbox that makes offline editing safe - Every timestamp is stored in coordinated universal time alongside its original zone, because a recurring event at nine in the morning stays at nine when the clocks change and a fixed instant does not. ### Phase 1 · Sync that does not lose events Build: Connect to a CalDAV account, discover calendars, and sync events using the collection sync report so only changes come down. Store the etag for each event. Do a full resynchronisation when the server says the token is invalid. Trap: Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. Done when: A real account's calendars and events appear. An event created on the phone appears after a sync. An event deleted elsewhere disappears rather than lingering. Deliberately invalidating the sync token triggers a full resynchronisation rather than an error. Do not build yet: No parsing, no views, no writing back. ### Phase 2 · Time zones and recurrence, done properly Build: Store the zone with every event. Expand recurrence rules only for the visible range. Handle exceptions: a single occurrence edited, an occurrence deleted, and the series edited from a point forward. Test explicitly across a daylight saving transition. Trap: A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. Done when: A weekly event spanning a daylight saving transition occurs at the same local time on both sides, verified against a real calendar. Deleting one occurrence leaves the rest. Editing one occurrence does not alter the series. An all day event does not shift when the machine's zone changes. Do not build yet: No natural language parsing and no writing back yet. ### Phase 3 · Natural language entry Build: Parse a typed sentence into a title, date, time, duration, location, calendar and recurrence with a deterministic grammar. Show the interpreted result before saving, always. Fall back to a model only for sentences the grammar cannot handle. Trap: A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. Done when: Twenty test sentences parse to the expected events, including relative dates, durations, times without minutes and a recurrence. The interpreted result is shown before saving in every case. An ambiguous sentence surfaces the ambiguity rather than picking silently. A sentence with no recognisable date creates an event today rather than failing. Do not build yet: No writing back to the server yet. ### Phase 4 · Writing back, and conflicts Build: Write events back over CalDAV using the stored etag for conditional updates. On an etag mismatch, fetch the server version and present both rather than overwriting. Queue changes made offline and drain them on reconnection. Trap: Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. Done when: An event created here appears on another device. Editing the same event on two devices produces a visible conflict rather than a silent overwrite, tested by editing on both while offline. Changes made offline are queued and applied on reconnection. A rejected write is retried with the fresh etag rather than abandoned. Do not build yet: No views beyond a list yet. ### Phase 5 · Views that are fast at a real calendar's size Build: Day, week and month views with overlapping event layout. Query and expand only the visible range. Test against a calendar with several years of events and several recurring series. Trap: Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. Done when: Month view renders in well under a hundred milliseconds on a calendar with five years of events and twenty recurring series. Overlapping events lay out side by side without covering each other. Navigating between months does not re-expand rules already cached. An all day event spanning several days renders across them. Do not build yet: Distribution and notifications come last. ### Phase 6 · Notifications, invitations and distribution Build: Local notifications for reminders, scheduled from the expanded occurrences. Handle meeting invitations at least well enough not to corrupt them: accept, decline and preserve the parts you do not understand. Sign and distribute. Trap: A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model. Done when: A reminder fires while the application is in the background. An invitation with attendees and a conferencing link round trips without losing either, verified by comparing the raw data before and after. Accepting an invitation updates the attendee status. The signed build opens on another machine without a warning. Do not build yet: Nothing after this. ### Out of scope (and why) - The native integrations behind the paid application: widgets, watch complications, menu bar presence and the system-level polish that makes it feel part of the machine. - Meeting proposals and scheduling links, which are a scheduling product rather than a calendar client. - The years of provider-specific compatibility work behind handling every calendar server's interpretation of the standard. - Weather, travel time and the contextual features that need external services and their bills. ### README must contain - That events are stored with their local time and zone, not only as an instant, and why - That recurrence expands only for the visible range - That the parsed interpretation is always shown before saving - That writes are conditional on the etag and a mismatch raises a conflict - That unknown event properties are preserved on round trip, and which providers were tested ===== ARCHITECTURE.md ===== # Architecture · Fantastical ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a desktop shell | the parsing and the views are where the work is, and both are comfortable here | | Sync | CalDAV with a well-tested client library | the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend | | Time | A library with a full time zone database, never naive dates | an event stored without a zone is an event that moves when the user travels | | Recurrence | A recurrence rule library plus your own exception handling | expanding rules is solved; deciding what an edited occurrence means is not | | Parsing | A deterministic grammar first, with a model only for what it cannot handle | a parser you can debug beats one you can only retrain | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | CalDAV discovery, incremental sync and etags. | A different protocol changes this module and nothing else, provided the event model stays protocol-neutral. | | Time | Zones, instants and clock change behaviour. | Not swappable casually. Every correctness property in the application depends on this being right. | | Recurrence | Rule expansion and exceptions. | Rule expansion is a library; exception semantics are yours and are where the subtle bugs live. | | Parse | Turning a sentence into a draft event. | Deterministic first, model as a fallback. Whatever the implementation, the preview stays mandatory. | | Views | Rendering ranges and laying out overlaps. | Presentation only, provided it keeps querying by visible range. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Local cache of accounts, calendars and events. Rebuildable from the server. - `SYNC_INTERVAL_SECONDS` · required · How often to poll for changes. Too frequent is rude to the provider and rarely necessary. - `EXPAND_RANGE_DAYS` · required · How far around the visible range to expand recurrence. Larger costs memory and time. - `REMINDER_LOOKAHEAD_MINUTES` · required · How far ahead notifications are scheduled. Must exceed your expansion refresh interval or reminders stop. - `DEFAULT_TIMEZONE` · required · Used when an event carries no zone. Should match the machine unless the user overrides it. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Fantastical product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with a desktop shell, CalDAV with a well-tested client library, A library with a full time zone database, never naive dates, A recurrence rule library plus your own exception handling, A deterministic grammar first, with a model only for what it cannot handle. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. - A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model. ===== MILESTONES.md ===== # Delivery milestones · Fantastical Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Sync that does not lose events Your real calendar appears, and a change made elsewhere shows up. ### Steps 1. Discover calendars Principal discovery then calendar listing. Providers differ here more than the specification suggests, so test against the account you actually use. Files: `src/caldav/discover.ts` 2. Sync incrementally with etags The collection sync report gives changes since a token. Store etags so you can detect conflicts later. Files: `src/caldav/sync.ts` 3. Handle an invalid token Fall back to a full sync. Test it by corrupting the stored token deliberately. Files: `src/caldav/sync.ts` ### Done when - [ ] Real calendars and events appear - [ ] An event created elsewhere appears after a sync - [ ] A deletion elsewhere removes it locally - [ ] An invalidated token triggers a full resynchronisation ### Watch out - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. ## M2 · Time zones and recurrence, done properly Recurring events land at the right local time, including across a clock change. ### Steps 1. Store local time plus zone Both, always. The coordinated universal time value is derived for querying, not authoritative for recurring events. Files: `src/time.ts` 2. Expand only the visible range Expanding an infinite rule for all time is a hang waiting to happen. Expand for the range being displayed and cache it. Files: `src/recurrence.ts` 3. Test across a transition A fixture series spanning a clock change in a zone that observes one. This test catches the bug that otherwise ships. Files: `src/recurrence.test.ts` ### Done when - [ ] A weekly event keeps its local time across a clock change - [ ] Deleting one occurrence leaves the series intact - [ ] Editing one occurrence does not alter the series - [ ] An all day event does not shift with the machine zone ### Watch out - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. ## M3 · Natural language entry Typing a sentence produces the event you meant, and shows you what it understood. ### Steps 1. Write a deterministic grammar Dates, times, durations, recurrence keywords, location prepositions and calendar names. Debuggable and testable without a model. Files: `src/parse/grammar.ts` 2. Build a test corpus of twenty sentences Real sentences you would type, with expected results. This corpus is what stops parser changes regressing. Files: `src/parse/corpus.json` 3. Always show the interpretation A preview of date, time, duration and calendar before saving. Not a confidence-gated preview; always. Files: `src/ui/Preview.tsx` ### Done when - [ ] Twenty corpus sentences parse to expected events - [ ] The interpretation is shown before every save - [ ] An ambiguous sentence surfaces the ambiguity - [ ] A sentence with no date creates an event today ### Watch out - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. ## M4 · Writing back, and conflicts Events you create appear on your phone, and a simultaneous edit elsewhere is not silently lost. ### Steps 1. Write conditionally with the etag The precondition header on every update. A mismatch is information, not a failure to swallow. Files: `src/caldav/write.ts` 2. Present conflicts Both versions side by side with their timestamps. The user chooses; the client does not guess. Files: `src/ui/Conflict.tsx` 3. Queue offline changes An outbox with retries and visible failures, drained on reconnection. Files: `src/outbox.ts` ### Done when - [ ] An event created here appears on another device - [ ] A simultaneous edit produces a visible conflict - [ ] Offline changes are queued and applied on reconnection - [ ] A rejected write retries with a fresh etag ### Watch out - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. ## M5 · Views that are fast at a real calendar's size Day, week and month views render quickly on a calendar with years of history. ### Steps 1. Query and expand by visible range The range drives everything. Nothing outside it is computed. Files: `src/views/range.ts` 2. Lay out overlapping events Column assignment so concurrent events share the width rather than hiding each other. Files: `src/views/layout.ts` 3. Build a large fixture calendar Five years and twenty series. Performance claims against a week of events are meaningless. Files: `src/fixtures/large.ts` ### Done when - [ ] Month view renders under a hundred milliseconds on the large fixture - [ ] Overlapping events lay out side by side - [ ] Navigating months reuses cached expansions - [ ] A multi-day all day event renders across its days ### Watch out - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. ## M6 · Notifications, invitations and distribution (production only) Reminders fire when you are not looking at the application, and invitations do not break. ### Steps 1. Schedule notifications from occurrences Reschedule when an event changes and when the visible range advances, so a recurring reminder does not stop after the cached window. Files: `src/notify.ts` 2. Preserve unknown properties Keep the original data and modify only what you understand. Compare raw round trips as a test. Files: `src/caldav/roundtrip.test.ts` 3. Sign and distribute Platform signing, and a note about which providers you have tested against. Files: `README.md` ### Done when - [ ] A reminder fires with the application in the background - [ ] An invitation round trips without losing attendees or a conferencing link - [ ] Accepting an invitation updates attendee status - [ ] The signed build opens on another machine without a warning ### Watch out - A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model. ===== OPERATIONS.md ===== # Operations · Fantastical ## Backup The local database is a cache and the server holds the events, so the backup that matters is your provider's. What is worth keeping locally is the outbox, because it holds changes not yet accepted anywhere. ## Restore Delete the cache and resynchronise. If a restore loses anything other than pending changes, something authoritative has leaked into the local database. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync failure rate per account and outbox age. An outbox row older than a few minutes means writes are failing, which the user will otherwise discover when an event does not appear elsewhere. ## Incident checklist For events appearing at the wrong time, check zone handling before anything else. For events vanishing, check whether an invalid sync token was treated as an error rather than triggering a full resynchronisation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The daylight saving transition test passes - [ ] The twenty sentence parse corpus passes - [ ] A conditional write conflict is confirmed to surface rather than overwrite - [ ] An invitation round trips without losing attendees or conferencing details - [ ] Month view performance on the large fixture is measured and has not regressed ## Launch constraint Do not market omitted Fantastical capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local cache of accounts, calendars and events. Rebuildable from the server. DB_PATH=./data/calendar.db # Required. How often to poll for changes. Too frequent is rude to the provider and rarely necessary. SYNC_INTERVAL_SECONDS=300 # Required. How far around the visible range to expand recurrence. Larger costs memory and time. EXPAND_RANGE_DAYS=120 # Required. How far ahead notifications are scheduled. Must exceed your expansion refresh interval or reminders stop. REMINDER_LOOKAHEAD_MINUTES=10080 # Required. Used when an event carries no zone. Should match the machine unless the user overrides it. DEFAULT_TIMEZONE=Europe/London
# Fantastical · indie build A calendar client that connects to existing accounts over CalDAV, parses a typed sentence into a correctly timed event, renders day, week and month views, handles recurrence and exceptions properly, and writes back so the event appears on every other device the user owns. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a desktop shell | the parsing and the views are where the work is, and both are comfortable here | | Sync | CalDAV with a well-tested client library | the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend | | Time | A library with a full time zone database, never naive dates | an event stored without a zone is an event that moves when the user travels | | Recurrence | A recurrence rule library plus your own exception handling | expanding rules is solved; deciding what an edited occurrence means is not | | Parsing | A deterministic grammar first, with a model only for what it cannot handle | a parser you can debug beats one you can only retrain | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the build and the desktop shell. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A real calendar account with CalDAV access** · free with an existing account - Why: Provider quirks are the substance of Phase 1 and cannot be discovered against a local test server. - Get it: Use an account you already have. Some providers require an application-specific password rather than your main one, so generate that first. - Verify: A client can list calendars and fetch events - [ ] **A second device using the same calendar** · free - Why: Phases 1 and 4 both test propagation and conflicts, which need a genuinely separate client. - Get it: Your phone or another computer with the same account configured in its own calendar application. - Verify: An event created on one appears on the other - [ ] **A time library with a full time zone database** · free - Why: Recurrence across clock changes is the single hardest correctness problem here, and a library without full zone data cannot get it right. - Get it: Choose a library with complete zone support rather than one that relies on the platform, and pin its version so a zone data update is a deliberate upgrade. - Verify: A test event across a daylight saving transition behaves correctly - [ ] **A calendar fixture with five years of events** · free - Why: Phase 5's performance work needs it, and every naive approach feels fine on a week of events. - Get it: Generate it, including several recurring series and some overlapping events. - Verify: The fixture loads and month view can be measured against it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. - native Apple integrations, widgets, sync, proposals, and years of calendar edge cases - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity If one of those is essential to you, that is the reason to keep paying for Fantastical, and the README should say so rather than pretend.
# Build brief · Fantastical The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a calendar client with natural language event entry that talks to the calendars you already use to replace Fantastical. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not build a calendar server. CalDAV exists, every major provider speaks it or an equivalent, and your events must remain readable by the phone and the watch you are not writing. Sync with what the user already has. Time zones and recurrence are where every calendar application eventually breaks. Treat them as the core of the build rather than as details to handle later, because retrofitting correct recurrence handling means rewriting the event model. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a desktop shell · the parsing and the views are where the work is, and both are comfortable here - Sync: CalDAV with a well-tested client library · the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend - Time: A library with a full time zone database, never naive dates · an event stored without a zone is an event that moves when the user travels - Recurrence: A recurrence rule library plus your own exception handling · expanding rules is solved; deciding what an edited occurrence means is not - Parsing: A deterministic grammar first, with a model only for what it cannot handle · a parser you can debug beats one you can only retrain ### Data model (create this before Phase 1) - accounts(id, name, url, username, credential_ref, sync_token, last_synced_at) - calendars(id, account_id, href, name, color, read_only, ctag) - events(id, calendar_id, uid, etag, summary, location, starts_at_utc, ends_at_utc, timezone, all_day, rrule, rdates, exdates, sequence) - occurrences(event_id, occurrence_start_utc, overridden_event_id) · expanded for the visible range only, never for all time - pending_changes(id, event_id, operation, payload, attempts, last_error) · the outbox that makes offline editing safe - Every timestamp is stored in coordinated universal time alongside its original zone, because a recurring event at nine in the morning stays at nine when the clocks change and a fixed instant does not. ### Phase 1 · Sync that does not lose events Build: Connect to a CalDAV account, discover calendars, and sync events using the collection sync report so only changes come down. Store the etag for each event. Do a full resynchronisation when the server says the token is invalid. Trap: Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. Done when: A real account's calendars and events appear. An event created on the phone appears after a sync. An event deleted elsewhere disappears rather than lingering. Deliberately invalidating the sync token triggers a full resynchronisation rather than an error. Do not build yet: No parsing, no views, no writing back. ### Phase 2 · Time zones and recurrence, done properly Build: Store the zone with every event. Expand recurrence rules only for the visible range. Handle exceptions: a single occurrence edited, an occurrence deleted, and the series edited from a point forward. Test explicitly across a daylight saving transition. Trap: A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. Done when: A weekly event spanning a daylight saving transition occurs at the same local time on both sides, verified against a real calendar. Deleting one occurrence leaves the rest. Editing one occurrence does not alter the series. An all day event does not shift when the machine's zone changes. Do not build yet: No natural language parsing and no writing back yet. ### Phase 3 · Natural language entry Build: Parse a typed sentence into a title, date, time, duration, location, calendar and recurrence with a deterministic grammar. Show the interpreted result before saving, always. Fall back to a model only for sentences the grammar cannot handle. Trap: A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. Done when: Twenty test sentences parse to the expected events, including relative dates, durations, times without minutes and a recurrence. The interpreted result is shown before saving in every case. An ambiguous sentence surfaces the ambiguity rather than picking silently. A sentence with no recognisable date creates an event today rather than failing. Do not build yet: No writing back to the server yet. ### Phase 4 · Writing back, and conflicts Build: Write events back over CalDAV using the stored etag for conditional updates. On an etag mismatch, fetch the server version and present both rather than overwriting. Queue changes made offline and drain them on reconnection. Trap: Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. Done when: An event created here appears on another device. Editing the same event on two devices produces a visible conflict rather than a silent overwrite, tested by editing on both while offline. Changes made offline are queued and applied on reconnection. A rejected write is retried with the fresh etag rather than abandoned. Do not build yet: No views beyond a list yet. ### Phase 5 · Views that are fast at a real calendar's size Build: Day, week and month views with overlapping event layout. Query and expand only the visible range. Test against a calendar with several years of events and several recurring series. Trap: Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. Done when: Month view renders in well under a hundred milliseconds on a calendar with five years of events and twenty recurring series. Overlapping events lay out side by side without covering each other. Navigating between months does not re-expand rules already cached. An all day event spanning several days renders across them. Do not build yet: Distribution and notifications come last. ### Phase 6 · Notifications, invitations and distribution Build: Local notifications for reminders, scheduled from the expanded occurrences. Handle meeting invitations at least well enough not to corrupt them: accept, decline and preserve the parts you do not understand. Sign and distribute. Trap: A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model. Done when: A reminder fires while the application is in the background. An invitation with attendees and a conferencing link round trips without losing either, verified by comparing the raw data before and after. Accepting an invitation updates the attendee status. The signed build opens on another machine without a warning. Do not build yet: Nothing after this. ### Out of scope (and why) - The native integrations behind the paid application: widgets, watch complications, menu bar presence and the system-level polish that makes it feel part of the machine. - Meeting proposals and scheduling links, which are a scheduling product rather than a calendar client. - The years of provider-specific compatibility work behind handling every calendar server's interpretation of the standard. - Weather, travel time and the contextual features that need external services and their bills. ### README must contain - That events are stored with their local time and zone, not only as an instant, and why - That recurrence expands only for the visible range - That the parsed interpretation is always shown before saving - That writes are conditional on the etag and a mismatch raises a conflict - That unknown event properties are preserved on round trip, and which providers were tested
# Agent instructions · Fantastical indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with a desktop shell, CalDAV with a well-tested client library, A library with a full time zone database, never naive dates, A recurrence rule library plus your own exception handling, A deterministic grammar first, with a model only for what it cannot handle. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes.
# Build plan · Fantastical A calendar client that connects to existing accounts over CalDAV, parses a typed sentence into a correctly timed event, renders day, week and month views, handles recurrence and exceptions properly, and writes back so the event appears on every other device the user owns. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Sync that does not lose events Your real calendar appears, and a change made elsewhere shows up. ### Steps 1. Discover calendars Principal discovery then calendar listing. Providers differ here more than the specification suggests, so test against the account you actually use. Files: `src/caldav/discover.ts` 2. Sync incrementally with etags The collection sync report gives changes since a token. Store etags so you can detect conflicts later. Files: `src/caldav/sync.ts` 3. Handle an invalid token Fall back to a full sync. Test it by corrupting the stored token deliberately. Files: `src/caldav/sync.ts` ### Done when - [ ] Real calendars and events appear - [ ] An event created elsewhere appears after a sync - [ ] A deletion elsewhere removes it locally - [ ] An invalidated token triggers a full resynchronisation ### Watch out - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. ## Phase 2 · Time zones and recurrence, done properly Recurring events land at the right local time, including across a clock change. ### Steps 1. Store local time plus zone Both, always. The coordinated universal time value is derived for querying, not authoritative for recurring events. Files: `src/time.ts` 2. Expand only the visible range Expanding an infinite rule for all time is a hang waiting to happen. Expand for the range being displayed and cache it. Files: `src/recurrence.ts` 3. Test across a transition A fixture series spanning a clock change in a zone that observes one. This test catches the bug that otherwise ships. Files: `src/recurrence.test.ts` ### Done when - [ ] A weekly event keeps its local time across a clock change - [ ] Deleting one occurrence leaves the series intact - [ ] Editing one occurrence does not alter the series - [ ] An all day event does not shift with the machine zone ### Watch out - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. ## Phase 3 · Natural language entry Typing a sentence produces the event you meant, and shows you what it understood. ### Steps 1. Write a deterministic grammar Dates, times, durations, recurrence keywords, location prepositions and calendar names. Debuggable and testable without a model. Files: `src/parse/grammar.ts` 2. Build a test corpus of twenty sentences Real sentences you would type, with expected results. This corpus is what stops parser changes regressing. Files: `src/parse/corpus.json` 3. Always show the interpretation A preview of date, time, duration and calendar before saving. Not a confidence-gated preview; always. Files: `src/ui/Preview.tsx` ### Done when - [ ] Twenty corpus sentences parse to expected events - [ ] The interpretation is shown before every save - [ ] An ambiguous sentence surfaces the ambiguity - [ ] A sentence with no date creates an event today ### Watch out - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. ## Phase 4 · Writing back, and conflicts Events you create appear on your phone, and a simultaneous edit elsewhere is not silently lost. ### Steps 1. Write conditionally with the etag The precondition header on every update. A mismatch is information, not a failure to swallow. Files: `src/caldav/write.ts` 2. Present conflicts Both versions side by side with their timestamps. The user chooses; the client does not guess. Files: `src/ui/Conflict.tsx` 3. Queue offline changes An outbox with retries and visible failures, drained on reconnection. Files: `src/outbox.ts` ### Done when - [ ] An event created here appears on another device - [ ] A simultaneous edit produces a visible conflict - [ ] Offline changes are queued and applied on reconnection - [ ] A rejected write retries with a fresh etag ### Watch out - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. ## Phase 5 · Views that are fast at a real calendar's size Day, week and month views render quickly on a calendar with years of history. ### Steps 1. Query and expand by visible range The range drives everything. Nothing outside it is computed. Files: `src/views/range.ts` 2. Lay out overlapping events Column assignment so concurrent events share the width rather than hiding each other. Files: `src/views/layout.ts` 3. Build a large fixture calendar Five years and twenty series. Performance claims against a week of events are meaningless. Files: `src/fixtures/large.ts` ### Done when - [ ] Month view renders under a hundred milliseconds on the large fixture - [ ] Overlapping events lay out side by side - [ ] Navigating months reuses cached expansions - [ ] A multi-day all day event renders across its days ### Watch out - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. ## Not in this build - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. ## After v1, if you want it - Multiple account support with a unified view, which mostly works already if the model stayed neutral - Time zone display for events in another zone, which travellers need constantly - A quick entry window on a global hotkey, since the parser is the valuable part
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local cache of accounts, calendars and events. Rebuildable from the server. DB_PATH=./data/calendar.db # Required. How often to poll for changes. Too frequent is rude to the provider and rarely necessary. SYNC_INTERVAL_SECONDS=300 # Required. How far around the visible range to expand recurrence. Larger costs memory and time. EXPAND_RANGE_DAYS=120 # Required. How far ahead notifications are scheduled. Must exceed your expansion refresh interval or reminders stop. REMINDER_LOOKAHEAD_MINUTES=10080 # Required. Used when an event carries no zone. Should match the machine unless the user overrides it. DEFAULT_TIMEZONE=Europe/London
# Fantastical · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Fantastical, create a polished Apple calendar client with natural-language event entry and local sets. The hard boundary is native apple integrations, widgets, sync, proposals, and years of calendar edge cases, plus calendar integrations and reliability. ## Product outcome A calendar client whose recurring events survive clock changes, whose parser always shows its interpretation before saving, whose writes never silently overwrite another device, and which does not corrupt events it only partly understands. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - PostgreSQL - public deploy target - Resend API key - domain with HTTPS ## Explicit non-goals for v1 - Widgets, watch complications and system-level integration. - Meeting proposals and scheduling links. - Provider-specific compatibility across every calendar server. - Weather, travel time and contextual features. - native Apple integrations, widgets, sync, proposals, and years of calendar edge cases - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity ## Success criteria - The daylight saving transition test passes - The twenty sentence parse corpus passes - A conditional write conflict is confirmed to surface rather than overwrite - An invitation round trips without losing attendees or conferencing details - Month view performance on the large fixture is measured and has not regressed
# Build brief · Fantastical The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a calendar client with natural language event entry that talks to the calendars you already use to replace Fantastical. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not build a calendar server. CalDAV exists, every major provider speaks it or an equivalent, and your events must remain readable by the phone and the watch you are not writing. Sync with what the user already has. Time zones and recurrence are where every calendar application eventually breaks. Treat them as the core of the build rather than as details to handle later, because retrofitting correct recurrence handling means rewriting the event model. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a desktop shell · the parsing and the views are where the work is, and both are comfortable here - Sync: CalDAV with a well-tested client library · the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend - Time: A library with a full time zone database, never naive dates · an event stored without a zone is an event that moves when the user travels - Recurrence: A recurrence rule library plus your own exception handling · expanding rules is solved; deciding what an edited occurrence means is not - Parsing: A deterministic grammar first, with a model only for what it cannot handle · a parser you can debug beats one you can only retrain ### Data model (create this before Phase 1) - accounts(id, name, url, username, credential_ref, sync_token, last_synced_at) - calendars(id, account_id, href, name, color, read_only, ctag) - events(id, calendar_id, uid, etag, summary, location, starts_at_utc, ends_at_utc, timezone, all_day, rrule, rdates, exdates, sequence) - occurrences(event_id, occurrence_start_utc, overridden_event_id) · expanded for the visible range only, never for all time - pending_changes(id, event_id, operation, payload, attempts, last_error) · the outbox that makes offline editing safe - Every timestamp is stored in coordinated universal time alongside its original zone, because a recurring event at nine in the morning stays at nine when the clocks change and a fixed instant does not. ### Phase 1 · Sync that does not lose events Build: Connect to a CalDAV account, discover calendars, and sync events using the collection sync report so only changes come down. Store the etag for each event. Do a full resynchronisation when the server says the token is invalid. Trap: Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. Done when: A real account's calendars and events appear. An event created on the phone appears after a sync. An event deleted elsewhere disappears rather than lingering. Deliberately invalidating the sync token triggers a full resynchronisation rather than an error. Do not build yet: No parsing, no views, no writing back. ### Phase 2 · Time zones and recurrence, done properly Build: Store the zone with every event. Expand recurrence rules only for the visible range. Handle exceptions: a single occurrence edited, an occurrence deleted, and the series edited from a point forward. Test explicitly across a daylight saving transition. Trap: A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. Done when: A weekly event spanning a daylight saving transition occurs at the same local time on both sides, verified against a real calendar. Deleting one occurrence leaves the rest. Editing one occurrence does not alter the series. An all day event does not shift when the machine's zone changes. Do not build yet: No natural language parsing and no writing back yet. ### Phase 3 · Natural language entry Build: Parse a typed sentence into a title, date, time, duration, location, calendar and recurrence with a deterministic grammar. Show the interpreted result before saving, always. Fall back to a model only for sentences the grammar cannot handle. Trap: A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. Done when: Twenty test sentences parse to the expected events, including relative dates, durations, times without minutes and a recurrence. The interpreted result is shown before saving in every case. An ambiguous sentence surfaces the ambiguity rather than picking silently. A sentence with no recognisable date creates an event today rather than failing. Do not build yet: No writing back to the server yet. ### Phase 4 · Writing back, and conflicts Build: Write events back over CalDAV using the stored etag for conditional updates. On an etag mismatch, fetch the server version and present both rather than overwriting. Queue changes made offline and drain them on reconnection. Trap: Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. Done when: An event created here appears on another device. Editing the same event on two devices produces a visible conflict rather than a silent overwrite, tested by editing on both while offline. Changes made offline are queued and applied on reconnection. A rejected write is retried with the fresh etag rather than abandoned. Do not build yet: No views beyond a list yet. ### Phase 5 · Views that are fast at a real calendar's size Build: Day, week and month views with overlapping event layout. Query and expand only the visible range. Test against a calendar with several years of events and several recurring series. Trap: Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. Done when: Month view renders in well under a hundred milliseconds on a calendar with five years of events and twenty recurring series. Overlapping events lay out side by side without covering each other. Navigating between months does not re-expand rules already cached. An all day event spanning several days renders across them. Do not build yet: Distribution and notifications come last. ### Phase 6 · Notifications, invitations and distribution Build: Local notifications for reminders, scheduled from the expanded occurrences. Handle meeting invitations at least well enough not to corrupt them: accept, decline and preserve the parts you do not understand. Sign and distribute. Trap: A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model. Done when: A reminder fires while the application is in the background. An invitation with attendees and a conferencing link round trips without losing either, verified by comparing the raw data before and after. Accepting an invitation updates the attendee status. The signed build opens on another machine without a warning. Do not build yet: Nothing after this. ### Out of scope (and why) - The native integrations behind the paid application: widgets, watch complications, menu bar presence and the system-level polish that makes it feel part of the machine. - Meeting proposals and scheduling links, which are a scheduling product rather than a calendar client. - The years of provider-specific compatibility work behind handling every calendar server's interpretation of the standard. - Weather, travel time and the contextual features that need external services and their bills. ### README must contain - That events are stored with their local time and zone, not only as an instant, and why - That recurrence expands only for the visible range - That the parsed interpretation is always shown before saving - That writes are conditional on the etag and a mismatch raises a conflict - That unknown event properties are preserved on round trip, and which providers were tested
# Architecture · Fantastical ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a desktop shell | the parsing and the views are where the work is, and both are comfortable here | | Sync | CalDAV with a well-tested client library | the protocol is old and quirky, and implementing it from the specification is a month you do not need to spend | | Time | A library with a full time zone database, never naive dates | an event stored without a zone is an event that moves when the user travels | | Recurrence | A recurrence rule library plus your own exception handling | expanding rules is solved; deciding what an edited occurrence means is not | | Parsing | A deterministic grammar first, with a model only for what it cannot handle | a parser you can debug beats one you can only retrain | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | CalDAV discovery, incremental sync and etags. | A different protocol changes this module and nothing else, provided the event model stays protocol-neutral. | | Time | Zones, instants and clock change behaviour. | Not swappable casually. Every correctness property in the application depends on this being right. | | Recurrence | Rule expansion and exceptions. | Rule expansion is a library; exception semantics are yours and are where the subtle bugs live. | | Parse | Turning a sentence into a draft event. | Deterministic first, model as a fallback. Whatever the implementation, the preview stays mandatory. | | Views | Rendering ranges and laying out overlaps. | Presentation only, provided it keeps querying by visible range. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Local cache of accounts, calendars and events. Rebuildable from the server. - `SYNC_INTERVAL_SECONDS` · required · How often to poll for changes. Too frequent is rude to the provider and rarely necessary. - `EXPAND_RANGE_DAYS` · required · How far around the visible range to expand recurrence. Larger costs memory and time. - `REMINDER_LOOKAHEAD_MINUTES` · required · How far ahead notifications are scheduled. Must exceed your expansion refresh interval or reminders stop. - `DEFAULT_TIMEZONE` · required · Used when an event carries no zone. Should match the machine unless the user overrides it. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Fantastical product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with a desktop shell, CalDAV with a well-tested client library, A library with a full time zone database, never naive dates, A recurrence rule library plus your own exception handling, A deterministic grammar first, with a model only for what it cannot handle. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. - A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model.
# Delivery milestones · Fantastical Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Sync that does not lose events Your real calendar appears, and a change made elsewhere shows up. ### Steps 1. Discover calendars Principal discovery then calendar listing. Providers differ here more than the specification suggests, so test against the account you actually use. Files: `src/caldav/discover.ts` 2. Sync incrementally with etags The collection sync report gives changes since a token. Store etags so you can detect conflicts later. Files: `src/caldav/sync.ts` 3. Handle an invalid token Fall back to a full sync. Test it by corrupting the stored token deliberately. Files: `src/caldav/sync.ts` ### Done when - [ ] Real calendars and events appear - [ ] An event created elsewhere appears after a sync - [ ] A deletion elsewhere removes it locally - [ ] An invalidated token triggers a full resynchronisation ### Watch out - Servers invalidate sync tokens without warning, and a client that treats an invalid token as an error stops syncing silently until someone notices missing events. Detect the specific response and fall back to a full synchronisation rather than failing. ## M2 · Time zones and recurrence, done properly Recurring events land at the right local time, including across a clock change. ### Steps 1. Store local time plus zone Both, always. The coordinated universal time value is derived for querying, not authoritative for recurring events. Files: `src/time.ts` 2. Expand only the visible range Expanding an infinite rule for all time is a hang waiting to happen. Expand for the range being displayed and cache it. Files: `src/recurrence.ts` 3. Test across a transition A fixture series spanning a clock change in a zone that observes one. This test catches the bug that otherwise ships. Files: `src/recurrence.test.ts` ### Done when - [ ] A weekly event keeps its local time across a clock change - [ ] Deleting one occurrence leaves the series intact - [ ] Editing one occurrence does not alter the series - [ ] An all day event does not shift with the machine zone ### Watch out - A weekly meeting at nine in the morning must stay at nine after the clocks change, which means storing the local time and the zone rather than a fixed instant. Storing only coordinated universal time makes every recurring event drift by an hour twice a year, and the bug is reported months after you introduced it. ## M3 · Natural language entry Typing a sentence produces the event you meant, and shows you what it understood. ### Steps 1. Write a deterministic grammar Dates, times, durations, recurrence keywords, location prepositions and calendar names. Debuggable and testable without a model. Files: `src/parse/grammar.ts` 2. Build a test corpus of twenty sentences Real sentences you would type, with expected results. This corpus is what stops parser changes regressing. Files: `src/parse/corpus.json` 3. Always show the interpretation A preview of date, time, duration and calendar before saving. Not a confidence-gated preview; always. Files: `src/ui/Preview.tsx` ### Done when - [ ] Twenty corpus sentences parse to expected events - [ ] The interpretation is shown before every save - [ ] An ambiguous sentence surfaces the ambiguity - [ ] A sentence with no date creates an event today ### Watch out - A parser that saves without showing its interpretation will silently create events on the wrong day, and the user finds out when they miss something. Show the parsed result as a preview every time, including when confidence is high, because the preview costs nothing and the failure is expensive. ## M4 · Writing back, and conflicts Events you create appear on your phone, and a simultaneous edit elsewhere is not silently lost. ### Steps 1. Write conditionally with the etag The precondition header on every update. A mismatch is information, not a failure to swallow. Files: `src/caldav/write.ts` 2. Present conflicts Both versions side by side with their timestamps. The user chooses; the client does not guess. Files: `src/ui/Conflict.tsx` 3. Queue offline changes An outbox with retries and visible failures, drained on reconnection. Files: `src/outbox.ts` ### Done when - [ ] An event created here appears on another device - [ ] A simultaneous edit produces a visible conflict - [ ] Offline changes are queued and applied on reconnection - [ ] A rejected write retries with a fresh etag ### Watch out - Writing without the etag precondition means a change made on the phone thirty seconds earlier is silently overwritten, and neither device shows an error. Always send the conditional header and handle the failure by asking, because the alternative is losing someone's edit without telling them. ## M5 · Views that are fast at a real calendar's size Day, week and month views render quickly on a calendar with years of history. ### Steps 1. Query and expand by visible range The range drives everything. Nothing outside it is computed. Files: `src/views/range.ts` 2. Lay out overlapping events Column assignment so concurrent events share the width rather than hiding each other. Files: `src/views/layout.ts` 3. Build a large fixture calendar Five years and twenty series. Performance claims against a week of events are meaningless. Files: `src/fixtures/large.ts` ### Done when - [ ] Month view renders under a hundred milliseconds on the large fixture - [ ] Overlapping events lay out side by side - [ ] Navigating months reuses cached expansions - [ ] A multi-day all day event renders across its days ### Watch out - Expanding every recurrence rule for every event on each render makes month view take seconds on a real calendar. Expand for the visible range only, cache the expansion, and invalidate the cache when the underlying event changes. ## M6 · Notifications, invitations and distribution (production only) Reminders fire when you are not looking at the application, and invitations do not break. ### Steps 1. Schedule notifications from occurrences Reschedule when an event changes and when the visible range advances, so a recurring reminder does not stop after the cached window. Files: `src/notify.ts` 2. Preserve unknown properties Keep the original data and modify only what you understand. Compare raw round trips as a test. Files: `src/caldav/roundtrip.test.ts` 3. Sign and distribute Platform signing, and a note about which providers you have tested against. Files: `README.md` ### Done when - [ ] A reminder fires with the application in the background - [ ] An invitation round trips without losing attendees or a conferencing link - [ ] Accepting an invitation updates attendee status - [ ] The signed build opens on another machine without a warning ### Watch out - A calendar client that drops fields it does not understand corrupts events for every other client the user has, and attendee lists and conferencing details are the usual casualties. Preserve unknown properties on round trip rather than rewriting the event from your own model.
# Operations · Fantastical ## Backup The local database is a cache and the server holds the events, so the backup that matters is your provider's. What is worth keeping locally is the outbox, because it holds changes not yet accepted anywhere. ## Restore Delete the cache and resynchronise. If a restore loses anything other than pending changes, something authoritative has leaked into the local database. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync failure rate per account and outbox age. An outbox row older than a few minutes means writes are failing, which the user will otherwise discover when an event does not appear elsewhere. ## Incident checklist For events appearing at the wrong time, check zone handling before anything else. For events vanishing, check whether an invalid sync token was treated as an error rather than triggering a full resynchronisation. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] The daylight saving transition test passes - [ ] The twenty sentence parse corpus passes - [ ] A conditional write conflict is confirmed to surface rather than overwrite - [ ] An invitation round trips without losing attendees or conferencing details - [ ] Month view performance on the large fixture is measured and has not regressed ## Launch constraint Do not market omitted Fantastical capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local cache of accounts, calendars and events. Rebuildable from the server. DB_PATH=./data/calendar.db # Required. How often to poll for changes. Too frequent is rude to the provider and rarely necessary. SYNC_INTERVAL_SECONDS=300 # Required. How far around the visible range to expand recurrence. Larger costs memory and time. EXPAND_RANGE_DAYS=120 # Required. How far ahead notifications are scheduled. Must exceed your expansion refresh interval or reminders stop. REMINDER_LOOKAHEAD_MINUTES=10080 # Required. Used when an event carries no zone. Should match the machine unless the user overrides it. DEFAULT_TIMEZONE=Europe/London
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Fantastical because people pay because one double booking or missed invite can cost more than years of subscription fees. The recurring cost buys OAuth refresh, webhooks, timezone databases, race conditions, reminders, deliverability, privacy, and uptime, not just the visible interface.
xnative Apple integrations, widgets, sync, proposals, and years of calendar edge cases
xmultiple calendar providers
xteam routing and round-robin
xpayments
xtimezone edge-case maturity
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Fantastical pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0 | $0 | 1 calendar set, 2 weeks of calendar mirroring, 3-day weather, 1 invitee/event and Meet With for 1 person; no Openings/Proposals/RSVP |
| individual | $6.99 | $4.75 | 1 person; unlimited calendar sets/invitees/Meet With, 6-month mirroring, 10-day weather and all Fantastical Scheduling features |
| family | $10.49 | $7.50 | Up to 5 family members with the full Individual feature set |
| team | custom | — | Teams of any size; Individual features plus SSO and multi-host Openings |
free tier1 calendar set, 2 weeks mirroring, 3-day weather, 1 invitee/event and Meet With for 1 person; no Fantastical Scheduling Openings/Proposals/RSVP
billingmonthly + annual; 14-day Premium trial
hidden costsTeam is per user; Family is a flat plan for up to 5 people. App-store taxes and regional prices can differ. Mid-cycle upgrades are prorated; education and military discounts require verification.
verified 2026-08-14 · source ↗
Vibecode Fantastical
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Fantastical replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Fantastical cost?
Fantastical costs about $4.75/month (Premium Individual, checked 2026-07-31), which is $57 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Fantastical?
Honestly: native Apple integrations, widgets, sync, proposals, and years of calendar edge cases; multiple calendar providers; team routing and round-robin; payments; timezone edge-case maturity. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Fantastical?
Yes: Apple Calendar (Already on every Apple device, with natural-language entry and local calendar groups; no booking links attached.) The prompt is for when you want it exactly your way.