A single-user, web-based goal tracker with push notification reminders. Installs as a PWA on desktop and mobile.
- Three goal types — binary (did it / didn't), quantitative (sum a value to hit a target), milestone (long projects)
- Daily / weekly / monthly cadence with per-period streaks
- Why-statement per goal — pinned during check-ins
- Pause goals so streaks don't break on vacation
- Reminders at a chosen time and weekday selection
- Escalating nudges when you miss a day — fired once, after 9am
- Sunday weekly digest push notification
- Quick-log from notification — tap "✓ Done" on binary goals without opening the app
- Calendar heatmap (GitHub-contributions style) for the last 6 months
- Trend chart per goal (line for daily, bar for weekly/monthly)
- 24-hour edit lock on past entries — keeps history honest
- Export all data as JSON or CSV
- Dark mode with system-preference auto-detect
- Command palette (⌘K / Ctrl+K) for jump-to-goal and quick actions
- PWA — installs to phone home screen, push works on Android and iOS 16.4+
Next.js 15 (App Router) · TypeScript · Tailwind · Drizzle ORM · Postgres (pg) · iron-session · web-push · Recharts
npm install
cp .env.example .env.local # fill in vars below
npm run db:migrate # apply schema to Postgres
npm run devRequired env vars (see .env.example):
DATABASE_URL=postgres://user:pass@localhost:5432/reminders
APP_PASSWORD=pick-anything-strong
SESSION_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
VAPID_PUBLIC_KEY=... # generate with: npm run vapid:gen
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:you@example.com
CRON_SECRET=... # any random string
APP_TIMEZONE=America/New_YorkVisit http://localhost:3152, log in, create a goal, enable push from Settings.
This is the production setup: Postgres + the app in Docker on Unraid, Cloudflare Tunnel for HTTPS, Ofelia for cron.
Two ways to run the containers — pick one:
- Pure Docker tab path (recommended) — install four Unraid templates from the Docker tab. GitHub Actions builds the image and publishes to GHCR; Unraid pulls from there. Zero command-line after the one-time fork + network creation.
docker composepath (below) — clone the repo to appdata and rundocker compose up -d. Better if you'd rather have one config file you can edit.
Both use the same image, same Postgres data dir, and the same env vars — switching later is just docker compose down and re-creating from the templates (or vice versa).
See unraid/templates/README.md for the full step-by-step. Summary:
- Fork this repo. The included GitHub Actions workflow auto-builds and publishes
ghcr.io/<your-username>/reminders-app:latest. Make the package public. - In Unraid → Docker → Network, add a bridge network named
reminders. - Install four templates from the Docker tab (postgres, app, ofelia, cloudflared).
- Done — open your Cloudflare hostname.
On the Unraid console (or via SSH):
mkdir -p /mnt/user/appdata/reminders
cd /mnt/user/appdata/reminders
git clone git@github.com:CopesaCola/reminders-app-web.git app
cd app# 32+ char session secret
docker run --rm node:20-alpine node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# VAPID keypair for Web Push
docker run --rm -v "$PWD":/work -w /work node:20-alpine sh -c "npm install --no-save web-push && node scripts/gen-vapid.mjs"
# Cron bearer token
docker run --rm node:20-alpine node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"cp .env.docker.example .env
nano .env # paste the values you just generated- In Cloudflare Zero Trust dashboard → Networks → Tunnels, click Create a tunnel → Cloudflared.
- Name it (e.g.
reminders), copy the connector token shown after install. - Paste it into
.envasCLOUDFLARE_TUNNEL_TOKEN=.... - Still in the Cloudflare dashboard, under your tunnel's Public Hostname tab, add a route:
- Subdomain + domain you control (e.g.
goals.example.com) - Service:
HTTP→app:3152 - That hostname is now your app URL. Cloudflare handles TLS automatically.
- Subdomain + domain you control (e.g.
docker compose build
docker compose up -d
docker compose logs -f app # watch migrations run then server startThe first start runs migrations (drizzle/0000_init.sql) against the Postgres container, then boots Next.js.
Visit your Cloudflare hostname. Log in with APP_PASSWORD. From your phone: "Add to Home Screen" first, then open the installed app and enable push from Settings.
There's nothing to set up — docker compose up -d already started the Ofelia sidecar (reminders-cron container). It reads schedule labels on the app container via the Docker socket and fires:
/api/cron/remindersevery 15 minutes/api/cron/weekly-digestSunday at 18:00 (inAPP_TIMEZONE)
To change schedules, edit the ofelia.job-exec.*.schedule labels in docker-compose.yml and docker compose up -d. To disable a job temporarily, comment out its labels and re-up.
Confirm it's working:
docker compose logs -f ofelia # you'll see jobs registered, then "Job started" entries on schedule# Trigger a reminder immediately
curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3152/api/cron/reminders
# Or click "Send test" in the app's Settings pagecd /mnt/user/appdata/reminders/app
git pull
docker compose build
docker compose up -dThe entrypoint runs drizzle/ migrations automatically on each start, so schema changes apply on rebuild.
The Postgres data lives at /mnt/user/appdata/reminders/postgres. Add it to your Unraid CA Backup / Restore Appdata plugin schedule, or pg_dump from outside:
docker exec -t reminders-db pg_dump -U reminders reminders > backup-$(date +%F).sqlcat backup-2026-05-18.sql | docker exec -i reminders-db psql -U reminders -d reminders- Auth — one password (
APP_PASSWORD), checked insrc/app/api/auth/login/route.ts. On success, an encrypted iron-session cookie is set.src/middleware.tsgates every page except/loginand the cron endpoints (which useCRON_SECRET). - Streaks —
src/lib/cadence.tsbuckets entries by period (day/week/month) and counts consecutive periods where the goal hit its target. Paused goals don't break streaks. - Reminders — the Ofelia sidecar container hits
/api/cron/remindersevery 15 minutes by exec-ingcurlinside the app container. Each goal hasremindAtMinutes(0–1439 in local time) andremindDaysMask(bit per weekday). If now falls within the 15-min window and the goal's not yet hit for its current period, it sends a push. Sent reminders are logged inreminder_logto prevent duplicates. - Push —
web-pushlibrary with VAPID. The service worker (public/sw.js) shows the notification and handles the "Done" action by POSTing to/api/entries. - 24h edit lock — entries created more than 24h ago can't be edited unless the entry is for today. Enforced in
/api/entriesPOST. - Migrations — Drizzle generates SQL files into
drizzle/. The container entrypoint (docker-entrypoint.sh) runsscripts/migrate.mjson every start. Idempotent.
⌘K/Ctrl+K— command paletten— new goalg— go to goals list?— open palette with help
- Cron interval — change the
ofelia.job-exec.reminders.schedulelabel indocker-compose.ymland update theWINDOWconstant insrc/app/api/cron/reminders/route.tsto match. - Timezone — change
APP_TIMEZONEin.envanddocker compose up -d. All date math runs in this zone. - Theme colors —
src/app/globals.css:rootand.darkCSS variables. - Schema changes — edit
src/lib/schema.ts, then locally runnpm run db:generateto produce a new SQL file indrizzle/. Commit it. The next deploy applies it.
- Stuck on the login screen (form reloads, no error) — you're accessing the app over HTTP (e.g.
http://<unraid-ip>:3152) butSECURE_COOKIES=true. Browsers refuse Secure cookies on HTTP, so the session never sticks. Either leaveSECURE_COOKIESunset (default), or set it totrueonly once you're using HTTPS via Cloudflare Tunnel. - Red "Wrong password" on login — the typed value doesn't match
APP_PASSWORD. Common causes: stray quotes around the value in the Unraid template (don't quote it), or accidental newline at the end. The app trims whitespace on both sides; quotes are not stripped. migratefails on first start — checkdocker compose logs postgresfor healthcheck failures. ConfirmPOSTGRES_PASSWORDmatches between the two services (compose interpolates it from.env).- Push doesn't fire — Settings page shows your subscription status. If
permission: grantedbut no notifications, run the curl test command above and checkdocker compose logs appfor VAPID errors. - Cloudflare 1033 / no DNS — the tunnel's public hostname route in the CF dashboard must be saved before traffic flows. Run
docker compose logs cloudflaredand confirm "Registered tunnel connection". - Cron not firing —
docker compose logs ofeliashows registered jobs and per-run output. If you seecannot find container with label, the app container probably restarted with a different name;docker compose up -d ofeliawill pick it back up.
- The PWA icon is currently an SVG; if you need PNGs for older platforms, place
icons/icon-192.pngandicons/icon-512.pnginpublic/and updatemanifest.json. - iOS push requires the app to be installed to home screen (PWA mode). Once installed, it works the same as Android.
- The
pgdriver is used (not@neondatabase/serverless) so the same image works with both local Postgres and any standard hosted Postgres if you ever switch.