Vibecode Rise Calendar
track this build4 phases, 12 steps, beginner friendly0%The visible calendar + tasks loop is buildable, but a credible replacement needs more than the first screen. Rise Calendar earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Rise Calendar. 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 ===== # Rise Calendar · indie build A personal calendar and task planner whose difficulty is not the grid but the sync underneath it: an incremental sync that invalidates, push channels that expire quietly, blocks that must never move a real meeting, and a plan that has to re-fit itself every time the day slips. 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 | A desktop application with a local database and one calendar account | a personal planner is not a service | | Sync | Incremental sync tokens with a full resync whenever the server invalidates one | the token expires and the client that ignores that shows a stale week | | Ownership | Blocks the tool creates carry a stored identifier and a marker; every other event is read-only | the planner may move its own blocks and nothing else | | Planning | A packer that fills free windows in working hours and keeps earlier placements stable | a plan that reshuffles on every change is a plan nobody follows | | Time | Instants stored, the zone applied at the edges, recurrence expanded with exceptions | a task placed at the wrong hour is worse than an unplaced one | | Freshness | Push channels renewed before expiry with polling as the backstop, and the sync age on screen | a lapsed channel looks exactly like a quiet calendar | ## 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 desktop application, the sync worker and the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A calendar account with a month of real meetings** · free - Why: Recurrence with exceptions, declined invitations and all-day events are the cases that break sync and planning, and an empty test calendar contains none of them. - Get it: Your own calendar, with at least one recurring series that has a cancelled instance and one declined invitation. - Verify: The cancelled instance is free and the rest of the series is busy after sync - [ ] **Your own OAuth client with the calendar scope** · free - Why: The tool syncs as you, through a client you created, and the unverified cap of a hundred users is why it stays personal. - Get it: Create a project and an OAuth client in the provider's console, add the narrowest calendar scope, and add yourself as a test user. - Verify: The consent screen grants the scope and an incremental sync completes - [ ] **A public HTTPS endpoint for push notifications** (optional) · free with a tunnel, pennies on a host - Why: Push channels deliver to a URL the provider can reach, and without one the tool is polling only, which is acceptable but should be a decision. - Get it: A tunnel to your machine or a tiny relay on a host you control. - Verify: The sync message arrives when a channel is created - [ ] **Your working hours and the declined and all-day rules** · free - Why: The planner packs into working hours and treats declined and all-day events by rules that are asserted in tests, so they belong decided before phase one. - Get it: Write down working hours per day in your zone, whether an all-day event blocks the day, and whether a declined invitation is free time. - Verify: All three are in the configuration and named in the README ## 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: - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for Rise Calendar, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Rise Calendar 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 desktop calendar that puts your tasks into real free time and keeps them there when the day slips to replace Rise Calendar. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and one calendar account · a personal planner is not a service - Sync: Incremental sync tokens with a full resync whenever the server invalidates one · the token expires and the client that ignores that shows a stale week - Ownership: Blocks the tool creates carry a stored identifier and a marker; every other event is read-only · the planner may move its own blocks and nothing else - Planning: A packer that fills free windows in working hours and keeps earlier placements stable · a plan that reshuffles on every change is a plan nobody follows - Time: Instants stored, the zone applied at the edges, recurrence expanded with exceptions · a task placed at the wrong hour is worse than an unplaced one - Freshness: Push channels renewed before expiry with polling as the backstop, and the sync age on screen · a lapsed channel looks exactly like a quiet calendar ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_granted, sync_token, last_full_sync_at, last_incremental_at, channel_id, channel_expires_at) - events(id, calendar_id, external_id, starts_at, ends_at, all_day, status, recurrence_json, is_ours, updated_at) - tasks(id, title, estimate_minutes, remaining_minutes, deadline_at, priority, project_id, state) - blocks(id, task_id, external_event_id, starts_at, ends_at, placed_at, pinned, at_risk) · the join between a task and an event the tool created - working_hours(day_of_week, start_local, end_local, timezone) - plan_runs(id, at, reason, placed, moved, unplaced, at_risk) - A block links a task to a calendar event identified by stored id, so the planner can move its own blocks freely and never touches a meeting it did not make. ### Phase 1 · Reading one calendar correctly Build: Connect with the narrowest scope that lets you read events and write your own. Sync incrementally with the sync token, and when the server answers 410 Gone, clear the store and perform a full sync rather than retrying, which is what the sync guide prescribes (checked). Expand recurring events with their exceptions, and apply stated rules for all-day and declined events. Trap: Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. Done when: An incremental sync picks up a change made on the server within one cycle. A forced 410 triggers a full resync that leaves the store identical to the server, tested by comparing event sets. A recurring series with a cancelled instance expands correctly. Declined and all-day events follow the stated rules, and the rules are written in the README. Do not build yet: No tasks, blocks, write-back or push. ### Phase 2 · Tasks as blocks in free time Build: Tasks with an estimate, a deadline and a priority. A planner that packs them into free windows inside working hours, around every event including expanded recurring ones, never overlapping anything. Keep earlier placements unless they now conflict, and report what was placed, moved and left unplaced on each run. Trap: Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. Done when: Tasks are placed only in free working time, asserted by a test that generates a busy week. A new meeting displaces only the blocks it overlaps and the run reports them. A task that cannot fit before its deadline is marked at risk rather than silently placed late. Pinned blocks are never moved by the planner. Do not build yet: No write-back, push or booking yet. ### Phase 3 · Writing blocks without touching anyone else's events Build: Create a calendar event per block with the block's id stored on both sides and a marker on the event. Update or delete only events whose id you stored. Detect a block event deleted or moved in the calendar directly and treat it as the person's decision: pin or unschedule the task rather than recreating the block. Make a full resync idempotent for your own events. Trap: Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. Done when: Every block has a stored event id, and a foreign event cannot be modified or deleted, tested by attempting it. A block event moved by hand in the calendar pins the task at the new time rather than being overwritten. A full resync after a 410 does not duplicate block events. Deleting a task removes its block event and nothing else. Do not build yet: No push, sync age or booking yet. ### Phase 4 · Staying current when the day slips Build: Register a push notification channel and renew it before expiry, since channels are not renewed automatically and a notification carries no body, only a signal to sync (checked against the push guide). Poll as the backstop and show the age of the last successful sync. When a block ends with the task unfinished, or a meeting overruns, roll the remaining work forward and re-run the planner with stable placement. Trap: Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. Done when: A change on the server appears within seconds while the channel is live, and within one poll interval after the channel is deliberately allowed to expire. Channel renewal happens before expiry, tested by shortening the lifetime. The sync age is visible and becomes a warning past a stated threshold. An unfinished block rolls its remaining minutes forward and the plan re-runs with only the affected blocks moving. Do not build yet: No booking or export yet. ### Phase 5 · A booking link, an export and your own OAuth client Build: A single booking page that offers slots computed from the same availability as the planner, and reserves the chosen slot inside the transaction that creates the booking, writing it back as an owned event. Export tasks and blocks as JSON and the calendar as an ICS file. State in the README that an unverified OAuth client is capped at a hundred new users once the unverified screen appears (checked), so this stays a personal tool until verification is done. Trap: Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction. Done when: Twenty concurrent bookings for one slot produce exactly one, tested with real concurrency. A slot occupied by a task block is not offered. Export produces JSON for tasks and blocks and an ICS file that opens in another calendar. The README states the OAuth client's user cap and what verification would take. Do not build yet: Nothing after this. ### Out of scope (and why) - Several calendars per person with conflict handling, since each is another sync token, another channel and another set of ownership rules. - Team routing and round robin, which need everyone's availability and a fairness rule that is a product on its own. - A hosted multi-user service, which needs a verified OAuth client and makes you custodian of other people's calendars. - Notifications across devices, which need a push service you would have to run and keep running. ### README must contain - That a 410 from the server means clear the store and resync fully, and that your own blocks survive it by stored id - That the tool owns only events carrying its stored identifier and never modifies anything else - That push channels are renewed before expiry, polling is the backstop, and the sync age is on screen - That a task which cannot fit before its deadline is marked at risk rather than placed late - That an unverified OAuth client is capped at a hundred new users, so this is a personal tool until verified ===== AGENTS.md ===== # Agent instructions · Rise Calendar indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application with a local database and one calendar account, Incremental sync tokens with a full resync whenever the server invalidates one, Blocks the tool creates carry a stored identifier and a marker; every other event is read-only, A packer that fills free windows in working hours and keeps earlier placements stable, Instants stored, the zone applied at the edges, recurrence expanded with exceptions, Push channels renewed before expiry with polling as the backstop, and the sync age on screen. 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 - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. ===== BUILD_PLAN.md ===== # Build plan · Rise Calendar A personal calendar and task planner whose difficulty is not the grid but the sync underneath it: an incremental sync that invalidates, push channels that expire quietly, blocks that must never move a real meeting, and a plan that has to re-fit itself every time the day slips. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading one calendar correctly The local copy matches the server, including after the server invalidates the sync token. ### Steps 1. Sync with tokens and resync fully on 410 Clear the store and start again; the token is gone and will not come back. Files: `src/sync.ts`, `tests/resync-410.test.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Request the narrowest working scope Read events and write your own is enough for everything here. Files: `src/oauth.ts` ### Done when - [ ] Server changes appear within one sync cycle - [ ] A 410 produces a full resync identical to the server - [ ] A series with a cancelled instance expands correctly - [ ] Declined and all-day rules are stated and followed ### Watch out - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. ## Phase 2 · Tasks as blocks in free time Tasks are packed into free working time and stay where they were put unless they must move. ### Steps 1. Pack into free windows only Working hours minus every event, recurring ones expanded first. Files: `src/planner.ts`, `src/planner.test.ts` 2. Keep placements stable Move what conflicts and nothing else, and say what moved. Files: `src/planner.ts` 3. Mark at risk, never place late silently A deadline the plan cannot meet is a fact the person needs today. Files: `src/risk.ts` ### Done when - [ ] Blocks land only in free working time - [ ] A new meeting moves only the blocks it overlaps - [ ] Unfittable tasks are marked at risk - [ ] Pinned blocks never move ### Watch out - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. ## Phase 3 · Writing blocks without touching anyone else's events Blocks appear in the calendar as events the tool owns, and no foreign event is ever changed. ### Steps 1. Own events by stored identifier A rename must not orphan a block or endanger a meeting. Files: `src/calendar-write.ts` 2. Refuse to touch foreign events in the write path Tested by attempting it, not by intending it. Files: `tests/foreign-events.test.ts` 3. Honour hand edits as decisions Moved by hand means pinned, not overwritten. Files: `src/reconcile.ts` ### Done when - [ ] Every block has a stored event id - [ ] Foreign events cannot be modified - [ ] A resync does not duplicate block events - [ ] Hand-moved blocks pin the task ### Watch out - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. ## Phase 4 · Staying current when the day slips The calendar is never quietly stale, and an overrun rolls the rest of the day forward. ### Steps 1. Renew channels before expiry, poll regardless A lapsed channel looks exactly like a quiet day. Files: `src/push.ts`, `tests/channel-expiry.test.ts` 2. Sync on the signal, never trust a body The notification says something changed. Go and look. Files: `src/push.ts` 3. Roll unfinished work forward Remaining minutes, stable placement, affected blocks only. Files: `src/rollover.ts` ### Done when - [ ] Changes appear within seconds while the channel lives - [ ] An expired channel falls back to polling - [ ] Sync age is shown and warns past the threshold - [ ] Unfinished blocks roll forward without reshuffling ### Watch out - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. ## Not in this build - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. ## After v1, if you want it - A focus mode that shows only the current block and the next one, which is what the day actually needs - A weekly review of at-risk tasks and estimate accuracy, computed from plan runs - A second calendar in read-only mode, once ownership and resync are provably correct for one ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, tasks, blocks and plan runs. DATABASE_PATH=./data/planner.db # Required · secret. Your own OAuth client. Unverified, and capped at a hundred users. CALENDAR_CLIENT_ID=a client id from your own project # Required · secret. Never in the repository. The desktop app holds it in the platform keychain. CALENDAR_CLIENT_SECRET=a client secret from the same project # Optional. Where notification channels deliver. Absent means polling only, stated on screen. PUSH_CALLBACK_URL=https://relay.example/calendar-push # Required. The backstop. Runs whether or not a channel is live. POLL_SECONDS=300 # Required. Past this the sync age becomes a warning on screen. SYNC_STALE_MINUTES=30 # Required. Per day, in your zone. The planner never places outside them. WORKING_HOURS_PATH=./config/working-hours.json # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
You are building a lean indie version of Rise Calendar. 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 ===== # Rise Calendar · indie build A personal calendar and task planner whose difficulty is not the grid but the sync underneath it: an incremental sync that invalidates, push channels that expire quietly, blocks that must never move a real meeting, and a plan that has to re-fit itself every time the day slips. 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 | A desktop application with a local database and one calendar account | a personal planner is not a service | | Sync | Incremental sync tokens with a full resync whenever the server invalidates one | the token expires and the client that ignores that shows a stale week | | Ownership | Blocks the tool creates carry a stored identifier and a marker; every other event is read-only | the planner may move its own blocks and nothing else | | Planning | A packer that fills free windows in working hours and keeps earlier placements stable | a plan that reshuffles on every change is a plan nobody follows | | Time | Instants stored, the zone applied at the edges, recurrence expanded with exceptions | a task placed at the wrong hour is worse than an unplaced one | | Freshness | Push channels renewed before expiry with polling as the backstop, and the sync age on screen | a lapsed channel looks exactly like a quiet calendar | ## 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 desktop application, the sync worker and the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A calendar account with a month of real meetings** · free - Why: Recurrence with exceptions, declined invitations and all-day events are the cases that break sync and planning, and an empty test calendar contains none of them. - Get it: Your own calendar, with at least one recurring series that has a cancelled instance and one declined invitation. - Verify: The cancelled instance is free and the rest of the series is busy after sync - [ ] **Your own OAuth client with the calendar scope** · free - Why: The tool syncs as you, through a client you created, and the unverified cap of a hundred users is why it stays personal. - Get it: Create a project and an OAuth client in the provider's console, add the narrowest calendar scope, and add yourself as a test user. - Verify: The consent screen grants the scope and an incremental sync completes - [ ] **A public HTTPS endpoint for push notifications** (optional) · free with a tunnel, pennies on a host - Why: Push channels deliver to a URL the provider can reach, and without one the tool is polling only, which is acceptable but should be a decision. - Get it: A tunnel to your machine or a tiny relay on a host you control. - Verify: The sync message arrives when a channel is created - [ ] **Your working hours and the declined and all-day rules** · free - Why: The planner packs into working hours and treats declined and all-day events by rules that are asserted in tests, so they belong decided before phase one. - Get it: Write down working hours per day in your zone, whether an all-day event blocks the day, and whether a declined invitation is free time. - Verify: All three are in the configuration and named in the README ## 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: - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for Rise Calendar, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Rise Calendar 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 desktop calendar that puts your tasks into real free time and keeps them there when the day slips to replace Rise Calendar. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and one calendar account · a personal planner is not a service - Sync: Incremental sync tokens with a full resync whenever the server invalidates one · the token expires and the client that ignores that shows a stale week - Ownership: Blocks the tool creates carry a stored identifier and a marker; every other event is read-only · the planner may move its own blocks and nothing else - Planning: A packer that fills free windows in working hours and keeps earlier placements stable · a plan that reshuffles on every change is a plan nobody follows - Time: Instants stored, the zone applied at the edges, recurrence expanded with exceptions · a task placed at the wrong hour is worse than an unplaced one - Freshness: Push channels renewed before expiry with polling as the backstop, and the sync age on screen · a lapsed channel looks exactly like a quiet calendar ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_granted, sync_token, last_full_sync_at, last_incremental_at, channel_id, channel_expires_at) - events(id, calendar_id, external_id, starts_at, ends_at, all_day, status, recurrence_json, is_ours, updated_at) - tasks(id, title, estimate_minutes, remaining_minutes, deadline_at, priority, project_id, state) - blocks(id, task_id, external_event_id, starts_at, ends_at, placed_at, pinned, at_risk) · the join between a task and an event the tool created - working_hours(day_of_week, start_local, end_local, timezone) - plan_runs(id, at, reason, placed, moved, unplaced, at_risk) - A block links a task to a calendar event identified by stored id, so the planner can move its own blocks freely and never touches a meeting it did not make. ### Phase 1 · Reading one calendar correctly Build: Connect with the narrowest scope that lets you read events and write your own. Sync incrementally with the sync token, and when the server answers 410 Gone, clear the store and perform a full sync rather than retrying, which is what the sync guide prescribes (checked). Expand recurring events with their exceptions, and apply stated rules for all-day and declined events. Trap: Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. Done when: An incremental sync picks up a change made on the server within one cycle. A forced 410 triggers a full resync that leaves the store identical to the server, tested by comparing event sets. A recurring series with a cancelled instance expands correctly. Declined and all-day events follow the stated rules, and the rules are written in the README. Do not build yet: No tasks, blocks, write-back or push. ### Phase 2 · Tasks as blocks in free time Build: Tasks with an estimate, a deadline and a priority. A planner that packs them into free windows inside working hours, around every event including expanded recurring ones, never overlapping anything. Keep earlier placements unless they now conflict, and report what was placed, moved and left unplaced on each run. Trap: Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. Done when: Tasks are placed only in free working time, asserted by a test that generates a busy week. A new meeting displaces only the blocks it overlaps and the run reports them. A task that cannot fit before its deadline is marked at risk rather than silently placed late. Pinned blocks are never moved by the planner. Do not build yet: No write-back, push or booking yet. ### Phase 3 · Writing blocks without touching anyone else's events Build: Create a calendar event per block with the block's id stored on both sides and a marker on the event. Update or delete only events whose id you stored. Detect a block event deleted or moved in the calendar directly and treat it as the person's decision: pin or unschedule the task rather than recreating the block. Make a full resync idempotent for your own events. Trap: Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. Done when: Every block has a stored event id, and a foreign event cannot be modified or deleted, tested by attempting it. A block event moved by hand in the calendar pins the task at the new time rather than being overwritten. A full resync after a 410 does not duplicate block events. Deleting a task removes its block event and nothing else. Do not build yet: No push, sync age or booking yet. ### Phase 4 · Staying current when the day slips Build: Register a push notification channel and renew it before expiry, since channels are not renewed automatically and a notification carries no body, only a signal to sync (checked against the push guide). Poll as the backstop and show the age of the last successful sync. When a block ends with the task unfinished, or a meeting overruns, roll the remaining work forward and re-run the planner with stable placement. Trap: Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. Done when: A change on the server appears within seconds while the channel is live, and within one poll interval after the channel is deliberately allowed to expire. Channel renewal happens before expiry, tested by shortening the lifetime. The sync age is visible and becomes a warning past a stated threshold. An unfinished block rolls its remaining minutes forward and the plan re-runs with only the affected blocks moving. Do not build yet: No booking or export yet. ### Phase 5 · A booking link, an export and your own OAuth client Build: A single booking page that offers slots computed from the same availability as the planner, and reserves the chosen slot inside the transaction that creates the booking, writing it back as an owned event. Export tasks and blocks as JSON and the calendar as an ICS file. State in the README that an unverified OAuth client is capped at a hundred new users once the unverified screen appears (checked), so this stays a personal tool until verification is done. Trap: Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction. Done when: Twenty concurrent bookings for one slot produce exactly one, tested with real concurrency. A slot occupied by a task block is not offered. Export produces JSON for tasks and blocks and an ICS file that opens in another calendar. The README states the OAuth client's user cap and what verification would take. Do not build yet: Nothing after this. ### Out of scope (and why) - Several calendars per person with conflict handling, since each is another sync token, another channel and another set of ownership rules. - Team routing and round robin, which need everyone's availability and a fairness rule that is a product on its own. - A hosted multi-user service, which needs a verified OAuth client and makes you custodian of other people's calendars. - Notifications across devices, which need a push service you would have to run and keep running. ### README must contain - That a 410 from the server means clear the store and resync fully, and that your own blocks survive it by stored id - That the tool owns only events carrying its stored identifier and never modifies anything else - That push channels are renewed before expiry, polling is the backstop, and the sync age is on screen - That a task which cannot fit before its deadline is marked at risk rather than placed late - That an unverified OAuth client is capped at a hundred new users, so this is a personal tool until verified ===== AGENTS.md ===== # Agent instructions · Rise Calendar indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application with a local database and one calendar account, Incremental sync tokens with a full resync whenever the server invalidates one, Blocks the tool creates carry a stored identifier and a marker; every other event is read-only, A packer that fills free windows in working hours and keeps earlier placements stable, Instants stored, the zone applied at the edges, recurrence expanded with exceptions, Push channels renewed before expiry with polling as the backstop, and the sync age on screen. 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 - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. ===== BUILD_PLAN.md ===== # Build plan · Rise Calendar A personal calendar and task planner whose difficulty is not the grid but the sync underneath it: an incremental sync that invalidates, push channels that expire quietly, blocks that must never move a real meeting, and a plan that has to re-fit itself every time the day slips. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading one calendar correctly The local copy matches the server, including after the server invalidates the sync token. ### Steps 1. Sync with tokens and resync fully on 410 Clear the store and start again; the token is gone and will not come back. Files: `src/sync.ts`, `tests/resync-410.test.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Request the narrowest working scope Read events and write your own is enough for everything here. Files: `src/oauth.ts` ### Done when - [ ] Server changes appear within one sync cycle - [ ] A 410 produces a full resync identical to the server - [ ] A series with a cancelled instance expands correctly - [ ] Declined and all-day rules are stated and followed ### Watch out - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. ## Phase 2 · Tasks as blocks in free time Tasks are packed into free working time and stay where they were put unless they must move. ### Steps 1. Pack into free windows only Working hours minus every event, recurring ones expanded first. Files: `src/planner.ts`, `src/planner.test.ts` 2. Keep placements stable Move what conflicts and nothing else, and say what moved. Files: `src/planner.ts` 3. Mark at risk, never place late silently A deadline the plan cannot meet is a fact the person needs today. Files: `src/risk.ts` ### Done when - [ ] Blocks land only in free working time - [ ] A new meeting moves only the blocks it overlaps - [ ] Unfittable tasks are marked at risk - [ ] Pinned blocks never move ### Watch out - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. ## Phase 3 · Writing blocks without touching anyone else's events Blocks appear in the calendar as events the tool owns, and no foreign event is ever changed. ### Steps 1. Own events by stored identifier A rename must not orphan a block or endanger a meeting. Files: `src/calendar-write.ts` 2. Refuse to touch foreign events in the write path Tested by attempting it, not by intending it. Files: `tests/foreign-events.test.ts` 3. Honour hand edits as decisions Moved by hand means pinned, not overwritten. Files: `src/reconcile.ts` ### Done when - [ ] Every block has a stored event id - [ ] Foreign events cannot be modified - [ ] A resync does not duplicate block events - [ ] Hand-moved blocks pin the task ### Watch out - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. ## Phase 4 · Staying current when the day slips The calendar is never quietly stale, and an overrun rolls the rest of the day forward. ### Steps 1. Renew channels before expiry, poll regardless A lapsed channel looks exactly like a quiet day. Files: `src/push.ts`, `tests/channel-expiry.test.ts` 2. Sync on the signal, never trust a body The notification says something changed. Go and look. Files: `src/push.ts` 3. Roll unfinished work forward Remaining minutes, stable placement, affected blocks only. Files: `src/rollover.ts` ### Done when - [ ] Changes appear within seconds while the channel lives - [ ] An expired channel falls back to polling - [ ] Sync age is shown and warns past the threshold - [ ] Unfinished blocks roll forward without reshuffling ### Watch out - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. ## Not in this build - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. ## After v1, if you want it - A focus mode that shows only the current block and the next one, which is what the day actually needs - A weekly review of at-risk tasks and estimate accuracy, computed from plan runs - A second calendar in read-only mode, once ownership and resync are provably correct for one ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, tasks, blocks and plan runs. DATABASE_PATH=./data/planner.db # Required · secret. Your own OAuth client. Unverified, and capped at a hundred users. CALENDAR_CLIENT_ID=a client id from your own project # Required · secret. Never in the repository. The desktop app holds it in the platform keychain. CALENDAR_CLIENT_SECRET=a client secret from the same project # Optional. Where notification channels deliver. Absent means polling only, stated on screen. PUSH_CALLBACK_URL=https://relay.example/calendar-push # Required. The backstop. Runs whether or not a channel is live. POLL_SECONDS=300 # Required. Past this the sync age becomes a warning on screen. SYNC_STALE_MINUTES=30 # Required. Per day, in your zone. The planner never places outside them. WORKING_HOURS_PATH=./config/working-hours.json # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
You are building a production product version of Rise Calendar. 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 ===== # Rise Calendar · product brief ## Problem The visible calendar + tasks loop is buildable, but a credible replacement needs more than the first screen. Rise Calendar earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A calendar whose local copy never quietly drifts from the server, whose task blocks live in real free time and move only when they must, and which never lays a finger on a meeting it did not create. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness ## Success criteria - A 410 is confirmed to produce a full resync identical to the server - Foreign events are confirmed unmodifiable - A new meeting is confirmed to move only the blocks it overlaps - An expired channel is confirmed to fall back to polling with the sync age shown - Twenty concurrent bookings are confirmed to yield one ===== BRIEF.md ===== # Build brief · Rise Calendar 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 desktop calendar that puts your tasks into real free time and keeps them there when the day slips to replace Rise Calendar. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and one calendar account · a personal planner is not a service - Sync: Incremental sync tokens with a full resync whenever the server invalidates one · the token expires and the client that ignores that shows a stale week - Ownership: Blocks the tool creates carry a stored identifier and a marker; every other event is read-only · the planner may move its own blocks and nothing else - Planning: A packer that fills free windows in working hours and keeps earlier placements stable · a plan that reshuffles on every change is a plan nobody follows - Time: Instants stored, the zone applied at the edges, recurrence expanded with exceptions · a task placed at the wrong hour is worse than an unplaced one - Freshness: Push channels renewed before expiry with polling as the backstop, and the sync age on screen · a lapsed channel looks exactly like a quiet calendar ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_granted, sync_token, last_full_sync_at, last_incremental_at, channel_id, channel_expires_at) - events(id, calendar_id, external_id, starts_at, ends_at, all_day, status, recurrence_json, is_ours, updated_at) - tasks(id, title, estimate_minutes, remaining_minutes, deadline_at, priority, project_id, state) - blocks(id, task_id, external_event_id, starts_at, ends_at, placed_at, pinned, at_risk) · the join between a task and an event the tool created - working_hours(day_of_week, start_local, end_local, timezone) - plan_runs(id, at, reason, placed, moved, unplaced, at_risk) - A block links a task to a calendar event identified by stored id, so the planner can move its own blocks freely and never touches a meeting it did not make. ### Phase 1 · Reading one calendar correctly Build: Connect with the narrowest scope that lets you read events and write your own. Sync incrementally with the sync token, and when the server answers 410 Gone, clear the store and perform a full sync rather than retrying, which is what the sync guide prescribes (checked). Expand recurring events with their exceptions, and apply stated rules for all-day and declined events. Trap: Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. Done when: An incremental sync picks up a change made on the server within one cycle. A forced 410 triggers a full resync that leaves the store identical to the server, tested by comparing event sets. A recurring series with a cancelled instance expands correctly. Declined and all-day events follow the stated rules, and the rules are written in the README. Do not build yet: No tasks, blocks, write-back or push. ### Phase 2 · Tasks as blocks in free time Build: Tasks with an estimate, a deadline and a priority. A planner that packs them into free windows inside working hours, around every event including expanded recurring ones, never overlapping anything. Keep earlier placements unless they now conflict, and report what was placed, moved and left unplaced on each run. Trap: Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. Done when: Tasks are placed only in free working time, asserted by a test that generates a busy week. A new meeting displaces only the blocks it overlaps and the run reports them. A task that cannot fit before its deadline is marked at risk rather than silently placed late. Pinned blocks are never moved by the planner. Do not build yet: No write-back, push or booking yet. ### Phase 3 · Writing blocks without touching anyone else's events Build: Create a calendar event per block with the block's id stored on both sides and a marker on the event. Update or delete only events whose id you stored. Detect a block event deleted or moved in the calendar directly and treat it as the person's decision: pin or unschedule the task rather than recreating the block. Make a full resync idempotent for your own events. Trap: Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. Done when: Every block has a stored event id, and a foreign event cannot be modified or deleted, tested by attempting it. A block event moved by hand in the calendar pins the task at the new time rather than being overwritten. A full resync after a 410 does not duplicate block events. Deleting a task removes its block event and nothing else. Do not build yet: No push, sync age or booking yet. ### Phase 4 · Staying current when the day slips Build: Register a push notification channel and renew it before expiry, since channels are not renewed automatically and a notification carries no body, only a signal to sync (checked against the push guide). Poll as the backstop and show the age of the last successful sync. When a block ends with the task unfinished, or a meeting overruns, roll the remaining work forward and re-run the planner with stable placement. Trap: Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. Done when: A change on the server appears within seconds while the channel is live, and within one poll interval after the channel is deliberately allowed to expire. Channel renewal happens before expiry, tested by shortening the lifetime. The sync age is visible and becomes a warning past a stated threshold. An unfinished block rolls its remaining minutes forward and the plan re-runs with only the affected blocks moving. Do not build yet: No booking or export yet. ### Phase 5 · A booking link, an export and your own OAuth client Build: A single booking page that offers slots computed from the same availability as the planner, and reserves the chosen slot inside the transaction that creates the booking, writing it back as an owned event. Export tasks and blocks as JSON and the calendar as an ICS file. State in the README that an unverified OAuth client is capped at a hundred new users once the unverified screen appears (checked), so this stays a personal tool until verification is done. Trap: Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction. Done when: Twenty concurrent bookings for one slot produce exactly one, tested with real concurrency. A slot occupied by a task block is not offered. Export produces JSON for tasks and blocks and an ICS file that opens in another calendar. The README states the OAuth client's user cap and what verification would take. Do not build yet: Nothing after this. ### Out of scope (and why) - Several calendars per person with conflict handling, since each is another sync token, another channel and another set of ownership rules. - Team routing and round robin, which need everyone's availability and a fairness rule that is a product on its own. - A hosted multi-user service, which needs a verified OAuth client and makes you custodian of other people's calendars. - Notifications across devices, which need a push service you would have to run and keep running. ### README must contain - That a 410 from the server means clear the store and resync fully, and that your own blocks survive it by stored id - That the tool owns only events carrying its stored identifier and never modifies anything else - That push channels are renewed before expiry, polling is the backstop, and the sync age is on screen - That a task which cannot fit before its deadline is marked at risk rather than placed late - That an unverified OAuth client is capped at a hundred new users, so this is a personal tool until verified ===== ARCHITECTURE.md ===== # Architecture · Rise Calendar ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application with a local database and one calendar account | a personal planner is not a service | | Sync | Incremental sync tokens with a full resync whenever the server invalidates one | the token expires and the client that ignores that shows a stale week | | Ownership | Blocks the tool creates carry a stored identifier and a marker; every other event is read-only | the planner may move its own blocks and nothing else | | Planning | A packer that fills free windows in working hours and keeps earlier placements stable | a plan that reshuffles on every change is a plan nobody follows | | Time | Instants stored, the zone applied at the edges, recurrence expanded with exceptions | a task placed at the wrong hour is worse than an unplaced one | | Freshness | Push channels renewed before expiry with polling as the backstop, and the sync age on screen | a lapsed channel looks exactly like a quiet calendar | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | Incremental tokens and full resync on invalidation. | Any provider, provided invalidation clears and resyncs rather than retries. | | Planner | Packing with stable placement and at-risk marking. | Any algorithm, provided existing placements are kept unless they conflict. | | Ownership | Events identified by stored id with a marker. | Not swappable for title matching, which endangers real meetings. | | Freshness | Channel renewal, polling and the sync age. | Any transport, provided the sync age is visible and polling continues. | | Booking | Reservation inside the transaction. | Not swappable for check-then-insert, which double-books when it matters. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Events, tasks, blocks and plan runs. - `CALENDAR_CLIENT_ID` · required, secret · Your own OAuth client. Unverified, and capped at a hundred users. - `CALENDAR_CLIENT_SECRET` · required, secret · Never in the repository. The desktop app holds it in the platform keychain. - `PUSH_CALLBACK_URL` · optional · Where notification channels deliver. Absent means polling only, stated on screen. - `POLL_SECONDS` · required · The backstop. Runs whether or not a channel is live. - `SYNC_STALE_MINUTES` · required · Past this the sync age becomes a warning on screen. - `WORKING_HOURS_PATH` · required · Per day, in your zone. The planner never places outside them. - `TOUCH_FOREIGN_EVENTS` · required · Present as a constant. There is no other value. ## 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 · Rise Calendar product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A desktop application with a local database and one calendar account, Incremental sync tokens with a full resync whenever the server invalidates one, Blocks the tool creates carry a stored identifier and a marker; every other event is read-only, A packer that fills free windows in working hours and keeps earlier placements stable, Instants stored, the zone applied at the edges, recurrence expanded with exceptions, Push channels renewed before expiry with polling as the backstop, and the sync age on screen. - 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 - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. - Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction. ===== MILESTONES.md ===== # Delivery milestones · Rise Calendar Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Reading one calendar correctly The local copy matches the server, including after the server invalidates the sync token. ### Steps 1. Sync with tokens and resync fully on 410 Clear the store and start again; the token is gone and will not come back. Files: `src/sync.ts`, `tests/resync-410.test.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Request the narrowest working scope Read events and write your own is enough for everything here. Files: `src/oauth.ts` ### Done when - [ ] Server changes appear within one sync cycle - [ ] A 410 produces a full resync identical to the server - [ ] A series with a cancelled instance expands correctly - [ ] Declined and all-day rules are stated and followed ### Watch out - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. ## M2 · Tasks as blocks in free time Tasks are packed into free working time and stay where they were put unless they must move. ### Steps 1. Pack into free windows only Working hours minus every event, recurring ones expanded first. Files: `src/planner.ts`, `src/planner.test.ts` 2. Keep placements stable Move what conflicts and nothing else, and say what moved. Files: `src/planner.ts` 3. Mark at risk, never place late silently A deadline the plan cannot meet is a fact the person needs today. Files: `src/risk.ts` ### Done when - [ ] Blocks land only in free working time - [ ] A new meeting moves only the blocks it overlaps - [ ] Unfittable tasks are marked at risk - [ ] Pinned blocks never move ### Watch out - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. ## M3 · Writing blocks without touching anyone else's events Blocks appear in the calendar as events the tool owns, and no foreign event is ever changed. ### Steps 1. Own events by stored identifier A rename must not orphan a block or endanger a meeting. Files: `src/calendar-write.ts` 2. Refuse to touch foreign events in the write path Tested by attempting it, not by intending it. Files: `tests/foreign-events.test.ts` 3. Honour hand edits as decisions Moved by hand means pinned, not overwritten. Files: `src/reconcile.ts` ### Done when - [ ] Every block has a stored event id - [ ] Foreign events cannot be modified - [ ] A resync does not duplicate block events - [ ] Hand-moved blocks pin the task ### Watch out - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. ## M4 · Staying current when the day slips The calendar is never quietly stale, and an overrun rolls the rest of the day forward. ### Steps 1. Renew channels before expiry, poll regardless A lapsed channel looks exactly like a quiet day. Files: `src/push.ts`, `tests/channel-expiry.test.ts` 2. Sync on the signal, never trust a body The notification says something changed. Go and look. Files: `src/push.ts` 3. Roll unfinished work forward Remaining minutes, stable placement, affected blocks only. Files: `src/rollover.ts` ### Done when - [ ] Changes appear within seconds while the channel lives - [ ] An expired channel falls back to polling - [ ] Sync age is shown and warns past the threshold - [ ] Unfinished blocks roll forward without reshuffling ### Watch out - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. ## M5 · A booking link, an export and your own OAuth client (production only) One-slot bookings cannot double-book you, the data leaves cleanly, and the OAuth reality is stated. ### Steps 1. Reserve inside the booking transaction Check-then-insert double-books when a page is popular. Files: `src/booking.ts`, `tests/concurrency.test.ts` 2. Offer only what the planner calls free A task block is busy time too. Files: `src/availability.ts` 3. Export JSON and ICS So the plan is not trapped in this application. Files: `src/export.ts` ### Done when - [ ] Twenty concurrent bookings yield one - [ ] Task blocks are not offered as free - [ ] The ICS export opens elsewhere - [ ] The OAuth user cap is stated in the README ### Watch out - Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction. ===== OPERATIONS.md ===== # Operations · Rise Calendar ## Backup Tasks, blocks and pinned placements are the data that does not come back from the server, since the calendar copy resyncs. Back those up nightly, and keep the block to event id links with them. ## Restore Restore and run a full resync before any plan run, then confirm every block still resolves to its event id. A restore that lost the links will recreate every block as a duplicate on the next write. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync age, channel expiry time and the count of blocks at risk. A sync age past the threshold is the leading indicator of every other problem here. ## Incident checklist A block placed over a meeting is the incident, and it means the sync was stale when the planner ran. The sync age at the time of the plan run is recorded, which is how the diagnosis takes a minute rather than a day. 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 - [ ] A 410 is confirmed to produce a full resync identical to the server - [ ] Foreign events are confirmed unmodifiable - [ ] A new meeting is confirmed to move only the blocks it overlaps - [ ] An expired channel is confirmed to fall back to polling with the sync age shown - [ ] Twenty concurrent bookings are confirmed to yield one ## Launch constraint Do not market omitted Rise Calendar 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. Events, tasks, blocks and plan runs. DATABASE_PATH=./data/planner.db # Required · secret. Your own OAuth client. Unverified, and capped at a hundred users. CALENDAR_CLIENT_ID=a client id from your own project # Required · secret. Never in the repository. The desktop app holds it in the platform keychain. CALENDAR_CLIENT_SECRET=a client secret from the same project # Optional. Where notification channels deliver. Absent means polling only, stated on screen. PUSH_CALLBACK_URL=https://relay.example/calendar-push # Required. The backstop. Runs whether or not a channel is live. POLL_SECONDS=300 # Required. Past this the sync age becomes a warning on screen. SYNC_STALE_MINUTES=30 # Required. Per day, in your zone. The planner never places outside them. WORKING_HOURS_PATH=./config/working-hours.json # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
# Rise Calendar · indie build A personal calendar and task planner whose difficulty is not the grid but the sync underneath it: an incremental sync that invalidates, push channels that expire quietly, blocks that must never move a real meeting, and a plan that has to re-fit itself every time the day slips. 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 | A desktop application with a local database and one calendar account | a personal planner is not a service | | Sync | Incremental sync tokens with a full resync whenever the server invalidates one | the token expires and the client that ignores that shows a stale week | | Ownership | Blocks the tool creates carry a stored identifier and a marker; every other event is read-only | the planner may move its own blocks and nothing else | | Planning | A packer that fills free windows in working hours and keeps earlier placements stable | a plan that reshuffles on every change is a plan nobody follows | | Time | Instants stored, the zone applied at the edges, recurrence expanded with exceptions | a task placed at the wrong hour is worse than an unplaced one | | Freshness | Push channels renewed before expiry with polling as the backstop, and the sync age on screen | a lapsed channel looks exactly like a quiet calendar | ## 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 desktop application, the sync worker and the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A calendar account with a month of real meetings** · free - Why: Recurrence with exceptions, declined invitations and all-day events are the cases that break sync and planning, and an empty test calendar contains none of them. - Get it: Your own calendar, with at least one recurring series that has a cancelled instance and one declined invitation. - Verify: The cancelled instance is free and the rest of the series is busy after sync - [ ] **Your own OAuth client with the calendar scope** · free - Why: The tool syncs as you, through a client you created, and the unverified cap of a hundred users is why it stays personal. - Get it: Create a project and an OAuth client in the provider's console, add the narrowest calendar scope, and add yourself as a test user. - Verify: The consent screen grants the scope and an incremental sync completes - [ ] **A public HTTPS endpoint for push notifications** (optional) · free with a tunnel, pennies on a host - Why: Push channels deliver to a URL the provider can reach, and without one the tool is polling only, which is acceptable but should be a decision. - Get it: A tunnel to your machine or a tiny relay on a host you control. - Verify: The sync message arrives when a channel is created - [ ] **Your working hours and the declined and all-day rules** · free - Why: The planner packs into working hours and treats declined and all-day events by rules that are asserted in tests, so they belong decided before phase one. - Get it: Write down working hours per day in your zone, whether an all-day event blocks the day, and whether a declined invitation is free time. - Verify: All three are in the configuration and named in the README ## 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: - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for Rise Calendar, and the README should say so rather than pretend.
# Build brief · Rise Calendar 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 desktop calendar that puts your tasks into real free time and keeps them there when the day slips to replace Rise Calendar. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and one calendar account · a personal planner is not a service - Sync: Incremental sync tokens with a full resync whenever the server invalidates one · the token expires and the client that ignores that shows a stale week - Ownership: Blocks the tool creates carry a stored identifier and a marker; every other event is read-only · the planner may move its own blocks and nothing else - Planning: A packer that fills free windows in working hours and keeps earlier placements stable · a plan that reshuffles on every change is a plan nobody follows - Time: Instants stored, the zone applied at the edges, recurrence expanded with exceptions · a task placed at the wrong hour is worse than an unplaced one - Freshness: Push channels renewed before expiry with polling as the backstop, and the sync age on screen · a lapsed channel looks exactly like a quiet calendar ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_granted, sync_token, last_full_sync_at, last_incremental_at, channel_id, channel_expires_at) - events(id, calendar_id, external_id, starts_at, ends_at, all_day, status, recurrence_json, is_ours, updated_at) - tasks(id, title, estimate_minutes, remaining_minutes, deadline_at, priority, project_id, state) - blocks(id, task_id, external_event_id, starts_at, ends_at, placed_at, pinned, at_risk) · the join between a task and an event the tool created - working_hours(day_of_week, start_local, end_local, timezone) - plan_runs(id, at, reason, placed, moved, unplaced, at_risk) - A block links a task to a calendar event identified by stored id, so the planner can move its own blocks freely and never touches a meeting it did not make. ### Phase 1 · Reading one calendar correctly Build: Connect with the narrowest scope that lets you read events and write your own. Sync incrementally with the sync token, and when the server answers 410 Gone, clear the store and perform a full sync rather than retrying, which is what the sync guide prescribes (checked). Expand recurring events with their exceptions, and apply stated rules for all-day and declined events. Trap: Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. Done when: An incremental sync picks up a change made on the server within one cycle. A forced 410 triggers a full resync that leaves the store identical to the server, tested by comparing event sets. A recurring series with a cancelled instance expands correctly. Declined and all-day events follow the stated rules, and the rules are written in the README. Do not build yet: No tasks, blocks, write-back or push. ### Phase 2 · Tasks as blocks in free time Build: Tasks with an estimate, a deadline and a priority. A planner that packs them into free windows inside working hours, around every event including expanded recurring ones, never overlapping anything. Keep earlier placements unless they now conflict, and report what was placed, moved and left unplaced on each run. Trap: Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. Done when: Tasks are placed only in free working time, asserted by a test that generates a busy week. A new meeting displaces only the blocks it overlaps and the run reports them. A task that cannot fit before its deadline is marked at risk rather than silently placed late. Pinned blocks are never moved by the planner. Do not build yet: No write-back, push or booking yet. ### Phase 3 · Writing blocks without touching anyone else's events Build: Create a calendar event per block with the block's id stored on both sides and a marker on the event. Update or delete only events whose id you stored. Detect a block event deleted or moved in the calendar directly and treat it as the person's decision: pin or unschedule the task rather than recreating the block. Make a full resync idempotent for your own events. Trap: Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. Done when: Every block has a stored event id, and a foreign event cannot be modified or deleted, tested by attempting it. A block event moved by hand in the calendar pins the task at the new time rather than being overwritten. A full resync after a 410 does not duplicate block events. Deleting a task removes its block event and nothing else. Do not build yet: No push, sync age or booking yet. ### Phase 4 · Staying current when the day slips Build: Register a push notification channel and renew it before expiry, since channels are not renewed automatically and a notification carries no body, only a signal to sync (checked against the push guide). Poll as the backstop and show the age of the last successful sync. When a block ends with the task unfinished, or a meeting overruns, roll the remaining work forward and re-run the planner with stable placement. Trap: Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. Done when: A change on the server appears within seconds while the channel is live, and within one poll interval after the channel is deliberately allowed to expire. Channel renewal happens before expiry, tested by shortening the lifetime. The sync age is visible and becomes a warning past a stated threshold. An unfinished block rolls its remaining minutes forward and the plan re-runs with only the affected blocks moving. Do not build yet: No booking or export yet. ### Phase 5 · A booking link, an export and your own OAuth client Build: A single booking page that offers slots computed from the same availability as the planner, and reserves the chosen slot inside the transaction that creates the booking, writing it back as an owned event. Export tasks and blocks as JSON and the calendar as an ICS file. State in the README that an unverified OAuth client is capped at a hundred new users once the unverified screen appears (checked), so this stays a personal tool until verification is done. Trap: Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction. Done when: Twenty concurrent bookings for one slot produce exactly one, tested with real concurrency. A slot occupied by a task block is not offered. Export produces JSON for tasks and blocks and an ICS file that opens in another calendar. The README states the OAuth client's user cap and what verification would take. Do not build yet: Nothing after this. ### Out of scope (and why) - Several calendars per person with conflict handling, since each is another sync token, another channel and another set of ownership rules. - Team routing and round robin, which need everyone's availability and a fairness rule that is a product on its own. - A hosted multi-user service, which needs a verified OAuth client and makes you custodian of other people's calendars. - Notifications across devices, which need a push service you would have to run and keep running. ### README must contain - That a 410 from the server means clear the store and resync fully, and that your own blocks survive it by stored id - That the tool owns only events carrying its stored identifier and never modifies anything else - That push channels are renewed before expiry, polling is the backstop, and the sync age is on screen - That a task which cannot fit before its deadline is marked at risk rather than placed late - That an unverified OAuth client is capped at a hundred new users, so this is a personal tool until verified
# Agent instructions · Rise Calendar indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A desktop application with a local database and one calendar account, Incremental sync tokens with a full resync whenever the server invalidates one, Blocks the tool creates carry a stored identifier and a marker; every other event is read-only, A packer that fills free windows in working hours and keeps earlier placements stable, Instants stored, the zone applied at the edges, recurrence expanded with exceptions, Push channels renewed before expiry with polling as the backstop, and the sync age on screen. 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 - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen.
# Build plan · Rise Calendar A personal calendar and task planner whose difficulty is not the grid but the sync underneath it: an incremental sync that invalidates, push channels that expire quietly, blocks that must never move a real meeting, and a plan that has to re-fit itself every time the day slips. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading one calendar correctly The local copy matches the server, including after the server invalidates the sync token. ### Steps 1. Sync with tokens and resync fully on 410 Clear the store and start again; the token is gone and will not come back. Files: `src/sync.ts`, `tests/resync-410.test.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Request the narrowest working scope Read events and write your own is enough for everything here. Files: `src/oauth.ts` ### Done when - [ ] Server changes appear within one sync cycle - [ ] A 410 produces a full resync identical to the server - [ ] A series with a cancelled instance expands correctly - [ ] Declined and all-day rules are stated and followed ### Watch out - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. ## Phase 2 · Tasks as blocks in free time Tasks are packed into free working time and stay where they were put unless they must move. ### Steps 1. Pack into free windows only Working hours minus every event, recurring ones expanded first. Files: `src/planner.ts`, `src/planner.test.ts` 2. Keep placements stable Move what conflicts and nothing else, and say what moved. Files: `src/planner.ts` 3. Mark at risk, never place late silently A deadline the plan cannot meet is a fact the person needs today. Files: `src/risk.ts` ### Done when - [ ] Blocks land only in free working time - [ ] A new meeting moves only the blocks it overlaps - [ ] Unfittable tasks are marked at risk - [ ] Pinned blocks never move ### Watch out - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. ## Phase 3 · Writing blocks without touching anyone else's events Blocks appear in the calendar as events the tool owns, and no foreign event is ever changed. ### Steps 1. Own events by stored identifier A rename must not orphan a block or endanger a meeting. Files: `src/calendar-write.ts` 2. Refuse to touch foreign events in the write path Tested by attempting it, not by intending it. Files: `tests/foreign-events.test.ts` 3. Honour hand edits as decisions Moved by hand means pinned, not overwritten. Files: `src/reconcile.ts` ### Done when - [ ] Every block has a stored event id - [ ] Foreign events cannot be modified - [ ] A resync does not duplicate block events - [ ] Hand-moved blocks pin the task ### Watch out - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. ## Phase 4 · Staying current when the day slips The calendar is never quietly stale, and an overrun rolls the rest of the day forward. ### Steps 1. Renew channels before expiry, poll regardless A lapsed channel looks exactly like a quiet day. Files: `src/push.ts`, `tests/channel-expiry.test.ts` 2. Sync on the signal, never trust a body The notification says something changed. Go and look. Files: `src/push.ts` 3. Roll unfinished work forward Remaining minutes, stable placement, affected blocks only. Files: `src/rollover.ts` ### Done when - [ ] Changes appear within seconds while the channel lives - [ ] An expired channel falls back to polling - [ ] Sync age is shown and warns past the threshold - [ ] Unfinished blocks roll forward without reshuffling ### Watch out - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. ## Not in this build - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. ## After v1, if you want it - A focus mode that shows only the current block and the next one, which is what the day actually needs - A weekly review of at-risk tasks and estimate accuracy, computed from plan runs - A second calendar in read-only mode, once ownership and resync are provably correct for one
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Events, tasks, blocks and plan runs. DATABASE_PATH=./data/planner.db # Required · secret. Your own OAuth client. Unverified, and capped at a hundred users. CALENDAR_CLIENT_ID=a client id from your own project # Required · secret. Never in the repository. The desktop app holds it in the platform keychain. CALENDAR_CLIENT_SECRET=a client secret from the same project # Optional. Where notification channels deliver. Absent means polling only, stated on screen. PUSH_CALLBACK_URL=https://relay.example/calendar-push # Required. The backstop. Runs whether or not a channel is live. POLL_SECONDS=300 # Required. Past this the sync age becomes a warning on screen. SYNC_STALE_MINUTES=30 # Required. Per day, in your zone. The planner never places outside them. WORKING_HOURS_PATH=./config/working-hours.json # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
# Rise Calendar · product brief ## Problem The visible calendar + tasks loop is buildable, but a credible replacement needs more than the first screen. Rise Calendar earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A calendar whose local copy never quietly drifts from the server, whose task blocks live in real free time and move only when they must, and which never lays a finger on a meeting it did not create. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Multi-calendar conflict handling. - Team routing and round robin. - A hosted multi-user service. - Cross-device notifications. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness ## Success criteria - A 410 is confirmed to produce a full resync identical to the server - Foreign events are confirmed unmodifiable - A new meeting is confirmed to move only the blocks it overlaps - An expired channel is confirmed to fall back to polling with the sync age shown - Twenty concurrent bookings are confirmed to yield one
# Build brief · Rise Calendar 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 desktop calendar that puts your tasks into real free time and keeps them there when the day slips to replace Rise Calendar. 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. ### Stack (fixed, do not substitute) - Runtime: A desktop application with a local database and one calendar account · a personal planner is not a service - Sync: Incremental sync tokens with a full resync whenever the server invalidates one · the token expires and the client that ignores that shows a stale week - Ownership: Blocks the tool creates carry a stored identifier and a marker; every other event is read-only · the planner may move its own blocks and nothing else - Planning: A packer that fills free windows in working hours and keeps earlier placements stable · a plan that reshuffles on every change is a plan nobody follows - Time: Instants stored, the zone applied at the edges, recurrence expanded with exceptions · a task placed at the wrong hour is worse than an unplaced one - Freshness: Push channels renewed before expiry with polling as the backstop, and the sync age on screen · a lapsed channel looks exactly like a quiet calendar ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_granted, sync_token, last_full_sync_at, last_incremental_at, channel_id, channel_expires_at) - events(id, calendar_id, external_id, starts_at, ends_at, all_day, status, recurrence_json, is_ours, updated_at) - tasks(id, title, estimate_minutes, remaining_minutes, deadline_at, priority, project_id, state) - blocks(id, task_id, external_event_id, starts_at, ends_at, placed_at, pinned, at_risk) · the join between a task and an event the tool created - working_hours(day_of_week, start_local, end_local, timezone) - plan_runs(id, at, reason, placed, moved, unplaced, at_risk) - A block links a task to a calendar event identified by stored id, so the planner can move its own blocks freely and never touches a meeting it did not make. ### Phase 1 · Reading one calendar correctly Build: Connect with the narrowest scope that lets you read events and write your own. Sync incrementally with the sync token, and when the server answers 410 Gone, clear the store and perform a full sync rather than retrying, which is what the sync guide prescribes (checked). Expand recurring events with their exceptions, and apply stated rules for all-day and declined events. Trap: Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. Done when: An incremental sync picks up a change made on the server within one cycle. A forced 410 triggers a full resync that leaves the store identical to the server, tested by comparing event sets. A recurring series with a cancelled instance expands correctly. Declined and all-day events follow the stated rules, and the rules are written in the README. Do not build yet: No tasks, blocks, write-back or push. ### Phase 2 · Tasks as blocks in free time Build: Tasks with an estimate, a deadline and a priority. A planner that packs them into free windows inside working hours, around every event including expanded recurring ones, never overlapping anything. Keep earlier placements unless they now conflict, and report what was placed, moved and left unplaced on each run. Trap: Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. Done when: Tasks are placed only in free working time, asserted by a test that generates a busy week. A new meeting displaces only the blocks it overlaps and the run reports them. A task that cannot fit before its deadline is marked at risk rather than silently placed late. Pinned blocks are never moved by the planner. Do not build yet: No write-back, push or booking yet. ### Phase 3 · Writing blocks without touching anyone else's events Build: Create a calendar event per block with the block's id stored on both sides and a marker on the event. Update or delete only events whose id you stored. Detect a block event deleted or moved in the calendar directly and treat it as the person's decision: pin or unschedule the task rather than recreating the block. Make a full resync idempotent for your own events. Trap: Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. Done when: Every block has a stored event id, and a foreign event cannot be modified or deleted, tested by attempting it. A block event moved by hand in the calendar pins the task at the new time rather than being overwritten. A full resync after a 410 does not duplicate block events. Deleting a task removes its block event and nothing else. Do not build yet: No push, sync age or booking yet. ### Phase 4 · Staying current when the day slips Build: Register a push notification channel and renew it before expiry, since channels are not renewed automatically and a notification carries no body, only a signal to sync (checked against the push guide). Poll as the backstop and show the age of the last successful sync. When a block ends with the task unfinished, or a meeting overruns, roll the remaining work forward and re-run the planner with stable placement. Trap: Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. Done when: A change on the server appears within seconds while the channel is live, and within one poll interval after the channel is deliberately allowed to expire. Channel renewal happens before expiry, tested by shortening the lifetime. The sync age is visible and becomes a warning past a stated threshold. An unfinished block rolls its remaining minutes forward and the plan re-runs with only the affected blocks moving. Do not build yet: No booking or export yet. ### Phase 5 · A booking link, an export and your own OAuth client Build: A single booking page that offers slots computed from the same availability as the planner, and reserves the chosen slot inside the transaction that creates the booking, writing it back as an owned event. Export tasks and blocks as JSON and the calendar as an ICS file. State in the README that an unverified OAuth client is capped at a hundred new users once the unverified screen appears (checked), so this stays a personal tool until verification is done. Trap: Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction. Done when: Twenty concurrent bookings for one slot produce exactly one, tested with real concurrency. A slot occupied by a task block is not offered. Export produces JSON for tasks and blocks and an ICS file that opens in another calendar. The README states the OAuth client's user cap and what verification would take. Do not build yet: Nothing after this. ### Out of scope (and why) - Several calendars per person with conflict handling, since each is another sync token, another channel and another set of ownership rules. - Team routing and round robin, which need everyone's availability and a fairness rule that is a product on its own. - A hosted multi-user service, which needs a verified OAuth client and makes you custodian of other people's calendars. - Notifications across devices, which need a push service you would have to run and keep running. ### README must contain - That a 410 from the server means clear the store and resync fully, and that your own blocks survive it by stored id - That the tool owns only events carrying its stored identifier and never modifies anything else - That push channels are renewed before expiry, polling is the backstop, and the sync age is on screen - That a task which cannot fit before its deadline is marked at risk rather than placed late - That an unverified OAuth client is capped at a hundred new users, so this is a personal tool until verified
# Architecture · Rise Calendar ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A desktop application with a local database and one calendar account | a personal planner is not a service | | Sync | Incremental sync tokens with a full resync whenever the server invalidates one | the token expires and the client that ignores that shows a stale week | | Ownership | Blocks the tool creates carry a stored identifier and a marker; every other event is read-only | the planner may move its own blocks and nothing else | | Planning | A packer that fills free windows in working hours and keeps earlier placements stable | a plan that reshuffles on every change is a plan nobody follows | | Time | Instants stored, the zone applied at the edges, recurrence expanded with exceptions | a task placed at the wrong hour is worse than an unplaced one | | Freshness | Push channels renewed before expiry with polling as the backstop, and the sync age on screen | a lapsed channel looks exactly like a quiet calendar | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | Incremental tokens and full resync on invalidation. | Any provider, provided invalidation clears and resyncs rather than retries. | | Planner | Packing with stable placement and at-risk marking. | Any algorithm, provided existing placements are kept unless they conflict. | | Ownership | Events identified by stored id with a marker. | Not swappable for title matching, which endangers real meetings. | | Freshness | Channel renewal, polling and the sync age. | Any transport, provided the sync age is visible and polling continues. | | Booking | Reservation inside the transaction. | Not swappable for check-then-insert, which double-books when it matters. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Events, tasks, blocks and plan runs. - `CALENDAR_CLIENT_ID` · required, secret · Your own OAuth client. Unverified, and capped at a hundred users. - `CALENDAR_CLIENT_SECRET` · required, secret · Never in the repository. The desktop app holds it in the platform keychain. - `PUSH_CALLBACK_URL` · optional · Where notification channels deliver. Absent means polling only, stated on screen. - `POLL_SECONDS` · required · The backstop. Runs whether or not a channel is live. - `SYNC_STALE_MINUTES` · required · Past this the sync age becomes a warning on screen. - `WORKING_HOURS_PATH` · required · Per day, in your zone. The planner never places outside them. - `TOUCH_FOREIGN_EVENTS` · required · Present as a constant. There is no other value. ## 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 · Rise Calendar product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A desktop application with a local database and one calendar account, Incremental sync tokens with a full resync whenever the server invalidates one, Blocks the tool creates carry a stored identifier and a marker; every other event is read-only, A packer that fills free windows in working hours and keeps earlier placements stable, Instants stored, the zone applied at the edges, recurrence expanded with exceptions, Push channels renewed before expiry with polling as the backstop, and the sync age on screen. - 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 - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. - Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction.
# Delivery milestones · Rise Calendar Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Reading one calendar correctly The local copy matches the server, including after the server invalidates the sync token. ### Steps 1. Sync with tokens and resync fully on 410 Clear the store and start again; the token is gone and will not come back. Files: `src/sync.ts`, `tests/resync-410.test.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Request the narrowest working scope Read events and write your own is enough for everything here. Files: `src/oauth.ts` ### Done when - [ ] Server changes appear within one sync cycle - [ ] A 410 produces a full resync identical to the server - [ ] A series with a cancelled instance expands correctly - [ ] Declined and all-day rules are stated and followed ### Watch out - Treating the 410 as an error and retrying the same token, or ignoring it and carrying on, leaves a calendar that drifts from the server without anything visibly breaking, until a meeting cancelled last week still blocks a slot. Clear and resync, and keep your own blocks through it by their stored ids. ## M2 · Tasks as blocks in free time Tasks are packed into free working time and stay where they were put unless they must move. ### Steps 1. Pack into free windows only Working hours minus every event, recurring ones expanded first. Files: `src/planner.ts`, `src/planner.test.ts` 2. Keep placements stable Move what conflicts and nothing else, and say what moved. Files: `src/planner.ts` 3. Mark at risk, never place late silently A deadline the plan cannot meet is a fact the person needs today. Files: `src/risk.ts` ### Done when - [ ] Blocks land only in free working time - [ ] A new meeting moves only the blocks it overlaps - [ ] Unfittable tasks are marked at risk - [ ] Pinned blocks never move ### Watch out - Re-planning from scratch on every change reshuffles the whole day each time a meeting lands, and the person stops trusting the plan within a week. Keep existing placements stable, move only what conflicts, and show what moved and why. ## M3 · Writing blocks without touching anyone else's events Blocks appear in the calendar as events the tool owns, and no foreign event is ever changed. ### Steps 1. Own events by stored identifier A rename must not orphan a block or endanger a meeting. Files: `src/calendar-write.ts` 2. Refuse to touch foreign events in the write path Tested by attempting it, not by intending it. Files: `tests/foreign-events.test.ts` 3. Honour hand edits as decisions Moved by hand means pinned, not overwritten. Files: `src/reconcile.ts` ### Done when - [ ] Every block has a stored event id - [ ] Foreign events cannot be modified - [ ] A resync does not duplicate block events - [ ] Hand-moved blocks pin the task ### Watch out - Identifying your own events by title means a renamed block goes invisible and gets recreated as a duplicate, and a coincidence of naming lets the planner move a real meeting. Store the created event id, and treat everything without one as untouchable. ## M4 · Staying current when the day slips The calendar is never quietly stale, and an overrun rolls the rest of the day forward. ### Steps 1. Renew channels before expiry, poll regardless A lapsed channel looks exactly like a quiet day. Files: `src/push.ts`, `tests/channel-expiry.test.ts` 2. Sync on the signal, never trust a body The notification says something changed. Go and look. Files: `src/push.ts` 3. Roll unfinished work forward Remaining minutes, stable placement, affected blocks only. Files: `src/rollover.ts` ### Done when - [ ] Changes appear within seconds while the channel lives - [ ] An expired channel falls back to polling - [ ] Sync age is shown and warns past the threshold - [ ] Unfinished blocks roll forward without reshuffling ### Watch out - Assuming a notification carries the changed data, or that a channel lives forever, produces a client that goes stale the day the channel expires while looking entirely normal. Renew before expiry, sync on every notification, poll regardless, and put the sync age on screen. ## M5 · A booking link, an export and your own OAuth client (production only) One-slot bookings cannot double-book you, the data leaves cleanly, and the OAuth reality is stated. ### Steps 1. Reserve inside the booking transaction Check-then-insert double-books when a page is popular. Files: `src/booking.ts`, `tests/concurrency.test.ts` 2. Offer only what the planner calls free A task block is busy time too. Files: `src/availability.ts` 3. Export JSON and ICS So the plan is not trapped in this application. Files: `src/export.ts` ### Done when - [ ] Twenty concurrent bookings yield one - [ ] Task blocks are not offered as free - [ ] The ICS export opens elsewhere - [ ] The OAuth user cap is stated in the README ### Watch out - Computing availability for the booking page from the calendar alone offers a slot a task block already occupies, and check-then-insert double-books when two people click at once. Use the planner's availability and reserve inside the booking transaction.
# Operations · Rise Calendar ## Backup Tasks, blocks and pinned placements are the data that does not come back from the server, since the calendar copy resyncs. Back those up nightly, and keep the block to event id links with them. ## Restore Restore and run a full resync before any plan run, then confirm every block still resolves to its event id. A restore that lost the links will recreate every block as a duplicate on the next write. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync age, channel expiry time and the count of blocks at risk. A sync age past the threshold is the leading indicator of every other problem here. ## Incident checklist A block placed over a meeting is the incident, and it means the sync was stale when the planner ran. The sync age at the time of the plan run is recorded, which is how the diagnosis takes a minute rather than a day. 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 - [ ] A 410 is confirmed to produce a full resync identical to the server - [ ] Foreign events are confirmed unmodifiable - [ ] A new meeting is confirmed to move only the blocks it overlaps - [ ] An expired channel is confirmed to fall back to polling with the sync age shown - [ ] Twenty concurrent bookings are confirmed to yield one ## Launch constraint Do not market omitted Rise Calendar 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. Events, tasks, blocks and plan runs. DATABASE_PATH=./data/planner.db # Required · secret. Your own OAuth client. Unverified, and capped at a hundred users. CALENDAR_CLIENT_ID=a client id from your own project # Required · secret. Never in the repository. The desktop app holds it in the platform keychain. CALENDAR_CLIENT_SECRET=a client secret from the same project # Optional. Where notification channels deliver. Absent means polling only, stated on screen. PUSH_CALLBACK_URL=https://relay.example/calendar-push # Required. The backstop. Runs whether or not a channel is live. POLL_SECONDS=300 # Required. Past this the sync age becomes a warning on screen. SYNC_STALE_MINUTES=30 # Required. Per day, in your zone. The planner never places outside them. WORKING_HOURS_PATH=./config/working-hours.json # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
$ choose a build depth, inspect the files, then open the complete pack in your agent
Rise Calendar: People pay because a booking tool must never double-book them, leak private events, or send the wrong time to a client.
xmulti-calendar conflict handling
xrace-free booking under load
xteam routing and round robin
xreliable reminders and timezone edge cases
xcalendar-provider edge cases and timezone correctness
Rise Calendar pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| solo | $0/user | $0/user | 1 personal user; unlimited tasks and projects; advanced scheduling. |
| teams | $11.55/user | $9.24/user | Team calendar, tasks, projects, and scheduling; no numeric task/project cap published. |
free tier1 personal user; unlimited tasks and projects; advanced scheduling.
billingmonthly + annual (-20%)
hidden costsVAT may apply depending on location.
verified 2026-08-14 · source ↗
Vibecode Rise Calendar
Kinda. The core of Rise Calendar is buildable in a weekend with the prompt on this page, but there are real gaps: multi-calendar conflict handling, race-free booking under load. Read the honest list above before committing.
How much does Rise Calendar cost?
Rise Calendar's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Rise Calendar?
Honestly: multi-calendar conflict handling; race-free booking under load; team routing and round robin; reliable reminders and timezone edge cases; calendar-provider edge cases and timezone correctness. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Rise Calendar?
Yes: Cal.com (Mature open-source scheduling platform and OAuth reference.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.