How to build a Doodle poll

How to build a Doodle poll with five time options, a vote grid, and a winner. Follow the prompts, check each step, then skip the calendar sync.

  • guide
  • vibe-coding
  • doodle

You can learn how to build a Doodle poll as five time options, one vote link, and a results grid. Doodle is that product. The catalog line is “Five time options, one link, zero reply-all threads.”

You pay $7–$9 a person a month. That is $84–$108 a year, and the card says the price scales with the team. The verdict is “Democracy in miniature”. The first estimate is 3 hours: poll link, votes, results grid.

There is no standalone teardown yet. The Calendly post already splits a vote grid from an availability engine. This guide is the walk. Prompts in order, then what to look at.

The audience is anyone herding a group toward a single meeting time. If you already run booking pages or you need calendar sync across a company, keep paying. The same v1 also replaces Rallly and When2meet.

Calendly is the other meeting product, $12–$20 a person a month, about 6 hours. That is weekly hours and a lock. If you already know your hours, use the Calendly guide. A one-question form is the Typeform guide.

How to build a Doodle poll without availability math

The Doodle page splits the work into three rows.

The first row is the one you want. A title. Five proposed times. A public vote link. A grid that highlights the least-hated slot. That is the 3-hour v1.

The second row is 1 day. Timezone labels, if-need-be votes, a finalize button, an .ics export. Do that after a real group has voted once.

The third row is 2 weeks and up. Calendar sync, booking pages, availability detection. Skip it.

If you tell an agent to “clone Doodle”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 3 hours.

const poll = {
  id: 'poll-standup',
  title: 'Thursday standup time',
  description: 'Pick a slot that works for the Rome and New York writers.',
  timezone: 'Europe/Rome',
  voteToken: '7c3a9e21-6b54-4d18-a0f2-8e91c4d5b6a7',
  manageToken: '2f8d1c0a-9e47-4b63-81d5-3a6c7e0f12b4',
  status: 'open',
}

const timeOption = {
  id: 'opt-thu-10',
  pollId: 'poll-standup',
  startAt: '2026-10-29T09:00:00.000Z',
  endAt: '2026-10-29T09:30:00.000Z',
}

const participant = {
  id: 'part-ada',
  pollId: 'poll-standup',
  name: 'Ada',
  voteToken: 'c4e8b012-71aa-4f33-9d6c-2b5a8e0f1749',
}

const vote = {
  participantId: 'part-ada',
  timeOptionId: 'opt-thu-10',
  choice: 'yes',
}

A poll, five time options, a participant, and one vote per option. The card also lists those four entities. if-need-be waits for the 1-day row. status is open tonight. Finalize is a later write.

polls.vote_token and polls.manage_token are unique. time_options.start_at is unique per poll. votes is unique on (participant_id, time_option_id).

CREATE THE POLL Thursday standup time Five UTC slots, Europe/Rome SHARE TWO LINKS /v/ plus a vote token /m/ is organizer only COLLECT THE VOTES Name, yes or no, no account One participant token per browser HIGHLIGHT THE WINNER Most yeses, then fewest nos
Propose times. Collect yes and no. The grid names the slot.

Pick the stack you already write

Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The capability line for Doodle is Astro, HTMX, and D1.

This site is an Astro app on Cloudflare, so that is the path I default to. Use TanStack Start if React is your weekday language. HTMX is on the card because the grid refreshes after a vote. A TanStack Start server function can do the same refresh. Do not invent a second product to send calendar invites.

Open Build your own Doodle. The guardrail is the useful sentence: use unguessable links for voting and management, and let only the organizer’s management link edit or finalize a poll. The non-goal sits next to it. No one-on-one booking pages, calendar sync, availability detection, or recurring meeting scheduling.

The stack post is which tab you copy. The prompt post is how you pin the first estimate. The scope post is why booking pages wait. Pick one stack and stay on it.

Prompt 1. Scaffold Quorum and stop

