Local-first operations calendar with privacy-first controls, profile isolation, encrypted backups, and a dedicated Safety Center.
- Overview
- Core Features
- Tech Stack
- Architecture
- Quick Start
- Configuration
- Scripts
- Deployment
- Security Notes
- Roadmap
NullCal is a React + TypeScript Progressive Web App for scheduling in high-privacy workflows.
It is built to run offline-first, keep data local by default, and provide operational security controls without requiring a backend for core use.
For remote delivery features (email/SMS OTP and reminders), run the optional notification gateway in server/notify-server.mjs or deploy server/notify-worker.mjs.
Current app routes:
/-> Calendar workspace/safety-> Safety Center (security, export/import, audit, panic wipe, profile hardening)/home,/about,/privacy,/contact-> product/marketing pages
- Multi-profile calendar workspaces with isolated events/calendars/templates
- Week and month scheduling views (FullCalendar) with drag/drop and resize
- Event creation wizard with reminders, recurrence, attendees, notes, and reusable templates
- 47 theme packs with dark/light variants and instant palette switching
- Local lock screen with selectable PIN/passphrase entry, passkey, and biometric unlock options
- Two-factor support with OTP + TOTP verification modes (including runtime method switching) plus privacy-screen hotkey (
Cmd/Ctrl+Shift+P) - Encrypted export/import backups with export hygiene modes (
full,clean,minimal) - Event export formats: CSV, ICS, and JSON
- Audit log, auto-lock rules, decoy profile flow, and panic wipe
- Relay-backed multi-device sync (
/api/sync) with durable persistence support (Cloudflare KV or file-backed Node storage) plus local P2P sync - End-to-end encrypted relay sync payloads (relay stores ciphertext only)
- Collaboration roles (
owner,editor,viewer) with invite links, invite acceptance codes, presence/status reconciliation, and per-calendar permission presets - Notification failover + retry queue (Node gateway and Worker)
- Background reminder retry visibility in Safety Center, plus service-worker retry support for
/api/notify - PWA install support with service worker caching and standalone mode
- Built-in localization support: English (
en), Russian (ru), Persian (fa) - Backup key rotation flow and one-time recovery code support for locked profiles
- Frontend: React 18, TypeScript, Vite 5
- UI/Animation: Tailwind CSS, Framer Motion
- Calendar Engine: FullCalendar
- Local Persistence: IndexedDB (
idb) + localStorage cache/audit - Security Primitives: Web Crypto API (PBKDF2 + AES-GCM, hash-based verification)
- PWA:
vite-plugin-pwa+ Workbox runtime caching
Key directories:
src/app-> app shell, store/provider wiring, top bar, sidebar, hotkeyssrc/pages-> main calendar page and Safety Centersrc/storage-> IndexedDB schema, persistence, cache, seed data, audit logsrc/security-> encryption, auth flows, export hygiene, reminders integrationssrc/reminders-> local reminder scheduler and secure ping adapterssrc/theme-> theme provider and theme packssrc/i18n-> translations and localization helpers
Persistence model:
- IndexedDB database:
nullcal-db - Object stores:
profiles,calendars,events,templates,settings,securityPrefs - Local cache key:
nullcal:cache - Audit log key:
nullcal:audit
- Node.js 20+ recommended
- npm 10+
npm install
npm run devOpen the local URL shown by Vite (typically http://localhost:5173).
npm run build
npm run previewnpm run build also generates dist/404.html for GitHub Pages SPA fallback support.
Environment variables:
VITE_BASE-> base path for deployment (for example/NullCal/on GitHub Pages)VITE_NOTIFICATION_API-> notification backend base URL (default:/api, for examplehttps://<worker>.workers.dev/api)VITE_NOTIFICATION_TOKEN-> optional request token sent asX-Nullcal-Token/ Bearer header by the frontendVITE_SYNC_API-> optional sync relay base URL (defaults toVITE_NOTIFICATION_APIor/api)VITE_SYNC_TOKEN-> optional sync request token (defaults toVITE_NOTIFICATION_TOKEN)VITE_NOTIFICATION_TOKENis bundled into client code; pair it with strictNOTIFY_CORS_ORIGIN+NOTIFY_ALLOWED_RECIPIENTSNOTIFY_PROXY_TARGET-> Vite dev proxy target for/api(default:http://127.0.0.1:8787)NOTIFY_SERVER_PORT-> optional notification server port (default:8787)NOTIFY_CORS_ORIGIN-> allowed origin(s) for notification server requests (recommended: exact site origin, comma-separated allowed); default is local dev origins only (http://127.0.0.1:5173,http://localhost:5173)RESEND_API_KEYandNOTIFY_FROM_EMAIL-> email delivery via ResendTWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_FROM_NUMBER-> SMS delivery via TwilioTEXTBELT_API_KEY-> optional SMS delivery via Textbelt (works in Node gateway and Worker)TEXTBELT_FREE=1-> use Textbelt free key (textbelt, very limited quota, useful for testing)EMAIL_WEBHOOK_URLandSMS_WEBHOOK_URL-> optional custom delivery webhooks (alternative to Resend/Twilio)NOTIFY_ALLOWED_RECIPIENTS-> optional recipient allowlist (email:alerts@example.com,sms:+15551234567,*@example.com)NOTIFY_REQUEST_TOKEN-> request token required by default for/api/notify; if missing, server returns 503 unlessNOTIFY_ALLOW_UNAUTH=1NOTIFY_ALLOW_UNAUTH=1-> explicitly allow unauthenticated notify requests (not recommended)NOTIFY_TRUST_PROXY=1-> trustX-Forwarded-Forfor rate-limit client IP derivation (only enable behind a trusted proxy)NOTIFY_RATE_LIMIT_MAXandNOTIFY_RATE_LIMIT_WINDOW_SEC-> per-IP in-memory rate limit controlsNOTIFY_MAX_REQUEST_BYTES-> max request size in bytes (default:8192)NOTIFY_QUEUE_DISABLE=1-> disable retry queue (enabled by default)NOTIFY_QUEUE_RETRY_SEC,NOTIFY_QUEUE_MAX_ATTEMPTS,NOTIFY_QUEUE_MAX_ITEMS-> queue retry/backlog controlsNOTIFY_SYNC_TTL_SEC,NOTIFY_SYNC_MAX_MESSAGES,NOTIFY_SYNC_MAX_PULL-> sync relay retention and pull-window controlsNOTIFY_SYNC_MAX_REQUEST_BYTES-> max accepted sync snapshot payload bytes (default:524288)NOTIFY_SYNC_DB_PATH-> optional file path for durable sync storage in the Node gateway (default:.nullcal-sync-store.json)SYNC_KV-> optional Cloudflare KV binding for durable sync storage inserver/notify-worker.mjs
Email/SMS 2FA and reminders require POST /api/notify.
Multi-device relay sync uses POST /api/sync and GET /api/sync.
- Start the gateway locally:
NOTIFY_REQUEST_TOKEN=dev-notify-token \
NOTIFY_CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173 \
npm run notify:server- Point the frontend to it:
VITE_NOTIFICATION_API=http://127.0.0.1:8787/api \
VITE_NOTIFICATION_TOKEN=dev-notify-token \
npm run dev- In Safety Center:
- Disable Network lock when you want remote delivery.
- Keep it enabled for strict offline mode.
server/notify-worker.mjs is compatible with Cloudflare Workers free tier.
- Deploy the worker:
npx wrangler deploy server/notify-worker.mjs --name nullcal-notify --compatibility-date 2026-02-11This repo also includes wrangler.jsonc, so you can deploy with:
npm run notify:deploy- Add secrets/vars to the worker (choose any provider path):
# CORS
npx wrangler secret put NOTIFY_CORS_ORIGIN
# Optional hardening (recommended)
npx wrangler secret put NOTIFY_ALLOWED_RECIPIENTS
# Email path (free tier possible via Resend)
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put NOTIFY_FROM_EMAIL
# SMS path option A (Textbelt; free key has very small quota)
npx wrangler secret put TEXTBELT_API_KEY
# SMS path option B (Twilio)
npx wrangler secret put TWILIO_ACCOUNT_SID
npx wrangler secret put TWILIO_AUTH_TOKEN
npx wrangler secret put TWILIO_FROM_NUMBER
# Optional rate limit tuning
npx wrangler secret put NOTIFY_RATE_LIMIT_MAX
npx wrangler secret put NOTIFY_RATE_LIMIT_WINDOW_SEC
# Optional queue tuning
npx wrangler secret put NOTIFY_QUEUE_RETRY_SEC
npx wrangler secret put NOTIFY_QUEUE_MAX_ATTEMPTS
npx wrangler secret put NOTIFY_QUEUE_MAX_ITEMS
# Optional sync relay tuning
npx wrangler secret put NOTIFY_SYNC_TTL_SEC
npx wrangler secret put NOTIFY_SYNC_MAX_MESSAGES
npx wrangler secret put NOTIFY_SYNC_MAX_PULL
npx wrangler secret put NOTIFY_SYNC_MAX_REQUEST_BYTES
# Required by default for /api/notify (client must send this header)
npx wrangler secret put NOTIFY_REQUEST_TOKENOptional durable sync persistence on Workers:
- Add a KV binding named
SYNC_KVand attach it to the worker. - When
SYNC_KVis bound, sync snapshots are persisted in KV; otherwise the worker falls back to in-memory storage.
- Build frontend against worker URL:
VITE_NOTIFICATION_API=https://nullcal-notify.<your-subdomain>.workers.dev/api \
VITE_NOTIFICATION_TOKEN=<same-token-value> \
npm run build- For GitHub Pages deployment, set repository variable:
VITE_NOTIFICATION_API=https://nullcal-notify.<your-subdomain>.workers.dev/apiVITE_NOTIFICATION_TOKEN=<same-token-value>
The workflow already reads this variable during build.
- Email can be no-cost on free tier quotas (for example Resend free tier).
- Reliable unlimited SMS is not truly free; Textbelt free key is quota-limited.
- If you want fully no-cost reminders long-term, prefer
local,push,telegram, orsignalchannels.
npm run dev-> ensure icons + start dev servernpm run notify:server-> start notification/sync gateway (/api/notify,/api/sync)npm run build-> ensure icons, run hook-order guard, build, generate404.htmlnpm run preview-> preview built output locallynpm run lint-> run hook dependency order validatornpm run typecheck-> TypeScript compile check (tsc --noEmit)npm run test-> run unit + integration + e2e suitesnpm run test:e2e-> run build smoke + browser journey coveragenpm run test:e2e:browser-> run Playwright browser journey testsnpm run test:a11y-> run Playwright accessibility checksnpm run test:visual-> run Playwright visual regression snapshotsnpm run test:visual:update-> generate/update visual snapshot baselinesnpm run test:unit-> run unit testsnpm run test:integration-> run integration tests
GitHub Actions workflow: .github/workflows/deploy.yml
- Triggers on pushes to
mainormaster - Resolves
VITE_BASEautomatically:- Uses
/whenpublic/CNAMEexists - Otherwise uses
/<repo-name>/
- Uses
- Uses optional repository variable
VITE_NOTIFICATION_APIfor production notification backend URL - Publishes
dist/to GitHub Pages
Custom domain in this repo:
public/CNAME->nullcal.kamranboroomand.ir
Important for OTP email/SMS on GitHub Pages:
- GitHub Pages is static;
/api/notifyis not available there. - Set
VITE_NOTIFICATION_APIto your deployed worker URL (for examplehttps://<worker>.workers.dev/api). - Set
VITE_NOTIFICATION_TOKENto match backendNOTIFY_REQUEST_TOKEN. - Set worker secrets
RESEND_API_KEYandNOTIFY_FROM_EMAIL. - Set worker CORS
NOTIFY_CORS_ORIGIN=https://nullcal.kamranboroomand.ir.
- Export encryption and note encryption use Web Crypto with PBKDF2-derived AES-GCM keys.
- PIN/local passphrase hashes are PBKDF2-derived and verified client-side.
- TOTP is implemented client-side for offline-friendly MFA.
- Panic wipe removes IndexedDB, localStorage state, caches, and service workers.
- Network lock can be toggled in Safety Center. It blocks fetch/XHR/WebSocket/EventSource/sendBeacon at runtime.
- Notification gateway hardening includes origin enforcement, payload size limits, recipient allowlists, request-token enforcement (default), and per-IP rate limiting.
- Relay sync messages can be wrapped as
e2ee-v1encrypted payloads; the relay stores ciphertext and metadata. - Recovery codes are one-time unlock secrets: successful use clears the stored recovery hash.
- End-to-end encrypt relay sync payloads (server stores ciphertext only)
- Add per-calendar permission presets and time-bound invite links
- Ship background reminder delivery improvements (service worker + offline retry visibility)
- Expand automated coverage with accessibility checks and visual regression snapshots
- Add key-rotation and recovery UX for encrypted backups and locked profiles
