A free group availability poll you can host yourself. Think Doodle, minus the subscription. Built for nonprofits and small teams who already pay for cheap shared hosting and don't have a developer on call.
The flow: you propose a few possible meeting times and share a link. Anyone with the link marks each time Yes, If-need-be, or No. No accounts. Answers land in a grid, you pick the winning time, and everyone gets one-click Add to Calendar links. Times show in each person's own timezone automatically.
TimePool finds one time that works for a whole group, Doodle-style. A personal 1:1 booking page (the Calendly job) is a different tool.
- Key Features
- Tech Stack
- Prerequisites
- Quick Start (Local)
- Installation (Production / Shared Hosting)
- Architecture
- Configuration
- Routes Reference
- Scripts & Commands
- Testing
- Deployment
- Backup & Restore
- Updating
- Troubleshooting
- Out of Scope (v1)
- License
- Group polls. Propose timed slots or all-day options. Participants vote Yes / If-need-be / No.
- No accounts for participants. They respond from a public link with just a name, and can come back later to change their answer.
- Timezones handled. Slots are stored as absolute UTC instants and shown in each viewer's local time. There's a manual override if the auto-detect guesses wrong.
- Best-slot ranking. The leading time gets a star. If-need-be always ranks below Yes.
- Blind polls (optional, per poll). Participants can't see anyone else's answers until they submit their own.
- Deadlines. Set a response deadline and the poll closes itself.
- Finalize + calendar. Pick the winning time. Everyone gets Add to Google / Outlook links and a downloadable .ics file.
- Email is optional. With SMTP configured, TimePool sends invites, new-response alerts, deadline reminders, and confirmations. Without it, you share links by hand and everything still works.
- One-file installer. A browser wizard sets everything up. No terminal, no separate database server.
- Your branding. Organization name, logo, and accent color.
- The UI holds up. Responsive, light and dark mode, keyboard-navigable, respects reduced motion.
- Language: PHP 7.4+ (8.1+ recommended)
- Database: SQLite via PDO. No MySQL, no database server to set up.
- Frontend: Server-rendered PHP templates, vanilla JavaScript, hand-written CSS
- Build step: None. No Composer, npm, bundler, or compile step. Upload and run.
- Email (optional): A built-in minimal SMTP client (PLAIN/LOGIN auth, STARTTLS/SSL). No third-party mail library.
- Server: Any Apache/PHP host: shared hosting, cPanel, a VPS, even a Raspberry Pi. Works with or without
mod_rewrite.
To run it, the host needs:
- PHP 7.4 or newer with the
pdo_sqliteextension (nearly every host has it) - A writable install folder, so the SQLite database can be created
- Optional:
mod_rewritefor clean URLs. Without it, the app falls back to query-string URLs on its own. - Optional: SMTP credentials, if you want email notifications
To develop locally you also want the PHP CLI (php -v) and, for the HTTP end-to-end test, curl.
# 1. From the project root, start PHP's built-in server
php -S localhost:8099 -t .
# 2. Open the installer in your browser
open http://localhost:8099/install.php # macOS (or just visit the URL)Fill in the wizard: organization name, your admin login, timezone. The SQLite database is created for you, and you land on the dashboard.
To reset and start over, stop the server and delete the generated data folder:
rm -rf dataPHP's built-in dev server has no
mod_rewrite, so the app installs with clean URLs off and usesindex.php?r=/…links. That's normal and fully functional. On a real Apache host, the included.htaccessturns clean URLs on automatically.
The whole path, start to finish, no terminal needed. (A shorter version lives in docs/INSTALL.md.)
- Create a subdomain in your hosting control panel (cPanel → Subdomains), such as
meet.yourorg.org. Note its document root folder. A subfolder likeyourorg.org/meetalso works, with no config changes. - Upload the files into that document root using File Manager or FTP. Upload the whole project's contents, so
index.phpandinstall.phpsit at the subdomain root. - Open
https://meet.yourorg.org/install.phpin a browser. - Follow the wizard.
- It checks the server first: PHP version, SQLite, writable folder.
- You set the organization name, logo, accent color, default timezone, and the admin account.
- Email (SMTP) is optional. Skip it now and add it later under Settings.
- It creates the SQLite database, writes the config, and locks itself so it can't run again.
- Delete
install.phpafterward. Re-running it is already blocked, but tidy is tidy.
That's it. Create a poll and share its link.
A small, deliberately boring front-controller app. No framework. The goal is code that runs on any PHP host and needs zero build tooling.
.
├── index.php # Front controller: routes every request
├── install.php # Single-file setup wizard (+ optional fetch/unpack)
├── .htaccess # Routes to index.php, hardens, denies DB files
├── assets/
│ ├── app.css # All styling: theme tokens, light/dark, responsive, grid
│ └── app.js # Timezone conversion, theme toggle, slot builder, copy, confirm
├── app/ # Application code (denied from web by app/.htaccess)
│ ├── bootstrap.php # Loads config, error handling, security headers, session, DB
│ ├── helpers.php # Escaping, base-path/URL builders, CSRF, flash, polyfills
│ ├── db.php # PDO/SQLite connection + schema migration + settings
│ ├── auth.php # Users, login, sessions
│ ├── poll.php # Poll/slot/response model, tally/ranking, timezone, rate limit
│ ├── ics.php # .ics generation + Google/Outlook add-to-calendar links
│ ├── mailer.php # Minimal SMTP client (optional email)
│ ├── notify.php # Invites, alerts, reminders, confirmations (+ absolute URLs)
│ ├── view.php # Template renderer, brand mark, availability grid
│ ├── controllers/
│ │ ├── auth.php # login/logout/forgot/reset, healthz
│ │ ├── polls.php # dashboard, create/edit/manage/finalize/delete/invite
│ │ ├── public.php # public respond page, submit, .ics, logo
│ │ └── settings.php # branding, SMTP, organizer management
│ └── views/ # PHP templates (layout, public, login, poll_form, grid, …)
├── data/ # CREATED BY INSTALLER (gitignore this)
│ ├── config.php # Generated config (db path, secret, pretty-URL flag)
│ ├── app.sqlite # The database
│ ├── uploads/ # Logo
│ └── .htaccess # Denies all web access
├── docs/INSTALL.md # Non-technical install guide
├── specs/timepool.md # The product spec (source of truth)
└── tests/run.php # Self-check suite
Web-root note: the project root is the web docroot. Application code lives in
app/and data indata/. Both are protected by their own.htaccess, so sensitive files are never served directly.
- Apache rewrites all non-file requests to
index.php(via.htaccess). On hosts withoutmod_rewrite, links useindex.php?r=/pathinstead. Same handler either way. app/bootstrap.phploadsdata/config.php. If the app isn't installed yet, it redirects toinstall.php.- Bootstrap sets the production error handler (visitors get a friendly 500 page, never a stack trace), sends security headers, starts the session, and opens the database. The schema migrates itself if needed.
index.phpmatches the request method and path against a flat route table and calls a controller function.- The controller does its work, then either
redirect()s or renders a template inside a layout withview().
The app is base-path aware and handles three situations with no configuration:
| Situation | Example link produced |
|---|---|
Subdomain root + mod_rewrite |
/dashboard |
Subfolder + mod_rewrite |
/meet/dashboard |
No mod_rewrite (any location) |
/meet/index.php?r=%2Fdashboard |
The installer checks for mod_rewrite by requesting /healthz, then stores a pretty flag in config.php. url() in app/helpers.php reads that flag and keeps query strings (like ?token= and ?slot=) intact in every mode.
SQLite schema (auto-created by app/db.php):
users id, email (unique), password_hash, name, role (admin|organizer),
active, reset_token, reset_expires, created_at
polls id, user_id, public_token (unique), title, description, location,
organizer_tz, blind, deadline_utc, closed, final_slot_id,
nudged_at, created_at, updated_at
slots id, poll_id, kind (datetime|date), start_utc, date,
duration_min, sort
participants id, poll_id, name, comment, edit_token, ip, created_at, updated_at
responses id, participant_id, slot_id, choice (yes|maybe|no)
invites id, poll_id, email, created_at
activity id, poll_id, message, created_at # in-app activity feed
settings key, value # org name, logo, accent, SMTP, …
rate ip, ts # per-IP rate-limit ledger
Identity without accounts. Each participant row gets an unguessable edit_token. It lives in a cookie and doubles as a shareable edit link. Two people named John stay separate rows, and anyone can come back later to change their own answer.
- Organizers enter slots in their own timezone. Timed slots are converted to absolute UTC instants (
slots.start_utc), so a 2pm meeting stays 2pm across daylight-saving changes. - The server renders a fallback label in the organizer's timezone. Then
assets/app.jsre-renders every<time data-utc>element in the viewer's detected timezone. A manual picker ("Show times in …") is saved inlocalStorage. - All-day slots are stored as plain dates (
slots.date) and look the same everywhere.
When a poll has blind = 1, a participant who hasn't responded sees no other answers and no tallies. After they submit (a cookie marks them), the full grid appears. The organizer always sees everything from the manage view.
- CSRF tokens on every state-changing form.
- Output escaping (
e()/htmlspecialchars) at every boundary. - Honeypot field, per-IP rate limiting, and a per-poll response cap on the public form.
- Login throttling (per IP).
- Content-Security-Policy plus
X-Frame-Options,X-Content-Type-Options, andReferrer-Policyheaders. They're sent in PHP, so they apply even withoutmod_headers. - Secrets and the database live in
data/, denied from the web by.htaccess..sqlitefiles are also blocked at the root. - Unguessable tokens for poll links and edit links. Passwords are hashed with
password_hash(), and the session ID is regenerated on login.
There is no .env file. Configuration splits between a generated PHP file and a database table.
data/config.php (written by the installer; do not commit it):
| Key | Meaning |
|---|---|
installed |
true once setup completes; gates the installer |
db |
Absolute path to the SQLite file |
secret |
Random app secret |
pretty |
Whether clean URLs (mod_rewrite) are available |
created |
Install timestamp |
settings table (edited in-app under Settings, admin only):
| Key | Meaning |
|---|---|
org_name, logo_file, accent |
Branding |
default_tz |
Default timezone for new polls |
max_participants |
Per-poll response cap (default 500) |
app_url |
Absolute base URL (used in emails) |
smtp_host, smtp_port, smtp_user, smtp_pass, smtp_secure, smtp_from |
Optional email |
Single-file install option:
install.phpdefines aRELEASE_URLconstant (empty by default). If you host a.zipof the app and set this constant, a user can upload onlyinstall.phpand the wizard will download and unpack the rest before setup. Otherwise, upload the full package.
| Method | Path | Purpose |
|---|---|---|
| GET | / |
Redirect to dashboard or login |
| GET | /healthz |
Plain-text probe (used to detect clean-URL support) |
| GET/POST | /login, /logout |
Auth |
| GET/POST | /forgot, /reset |
Password reset |
| GET | /dashboard |
Organizer's polls |
| GET/POST | /polls/new |
Create a poll |
| GET | /polls/{id} |
Manage a poll (grid, share, finalize, activity) |
| GET/POST | /polls/{id}/edit |
Edit a poll |
| POST | /polls/{id}/duplicate …/close …/finalize …/delete …/invite |
Poll actions |
| GET/POST | /settings |
Branding + email (admin) |
| GET/POST | /users, /users/{id}/toggle |
Manage organizers (admin) |
| GET | /logo |
Serve the uploaded logo |
| GET | /p/{token} |
Public respond page |
| POST | /p/{token} |
Submit / edit a response |
| GET | /p/{token}/ics?slot=final |
Download the .ics |
There's no build system, just PHP. Common tasks:
| Command | Description |
|---|---|
php -S localhost:8099 -t . |
Run the app locally |
php tests/run.php |
Run the unit self-checks |
php -l <file>.php |
Lint a PHP file for syntax errors |
rm -rf data |
Reset a local install (deletes DB + config) |
Two layers. Both run with just PHP and curl.
php tests/run.phpThis covers the logic most likely to break quietly: UTC storage across DST, best-slot ranking (If-need-be below Yes), edit-token de-duplication, deadline auto-close, ICS generation, and base-path/query-string URL building. Expected output ends with 23 passed, 0 failed.
Start the server, then drive the full flow with curl: install, log in, create a poll, respond anonymously, check the tally, finalize, download the app's own .ics link, and confirm blind-poll hiding. A reference script pattern:
php -S 127.0.0.1:8099 -t . & # start server
# ... curl the install POST, log in, create a poll, respond, finalize, fetch .ics ...The flow exercises CSRF, sessions, the no-rewrite fallback URLs, security headers, and the login throttle.
Primary target: shared hosting on a subdomain (see Installation). The deploy is literally "upload the files, run the wizard." No pipeline.
Notes for any host:
- Point the subdomain's document root at the uploaded folder. A subfolder works too.
- Make the folder writable so
data/can be created (typically0755). - Keep HTTPS on. The session cookie is marked
Secureautomatically when served over HTTPS. - On a VPS with Nginx instead of Apache, use the full server block in
docs/nginx.example.conf. It routes non-file requests toindex.phpand denies/appand/datawith^~prefix locations — plain regexdenyblocks can be out-ordered by the\.php$handler, which would let/data/config.phpreach PHP. (Or skip the rewrite rules entirely and let the app run with clean URLs off. It works without them.)
Everything that matters lives in data/.
- Back up: copy the entire
data/folder. It containsapp.sqlite,config.php, anduploads/. For a consistent SQLite copy, do it while the site is idle, or runsqlite3 data/app.sqlite ".backup data/backup.sqlite". - Restore or move to a new host: upload the app files, then drop your saved
data/folder in place. Do not re-run the installer. The app picks it up immediately.
Back up data/ first (see above). All runtime state lives in data/, which is gitignored — an update only ever replaces code.
With git (VPS / SSH access):
cd /var/www/timepool && git pullRun it as the user that owns the files — a sudo git pull trips git's "dubious ownership" guard and leaves root-owned files behind that break the next pull. A pull may restore a deleted install.php; that's harmless, since it refuses to run on an installed site.
Git writes updated files using your shell's umask. If the deploy user's umask is restrictive (e.g. 077), pulled files come out unreadable to PHP-FPM and pages go blank. Set umask 022 in the deploy user's ~/.profile and ~/.bashrc (above any non-interactive early-return line), or make the deploy command self-contained:
sudo -u <owner> sh -c 'umask 022 && git -C /var/www/timepool pull'Without git (shared hosting):
- Download the latest ZIP from GitHub (a tagged release, or Code → Download ZIP).
- Upload it over the existing install, replacing
app/,assets/,index.php, and.htaccess. Leavedata/untouched.
Either way, load any page afterwards. app/db.php runs its CREATE TABLE IF NOT EXISTS migrations automatically, and running them twice is safe.
Clean URLs 404 (e.g. /dashboard not found).
The host lacks mod_rewrite. The app already falls back to index.php?r=/… links if it detected this at install time. If mod_rewrite was enabled after install, edit data/config.php and set 'pretty' => true.
"Writable folder" check fails in the installer.
Make the install folder writable (e.g. chmod 755) so data/ and the SQLite file can be created.
Logo or assets not loading.
Check that assets/ was uploaded and is web-readable. The logo is served via the /logo route from data/uploads/.
Blank page after a git pull.
Almost always file permissions: git writes updated files with the deploy user's umask, and a restrictive one (077) makes them unreadable to PHP-FPM. Confirm with ls -l app/views/layout.php (should be -rw-r--r--), check the web server's error log for Failed to open stream: Permission denied, then fix the code files — leaving data/ alone so its secrets stay owner-only:
cd /var/www/timepool
find app assets docs -type f -exec chmod 644 {} +
find app assets docs -type d -exec chmod 755 {} +
chmod 644 index.php install.php .htaccessPrevent it by setting umask 022 for the deploy user (see Updating).
Email isn't sending. Email is optional. Configure SMTP under Settings → Email and use the test email field. STARTTLS uses port 587; SSL/TLS uses 465. Without email, the app still works fully by sharing links.
Forgot the admin password and email isn't configured.
Run the documented file-based reset from the install folder (shown in docs/INSTALL.md):
php -r 'require "app/helpers.php"; $GLOBALS["config"]=require "data/config.php"; require "app/db.php"; require "app/auth.php"; db()->prepare("UPDATE users SET password_hash=? WHERE email=?")->execute([password_hash("NEW_PASSWORD", PASSWORD_DEFAULT), "you@example.org"]);'"Database is locked" under load. SQLite runs in WAL mode here, which handles typical poll traffic fine. If you expect heavy concurrency, host on a faster disk or consider a different backend (out of scope for v1).
Re-running install.php says "Already installed."
That's the safety lock. To genuinely reinstall, remove data/config.php. That deletes your config, so back up data/ first.
Left out on purpose: Calendly-style 1:1 booking, per-slot capacity caps, Google Calendar OAuth sync, multi-tenant hosting and billing, native mobile apps, recurring polls, and shipped translations (UI strings are centralized for future i18n, but the app is English-only today). See specs/timepool.md for the full contract.
MIT. See LICENSE. Any nonprofit (or anyone else) can self-host, modify, and share TimePool freely, as long as the copyright notice stays. If you'd rather require that hosted or modified versions stay open source, swap to AGPL-3.0.