Technical notes
Under the hood.
This page is for the technical reader. It describes how the system is put together and why. The non-technical reader will find everything they need on the main page.
The one design decision that shapes everything else
The backend holds the memories and does all of the finding. When a question comes in, the backend works out what it is asking for — a time window, a named thing, or both — matches that against the database, and only then writes the answer as one sentence. The step that understands a sentence never sees the user's stored memories in order to decide which one is relevant; that decision is made against the database, in ordinary queries.
Two things follow from that. The cost of answering a question doesn't grow with the number of memories a user has. And the understanding step sits behind a single boundary, so it can be improved later without the behaviour of the rest of the system changing. How that step works is deliberately not described here.
Architecture
- Mobile app on the phone. Screens, voice capture, an on-device SQLite database for local-only memories, and the scheduling of notifications. The only part the user sees.
- Backend API — a single Node.js process. Accounts, cloud memories, money records, and all of the logic that decides which memory answers a question. A scheduled job wakes every minute to send due reminders; it takes a database advisory lock first so that multiple instances can never send one twice.
- PostgreSQL, managed. Only the backend talks to it. A memory has a fixed, indexed part (owner, reminder time, deadline) and a free-form JSONB part, so a memory can hold any set of fields without a migration.
- External services — an email service for verification and password-reset messages, and the phone platform's notification service. Both reached only from the backend, never from the phone, so no key ships inside the app.
Every request carries a signed JWT. The backend checks it, loads the user, and refuses if the token was issued before the last password reset. Every external call has a timeout. Repeated login attempts on one account are limited regardless of where they come from.
Stack, and why each piece
| Layer | Choice | Why this one |
|---|---|---|
| Mobile app | React Native + Expo, TypeScript | One codebase for iOS and Android. Over-the-air updates meant the phone could be updated during development without a rebuild. |
| Backend | Node.js + Fastify, TypeScript | Same language both sides, so API types are written once. Fastify over Express for built-in schema validation and speed per request. |
| Database access | Prisma ORM | Typed access; migrations generated from the schema, so data shape and code can't drift apart silently. |
| Cloud database | PostgreSQL | Relational for the indexed part, JSONB for the free-form part. |
| On-device database | SQLite via expo-sqlite | Already on both platforms, needs no server. Holds local-only memories. |
| Voice to text | expo-speech-recognition, on device | Audio never leaves the phone; only the transcript is sent. |
| Notifications | expo-notifications, scheduled locally | Fire offline, cost nothing to send. |
| Authentication | Custom JWT + bcrypt | No third party holds the user list. |
Three small decisions that turned out to matter
category and kind are two columns
Category is free text the application writes for the user to read — "travel", "boiler" — and nothing branches on it; even a card's colour is a hash of the string. Kind is a closed set the code does branch on: place, credential, medical, other. They were one column once, and a category containing "workplace" got matched as a place.
Money is stored as integers
Every amount is in minor units — cents — as an integer, never a float. Adding ordinary prices as floats eventually produces a total a fraction of a cent wrong, and in a budget that's visible.
The phone stores; the backend understands
Understanding a sentence stores nothing. The result goes back to the phone, which stores it — locally or by calling the backend. If the understanding needs a clarifying question, the memory must not already exist.