Reminders have been settable since the editor landed and have never once gone off. The board showed them overdue in red, which tells you what you already know by the time you are looking at the board. **AlarmManager, not WorkManager.** The background sync is right to be on WorkManager — nobody minds whether it runs at 3:05 or 3:19. A reminder minds very much. WorkManager's periodic floor is fifteen minutes and it batches into maintenance windows, so "remind me at 09:00" would routinely arrive at 09:14, which is not a reminder, it is a rebuke. **One alarm, not one per reminder.** Only the earliest future reminder is ever scheduled; when it fires, everything due is announced and the next is scheduled. A hundred reminders cost one alarm, and there is no incremental bookkeeping to drift — `Reminders.refresh` recomputes the whole picture from the store, and is called from everywhere anything could have changed: an edit, a foreground, a background sync, boot, and an app update. Boot and MY_PACKAGE_REPLACED both matter and both are easy to forget. Pending alarms survive neither, and this app updates by APK from its own server, so without that receiver a phone would silently stop reminding anyone of anything after a restart — the worst kind of failure, because nothing appears wrong. **Neither permission is treated as a prerequisite.** SCHEDULE_EXACT_ALARM, not USE_EXACT_ALARM: the latter is granted at install with no prompt and is reserved for apps whose whole purpose is an alarm clock or a calendar, which this is not. Refusing the former costs precision, not the feature — it falls back to an inexact alarm, because a reminder a few minutes late beats no reminder. POST_NOTIFICATIONS is asked for on the first launch where a reminder actually exists, never at launch on an empty board. Android gives an app essentially one chance at that dialog, and spending it before the person has any idea what this app would send them is spending it on nothing. For anyone who refuses, or who turns notifications off later in system settings, the Reminders view carries a standing notice with a button to the right screen — a feature that silently does nothing is worse than one that is plainly absent. **A first run adopts overdue reminders silently.** The storm case is linking a server and pulling months of history; a hundred notifications the moment someone signs in is a good way to have the feature turned off before it is ever useful. After that, a missed reminder is announced up to a day late — the web uses fifteen minutes because an open tab has been polling every forty-five seconds, but a phone can be switched off all night. Done and Snooze act from the shade without opening the app. The dedupe key is note id plus remind_at, the same one the web store uses, so snoozing produces a new occurrence rather than one already dealt with. Tapping a notification opens that note. The extra is CONSUMED when read: the Activity keeps the intent it was launched with, so without that, rotating the phone would replay it and reopen a note the person had already closed. `Reminders` split into scheduling policy and `ReminderNotification` rendering after detekt counted fourteen functions in one object — it was right, they answer different questions and change for different reasons. `ForegroundTransitions` moves to the ui package; the reminder notice needs it to re-read a permission the person may have just changed in a system screen this app cannot observe. Known gap, pre-existing and shared with every surface: `complete_reminder` in the core clears a reminder without advancing recurrence — its own comment says so. So tapping Done on a daily reminder ends it rather than moving it to tomorrow. Not changed here because it is core behaviour the desktop and web also have, but notifications make it much easier to hit, and it should be next.
ThoughtSync
Self-hosted personal thought-capture web app in the FabledSword family — a
Google-Keep-style masonry post-it board for capturing disparate thoughts in
under a second, designed to grow into a lightweight second brain (labels, search,
[[wiki-links]], graph).
- Stack: Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
- Auth: native email + password (signed-cookie sessions). Multi-user with the family owner + direct-share + group-share ACL.
- Web-first. An Android companion is a later, conditional milestone.
Layout
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
docker-compose.yml local app + Postgres stack
Development
Backend (needs a Postgres reachable at THOUGHTSYNC_DATABASE_URL):
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
Frontend (proxies /api to :5000):
cd frontend
npm install
npm run dev # http://localhost:5173
Or run it in Docker. Hot-reload dev stack (edit code, changes reload live) — Postgres + backend + Vite dev server, app at http://localhost:5173:
docker compose -f docker-compose.dev.yml up
Or the full production image (SPA baked in) at http://localhost:5000:
docker compose up --build
Deploy (self-host)
A complete two-container stack (app + Postgres) you can copy and run. Save it as
docker-compose.yml, change the two CHANGE_ME passwords (they must match), then
docker compose up -d:
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: CHANGE_ME # change this
POSTGRES_DB: thoughtsync
volumes:
- thoughtsync-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
interval: 5s
timeout: 5s
retries: 10
app:
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
volumes:
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
ports:
- "5000:5000"
volumes:
thoughtsync-db:
thoughtsync-data:
Then open http://<host>:5000 and register — the first account becomes the admin.
- Only
THOUGHTSYNC_DATABASE_URLis required.THOUGHTSYNC_SECRET_KEYis optional; if unset, a signing key is generated and persisted in the database (sessions survive restarts). - Uploaded images live under the
thoughtsync-datavolume at/var/thoughtsync. - The app waits for the database and runs migrations (
alembic upgrade head) automatically on start. - Image tags:
:latest(stable, built frommain) ·:dev(latestdevbuild) ·:<git-sha>(immutable, for pinning / rollback). - Install as an app (PWA): ThoughtSync is installable ("Add to Home Screen" / the
browser's install button) for an app-like window. Browsers only offer install over a
secure context, so put the app behind a reverse proxy terminating HTTPS (or reach
it via
localhost) — plainhttp://<host>:5000won't show the install prompt.
Milestones
- M0 — Foundation & Identity ✅: Quart+Vue+Postgres skeleton, native auth, sharing-ACL spine, Fabled-Git CI.
- M1 — Capture Core ✅: note model + masonry board (quick-add, colors, pin, edit-in-place, archive, trash+restore).
- M1.5 — Roles & DB-backed Settings ✅: first user is admin, admin Settings UI,
DATABASE_URLis the only required env. - M2 — Organize: labels/tags, search, checklists, attachments.
- M3 — Second-brain seeds:
[[wiki-links]], backlinks, graph view, reminders. - M4 — Launch hardening: responsive/PWA, settings UI, polish.
Work happens on dev; main is protected (PR-only).