Copy the Astro prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 3-hour row only. Poll link,
votes, results grid.

Project name: Quorum.
Public URL is https://poll.flaviocopes.com.
Organizer is Flavio. Nobody creates
an account. The manage token is the
login.

First poll title: Thursday standup time.
Timezone Europe/Rome.

Do not add timezone pickers, if-need-be
votes, finalize, or .ics export yet.
Do not add calendar sync, booking pages,
availability detection, or recurring
meetings.

When the plan is done, stop and wait.
Do not write implementation code
until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /new for the create form
  • /v/:voteToken for the public vote
  • /m/:manageToken for the organizer
  • / can point at /new

You want tables like polls, time_options, participants, votes. calendar_connections should not appear. bookings should not appear. A users table for voters should not appear either.

If the plan includes Google OAuth, a booking page, or /poll/1 as a public URL, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Open /new. You should see a title field and a place to add times. /v/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee is a 404, not a stack dump.

Open the migrations. polls.vote_token and polls.manage_token are unique text. time_options.start_at is a UTC timestamp. There is no busy_times table. If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say the manage token is the only edit key.

The organizer types a title and five times. The app stores UTC instants and hands back two URLs.

Build poll creation for Quorum on
https://poll.flaviocopes.com.

POST /new takes title, optional
description, an IANA timezone, and
2 to 8 time options. Each option is
a start and an end. Store both as
UTC instants. Keep the timezone name
on the poll. Show the times in that
zone with the zone label next to them.

Refuse an empty title. Refuse a
zero-length slot. Refuse two options
that normalize to the same start.
Refuse a manage or vote token that
is not a UUID.

Mint voteToken and manageToken with
crypto.randomUUID(). Never put the
integer id in a public URL.

After save, /new shows both links:

https://poll.flaviocopes.com/v/7c3a9e21-6b54-4d18-a0f2-8e91c4d5b6a7
https://poll.flaviocopes.com/m/2f8d1c0a-9e47-4b63-81d5-3a6c7e0f12b4

The vote link cannot edit the poll.
The manage link can.

Seed this poll:
- title Thursday standup time
- timezone Europe/Rome
- Thu 2026-10-29 09:00-09:30 UTC
- Thu 2026-10-29 14:00-14:30 UTC
- Thu 2026-10-29 16:00-16:30 UTC
- Fri 2026-10-30 09:00-09:30 UTC
- Fri 2026-10-30 14:00-14:30 UTC

Done when I save those five slots,
refresh /m/{manageToken}, and see
the same five UTC instants. /v/1
is still a 404.

29 October 2026 09:00 UTC is 10:00 in Rome and 05:00 in New York. Write the UTC value. Format for the screen. Do not store 10:00 as a string and hope.

function optionKey(startAt) {
  return new Date(startAt).toISOString()
}

optionKey('2026-10-29T09:00:00.000Z')
//'2026-10-29T09:00:00.000Z'

Two local fields that collapse to one instant are one option. Catch that after you convert to UTC, not before.

What to check after the poll exists

Create the standup poll. Refresh /m/2f8d1c0a-9e47-4b63-81d5-3a6c7e0f12b4. Title, five rows, Europe/Rome on the page.

Open /v/7c3a9e21-6b54-4d18-a0f2-8e91c4d5b6a7. You see the five times. You do not see an edit button.

Submit /new with two slots at 2026-10-29T09:00:00.000Z. The form comes back. No second row.

Guess /v/1. 404. Guess /m/1. 404. The integer id never appears in the address bar.

If the agent stored Thu 10:00 with no zone, send prompt 2 back.

Prompt 3. Collect votes without accounts

The third mini-prompt on the card is “One participant, one vote set, no accounts”. Steal that token rule tonight. Skip if-need-be. Tonight a choice is yes or no.

Build voting for Quorum on
https://poll.flaviocopes.com/v/{voteToken}.

The voter types a name, 2 to 40
characters, and yes or no on every
time option. Email is not required.

