Todo Calendar

A local-first todo calendar PWA. Tasks, dates, and preferences live in your browser — no accounts, no backend, no build step.

Why

I wanted a todo list that respects calendar dates, works offline, and stays private to one browser. Existing tools either pushed accounts and sync, or ignored the calendar view entirely. This sits in the middle: a month/week/day calendar where each date holds your tasks, running from three static files and localStorage.

What it does

  • Month, week, and today views — navigate periods with arrow keys or toolbar buttons. Today's date is highlighted; selected date has tinted background.
  • Natural-language quick add — type "Laundry tomorrow at 3pm #chores" and it parses the date, time, and tags.
  • Repeating tasks — daily, weekly, or monthly recurrence. Completing a repeat task auto-creates the next occurrence. Monthly repeats handle month-end edge cases (Jan 31 → Feb 28).
  • Task panel — slides in from the right on desktop, bottom sheet on mobile. Click a date cell to see all tasks for that day, then create or edit inline.
  • Search — searches across titles, notes, tags, priority, and dates. Results appear in the sidebar.
  • Priority badges — low, medium, high with distinct colors in both light and dark themes.
  • Undo — toast notifications with an Undo button for deletions, completions, imports, and restores. Reverts to the previous state snapshot.
  • Data export/import — download all tasks as versioned JSON. Import validates structure, rejects duplicates and malformed entries, and snaps a backup before overwriting.
  • Automatic backups — a snapshot is taken before every import and task deletion. Restore from the most recent backup in the Data dialog.
  • Dark mode — follows the system theme by default, with manual light/dark overrides that persist across sessions.
  • Browser notifications — fires a reminder 5 minutes before timed tasks. All-day tasks never trigger reminders.
  • AFK mode — fullscreen clock with date display. Exit with Esc, click anywhere, or the visible exit button.
  • Keyboard shortcuts — N new task, / search, T today, M month, W week, ? shortcuts reference, arrows to navigate periods, Esc to close anything.
  • PWA — installable on desktop and mobile. Service worker caches the app shell for offline access.
  • Responsive — desktop 2-column layout, tablet single-column, mobile compact cells and bottom-sheet panel. prefers-reduced-motion respected throughout.

How it works

Three static files carry the entire application:

File Role
index.html DOM structure: calendar grid, sidebar, task panel, dialogs, toolbar
app.js All state, rendering, event binding, and localStorage persistence — single file, ~1,900 lines
styles.css Design system via CSS custom properties, light/dark themes, responsive breakpoints, transitions

There is no framework, no CSS library, no package.json at root, and zero runtime dependencies. The system font stack is the only font loaded.

Data flow

localStorage ──▶ loadTasks() ──▶ state.tasks[] ──▶ renderApp()
                     ▲                              │
                     │                              ▼
               saveTasks() ◀── CRUD handlers ── DOM events

All task data lives under a single localStorage key (notionTodo.tasks) as a JSON array. Nine additional keys store theme preference, view state, notification tracking, and the most recent backup snapshot.

Task normalization runs on every load — missing or invalid fields are filled with defaults so older stored data stays compatible. If localStorage data is corrupt (malformed JSON, non-array), the raw bytes are captured to a separate key (notionTodo.corruptData) rather than silently discarded.

Service worker strategy

sw.js uses a cache-first strategy for the app shell (index.html, app.js, styles.css, manifest.json, icons) and network-first for navigations. When offline, navigation requests fall back to the cached shell.

Things I figured out the hard way

Month-end recurrence (app.js:1030–1035)

A task repeating monthly on the 31st must land on the 28th (or 29th) in shorter months, not skip the month or overflow into the next. The fix stores an repeatAnchorDay on the task — the day-of-month of the original task — and clamps to Math.min(originalDay, lastDay) when advancing.

Removing the access gate

The app originally had a local access-code screen with a persistent "logged in" flag in localStorage. After living with it, the friction of an extra step before reaching the calendar outweighed any benefit. Removing it meant deleting the lock UI, the access-code constant, the login-flag key, all related handlers, and the CSS for the gate screen — then confirming no stale references remained in the DOM or Graphify output.

Data safety without a backend

With no server to back up to, data loss is permanent. Four decisions came out of this:

  • Automatic snapshots before every destructive action (import, task deletion). The snapshot is JSON with a timestamp and lives under notionTodo.lastBackup.
  • Undo system that stores a full clone of the task array before each destructive action and restores it if the user hits Undo within the toast window.
  • Import validation that rejects collections over 10,000 items, duplicate IDs, and any task that fails normalizeTask().
  • Corrupt data preservation — if localStorage parsing throws, the raw string is moved to a separate key so it can be inspected, rather than being swallowed by a default empty array.

Natural-language quick-add parsing (app.js:912–970)

parseQuickTask() handles six date forms (today, tomorrow, weekday names, "next monday"), 12-hour times with optional minutes (3pm, 3:30pm), 24-hour times (15:30), bare hour shorthand (at 3), and inline hashtags — all from a single input string. Each matched token is stripped so the remaining text becomes the task title.

How to run it

No install, no build, no dependencies.

# Option 1: open directly
open index.html

# Option 2: serve with Python
python -m http.server 8000

# Option 3: serve with Node
npx serve .

Then open http://localhost:8000 (or the file path). Browser notifications require localhost or HTTPS.

Requirements

Thing Details
Browser Any modern browser (Chrome, Firefox, Safari, Edge)
Notifications Only on localhost or HTTPS; requires permission grant
Storage localStorage — clearing browser data wipes everything
Hosting Any static host (GitHub Pages, Vercel, Netlify, S3)

License

MIT — see LICENSE.

Contributing

This is a personal project. If you find a bug or have a feature idea, open an issue — but please check that it fits the project's scope (local-first, single-user, no backend) before suggesting something that adds accounts, sync, or server infrastructure.

Built With

Share this project:

Updates