· 8 min read

Capture first: Building a journal with no filing step

A journal that never makes you file a thought before you've finished having it. Fully private and self-hosted.

For a long time I had the urge to write down thoughts as they happened: reflecting on the day, a note for university, an observation or a creative idea, whatever else came up. None of the tools I tried held up. A physical notebook, voice memos, note apps: all of them assumed I already knew where a thought belonged before I’d finished having it.

What I actually needed was something that let me write or speak a thought immediately, without doing anything else first, and that was fully private and completely mine, so I never had to think about whether something was too sensitive to go in. I also wanted the notes to end up sorted, not just dumped somewhere. And I didn’t want a routine bolted on top: some stretches produce a dozen unrelated thoughts, others produce none, so a fixed prompt or schedule was never going to fit how I actually think.

The data model

The solution was to make the unit of capture as small as possible. Each thought is an entry: just text and a timestamp. Structure comes later, from threads, which are named collections of entries.

An entry can belong to any number of threads, and a thread can hold anywhere from zero entries to as many as I put in it: a classic n:m relation. I implemented it as a single polymorphic edge table rather than a join table per relationship, since I already knew I wanted to attach other kinds of objects to entries later and didn’t want an exponential number of join tables to get there. More on that table in another article.

sql
CREATE TABLE threads (
id         TEXT PRIMARY KEY,
name       TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE entries (
id         TEXT PRIMARY KEY,
text       TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE links (
id           TEXT PRIMARY KEY,
subject_type TEXT NOT NULL,
subject_id   TEXT NOT NULL,
relation     TEXT NOT NULL,
object_type  TEXT NOT NULL,
object_id    TEXT NOT NULL,
created_at   TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (subject_type, subject_id, relation, object_type, object_id)
);

Notice that entries has no thread_id column at all. Membership is a row in links, nothing more. That single decision is what makes the inbox possible: the inbox isn’t a thread, it’s not a real object in the schema, it’s just the set of entries that have no row in links yet.

Entries and threads connected only through a links edge tableentriesthreadslinksrelation = “contains”
Entries and threads never reference each other directly. The links table is the only thing that connects them, and the same table is what lets other kinds of objects attach later without touching entries.

That missing line between entries and threads is the point. Nothing in the schema lets an entry “belong” to a thread directly, which is exactly what keeps an unfiled entry from ever needing a special-cased state: it’s not marked unfiled, it’s just missing a row. The same table that makes the inbox free also means new kinds of objects, people, calendar items, whatever comes next, can attach to entries later without a migration touching entries itself. The cost is on the write side: there’s no foreign key holding links honest, so deleting a thread has to clean up its edges in application code instead of getting that for free from the database. For a personal journal, that’s a trade I’ll keep making until the table gets hot enough to need real integrity.

sql
SELECT e.id, e.created_at, e.text
FROM entries e
WHERE NOT EXISTS (
  SELECT 1 FROM links l
  WHERE l.object_type = 'entry'
    AND l.object_id = e.id
    AND l.relation = 'contains'
)
ORDER BY e.created_at DESC;

That’s the whole mechanism. Every entry starts here, because starting here is just the absence of a link, not a state I have to set. Filing an entry into a thread doesn’t update the entry: it inserts a row into links, and the entry falls out of this query by definition. There’s no filed boolean anywhere that could disagree with reality.

Two consequences fall out of this. Deleting a thread requires it to be empty. I could have cascaded onto its entries or silently returned them to the inbox, but both make deletion a decision I didn’t consciously make, so a non-empty thread refuses to delete until I’ve reassigned or deleted what’s inside. And unlinking an entry from its only thread returns it to the inbox, necessarily, because the inbox is the absence of a link. That looked like a bug the first time I hit it. It isn’t: an entry that no longer belongs anywhere is unfiled by definition, and the alternative would mean introducing exactly the state flag the schema exists to avoid.

The stack

With the what settled, the how came down to a short list of constraints:

  1. I wanted a desktop version and a mobile one, from one codebase.
  2. I use an iPhone and didn’t want to get into native mobile development, or pay Apple’s 100€/year developer fee for something this small.
  3. I wanted to use the iPhone’s action button for dictation, so capture could start without even unlocking the phone.
  4. I’m already comfortable with React and Docker, and I host everything myself rather than renting a VPS.

Put together, that leaves one real option: a web app that works on desktop, scales down to mobile with CSS rather than a separate layout, and ships a manifest.json so it can be installed as a PWA and behave like a native app on the lock screen.

Since I needed a backend anyway, I used the chance to try Go: net/http is a good fit for a small API with no framework overhead. For storage I picked SQLite, mainly because it’s the fastest thing to get running: a file, no separate service to operate. The frontend is built and served by the same Go binary that exposes the API, and the whole thing is packaged into one Docker image, deployed with Docker Compose because Compose is easy to extend later without becoming a second thing to maintain.

The user interface

The design language

I wanted the interface to feel like a reflection of the real world rather than a generic app shell, and roughly half of what’s outside is sky, so I built the background around it. Sun position, cloud cover, and time of day drive a calculated gradient that changes as the day passes:

  1. Colorful gradients at dawn and dusk.
  2. A blue sky through the day.
  3. Stars at night, slowly rotating: real constellations would be a nice addition later, but I haven’t built that yet.
  4. When it’s cloudy, the whole scene desaturates and mutes rather than just adding cloud shapes on top.

I also tried adding auroras to the night sky. Getting them to look good and to perform well at the same time turned out to be more work than the feature was worth, so I abandoned it rather than shipping something that looked cheap or dragged the frame rate down.

The interface itself sits above that background with a frosted glass look, which keeps text legible against a sky that’s constantly shifting underneath it while still letting the background read through.

The input channels

On desktop there are two text inputs: one on the home page that sends straight into the inbox, and one inside a thread view that sends directly into that thread, skipping the inbox entirely.

Mobile has both of those, plus two more, since a phone is where capture actually has to survive being fast or not happen at all:

  1. An iOS shortcut on the lock screen, so a thought can be captured without unlocking the phone or getting pulled into anything else on it.
  2. A second shortcut, bound to the action button, that starts dictation. The recording gets sent to the server, which transcribes it using Mistral’s API with the Voxtral model. It’s currently the only external service the app depends on, and I plan to replace it with something self-hosted eventually.

This is the one place the design contradicts itself. The requirement was that nothing leaves the machine, precisely so I never have to judge whether a thought is too sensitive to write down, and dictation is the channel where that judgment is least likely to happen, since it fires before the phone is even unlocked. Voxtral gets the audio. I accepted that to get the feature working, and opted specifically to a provider under EU jurisdiction. Replacing it with a local transcription variant is high up on the roadmap.

Closing thoughts

Writing this down made me notice that almost nothing here is specific to a journal. An entry is text and a timestamp; a thread is a name; everything else is a row in links saying one thing contains another. The same table would happily hold a book I’m reading, a person I met, a workout, a bank transaction, none of which require touching entries to add. I built this because I wanted somewhere to put thoughts, and what I ended up with is closer to a substrate than an app: a capture path that doesn’t ask questions, and a schema that doesn’t care what’s being captured. I don’t know yet how far that generalizes, and there’s a real chance the answer is “not as far as I think.” But the next thing I’m building sits on top of it rather than beside it, and I’d rather find out where the abstraction breaks by leaning on it than by reasoning about it.