On first submit, create a participant
keyed by an unguessable token. Store
that token in a cookie scoped to the
vote path. Attach every vote to it.

A later submit with the same cookie
updates the same row. Do not insert
a second Ada.

Write in one transaction: upsert the
participant, then upsert one vote
per option. Unique on
(participant_id, time_option_id).

Two different people voting at the
same moment both land. Two submits
from one cookie end as one row and
the later choices.

A second device with the same name
is a new participant. Flag the
duplicate name on the manage page.
Do not merge by name.

Closed or missing polls return a
plain closed message, not a 500.
The vote link cannot change title
or slots.

Seed votes on the standup poll:
- Ada: yes, yes, no, yes, no
- Sam Rivera: yes, no, no, yes, yes

Done when Ada changes the third
slot to yes, I still have two
participants, and a third browser
named Ada shows as a flagged
duplicate on /m/{manageToken}.

Name plus a cookie is the identity. The waitlist guide uses the same idea for a status token: the secret lives in the URL or the cookie, never in /user/ada.

async function saveVotes(db, poll, person, choices) {
  return db.transaction(async (tx) => {
    const participant = await tx.upsertParticipant({
      pollId: poll.id,
      name: person.name,
      voteToken: person.voteToken,
    })

    for (const choice of choices) {
      if (choice.choice !== 'yes' && choice.choice !== 'no') {
        throw new Error('choice must be yes or no')
      }
      await tx.upsertVote({
        participantId: participant.id,
        timeOptionId: choice.timeOptionId,
        choice: choice.choice,
      })
    }

    return participant
  })
}

The later submit overwrites the vote set. The unique pair stops a double row for the same person and slot.

What to check after the first two voters

Open the vote link as Ada. Submit the seed choices. Refresh. The form still shows her name and her yes/no set.

Change the third slot to yes. Submit again. /m/2f8d1c0a-9e47-4b63-81d5-3a6c7e0f12b4 still lists two people. Ada’s third cell flipped.

Open a private window. Vote as Ada again. The manage page now has three rows. Two of them say Ada. One is flagged as a name collision.

Submit from the vote URL with an empty name. The form stays filled. No participant row.

If the agent created users.email so Ada can “log in later”, delete that table. Accounts are the 1-day row in disguise.

Prompt 4. Show the results grid and name a winner

The other mini-prompt on the card is “Scoring the least-hated meeting time”. Tonight the rule is shorter because if-need-be is off the table.

Build the results grid for Quorum.

Participants are rows. Time options
are columns. Each cell is yes or no.
Per-slot totals sit at the bottom.

Score in one module:
- sort by yes count
- then by fewest nos
- then by earliest startAt

Write that rule on the manage page
so the highlight is not a mystery.
When two slots tie on yes, no, and
start, highlight both.

Recompute on every vote. The vote
page can show the same grid after
submit. HTMX swap is fine. A
TanStack Start server function is
fine. Do not open a websocket.

Empty state on /m/{manageToken}
says "Waiting for the first vote"
and repeats the vote URL.

Do not add if-need-be.
Do not add a finalize button.
Do not attach an .ics file.

