Vibecode Doodle
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Doodle, run group availability polls and convert the selected option into a calendar event. The hard boundary is participant familiarity, calendar connections, reminders, branding, and hosted delivery, plus calendar integrations and reliability.
You are building a lean indie version of Doodle. 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 ===== # Doodle · indie build A scheduling poll whose two real problems are that a time slot means a different wall clock time for every participant and that a poll showing everyone's availability to everyone is a disclosure people do not always expect. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | polls, options and responses are small and relational | | Time | Options stored as instants, displayed in each viewer's zone | the same slot must mean the same moment for everyone | | Identity | No account required, with a durable per-browser identity | requiring signup collapses response rates | | Visibility | Per-poll setting for whether responses are visible to participants | who can see whose availability is a real decision | | Delivery | Notification on completion, not on every response | a poll with twelve participants should not send twelve emails | ## 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 server and the notification worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Polls, responses and expiring invites with correct instant handling. - Get it: Docker or your package manager. - Verify: psql connects and stores timestamps with zone correctly - [ ] **A current time zone database** · free - Why: Zone rules change by government decision and stale data produces a poll that is an hour wrong for some participants. - Get it: Your platform's zone data, kept current, plus a library that handles ambiguous and nonexistent local times explicitly. - Verify: An ambiguous local time during a daylight transition is detected rather than silently resolved - [ ] **Participants in at least three zones** · free - Why: Every claim about time correctness is untestable within one zone, and the interesting failures happen across a daylight transition boundary that different countries cross on different dates. - Get it: Colleagues or friends in three zones, ideally including two that change clocks on different dates. - Verify: A test poll is answered correctly by participants in three zones - [ ] **Your default response visibility** · free - Why: Whether participants see each other's availability is a disclosure decision, and the default is what almost every poll will use. - Get it: Decide the default and write down why, considering that availability reveals more than people expect. - Verify: A written note states the default and the reasoning ## 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: - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. - participant familiarity, calendar connections, reminders, branding, and hosted delivery - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity If one of those is essential to you, that is the reason to keep paying for Doodle, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Doodle 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 group availability poll that gets the times right for everyone to replace Doodle. 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: TypeScript with Fastify and PostgreSQL · polls, options and responses are small and relational - Time: Options stored as instants, displayed in each viewer's zone · the same slot must mean the same moment for everyone - Identity: No account required, with a durable per-browser identity · requiring signup collapses response rates - Visibility: Per-poll setting for whether responses are visible to participants · who can see whose availability is a real decision - Delivery: Notification on completion, not on every response · a poll with twelve participants should not send twelve emails ### Data model (create this before Phase 1) - polls(id, title, organiser_id, visibility, deadline_at, timezone_hint, finalised_option_id, created_at) - options(id, poll_id, starts_at_utc, ends_at_utc, ordinal) - participants(id, poll_id, name, email, identity_hash, responded_at) - responses(participant_id, option_id, answer) · answer is yes, no or if_needed - invites(poll_id, token_hash, sent_to, expires_at) · a poll link is a bearer credential to a list of names - finalisations(poll_id, option_id, decided_at, notified_at) - Visibility is a poll-level setting evaluated server side, because hiding other people's answers in the interface while returning them in the response is not hiding them. ### Phase 1 · Times that mean the same moment Build: Store options as instants and render them in each viewer's own zone, showing the zone explicitly. Handle a poll spanning a daylight transition, where a slot may not exist or may occur twice locally. Trap: A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. Done when: One option shows as the correct local time for viewers in three zones. A poll spanning a daylight transition is handled with ambiguous or nonexistent local times detected at creation. Each displayed time states its zone. An organiser creating options in their own zone sees them stored as the intended instants. Do not build yet: No responses, visibility or notification. ### Phase 2 · Responding without an account Build: Respond via a link with no account, using a durable identity so someone can return and change their answer. Support a three-way answer rather than only yes and no. Handle two people with the same name. Trap: Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. Done when: A response takes under a minute with no account, timed by hand. Returning later allows editing the same response rather than creating a second one. Two participants with the same name are kept separate, tested directly. The if-needed answer is distinct from yes in the results. Do not build yet: No visibility control or finalisation yet. ### Phase 3 · Who can see whose availability Build: A per-poll setting for whether participants see each other's responses, enforced server side. Where hidden, the organiser still sees everything and participants see only counts. Verify hidden responses never appear in any API response. Trap: Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. Done when: With responses hidden, another participant's answers are absent from every API response, asserted in the suite. The organiser still sees everything. Participants see aggregate counts rather than nothing at all. The setting cannot be changed after responses exist without warning the organiser what it exposes. Do not build yet: No finalisation or notification yet. ### Phase 4 · Choosing a time and telling everyone Build: Show the best options by yes count with if-needed as a tiebreak. Finalise an option, notifying participants once. Notify the organiser when everyone has responded or the deadline passes, not on every response. Trap: Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. Done when: The organiser is notified once when everyone has responded or the deadline passes, not per response. Finalising sends one notification per participant with the time in their own zone. Options rank by yes count with if-needed as a tiebreak, shown explicitly. A participant added after finalisation is told the decision rather than asked to vote. Do not build yet: No expiry or export yet. ### Phase 5 · Links expire and data goes away Build: Expire poll links after a stated period. Delete polls and their responses on a retention policy. Allow a participant to remove their own response and be forgotten from a poll. Trap: A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find. Done when: A poll link stops working after its expiry, which is set by default rather than chosen. Polls and responses delete on the retention policy including expired links. A participant can remove their own response and their name. Deleting a poll removes responses, participants and invites together. Do not build yet: Nothing after this. ### Out of scope (and why) - Calendar integration showing real availability, which is a far larger permission and a different product. - Recurring meeting scheduling, which is a different problem with different failure modes. - Paid booking pages, which is the adjacent product rather than this one. - Mobile applications, which is where a lot of responses actually come from. ### README must contain - That options are stored as instants and rendered per viewer with the zone always shown - That daylight transition edge cases are detected when the poll is created - That response visibility is enforced server side and hidden answers never reach the client - That the organiser is notified on completion rather than on every response - That poll links expire by default because they list named people and their availability ===== AGENTS.md ===== # Agent instructions · Doodle indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Options stored as instants, displayed in each viewer's zone, No account required, with a durable per-browser identity, Per-poll setting for whether responses are visible to participants, Notification on completion, not on every response. 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 - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. ===== BUILD_PLAN.md ===== # Build plan · Doodle A scheduling poll whose two real problems are that a time slot means a different wall clock time for every participant and that a poll showing everyone's availability to everyone is a disclosure people do not always expect. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Times that mean the same moment A slot shown to someone in another country is the same moment. ### Steps 1. Store instants, display per viewer The slot is a moment. Local times are a rendering. Files: `src/options.ts` 2. Detect daylight edge cases at creation A local time that does not exist should be caught before anyone answers. Files: `src/timezones.ts`, `src/timezones.test.ts` 3. Always show the zone A bare time in a cross-border poll is an ambiguity, not information. Files: `src/display.ts` ### Done when - [ ] One option shows correctly in three zones - [ ] Daylight edge cases are detected at creation - [ ] Every displayed time states its zone - [ ] Organiser input maps to the intended instants ### Watch out - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. ## Phase 2 · Responding without an account A participant answers in under a minute with no signup. ### Steps 1. No account, durable identity The friction is what costs you the responses. Files: `src/participants.ts` 2. Support a three-way answer If needed is genuinely different information from yes. Files: `src/responses.ts` 3. Handle name collisions Two people called Sam is normal and silently merging them loses an answer. Files: `src/participants.ts` ### Done when - [ ] Responding takes under a minute with no account - [ ] Returning edits the same response - [ ] Two same-named participants stay separate - [ ] If-needed is distinct from yes ### Watch out - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. ## Phase 3 · Who can see whose availability A poll's visibility setting is enforced where the data is fetched. ### Steps 1. Filter visibility server side In the query. Interface hiding is not hiding. Files: `src/visibility.ts`, `tests/visibility.test.ts` 2. Show counts rather than nothing Aggregate information is useful and reveals much less. Files: `src/results.ts` 3. Warn when changing the setting mid-poll Making a hidden poll visible exposes answers people gave in confidence. Files: `src/polls.ts` ### Done when - [ ] Hidden responses are absent from API responses - [ ] The organiser still sees everything - [ ] Participants see aggregate counts - [ ] Changing visibility mid-poll warns first ### Watch out - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. ## Phase 4 · Choosing a time and telling everyone Finalising sends one clear notification, not a stream. ### Steps 1. Notify on completion or deadline Twelve emails for a twelve person poll is a worse problem. Files: `src/notify.ts` 2. Rank with if-needed as a tiebreak And show that is what happened, rather than a bare ordering. Files: `src/ranking.ts` 3. Send the finalised time in each person's zone It is the one message that must not be misread. Files: `src/notify.ts` ### Done when - [ ] The organiser is notified once, not per response - [ ] Finalising sends one notification per participant - [ ] Ranking shows how if-needed was used - [ ] A late participant is told the decision ### Watch out - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. ## Not in this build - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. ## After v1, if you want it - Suggesting slots from the responses already given, which shortens long polls - A calendar file for the finalised time, which is what people actually want at the end - Reminders to non-responders, worded once and sent once ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Named participants and their availability. DATABASE_URL=postgres://localhost:5432/polls # Required. Whether participants see each other's answers. Availability reveals more than people expect. DEFAULT_VISIBILITY=hidden # Required. A poll link lists named people and gets forwarded indefinitely. INVITE_EXPIRY_DAYS=30 # Required. A scheduling poll is not an archive. POLL_RETENTION_DAYS=180 # Required. Present as a constant. Twelve emails for a twelve person poll is a worse problem. NOTIFY_ON_EVERY_RESPONSE=never # Required · secret. Completion and finalisation notifications. SMTP_URL=smtp://user:pass@host:587
You are building a lean indie version of Doodle. 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 ===== # Doodle · indie build A scheduling poll whose two real problems are that a time slot means a different wall clock time for every participant and that a poll showing everyone's availability to everyone is a disclosure people do not always expect. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | polls, options and responses are small and relational | | Time | Options stored as instants, displayed in each viewer's zone | the same slot must mean the same moment for everyone | | Identity | No account required, with a durable per-browser identity | requiring signup collapses response rates | | Visibility | Per-poll setting for whether responses are visible to participants | who can see whose availability is a real decision | | Delivery | Notification on completion, not on every response | a poll with twelve participants should not send twelve emails | ## 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 server and the notification worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Polls, responses and expiring invites with correct instant handling. - Get it: Docker or your package manager. - Verify: psql connects and stores timestamps with zone correctly - [ ] **A current time zone database** · free - Why: Zone rules change by government decision and stale data produces a poll that is an hour wrong for some participants. - Get it: Your platform's zone data, kept current, plus a library that handles ambiguous and nonexistent local times explicitly. - Verify: An ambiguous local time during a daylight transition is detected rather than silently resolved - [ ] **Participants in at least three zones** · free - Why: Every claim about time correctness is untestable within one zone, and the interesting failures happen across a daylight transition boundary that different countries cross on different dates. - Get it: Colleagues or friends in three zones, ideally including two that change clocks on different dates. - Verify: A test poll is answered correctly by participants in three zones - [ ] **Your default response visibility** · free - Why: Whether participants see each other's availability is a disclosure decision, and the default is what almost every poll will use. - Get it: Decide the default and write down why, considering that availability reveals more than people expect. - Verify: A written note states the default and the reasoning ## 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: - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. - participant familiarity, calendar connections, reminders, branding, and hosted delivery - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity If one of those is essential to you, that is the reason to keep paying for Doodle, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Doodle 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 group availability poll that gets the times right for everyone to replace Doodle. 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: TypeScript with Fastify and PostgreSQL · polls, options and responses are small and relational - Time: Options stored as instants, displayed in each viewer's zone · the same slot must mean the same moment for everyone - Identity: No account required, with a durable per-browser identity · requiring signup collapses response rates - Visibility: Per-poll setting for whether responses are visible to participants · who can see whose availability is a real decision - Delivery: Notification on completion, not on every response · a poll with twelve participants should not send twelve emails ### Data model (create this before Phase 1) - polls(id, title, organiser_id, visibility, deadline_at, timezone_hint, finalised_option_id, created_at) - options(id, poll_id, starts_at_utc, ends_at_utc, ordinal) - participants(id, poll_id, name, email, identity_hash, responded_at) - responses(participant_id, option_id, answer) · answer is yes, no or if_needed - invites(poll_id, token_hash, sent_to, expires_at) · a poll link is a bearer credential to a list of names - finalisations(poll_id, option_id, decided_at, notified_at) - Visibility is a poll-level setting evaluated server side, because hiding other people's answers in the interface while returning them in the response is not hiding them. ### Phase 1 · Times that mean the same moment Build: Store options as instants and render them in each viewer's own zone, showing the zone explicitly. Handle a poll spanning a daylight transition, where a slot may not exist or may occur twice locally. Trap: A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. Done when: One option shows as the correct local time for viewers in three zones. A poll spanning a daylight transition is handled with ambiguous or nonexistent local times detected at creation. Each displayed time states its zone. An organiser creating options in their own zone sees them stored as the intended instants. Do not build yet: No responses, visibility or notification. ### Phase 2 · Responding without an account Build: Respond via a link with no account, using a durable identity so someone can return and change their answer. Support a three-way answer rather than only yes and no. Handle two people with the same name. Trap: Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. Done when: A response takes under a minute with no account, timed by hand. Returning later allows editing the same response rather than creating a second one. Two participants with the same name are kept separate, tested directly. The if-needed answer is distinct from yes in the results. Do not build yet: No visibility control or finalisation yet. ### Phase 3 · Who can see whose availability Build: A per-poll setting for whether participants see each other's responses, enforced server side. Where hidden, the organiser still sees everything and participants see only counts. Verify hidden responses never appear in any API response. Trap: Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. Done when: With responses hidden, another participant's answers are absent from every API response, asserted in the suite. The organiser still sees everything. Participants see aggregate counts rather than nothing at all. The setting cannot be changed after responses exist without warning the organiser what it exposes. Do not build yet: No finalisation or notification yet. ### Phase 4 · Choosing a time and telling everyone Build: Show the best options by yes count with if-needed as a tiebreak. Finalise an option, notifying participants once. Notify the organiser when everyone has responded or the deadline passes, not on every response. Trap: Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. Done when: The organiser is notified once when everyone has responded or the deadline passes, not per response. Finalising sends one notification per participant with the time in their own zone. Options rank by yes count with if-needed as a tiebreak, shown explicitly. A participant added after finalisation is told the decision rather than asked to vote. Do not build yet: No expiry or export yet. ### Phase 5 · Links expire and data goes away Build: Expire poll links after a stated period. Delete polls and their responses on a retention policy. Allow a participant to remove their own response and be forgotten from a poll. Trap: A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find. Done when: A poll link stops working after its expiry, which is set by default rather than chosen. Polls and responses delete on the retention policy including expired links. A participant can remove their own response and their name. Deleting a poll removes responses, participants and invites together. Do not build yet: Nothing after this. ### Out of scope (and why) - Calendar integration showing real availability, which is a far larger permission and a different product. - Recurring meeting scheduling, which is a different problem with different failure modes. - Paid booking pages, which is the adjacent product rather than this one. - Mobile applications, which is where a lot of responses actually come from. ### README must contain - That options are stored as instants and rendered per viewer with the zone always shown - That daylight transition edge cases are detected when the poll is created - That response visibility is enforced server side and hidden answers never reach the client - That the organiser is notified on completion rather than on every response - That poll links expire by default because they list named people and their availability ===== AGENTS.md ===== # Agent instructions · Doodle indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Options stored as instants, displayed in each viewer's zone, No account required, with a durable per-browser identity, Per-poll setting for whether responses are visible to participants, Notification on completion, not on every response. 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 - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. ===== BUILD_PLAN.md ===== # Build plan · Doodle A scheduling poll whose two real problems are that a time slot means a different wall clock time for every participant and that a poll showing everyone's availability to everyone is a disclosure people do not always expect. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Times that mean the same moment A slot shown to someone in another country is the same moment. ### Steps 1. Store instants, display per viewer The slot is a moment. Local times are a rendering. Files: `src/options.ts` 2. Detect daylight edge cases at creation A local time that does not exist should be caught before anyone answers. Files: `src/timezones.ts`, `src/timezones.test.ts` 3. Always show the zone A bare time in a cross-border poll is an ambiguity, not information. Files: `src/display.ts` ### Done when - [ ] One option shows correctly in three zones - [ ] Daylight edge cases are detected at creation - [ ] Every displayed time states its zone - [ ] Organiser input maps to the intended instants ### Watch out - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. ## Phase 2 · Responding without an account A participant answers in under a minute with no signup. ### Steps 1. No account, durable identity The friction is what costs you the responses. Files: `src/participants.ts` 2. Support a three-way answer If needed is genuinely different information from yes. Files: `src/responses.ts` 3. Handle name collisions Two people called Sam is normal and silently merging them loses an answer. Files: `src/participants.ts` ### Done when - [ ] Responding takes under a minute with no account - [ ] Returning edits the same response - [ ] Two same-named participants stay separate - [ ] If-needed is distinct from yes ### Watch out - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. ## Phase 3 · Who can see whose availability A poll's visibility setting is enforced where the data is fetched. ### Steps 1. Filter visibility server side In the query. Interface hiding is not hiding. Files: `src/visibility.ts`, `tests/visibility.test.ts` 2. Show counts rather than nothing Aggregate information is useful and reveals much less. Files: `src/results.ts` 3. Warn when changing the setting mid-poll Making a hidden poll visible exposes answers people gave in confidence. Files: `src/polls.ts` ### Done when - [ ] Hidden responses are absent from API responses - [ ] The organiser still sees everything - [ ] Participants see aggregate counts - [ ] Changing visibility mid-poll warns first ### Watch out - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. ## Phase 4 · Choosing a time and telling everyone Finalising sends one clear notification, not a stream. ### Steps 1. Notify on completion or deadline Twelve emails for a twelve person poll is a worse problem. Files: `src/notify.ts` 2. Rank with if-needed as a tiebreak And show that is what happened, rather than a bare ordering. Files: `src/ranking.ts` 3. Send the finalised time in each person's zone It is the one message that must not be misread. Files: `src/notify.ts` ### Done when - [ ] The organiser is notified once, not per response - [ ] Finalising sends one notification per participant - [ ] Ranking shows how if-needed was used - [ ] A late participant is told the decision ### Watch out - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. ## Not in this build - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. ## After v1, if you want it - Suggesting slots from the responses already given, which shortens long polls - A calendar file for the finalised time, which is what people actually want at the end - Reminders to non-responders, worded once and sent once ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Named participants and their availability. DATABASE_URL=postgres://localhost:5432/polls # Required. Whether participants see each other's answers. Availability reveals more than people expect. DEFAULT_VISIBILITY=hidden # Required. A poll link lists named people and gets forwarded indefinitely. INVITE_EXPIRY_DAYS=30 # Required. A scheduling poll is not an archive. POLL_RETENTION_DAYS=180 # Required. Present as a constant. Twelve emails for a twelve person poll is a worse problem. NOTIFY_ON_EVERY_RESPONSE=never # Required · secret. Completion and finalisation notifications. SMTP_URL=smtp://user:pass@host:587
You are building a production product version of Doodle. 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 ===== # Doodle · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Doodle, run group availability polls and convert the selected option into a calendar event. The hard boundary is participant familiarity, calendar connections, reminders, branding, and hosted delivery, plus calendar integrations and reliability. ## Product outcome A poll where a slot means the same moment to everyone who sees it, where nobody's availability leaks to people it was hidden from, and where the organiser gets one email rather than twelve. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - PostgreSQL - public deploy target - Resend API key - domain with HTTPS ## Explicit non-goals for v1 - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. - participant familiarity, calendar connections, reminders, branding, and hosted delivery - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity ## Success criteria - One option is confirmed correct across three zones - Daylight edge cases are confirmed detected at creation - Hidden responses are confirmed absent from API responses - Per-response notification is confirmed unavailable - Poll links are confirmed to expire by default ===== BRIEF.md ===== # Build brief · Doodle 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 group availability poll that gets the times right for everyone to replace Doodle. 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: TypeScript with Fastify and PostgreSQL · polls, options and responses are small and relational - Time: Options stored as instants, displayed in each viewer's zone · the same slot must mean the same moment for everyone - Identity: No account required, with a durable per-browser identity · requiring signup collapses response rates - Visibility: Per-poll setting for whether responses are visible to participants · who can see whose availability is a real decision - Delivery: Notification on completion, not on every response · a poll with twelve participants should not send twelve emails ### Data model (create this before Phase 1) - polls(id, title, organiser_id, visibility, deadline_at, timezone_hint, finalised_option_id, created_at) - options(id, poll_id, starts_at_utc, ends_at_utc, ordinal) - participants(id, poll_id, name, email, identity_hash, responded_at) - responses(participant_id, option_id, answer) · answer is yes, no or if_needed - invites(poll_id, token_hash, sent_to, expires_at) · a poll link is a bearer credential to a list of names - finalisations(poll_id, option_id, decided_at, notified_at) - Visibility is a poll-level setting evaluated server side, because hiding other people's answers in the interface while returning them in the response is not hiding them. ### Phase 1 · Times that mean the same moment Build: Store options as instants and render them in each viewer's own zone, showing the zone explicitly. Handle a poll spanning a daylight transition, where a slot may not exist or may occur twice locally. Trap: A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. Done when: One option shows as the correct local time for viewers in three zones. A poll spanning a daylight transition is handled with ambiguous or nonexistent local times detected at creation. Each displayed time states its zone. An organiser creating options in their own zone sees them stored as the intended instants. Do not build yet: No responses, visibility or notification. ### Phase 2 · Responding without an account Build: Respond via a link with no account, using a durable identity so someone can return and change their answer. Support a three-way answer rather than only yes and no. Handle two people with the same name. Trap: Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. Done when: A response takes under a minute with no account, timed by hand. Returning later allows editing the same response rather than creating a second one. Two participants with the same name are kept separate, tested directly. The if-needed answer is distinct from yes in the results. Do not build yet: No visibility control or finalisation yet. ### Phase 3 · Who can see whose availability Build: A per-poll setting for whether participants see each other's responses, enforced server side. Where hidden, the organiser still sees everything and participants see only counts. Verify hidden responses never appear in any API response. Trap: Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. Done when: With responses hidden, another participant's answers are absent from every API response, asserted in the suite. The organiser still sees everything. Participants see aggregate counts rather than nothing at all. The setting cannot be changed after responses exist without warning the organiser what it exposes. Do not build yet: No finalisation or notification yet. ### Phase 4 · Choosing a time and telling everyone Build: Show the best options by yes count with if-needed as a tiebreak. Finalise an option, notifying participants once. Notify the organiser when everyone has responded or the deadline passes, not on every response. Trap: Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. Done when: The organiser is notified once when everyone has responded or the deadline passes, not per response. Finalising sends one notification per participant with the time in their own zone. Options rank by yes count with if-needed as a tiebreak, shown explicitly. A participant added after finalisation is told the decision rather than asked to vote. Do not build yet: No expiry or export yet. ### Phase 5 · Links expire and data goes away Build: Expire poll links after a stated period. Delete polls and their responses on a retention policy. Allow a participant to remove their own response and be forgotten from a poll. Trap: A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find. Done when: A poll link stops working after its expiry, which is set by default rather than chosen. Polls and responses delete on the retention policy including expired links. A participant can remove their own response and their name. Deleting a poll removes responses, participants and invites together. Do not build yet: Nothing after this. ### Out of scope (and why) - Calendar integration showing real availability, which is a far larger permission and a different product. - Recurring meeting scheduling, which is a different problem with different failure modes. - Paid booking pages, which is the adjacent product rather than this one. - Mobile applications, which is where a lot of responses actually come from. ### README must contain - That options are stored as instants and rendered per viewer with the zone always shown - That daylight transition edge cases are detected when the poll is created - That response visibility is enforced server side and hidden answers never reach the client - That the organiser is notified on completion rather than on every response - That poll links expire by default because they list named people and their availability ===== ARCHITECTURE.md ===== # Architecture · Doodle ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | polls, options and responses are small and relational | | Time | Options stored as instants, displayed in each viewer's zone | the same slot must mean the same moment for everyone | | Identity | No account required, with a durable per-browser identity | requiring signup collapses response rates | | Visibility | Per-poll setting for whether responses are visible to participants | who can see whose availability is a real decision | | Delivery | Notification on completion, not on every response | a poll with twelve participants should not send twelve emails | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Time | Instants, per-viewer rendering and edge case detection. | Any library, provided ambiguous local times are detected rather than resolved silently. | | Identity | Accountless durable participants. | Any scheme, provided name collisions do not merge people. | | Visibility | Server-side response filtering. | Not swappable for interface hiding, which is not hiding. | | Notification | Completion and finalisation messages. | Any transport, provided per-response notification is not offered. | | Expiry | Link expiry and retention. | Any policy, provided expiry is the default rather than an option. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Named participants and their availability. - `DEFAULT_VISIBILITY` · required · Whether participants see each other's answers. Availability reveals more than people expect. - `INVITE_EXPIRY_DAYS` · required · A poll link lists named people and gets forwarded indefinitely. - `POLL_RETENTION_DAYS` · required · A scheduling poll is not an archive. - `NOTIFY_ON_EVERY_RESPONSE` · required · Present as a constant. Twelve emails for a twelve person poll is a worse problem. - `SMTP_URL` · required, secret · Completion and finalisation notifications. ## 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 · Doodle product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Options stored as instants, displayed in each viewer's zone, No account required, with a durable per-browser identity, Per-poll setting for whether responses are visible to participants, Notification on completion, not on every response. - 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 - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. - A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find. ===== MILESTONES.md ===== # Delivery milestones · Doodle Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Times that mean the same moment A slot shown to someone in another country is the same moment. ### Steps 1. Store instants, display per viewer The slot is a moment. Local times are a rendering. Files: `src/options.ts` 2. Detect daylight edge cases at creation A local time that does not exist should be caught before anyone answers. Files: `src/timezones.ts`, `src/timezones.test.ts` 3. Always show the zone A bare time in a cross-border poll is an ambiguity, not information. Files: `src/display.ts` ### Done when - [ ] One option shows correctly in three zones - [ ] Daylight edge cases are detected at creation - [ ] Every displayed time states its zone - [ ] Organiser input maps to the intended instants ### Watch out - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. ## M2 · Responding without an account A participant answers in under a minute with no signup. ### Steps 1. No account, durable identity The friction is what costs you the responses. Files: `src/participants.ts` 2. Support a three-way answer If needed is genuinely different information from yes. Files: `src/responses.ts` 3. Handle name collisions Two people called Sam is normal and silently merging them loses an answer. Files: `src/participants.ts` ### Done when - [ ] Responding takes under a minute with no account - [ ] Returning edits the same response - [ ] Two same-named participants stay separate - [ ] If-needed is distinct from yes ### Watch out - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. ## M3 · Who can see whose availability A poll's visibility setting is enforced where the data is fetched. ### Steps 1. Filter visibility server side In the query. Interface hiding is not hiding. Files: `src/visibility.ts`, `tests/visibility.test.ts` 2. Show counts rather than nothing Aggregate information is useful and reveals much less. Files: `src/results.ts` 3. Warn when changing the setting mid-poll Making a hidden poll visible exposes answers people gave in confidence. Files: `src/polls.ts` ### Done when - [ ] Hidden responses are absent from API responses - [ ] The organiser still sees everything - [ ] Participants see aggregate counts - [ ] Changing visibility mid-poll warns first ### Watch out - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. ## M4 · Choosing a time and telling everyone Finalising sends one clear notification, not a stream. ### Steps 1. Notify on completion or deadline Twelve emails for a twelve person poll is a worse problem. Files: `src/notify.ts` 2. Rank with if-needed as a tiebreak And show that is what happened, rather than a bare ordering. Files: `src/ranking.ts` 3. Send the finalised time in each person's zone It is the one message that must not be misread. Files: `src/notify.ts` ### Done when - [ ] The organiser is notified once, not per response - [ ] Finalising sends one notification per participant - [ ] Ranking shows how if-needed was used - [ ] A late participant is told the decision ### Watch out - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. ## M5 · Links expire and data goes away (production only) A poll link stops working and the names do not sit around forever. ### Steps 1. Expire links by default They contain a list of named people and get forwarded indefinitely. Files: `src/invites.ts` 2. Delete on a retention policy A scheduling poll is not an archive. Files: `src/retention.ts` 3. Let a participant remove themselves They gave a name and their availability. They can take it back. Files: `src/participants.ts` ### Done when - [ ] Poll links expire by default - [ ] Retention deletes polls and responses - [ ] A participant can remove their own response - [ ] Deleting removes responses, participants and invites ### Watch out - A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find. ===== OPERATIONS.md ===== # Operations · Doodle ## Backup Polls are short-lived and mostly not worth long retention, which is the point. Back up what you must, but the more important operational property is that retention actually runs. ## Restore Restore and confirm retention resumes and expired invites remain expired, since restoring a live invite reopens a link someone may have forwarded years ago. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Confirm the retention job is running, because a silently failed retention leaves a growing store of people's names and availability against a policy that says otherwise. ## Incident checklist A hidden response appearing in an API result is the failure that matters, since it exposes colleagues' availability to each other. The suite assertion covers it and error responses are where it usually reappears. 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 - [ ] One option is confirmed correct across three zones - [ ] Daylight edge cases are confirmed detected at creation - [ ] Hidden responses are confirmed absent from API responses - [ ] Per-response notification is confirmed unavailable - [ ] Poll links are confirmed to expire by default ## Launch constraint Do not market omitted Doodle 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 · secret. Named participants and their availability. DATABASE_URL=postgres://localhost:5432/polls # Required. Whether participants see each other's answers. Availability reveals more than people expect. DEFAULT_VISIBILITY=hidden # Required. A poll link lists named people and gets forwarded indefinitely. INVITE_EXPIRY_DAYS=30 # Required. A scheduling poll is not an archive. POLL_RETENTION_DAYS=180 # Required. Present as a constant. Twelve emails for a twelve person poll is a worse problem. NOTIFY_ON_EVERY_RESPONSE=never # Required · secret. Completion and finalisation notifications. SMTP_URL=smtp://user:pass@host:587
# Doodle · indie build A scheduling poll whose two real problems are that a time slot means a different wall clock time for every participant and that a poll showing everyone's availability to everyone is a disclosure people do not always expect. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | polls, options and responses are small and relational | | Time | Options stored as instants, displayed in each viewer's zone | the same slot must mean the same moment for everyone | | Identity | No account required, with a durable per-browser identity | requiring signup collapses response rates | | Visibility | Per-poll setting for whether responses are visible to participants | who can see whose availability is a real decision | | Delivery | Notification on completion, not on every response | a poll with twelve participants should not send twelve emails | ## 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 server and the notification worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Polls, responses and expiring invites with correct instant handling. - Get it: Docker or your package manager. - Verify: psql connects and stores timestamps with zone correctly - [ ] **A current time zone database** · free - Why: Zone rules change by government decision and stale data produces a poll that is an hour wrong for some participants. - Get it: Your platform's zone data, kept current, plus a library that handles ambiguous and nonexistent local times explicitly. - Verify: An ambiguous local time during a daylight transition is detected rather than silently resolved - [ ] **Participants in at least three zones** · free - Why: Every claim about time correctness is untestable within one zone, and the interesting failures happen across a daylight transition boundary that different countries cross on different dates. - Get it: Colleagues or friends in three zones, ideally including two that change clocks on different dates. - Verify: A test poll is answered correctly by participants in three zones - [ ] **Your default response visibility** · free - Why: Whether participants see each other's availability is a disclosure decision, and the default is what almost every poll will use. - Get it: Decide the default and write down why, considering that availability reveals more than people expect. - Verify: A written note states the default and the reasoning ## 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: - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. - participant familiarity, calendar connections, reminders, branding, and hosted delivery - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity If one of those is essential to you, that is the reason to keep paying for Doodle, and the README should say so rather than pretend.
# Build brief · Doodle 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 group availability poll that gets the times right for everyone to replace Doodle. 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: TypeScript with Fastify and PostgreSQL · polls, options and responses are small and relational - Time: Options stored as instants, displayed in each viewer's zone · the same slot must mean the same moment for everyone - Identity: No account required, with a durable per-browser identity · requiring signup collapses response rates - Visibility: Per-poll setting for whether responses are visible to participants · who can see whose availability is a real decision - Delivery: Notification on completion, not on every response · a poll with twelve participants should not send twelve emails ### Data model (create this before Phase 1) - polls(id, title, organiser_id, visibility, deadline_at, timezone_hint, finalised_option_id, created_at) - options(id, poll_id, starts_at_utc, ends_at_utc, ordinal) - participants(id, poll_id, name, email, identity_hash, responded_at) - responses(participant_id, option_id, answer) · answer is yes, no or if_needed - invites(poll_id, token_hash, sent_to, expires_at) · a poll link is a bearer credential to a list of names - finalisations(poll_id, option_id, decided_at, notified_at) - Visibility is a poll-level setting evaluated server side, because hiding other people's answers in the interface while returning them in the response is not hiding them. ### Phase 1 · Times that mean the same moment Build: Store options as instants and render them in each viewer's own zone, showing the zone explicitly. Handle a poll spanning a daylight transition, where a slot may not exist or may occur twice locally. Trap: A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. Done when: One option shows as the correct local time for viewers in three zones. A poll spanning a daylight transition is handled with ambiguous or nonexistent local times detected at creation. Each displayed time states its zone. An organiser creating options in their own zone sees them stored as the intended instants. Do not build yet: No responses, visibility or notification. ### Phase 2 · Responding without an account Build: Respond via a link with no account, using a durable identity so someone can return and change their answer. Support a three-way answer rather than only yes and no. Handle two people with the same name. Trap: Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. Done when: A response takes under a minute with no account, timed by hand. Returning later allows editing the same response rather than creating a second one. Two participants with the same name are kept separate, tested directly. The if-needed answer is distinct from yes in the results. Do not build yet: No visibility control or finalisation yet. ### Phase 3 · Who can see whose availability Build: A per-poll setting for whether participants see each other's responses, enforced server side. Where hidden, the organiser still sees everything and participants see only counts. Verify hidden responses never appear in any API response. Trap: Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. Done when: With responses hidden, another participant's answers are absent from every API response, asserted in the suite. The organiser still sees everything. Participants see aggregate counts rather than nothing at all. The setting cannot be changed after responses exist without warning the organiser what it exposes. Do not build yet: No finalisation or notification yet. ### Phase 4 · Choosing a time and telling everyone Build: Show the best options by yes count with if-needed as a tiebreak. Finalise an option, notifying participants once. Notify the organiser when everyone has responded or the deadline passes, not on every response. Trap: Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. Done when: The organiser is notified once when everyone has responded or the deadline passes, not per response. Finalising sends one notification per participant with the time in their own zone. Options rank by yes count with if-needed as a tiebreak, shown explicitly. A participant added after finalisation is told the decision rather than asked to vote. Do not build yet: No expiry or export yet. ### Phase 5 · Links expire and data goes away Build: Expire poll links after a stated period. Delete polls and their responses on a retention policy. Allow a participant to remove their own response and be forgotten from a poll. Trap: A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find. Done when: A poll link stops working after its expiry, which is set by default rather than chosen. Polls and responses delete on the retention policy including expired links. A participant can remove their own response and their name. Deleting a poll removes responses, participants and invites together. Do not build yet: Nothing after this. ### Out of scope (and why) - Calendar integration showing real availability, which is a far larger permission and a different product. - Recurring meeting scheduling, which is a different problem with different failure modes. - Paid booking pages, which is the adjacent product rather than this one. - Mobile applications, which is where a lot of responses actually come from. ### README must contain - That options are stored as instants and rendered per viewer with the zone always shown - That daylight transition edge cases are detected when the poll is created - That response visibility is enforced server side and hidden answers never reach the client - That the organiser is notified on completion rather than on every response - That poll links expire by default because they list named people and their availability
# Agent instructions · Doodle indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Options stored as instants, displayed in each viewer's zone, No account required, with a durable per-browser identity, Per-poll setting for whether responses are visible to participants, Notification on completion, not on every response. 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 - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it.
# Build plan · Doodle A scheduling poll whose two real problems are that a time slot means a different wall clock time for every participant and that a poll showing everyone's availability to everyone is a disclosure people do not always expect. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Times that mean the same moment A slot shown to someone in another country is the same moment. ### Steps 1. Store instants, display per viewer The slot is a moment. Local times are a rendering. Files: `src/options.ts` 2. Detect daylight edge cases at creation A local time that does not exist should be caught before anyone answers. Files: `src/timezones.ts`, `src/timezones.test.ts` 3. Always show the zone A bare time in a cross-border poll is an ambiguity, not information. Files: `src/display.ts` ### Done when - [ ] One option shows correctly in three zones - [ ] Daylight edge cases are detected at creation - [ ] Every displayed time states its zone - [ ] Organiser input maps to the intended instants ### Watch out - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. ## Phase 2 · Responding without an account A participant answers in under a minute with no signup. ### Steps 1. No account, durable identity The friction is what costs you the responses. Files: `src/participants.ts` 2. Support a three-way answer If needed is genuinely different information from yes. Files: `src/responses.ts` 3. Handle name collisions Two people called Sam is normal and silently merging them loses an answer. Files: `src/participants.ts` ### Done when - [ ] Responding takes under a minute with no account - [ ] Returning edits the same response - [ ] Two same-named participants stay separate - [ ] If-needed is distinct from yes ### Watch out - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. ## Phase 3 · Who can see whose availability A poll's visibility setting is enforced where the data is fetched. ### Steps 1. Filter visibility server side In the query. Interface hiding is not hiding. Files: `src/visibility.ts`, `tests/visibility.test.ts` 2. Show counts rather than nothing Aggregate information is useful and reveals much less. Files: `src/results.ts` 3. Warn when changing the setting mid-poll Making a hidden poll visible exposes answers people gave in confidence. Files: `src/polls.ts` ### Done when - [ ] Hidden responses are absent from API responses - [ ] The organiser still sees everything - [ ] Participants see aggregate counts - [ ] Changing visibility mid-poll warns first ### Watch out - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. ## Phase 4 · Choosing a time and telling everyone Finalising sends one clear notification, not a stream. ### Steps 1. Notify on completion or deadline Twelve emails for a twelve person poll is a worse problem. Files: `src/notify.ts` 2. Rank with if-needed as a tiebreak And show that is what happened, rather than a bare ordering. Files: `src/ranking.ts` 3. Send the finalised time in each person's zone It is the one message that must not be misread. Files: `src/notify.ts` ### Done when - [ ] The organiser is notified once, not per response - [ ] Finalising sends one notification per participant - [ ] Ranking shows how if-needed was used - [ ] A late participant is told the decision ### Watch out - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. ## Not in this build - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. ## After v1, if you want it - Suggesting slots from the responses already given, which shortens long polls - A calendar file for the finalised time, which is what people actually want at the end - Reminders to non-responders, worded once and sent once
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Named participants and their availability. DATABASE_URL=postgres://localhost:5432/polls # Required. Whether participants see each other's answers. Availability reveals more than people expect. DEFAULT_VISIBILITY=hidden # Required. A poll link lists named people and gets forwarded indefinitely. INVITE_EXPIRY_DAYS=30 # Required. A scheduling poll is not an archive. POLL_RETENTION_DAYS=180 # Required. Present as a constant. Twelve emails for a twelve person poll is a worse problem. NOTIFY_ON_EVERY_RESPONSE=never # Required · secret. Completion and finalisation notifications. SMTP_URL=smtp://user:pass@host:587
# Doodle · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Doodle, run group availability polls and convert the selected option into a calendar event. The hard boundary is participant familiarity, calendar connections, reminders, branding, and hosted delivery, plus calendar integrations and reliability. ## Product outcome A poll where a slot means the same moment to everyone who sees it, where nobody's availability leaks to people it was hidden from, and where the organiser gets one email rather than twelve. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - PostgreSQL - public deploy target - Resend API key - domain with HTTPS ## Explicit non-goals for v1 - Calendar integration showing real availability. - Recurring meeting scheduling. - Paid booking pages. - Mobile applications. - participant familiarity, calendar connections, reminders, branding, and hosted delivery - multiple calendar providers - team routing and round-robin - payments - timezone edge-case maturity ## Success criteria - One option is confirmed correct across three zones - Daylight edge cases are confirmed detected at creation - Hidden responses are confirmed absent from API responses - Per-response notification is confirmed unavailable - Poll links are confirmed to expire by default
# Build brief · Doodle 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 group availability poll that gets the times right for everyone to replace Doodle. 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: TypeScript with Fastify and PostgreSQL · polls, options and responses are small and relational - Time: Options stored as instants, displayed in each viewer's zone · the same slot must mean the same moment for everyone - Identity: No account required, with a durable per-browser identity · requiring signup collapses response rates - Visibility: Per-poll setting for whether responses are visible to participants · who can see whose availability is a real decision - Delivery: Notification on completion, not on every response · a poll with twelve participants should not send twelve emails ### Data model (create this before Phase 1) - polls(id, title, organiser_id, visibility, deadline_at, timezone_hint, finalised_option_id, created_at) - options(id, poll_id, starts_at_utc, ends_at_utc, ordinal) - participants(id, poll_id, name, email, identity_hash, responded_at) - responses(participant_id, option_id, answer) · answer is yes, no or if_needed - invites(poll_id, token_hash, sent_to, expires_at) · a poll link is a bearer credential to a list of names - finalisations(poll_id, option_id, decided_at, notified_at) - Visibility is a poll-level setting evaluated server side, because hiding other people's answers in the interface while returning them in the response is not hiding them. ### Phase 1 · Times that mean the same moment Build: Store options as instants and render them in each viewer's own zone, showing the zone explicitly. Handle a poll spanning a daylight transition, where a slot may not exist or may occur twice locally. Trap: A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. Done when: One option shows as the correct local time for viewers in three zones. A poll spanning a daylight transition is handled with ambiguous or nonexistent local times detected at creation. Each displayed time states its zone. An organiser creating options in their own zone sees them stored as the intended instants. Do not build yet: No responses, visibility or notification. ### Phase 2 · Responding without an account Build: Respond via a link with no account, using a durable identity so someone can return and change their answer. Support a three-way answer rather than only yes and no. Handle two people with the same name. Trap: Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. Done when: A response takes under a minute with no account, timed by hand. Returning later allows editing the same response rather than creating a second one. Two participants with the same name are kept separate, tested directly. The if-needed answer is distinct from yes in the results. Do not build yet: No visibility control or finalisation yet. ### Phase 3 · Who can see whose availability Build: A per-poll setting for whether participants see each other's responses, enforced server side. Where hidden, the organiser still sees everything and participants see only counts. Verify hidden responses never appear in any API response. Trap: Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. Done when: With responses hidden, another participant's answers are absent from every API response, asserted in the suite. The organiser still sees everything. Participants see aggregate counts rather than nothing at all. The setting cannot be changed after responses exist without warning the organiser what it exposes. Do not build yet: No finalisation or notification yet. ### Phase 4 · Choosing a time and telling everyone Build: Show the best options by yes count with if-needed as a tiebreak. Finalise an option, notifying participants once. Notify the organiser when everyone has responded or the deadline passes, not on every response. Trap: Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. Done when: The organiser is notified once when everyone has responded or the deadline passes, not per response. Finalising sends one notification per participant with the time in their own zone. Options rank by yes count with if-needed as a tiebreak, shown explicitly. A participant added after finalisation is told the decision rather than asked to vote. Do not build yet: No expiry or export yet. ### Phase 5 · Links expire and data goes away Build: Expire poll links after a stated period. Delete polls and their responses on a retention policy. Allow a participant to remove their own response and be forgotten from a poll. Trap: A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find. Done when: A poll link stops working after its expiry, which is set by default rather than chosen. Polls and responses delete on the retention policy including expired links. A participant can remove their own response and their name. Deleting a poll removes responses, participants and invites together. Do not build yet: Nothing after this. ### Out of scope (and why) - Calendar integration showing real availability, which is a far larger permission and a different product. - Recurring meeting scheduling, which is a different problem with different failure modes. - Paid booking pages, which is the adjacent product rather than this one. - Mobile applications, which is where a lot of responses actually come from. ### README must contain - That options are stored as instants and rendered per viewer with the zone always shown - That daylight transition edge cases are detected when the poll is created - That response visibility is enforced server side and hidden answers never reach the client - That the organiser is notified on completion rather than on every response - That poll links expire by default because they list named people and their availability
# Architecture · Doodle ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | polls, options and responses are small and relational | | Time | Options stored as instants, displayed in each viewer's zone | the same slot must mean the same moment for everyone | | Identity | No account required, with a durable per-browser identity | requiring signup collapses response rates | | Visibility | Per-poll setting for whether responses are visible to participants | who can see whose availability is a real decision | | Delivery | Notification on completion, not on every response | a poll with twelve participants should not send twelve emails | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Time | Instants, per-viewer rendering and edge case detection. | Any library, provided ambiguous local times are detected rather than resolved silently. | | Identity | Accountless durable participants. | Any scheme, provided name collisions do not merge people. | | Visibility | Server-side response filtering. | Not swappable for interface hiding, which is not hiding. | | Notification | Completion and finalisation messages. | Any transport, provided per-response notification is not offered. | | Expiry | Link expiry and retention. | Any policy, provided expiry is the default rather than an option. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Named participants and their availability. - `DEFAULT_VISIBILITY` · required · Whether participants see each other's answers. Availability reveals more than people expect. - `INVITE_EXPIRY_DAYS` · required · A poll link lists named people and gets forwarded indefinitely. - `POLL_RETENTION_DAYS` · required · A scheduling poll is not an archive. - `NOTIFY_ON_EVERY_RESPONSE` · required · Present as a constant. Twelve emails for a twelve person poll is a worse problem. - `SMTP_URL` · required, secret · Completion and finalisation notifications. ## 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 · Doodle product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Options stored as instants, displayed in each viewer's zone, No account required, with a durable per-browser identity, Per-poll setting for whether responses are visible to participants, Notification on completion, not on every response. - 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 - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. - A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find.
# Delivery milestones · Doodle Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Times that mean the same moment A slot shown to someone in another country is the same moment. ### Steps 1. Store instants, display per viewer The slot is a moment. Local times are a rendering. Files: `src/options.ts` 2. Detect daylight edge cases at creation A local time that does not exist should be caught before anyone answers. Files: `src/timezones.ts`, `src/timezones.test.ts` 3. Always show the zone A bare time in a cross-border poll is an ambiguity, not information. Files: `src/display.ts` ### Done when - [ ] One option shows correctly in three zones - [ ] Daylight edge cases are detected at creation - [ ] Every displayed time states its zone - [ ] Organiser input maps to the intended instants ### Watch out - A poll spanning a daylight transition can contain a local time that does not exist that day or that happens twice, and naive handling silently shifts the slot by an hour for some participants. Store instants, render per viewer, and detect the ambiguous cases when the poll is created rather than when someone answers wrongly. ## M2 · Responding without an account A participant answers in under a minute with no signup. ### Steps 1. No account, durable identity The friction is what costs you the responses. Files: `src/participants.ts` 2. Support a three-way answer If needed is genuinely different information from yes. Files: `src/responses.ts` 3. Handle name collisions Two people called Sam is normal and silently merging them loses an answer. Files: `src/participants.ts` ### Done when - [ ] Responding takes under a minute with no account - [ ] Returning edits the same response - [ ] Two same-named participants stay separate - [ ] If-needed is distinct from yes ### Watch out - Identifying participants by the name they typed means two people called Sam overwrite each other's responses, and the organiser sees a poll that quietly lost an answer. Use a durable identity alongside the display name, and prompt when a name collides rather than merging. ## M3 · Who can see whose availability A poll's visibility setting is enforced where the data is fetched. ### Steps 1. Filter visibility server side In the query. Interface hiding is not hiding. Files: `src/visibility.ts`, `tests/visibility.test.ts` 2. Show counts rather than nothing Aggregate information is useful and reveals much less. Files: `src/results.ts` 3. Warn when changing the setting mid-poll Making a hidden poll visible exposes answers people gave in confidence. Files: `src/polls.ts` ### Done when - [ ] Hidden responses are absent from API responses - [ ] The organiser still sees everything - [ ] Participants see aggregate counts - [ ] Changing visibility mid-poll warns first ### Watch out - Hiding other people's answers in the interface while the API still returns them means anyone who looks at the network traffic sees the availability of every colleague on the poll, which is exactly the information the setting was meant to protect. Filter server side and assert absence in the suite. ## M4 · Choosing a time and telling everyone Finalising sends one clear notification, not a stream. ### Steps 1. Notify on completion or deadline Twelve emails for a twelve person poll is a worse problem. Files: `src/notify.ts` 2. Rank with if-needed as a tiebreak And show that is what happened, rather than a bare ordering. Files: `src/ranking.ts` 3. Send the finalised time in each person's zone It is the one message that must not be misread. Files: `src/notify.ts` ### Done when - [ ] The organiser is notified once, not per response - [ ] Finalising sends one notification per participant - [ ] Ranking shows how if-needed was used - [ ] A late participant is told the decision ### Watch out - Emailing the organiser on every response means a twelve person poll sends twelve emails, which is worse than the scheduling problem it was meant to solve. Notify on completion or deadline, and let the organiser opt into more rather than out of it. ## M5 · Links expire and data goes away (production only) A poll link stops working and the names do not sit around forever. ### Steps 1. Expire links by default They contain a list of named people and get forwarded indefinitely. Files: `src/invites.ts` 2. Delete on a retention policy A scheduling poll is not an archive. Files: `src/retention.ts` 3. Let a participant remove themselves They gave a name and their availability. They can take it back. Files: `src/participants.ts` ### Done when - [ ] Poll links expire by default - [ ] Retention deletes polls and responses - [ ] A participant can remove their own response - [ ] Deleting removes responses, participants and invites ### Watch out - A poll link is a bearer credential exposing a list of named people and their availability, and those links end up in group chats and forwarded email indefinitely. Expire them, and make expiry the default rather than a setting the organiser has to find.
# Operations · Doodle ## Backup Polls are short-lived and mostly not worth long retention, which is the point. Back up what you must, but the more important operational property is that retention actually runs. ## Restore Restore and confirm retention resumes and expired invites remain expired, since restoring a live invite reopens a link someone may have forwarded years ago. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Confirm the retention job is running, because a silently failed retention leaves a growing store of people's names and availability against a policy that says otherwise. ## Incident checklist A hidden response appearing in an API result is the failure that matters, since it exposes colleagues' availability to each other. The suite assertion covers it and error responses are where it usually reappears. 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 - [ ] One option is confirmed correct across three zones - [ ] Daylight edge cases are confirmed detected at creation - [ ] Hidden responses are confirmed absent from API responses - [ ] Per-response notification is confirmed unavailable - [ ] Poll links are confirmed to expire by default ## Launch constraint Do not market omitted Doodle 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 · secret. Named participants and their availability. DATABASE_URL=postgres://localhost:5432/polls # Required. Whether participants see each other's answers. Availability reveals more than people expect. DEFAULT_VISIBILITY=hidden # Required. A poll link lists named people and gets forwarded indefinitely. INVITE_EXPIRY_DAYS=30 # Required. A scheduling poll is not an archive. POLL_RETENTION_DAYS=180 # Required. Present as a constant. Twelve emails for a twelve person poll is a worse problem. NOTIFY_ON_EVERY_RESPONSE=never # Required · secret. Completion and finalisation notifications. SMTP_URL=smtp://user:pass@host:587
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Doodle because people pay because one double booking or missed invite can cost more than years of subscription fees. The recurring cost buys OAuth refresh, webhooks, timezone databases, race conditions, reminders, deliverability, privacy, and uptime, not just the visible interface.
xparticipant familiarity, calendar connections, reminders, branding, and hosted delivery
xmultiple calendar providers
xteam routing and round-robin
xpayments
xtimezone edge-case maturity
Don't feel like building it? These folks already made it free.
all 6 free alternatives to Doodle →· no votes, no pay-to-list · just what's real
Doodle pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 1 Booking Page, 1 one-to-one page, max 10 time slots/group poll, max 20 sessions/sign-up sheet and up to 1,000 participants/group poll |
| pro | $15/user | $11/user | 1 user; unlimited group polls, one-to-ones, Booking Pages and sign-up sheets; up to 1,000 participants/group poll |
| team | $21/user | $16/user | 2–100 seats, unlimited scheduling per user and up to 5 hosts per Booking Page |
| enterprise | custom | — | More than 100 users, SSO/SAML, dedicated account management, advanced reporting and SLA |
free tier1 Booking Page, 1 one-to-one page, 10 time slots/group poll, 20 sessions/sign-up sheet and up to 1,000 participants/group poll
billingmonthly + annual; subscriptions auto-renew
hidden costsTeam has a 2-seat minimum. Annual subscribers can request a full refund within 14 days; monthly refunds are limited to rights required by law. Enterprise features and pricing require a quote.
verified 2026-08-14 · source ↗
Vibecode Doodle
Kinda. The core of Doodle is buildable in a weekend with the prompt on this page, but there are real gaps: participant familiarity, calendar connections, reminders, branding, and hosted delivery, multiple calendar providers. Read the honest list above before committing.
How much does Doodle cost?
Doodle costs about $15/month (Pro, checked 2026-08-14), which is $180 per year.
What do I lose by replacing Doodle?
Honestly: participant familiarity, calendar connections, reminders, branding, and hosted delivery; multiple calendar providers; team routing and round-robin; payments; timezone edge-case maturity. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Doodle?
Yes: Rallly (Doodle’s useful half, date polls, votes, comments and finalization, without the account ambush.) Nextcloud Polls (Excellent Doodle replacement hiding inside a whole Nextcloud server; the poll is easy, the platform is not.) Calendly Free (One active event type and unlimited bookings; meeting polls survive, workflows and teams do not.) All 6 curated free alternatives are at vibecodeit.com/doodle/alternatives. The prompt is for when you want it exactly your way.