A small web app for collecting recurring weekly availability (fully available / not preferable / not available) from a group of people — e.g. a team picking recurring meeting slots — and exporting it as a CSV for a downstream scheduling script.
Built with Next.js (App Router, TypeScript), Tailwind CSS, Prisma + SQLite. Everything runs in Docker — no local Node.js install required.
docker compose upThen open http://localhost:3000 — it redirects to /login.
A bootstrap admin account is seeded on first run from .env (created from
.env.example — change these before sharing the app with anyone, and
definitely before deploying it):
- Username:
admin - Password:
changeme
These env vars only matter the first time the app starts — after that,
the account lives in the database and env changes have no further effect.
Manage accounts from /dashboard/users instead (see Roles below).
First run will take a few extra seconds while the image builds and
dependencies install. Subsequent docker compose up runs are fast — source
files are bind-mounted, so edits hot-reload without a rebuild.
To stop: docker compose down (data persists in prisma/dev.db on disk;
down -v does not delete it since it isn't a named volume).
- Admin: signs in at
/login, same as a registered user, but additionally manages accounts from/dashboard/users(create/delete registered users, reset passwords, promote/demote), and can see and manage every poll in the system, not just their own — the "All polls" section on/dashboard. There's always at least one (the last remaining admin can't be deleted, and an admin can't delete or demote their own account while signed in). - Registered user: created by an admin (who sets the initial username,
display name, and password — no self-serve signup, no email required).
Signs in at
/login, creates and manages their own polls from/dashboard("My polls"), and can respond to any poll's link while signed in — those show up in a separate "Polls you've participated in" section. Their name when responding is always their account's display name, not something they type — see the poll-password section below for why that matters. - Guest: anyone with a poll's link, no account. Enters a name, fills in
the grid, submits — exactly as before. Registered users don't need this
flow at all; the respondent page detects who's signed in and skips the name
field entirely, showing "Responding as
<name>" instead.
/dashboard: create polls, get a shareable link per poll, optionally set a poll password, watch responses come in, preview/edit/delete individual submissions, close/reopen or delete a poll, and export a CSV.- Respondent (
/poll/<token>): guests enter a name; registered users (if signed in) respond under their account automatically. Either way, filling in the weekly grid works the same — click to cycle a cell, click-drag or touch-swipe to paint a range — then submit. Resubmitting updates the existing answer instead of creating a duplicate (matched by name for guests, by account for registered users). Guests' browsers remember their name locally so returning to the link reloads their previous grid for editing.
Each poll can optionally have a password, set or changed from its dashboard. If set, a respondent must enter it once before the grid appears; the browser remembers it (via a long-lived, server-signed cookie) so they aren't asked again on return visits. This is defense-in-depth on top of the unguessable link token, e.g. for cases where the link might get forwarded somewhere you didn't intend — it isn't required, and most polls won't need it. The poll's owner and any admin always bypass their own poll's password (no reason to lock yourself out of your own poll to test it).
The password check is enforced server-side on every response-related API call, not just hidden in the UI, and the verify endpoint is rate-limited against guessing.
Export matches this schema exactly — see weekly_availability_template.csv
for a ready-to-diff reference (default 84-column header + one example row):
- One row per respondent, first column
Name. - One column per grid cell, named
{Day}_{StartHour:02d}-{EndHour:02d}(e.g.Mon_09-10), in day-major/hour-minor order. - Cell values are exactly
Fully available,Not preferable, orNot available.
Polls with a narrower day/hour range export fewer/different columns using the same naming convention.
Every push to main and every version tag publishes a multi-arch
(amd64/arm64) production image to GitHub Container Registry — no build
step required to run it:
docker pull ghcr.io/ssadras/syncupgrid:latestdocker-compose.prod.yml builds locally by default; swap its image: line
(see the comment there) to run the published image instead. Either way you
still need a .env.production file — see Production deployment below.
prisma/schema.prisma User (roles) + Poll (owned) + Response (guest or account-linked) models
scripts/seed-admin.cjs Idempotent bootstrap-admin seeding, run on every startup
src/lib/grid.ts Shared grid vocabulary (days, states, cycle, column naming)
src/lib/csv.ts CSV builder — the single source of truth for the export schema
src/lib/session.ts App session cookie: {userId, role} (iron-session)
src/lib/userAuth.ts DB-backed username/password check
src/lib/auth.ts Route-handler guards: requireUser, requireAdmin, requirePollAccess
src/lib/pollAccess.ts Per-poll password-unlock cookie (same seal/unseal mechanism)
src/lib/password.ts scrypt hashing — shared by poll passwords AND user account passwords
src/lib/secrets.ts Reads/validates SESSION_SECRET; production placeholder guard
src/lib/rateLimit.ts In-memory per-IP rate limiter for public + login endpoints
src/components/AvailabilityGrid.tsx The interactive/read-only grid (click-cycle + drag/swipe paint)
src/app/login/... Unified sign-in for admins and registered users
src/app/dashboard/... Poll list/detail/create + (admin-only) user management
src/app/poll/[token]/... Respondent-facing page — guest name flow or signed-in auto-identity
src/app/api/auth/... Login/logout
src/app/api/admin/polls/... Poll CRUD + export (any authenticated user, ownership/admin-checked)
src/app/api/admin/users/... User management (admin-only)
src/app/api/polls/[token]/... Public respondent endpoints (submit, prefill, password verify)
src/app/api/health/ Unauthenticated health check (DB connectivity) for Docker/monitoring
- A poll's shareable link embeds an unguessable token (
/poll/<token>) that doubles as the primary access control; the optional poll password is an extra layer on top, not a replacement. - Block size is fixed at 60 minutes for v1 (the spec calls other block sizes a nice-to-have); everything else (days, hour range, expected respondent count) is configurable per poll.
- Run
docker compose exec app npx prisma studioto browse the SQLite data directly, ordocker compose exec app npx eslint ./npx next buildto lint/typecheck. - See CONTRIBUTING.md for the full contributor workflow.
A separate Dockerfile.prod + docker-compose.prod.yml build an optimized,
non-dev image — distinct from the docker compose up dev setup above (which
runs next dev against a bind-mounted source tree and is not meant for
production).
1. Configure secrets — copy the template and fill in real values:
cp .env.production.example .env.productionGenerate a session secret:
docker run --rm node:20-slim node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Set a real ADMIN_PASSWORD too. Do not use quotes around values in
.env.production — unlike .env (which Next's own dotenv-aware loader
reads), this file is consumed by Docker's env_file: mechanism, which
treats quote characters as part of the literal value.
The app actively refuses admin logins in production if it detects either
value is still the sample from .env.production.example — you'll see a
clear error in the server logs if that happens, rather than the app quietly
running with a known password.
2. Build and run:
docker compose -f docker-compose.prod.yml up -d --buildThis builds a multi-stage image (npm ci → next build → prod-only
dependencies), applies pending Prisma migrations, and starts next start —
all running as the non-root node user. The SQLite database lives in a
named Docker volume (poll-db-data) at /app/prisma/data, so it survives
container recreates and image rebuilds independent of the source tree.
3. Put a reverse proxy in front of it (Caddy, nginx, Traefik, or your platform's load balancer) for TLS/HTTPS — this app itself only speaks plain HTTP on port 3000. Terminating TLS is left to the proxy since certificate management is deployment-specific (domain, DNS, ACME provider, etc.).
- Production build:
next build+next start, notnext dev/Turbopack. - Non-root container user: runs as
node, not root. - Security headers: CSP,
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy, restrictivePermissions-Policy— applied only in production (next.config.ts) so they don't interfere with dev-mode HMR. - Rate limiting: admin login, poll-password verification, and response
submission are all rate-limited per IP (in-memory — see the caveat in
src/lib/rateLimit.tsif you ever scale to multiple instances). - Refuses default secrets: in production, refuses to seed the bootstrap
admin account if
ADMIN_PASSWORDis still the shipped sample (scripts/seed-admin.cjs), and refuses to start at all ifSESSION_SECRETis (src/lib/secrets.ts). - SQLite reliability: WAL journal mode + a busy-timeout, so concurrent respondents don't hit "database is locked" under light write contention.
- Health check:
GET /api/health(DB connectivity), wired into the DockerHEALTHCHECKinstruction. - Cookies:
Secureflag on both the session and poll-unlock cookies wheneverNODE_ENV=production.
This is still a single self-hosted SQLite instance — appropriate for the scale this app targets (one team's polls), not a multi-region deployment. If you outgrow that, the natural next steps are a real Postgres instance and a shared (e.g. Redis-backed) rate limiter — neither is set up here, by design, to keep the default path dependency-free.
weekly_availability_template.csv is generated from the same
column-naming rules as the real export (see scripts/generate-template.mjs),
so it can't drift from actual app behavior:
docker run --rm -v "${PWD}:/app" -w /app node:20-slim node scripts/generate-template.mjsBug reports, feature requests, and pull requests are welcome — see CONTRIBUTING.md for the dev workflow and CODE_OF_CONDUCT.md for expected conduct. Found a security issue? Please follow SECURITY.md instead of opening a public issue.
SyncUpGrid is licensed under the GNU GPLv3.