Done when Ada and Sam's seed votes
highlight Thu 2026-10-29 09:00 UTC
(two yes, zero no, earlier than
Friday's same score), and Ada
setting that first slot to no
moves the highlight to Fri
2026-10-30 09:00 UTC.

Put the sort in one file. The vote page and the manage page both import it. If those two pages invent their own totals, the highlight will lie.

function scoreSlot(votes) {
  const yes = votes.filter((vote) => vote.choice === 'yes').length
  const no = votes.filter((vote) => vote.choice === 'no').length
  return { yes, no }
}

function rankSlots(options) {
  return [...options].sort((a, b) => {
    if (b.yes !== a.yes) return b.yes - a.yes
    if (a.no !== b.no) return a.no - b.no
    return Date.parse(a.startAt) - Date.parse(b.startAt)
  })
}

const ranked = rankSlots([
  { startAt: '2026-10-29T09:00:00.000Z', yes: 2, no: 0 },
  { startAt: '2026-10-30T09:00:00.000Z', yes: 2, no: 0 },
  { startAt: '2026-10-29T14:00:00.000Z', yes: 1, no: 1 },
])

ranked[0].startAt
//'2026-10-29T09:00:00.000Z'

Thursday 09:00 and Friday 09:00 both have two yeses and zero nos. The earlier instant wins. A true tie, same start included, keeps both highlighted.

What to check after the grid lights up

Load the manage link. Ada and Sam are rows. Five columns. Thursday 09:00 UTC and Friday 09:00 UTC both have two yeses and zero nos. The earlier instant is the highlight.

Flip Ada’s first cell to no. Thursday 09:00 UTC falls to one yes and one no. The highlight moves to Friday 09:00 UTC.

Add Jordan Lee from a third browser: yes on Thursday 14:00 UTC, no on the rest. The highlight must still follow the documented rule, not “the last column”.

Refresh the vote page. The grid matches the manage page. Same yes counts. Same highlight.

If the agent built a week calendar with grey unavailable hours, stop. That is Calendly. Paste the non-goal.

Failures the agent ships anyway

/poll/1 as the share URL

Sequential ids are guessable. Anyone who creates a poll can walk /v/1, /v/2, /v/3 and vote on yours. The guardrail wants a UUID in both links. A leaked manage token can edit or close every vote.

Google Calendar in step one

The official journeys mention an .ics file. That is the 1-day row. Calendar sync is the 2-week row. If CALENDAR_CLIENT_ID appears in .env.example during the scaffold, delete it and paste the first prompt again.

A booking page in disguise

Agents have just read the Calendly guide. They start generating slots from weekly hours. A Doodle poll stores the five instants Flavio typed, because it never looks up free time.

Merging two people named Ada

The manage page flags the duplicate. It does not collapse the rows. Ada on her phone and Ada on her laptop are two voters until you add accounts, which you will not do tonight.

Floating local times

2026-10-29 10:00 with no zone is 10:00 in the Worker, not 10:00 in Rome. Store UTC. Keep Europe/Rome on the poll. Format for the screen. The 1-day row adds a viewer timezone picker. The storage rule does not change.

If-need-be and a calendar file on Saturday

If-need-be, finalize, and a hand-written .ics are the 1-day row. The first estimate is the grid. Ship the grid. The card already has mini-prompts for the rest.

Deploy the poll

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Quorum wants Astro, HTMX, and D1. Put it on a subdomain you already own, poll.flaviocopes.com. The Astro prompt points at Cloudflare D1 for the tables and Workers for the form posts.

The hosting post is that path: the adapter, then the bindings, then the custom domain.

Ship with the seed standup poll. Open the vote link on a phone. Vote as yourself. Confirm the manage grid shows that row after a refresh. Send the vote URL to one real group this week.

Take a D1 backup before you delete the paid account. The vote rows are the history you will miss, so treat that backup like a password.

Use it as the real poll for a week, not as a demo. If you miss if-need-be or an .ics, those are the 1-day row and the other mini-prompts on the card. Add them after a group has picked a time.

Then cancel Doodle.

What to skip on purpose

Leave out calendar sync, booking pages, and availability detection.

3-HOUR V1 Poll link with five UTC slots Yes and no, no accounts A results grid with a winner SKIP THIS HALF Calendar sync across providers One-on-one booking pages Availability detection
Ship the poll link and the grid. Leave booking pages on the skip row.

Timezone labels come after the first real group votes. If-need-be can wait until someone asks for a soft yes. I would not build a booking page.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own Doodle. Copy the Astro prompt, or TanStack Start if that is what you write.

Tell the agent the first estimate only: poll link, votes, results grid. Three hours.

Send the vote URL to one group tonight. If the grid names a time, you can cancel.