A minimalist day planner that lives in your browser. One HTML file, no framework, no accounts, no server required. Works offline, installs to the home screen, and syncs between devices through a single access code when you want it to.
Live demo → · Русская версия →
- Day — a vertical timeline where each task is a block as tall as its duration. Tap a time to add, drag to move, pull the bottom edge to resize. Overlaps split into columns. A list view shows the same day as rows with free windows between tasks.
- Repeats — every day or chosen weekdays. Edits apply to one day; "apply to all future" pushes them down the series.
- Calendar — tap the date to open a month view; days with tasks are underlined.
- Tasks — things without a time, in folders you create, with three priorities. Drag to reorder or change priority; schedule into a free window or automatically.
- Notes — drafts by topic with headings, bullet and numbered lists, bold and italic. Saves as you type.
- Archive — completed items, grouped by day, auto-removed after two weeks.
- Sync — optional. One code = one shared list. Per-item merge by modification time, tombstones for deletions, offline-first.
- Light/dark theme, seven accent colors, six languages (en, ru, es, de, zh, hi), keyboard shortcuts on desktop.
Everything lives in the browser's storage on your device. The server, when you use one, holds a single JSON document per access code and knows nothing else.
The demo's sync is invite-only (it's the author's instance); deploy your own in five minutes — see below.
git clone https://github.com/<you>/planly && cd planly
python3 -m http.server 4321Open http://localhost:4321. The app works fully offline once loaded; only sync needs a backend.
src/app.html the whole app: styles, markup, logic (edit this)
src/i18n.js UI translations, keyed by the Russian source string
build.sh assembles index.html and dist/ from src/
deploy.sh build + publish to Cloudflare Pages
index.html built app (generated — do not edit)
sw.js service worker: offline cache, versioned per build
manifest.json PWA manifest
icons/ app icons (make_icons.py regenerates them)
functions/api/sync.js sync API as a Cloudflare Pages Function (KV storage)
wrangler.example.toml Pages project config template (copy to wrangler.toml, git-ignored)
server/ self-hosted alternative: Node server (app + sync), nginx and systemd examples
Dockerfile whole instance in one container
docker-compose.yml the same, with a named volume for the data
tools/shots/ headless-Chrome rig that renders the README screenshots
docs/ screenshots, GIF and the social preview image
Static files are served from Cloudflare's edge; the sync API runs as a Pages Function backed by Workers KV. The free plan is more than enough: unlimited static requests, 100k function calls/day, 1k KV writes/day (the app only writes when something actually changed).
You need a Cloudflare account and Node.js 18+.
npx wrangler login # opens the browser once
npx wrangler pages project create planly --production-branch=main
npx wrangler kv namespace create PLANLY_SYNC # prints the namespace idCopy the example config and put the printed id into it (wrangler.toml is
git-ignored, so your ids stay out of the repository):
cp wrangler.example.toml wrangler.toml # then edit the id lineThen deploy:
./deploy.shThe script builds dist/, stamps a new cache version into sw.js and uploads
everything with wrangler pages deploy. The app is live at
https://planly-<hash>.pages.dev; every later ./deploy.sh ships an update
that installed home-screen apps pick up on their next launch.
Custom domain. If your domain's DNS is on Cloudflare: Workers & Pages →
your project → Custom domains → Set up a custom domain. Cloudflare creates the
DNS record and the certificate. If DNS is elsewhere, add a CNAME to
planly-<hash>.pages.dev (apex domains need CNAME flattening, which most
registrars lack — moving DNS to Cloudflare is the easy path).
Restricting sync to known codes. By default any code works. To allow only specific codes (e.g. while the deployment is private), set a secret with a comma-separated list:
printf 'AAAA-BBBB-CCCC-DDDD,EEEE-FFFF-GGGG-HHHH' | npx wrangler pages secret put ALLOWED_CODES --project-name planlyOther codes get 403 not_allowed and the app shows "code not allowed on the server".
Delete the secret to open sync again.
If
wrangler pages project createcomplains about a missingworkers.devsubdomain, re-run it once with--forceto create a classic Pages project.
git clone https://github.com/drkokorev/Planly && cd Planly
docker compose up -dThat's the whole thing: http://localhost:8080 serves the app and the sync API from the same origin, with documents in a named volume. Plain Docker works too:
docker build -t planly .
docker run -d -p 8080:8080 -v planly-data:/data --name planly planlyPut it behind your usual reverse proxy with a certificate — service workers, offline mode and "Add to Home Screen" all require a secure origin.
The image is node:22-alpine with no dependencies to install, runs as the
unprivileged node user and has a healthcheck. Useful environment variables:
Updating: git pull && docker compose up -d --build. Your data lives in the
volume, not in the image.
The app is static: copy index.html, sw.js, manifest.json, version.txt
and icons/ (all produced in dist/ by ./build.sh) to any web root behind
HTTPS. HTTPS is required for the service worker and for "Add to Home Screen".
For sync, run the reference server — a single Node file with no dependencies that stores one JSON file per code:
PORT=8787 DATA_DIR=/var/lib/planly SERVE_STATIC=0 node server/sync-node.jsand proxy /api/sync to it from your web server. server/nginx.example.conf
is a complete nginx site (static files with correct cache headers + the proxy),
server/planly-sync.service a systemd unit. Set ALLOWED_CODES in the unit's
environment to restrict access, or leave it unset to allow any code.
The same file can serve the app itself instead of nginx — drop SERVE_STATIC=0
and point STATIC_DIR at the folder with index.html. That is exactly what the
Docker image does.
The app calls api/sync relative to its own URL, so it works both at a domain
root and in a sub-folder.
Any other backend works too — the protocol is two endpoints:
GET /api/sync?code=XXX → 200 { version, updatedAt, doc } (doc: null if none)
PUT /api/sync?code=XXX { base, doc } → 200 { version }
→ 409 { version, doc } server is ahead: merge and retry
version is an integer that grows on every write; the client sends the
version it based its document on, so two devices writing at once never lose
data — the loser merges and retries. Codes are [A-Za-z0-9-]{8,64}; the client
generates 16 characters from a 32-symbol alphabet.
Install. Open the URL in Safari (iPhone) or Chrome (Android/desktop) → Share → Add to Home Screen (Chrome: Install app). It launches full-screen, works offline and checks for updates when you return to it.
Day. Tap an empty time to add a task there. Long-press a block (or just drag with a mouse) to move it; the bottom edge changes duration. The circle in the corner marks it done and sends it to the archive. The toggle in the header switches between timeline and list; tap the date for the month calendar.
Repeats. In a scheduled task's card: Repeat → Every day or Weekdays. The app creates the task on every matching day five weeks ahead. Edits in a repeating task's card apply to that day only; Apply to all future pushes them to the whole series, Delete only this one skips one day, Delete all repeats removes the series (completed days stay in the archive).
Tasks. Untimed items with folders and priorities. Drag between priority sections. The clock button opens scheduling: pick a date, a free window, a manual time, or let it choose the first free window.
Sync. Settings → Sync between devices → Enable creates a code. On the other device enter it under Code from another device. Devices exchange changes on launch, when you return to the app, every two minutes, and a few seconds after each edit. The code is the only key to your data — keep it like a password. Losing it means losing the server copy (your device copy stays).
Backup. Settings → Copy backup puts the whole dataset as JSON on the clipboard; Paste from clipboard restores it. Storage is per URL: the same app opened at a different address starts empty.
Keyboard (desktop). ←/→ day, T today, N new task, V timeline/list,
1–4 tabs, Esc close.
Sync merges per item: each task, note, folder and series carries the time of
its last change, the newer one wins. Deletions are kept as tombstones for 30
days so a device that was offline cannot resurrect them. Repeating tasks are
materialized with deterministic ids (<series>@<date>), so two devices that
create the same day independently converge on one record.
./build.sh # src/ → index.html + dist/
./deploy.sh # build + wrangler pages deploy
python3 make_icons.py # regenerate icons/build.sh stamps the build time into the settings screen and version.txt;
the running app compares them to offer an update. sw.js is network-first:
online you always get fresh files, offline you get the cache.
Adding a language: add a block to src/i18n.js (keys are the Russian UI
strings; nouns with counts are objects with one/few/many/other forms chosen
via Intl.PluralRules) and a row to LANGS in src/app.html. Missing keys
fall back to Russian, so partial translations are visible rather than silent.
MIT