Services

Every service module, with the comment it carries at the top and the functions it exports. All database access lives here: routes are adapters that read a form and call one of these, which is why this list is the closest thing the app has to a table of contents. Modules in src/lib/services run on any instance — a server, or the device itself; the ones in src/lib/server/services only make sense on a server.

Collected from the source, so a function renamed or a header rewritten shows up here on the next build.

Module What it is for
access The account-level holds, decided in exactly one place.
account-data The account's data, table by table.
account-import Putting an exported account back.
server/account-import Putting an exported account back, on a server — and the copy it keeps first.
account-profile The account row itself, for whatever draws a name at the top of a page — and the name on it, which both instances let a person change.
account Taking your data out, and closing your account — and what this instance allows of both.
activities Categories are the areas of a life; activities are the named recurring things inside them. Both are referenced by planner slots and by history. A category cannot be deleted while something still points at it — history that loses its category stops being readable. An activity can: what named it falls back to its category, or moves to another activity (deleteActivity).
admin Administration: looking at somebody else's account.
assistant-chat The in-app chat: the same assistant surface MCP offers, spoken to a model the person brought a key for.
assistant-log What an assistant did to an account, and the way back.
assistant-notify Telling somebody what an assistant just did to their account.
attack-watch What an attack on the app itself looks like from inside the process.
attributes The attributes an account has actually used, and what they are worth reading.
audio Recordings: what is accepted, where they go, and who may hear one.
audit What happened to an account.
backlinks Which goal a thing belongs to.
bill-actions Everything that can be done to a bill, wherever the row is on screen.
billing Billing, as the rest of the app sees it.
bills Bills: money expected to go out, on a rhythm.
birthdays Being told it is somebody's birthday, on the morning of it.
budgets Monthly ceilings share statement spending; they never write a bank or stock entry.
calendar-feed The plan, published as a calendar anybody's software can read.
calendars Calendars somebody else controls.
client-errors Client-side errors, sent in with permission.
commitments Custody, simple debts and reserves are stocks, not bank movements or budgets. A balance is the declared opening plus dated entries in its original currency. Splits and settlements retain their source evidence; nothing here invents money.
companions The processes an instance needs BESIDE the app, and whether they exist.
ctx The single argument every service function takes.
demo A demo where everybody gets their own copy.
diary The journal: free text, free-form tags, one running number per account.
digest The content hash a picture is deduplicated by.
entry-actions Several notes at once, from wherever they are listed.
errors Typed errors thrown by service functions.
family-invite Inviting somebody to the plan, and the account that makes for them.
finance-summary The four questions, answered once.
gallery Albums: lists of references over the one media table.
goal-actions Everything done to a goal, from wherever a goal is shown.
goals Goals, and the progress that makes them more than a wish list.
habit-actions Everything that can be done to a habit, wherever the row is on screen.
habits Habits are things to do or to avoid, logged one day at a time.
health Can this process actually reach the database?
host What the surrounding instance does for the services.
idea-actions Everything that can be done to an idea, wherever the row is on screen.
ideas Quick capture: a thought, optionally tagged, optionally marked as applied.
import-vault A vault of markdown becomes notebook entries.
imports Bringing a list in from somewhere else.
instances The one answer to "what is on, between these dates".
inventory Two lists that share a table: replenish is stock you keep, someday is a wishlist. The difference is what "bought" means — a replenish item comes back when it runs out, a someday item is done.
item-actions Everything that can be done to an inventory item, wherever the row is.
ledger-actions Everything that can be done to a ledger, wherever the row is on screen.
ledgers Ledgers: the places money moves through.
legal The facts the policies are written around.
locations Locations: the tree an inventory hangs on.
mail-log Mail that must not fail silently.
media-kind Which kind of thing a media row is.
media-limits What an instance allows a picture to be.
media-permission Whether a caller may see a file, decided by what the file is used for.
media-referrers What points at a picture or a recording, and where it lives.
media Pictures: what is accepted, where they go, and who may see one.
model-catalog What a provider will actually answer to, asked rather than typed.
model-keys The model-provider key behind the in-app chat.
newsletter The one channel nobody else can take away.
note-todos Turning a note that is really a checklist into the todos it describes.
notebook-linking Putting something that already exists under a subject.
notebook-media Every picture that is in a notebook, as a gallery album.
notebook-sections One tab of one notebook, as lines.
notebook-seq Numbers inside a notebook — what TASK:#4, GOAL:#2 and IDEA:#7 point at.
notebooks Notebooks: a subject you write against, with no deadline.
notifications Everything the app will tell you about, in one list.
oauth Connecting an assistant without anybody handling a key.
onboarding-templates The starter weeks, as data.
onboarding First run.
people The people in your life, and where they turn up.
phone-widgets Home-screen widgets that show one tab of one notebook.
plan-intent Which plan somebody said they wanted, carried from the front page to the card.
plugins Plugin manifests: what a plugin says it understands.
preferences The settings a person chooses about themselves.
push Telling somebody something while the app is closed.
quotes The quotes shown one-per-day on the dashboard.
recipes Recipes, and the loop they close.
regex-safety Patterns somebody types, and the ones that never finish.
registration Who is allowed to create an account here.
reminder-clock The thing that makes a reminder arrive when it says it will.
reminder-delivery The pass that makes a reminder arrive with the app shut.
reminder-sources The reminders nobody types.
reminders Something that reaches out.
request-replays A create sent twice, answered once.
review-mail The morning a week begins: what last week actually was, in the inbox.
review Closing a week.
ringtones The sounds a reminder can make.
saved-filters A narrowing somebody wants back, under a name they chose.
schedule Read-only view of what's coming up.
schemes Saved weeks.
scoped-actions What a form handler needs of the request: the body, and who is signed in.
search One box over everything the account owns.
sent-notifications What the app has told somebody, kept so they can read it again.
sessions The sessions an account currently has open.
settings A person's own settings, kept in their rows.
slots The plan itself: blocks that repeat (recurring_tasks) and blocks that happen once (exceptional_tasks), plus the skips that cancel a single occurrence.
stale Things that never ended.
statements What moved through a ledger, and the rules that make sense of it.
streams Declare a stream. Idempotent per (user, slug) so producers can call it at every startup.
subscriptions What an account may do, and until when.
tags Tags, and the rows that join them to what they tag.
task-attributes A task's attributes: user-defined key/value pairs on a task block or a todo.
time Time, in the two shapes this app actually has.
today One day, in one request.
todo-actions Everything that can be done to a todo, wherever the row is on screen.
todos Todos: tasks that have no date yet.
tokens Scopes an API token can hold.
unused-media The pictures nothing points at any more.
validate Small hand-rolled validators.
version What is running here, and since when.
webhooks Webhooks: plugins that listen instead of push.
week-generator Format a Date as 'YYYY-MM-DDTHH:MM:SS' in local time (no UTC conversion).
wins Three things that went well today.
workout-actions Everything that can be done to a workout, wherever the row is on screen.
workouts Workouts: workouts you plan like meals.

access

The account-level holds, decided in exactly one place.

A hold is a state in which the app must not be usable: the address is unconfirmed, the card was never given, the subscription ran out. The page gate in hooks.server.ts and the API door in api/auth.ts both ask THIS — so a future route, action or plugin endpoint cannot forget a rule it never has to remember. Add new holds here and nowhere else.

Functions

accessHoldFor(user)

paymentHoldFor(userId)

The payment half alone — what an API token can be checked against without fetching the user row. Plugins stop when the subscription does.

holdDestination(hold)

Where a held browser goes.

Types

account-data

The account's data, table by table.

One explicit list of every table holding user data, written out by reference rather than looked up by name — adding a table and forgetting it here is then a compile error, not a silent leak. Taking an account out, and destroying one, both walk it.

Here rather than under $lib/server because it is data and not deployment: an instance that is a phone exports, imports and deletes exactly as a server does, over its own database, and this is the half of the old server/services/account.ts that never needed a server. What stayed behind is the policy around it — how many exports a plan allows in a day, and the audit line each one writes.

Functions

unaccountedTables()

Tables carrying a user_id that this module does not handle.

A guard rather than documentation: add a table and forget it here, and this returns its name.

hoursUntil(iso, now)

Everything the account owns, as plain JSON.

Deliberately the raw rows rather than a prettied-up shape: an export is for being complete and re-importable, not for reading nicely.

/** "in about 7 hours", for a message a person reads once and acts on.

accountRow(userId)

Who the export is of: the three facts about the account it opens with.

collectAccount(userId, now)

The same file the export produces, without asking permission.

exportAccount counts against the day's allowance and writes an audit line, both of which are right when a person asks for their data — and both of which are wrong when the app is taking a safety copy on their behalf. This is the rows and nothing else.

emptyAccount(userId)

Empty the account, and leave the account.

Everything the person made — the same rows an export carries — in one transaction, so a failure part-way leaves them with what they had rather than with half of it. They stay signed in, on the same plan, with the same address and password, looking at an app with nothing in it.

deleteAccount below is the other one: this walks the same tables and stops before the rows that ARE the account.

deleteAccount(userId)

Delete the account and everything in it.

One transaction, so a failure part-way leaves the account intact rather than half-erased. The auth rows go last: while they exist the user can still sign in and retry, which beats being locked out of a shell of an account.

tableCount()

How much schema this database has.

One number, and it is the honest answer to "is this copy up to date with the build it is running" — which is a real question on an instance that is a device, because a device runs the app's own migrations on itself and one that has not opened the app in a month is genuinely behind.

Types

account-import

Putting an exported account back.

The export was always the easy half — every row this account owns, as JSON. This is the other half, and it is the one that makes the export a way out rather than a souvenir: an instance you can leave is only true if there is somewhere to arrive.

The hard part is the ids

Every app table has an integer autoincrement primary key, and rows point at each other with it — a task_records row names a recurring_tasks id, a inventory_items row names a inventory_categories id. Those numbers mean nothing in the database being imported into: id 7 over there is somebody else's row over here, or nothing at all.

So the ids are not carried. Each row is inserted without its own id, the id SQLite gives it is remembered against the one it had, and every column that pointed at another table is rewritten through that map before it is written. Which columns those are is read from the schema itself — drizzle knows every foreign key, so there is no second list to keep in step with the first.

Tables go in parents-first, which is USER_TABLES reversed: that list is ordered children-first so deletion can walk it, and an insert is a deletion backwards.

What does not travel

An export contains rows that describe the instance rather than the person. Carrying them across would be wrong in ways that range from useless to dangerous, so they are dropped on the way in and NOT_PORTABLE says why for each one. The export still contains them: it is a copy of your account, and what an import does with a row is a separate question from whether you are entitled to have it.

It replaces, and it is one transaction

Merging two accounts is a different feature with different questions — whether two categories called "work" are one category, and nobody can answer that but the person. So this empties the account first and then fills it, inside a single transaction: it either all lands or none of it does, and there is no state where half a week exists.

Here rather than under $lib/server because none of that needs a server: an instance that is a phone takes an export back exactly as one with a database on a disk does. What stayed behind is the copy the server keeps beside that disk first — see server/services/account-import.ts.

Functions

parseExport(raw)

The shape exportAccount produces, checked rather than trusted.

previewImport(payload)

Everything the import would decide, decided before anything is written.

A restore empties the account first, so the moment to learn that the file carries a picture the import will refuse is before agreeing to that, not three seconds into a transaction that then rolls back with one sentence. This runs the same parsing, the same skip rules and the same byte-sniffing the import runs, and writes nothing.

importAccount(userId, payload, opts)

Types

server/account-import

Putting an exported account back, on a server — and the copy it keeps first.

The work of a restore is in $lib/services/account-import.ts, which a device runs too: reading the file, checking its shape, and refilling every table in one transaction. What is here is the one part that needs a disk — a copy of what is about to be destroyed, written beside the database, which exists whether or not anybody is still looking at the page.

Functions

keepBeforeImport(userId, now)

A copy of the account, on disk, before an import replaces it.

An import empties the account and refills it from a file, in one transaction — so if the file turns out to be the wrong one, or a year older than somebody thought, there is nothing to go back to. The database snapshot the deploy takes is the instance's; this is the person's.

Written beside the database rather than handed to the browser: it is a safety net rather than a download, and it has to exist whether or not anybody is still looking at the page. Named for the account and the moment, so an operator asked "can you put Ana back" has something to answer with — the path is on the audit line the import writes.

Best effort by design: a disk that will not take the copy is not a reason to refuse somebody their own restore. It says so and carries on.

importAccount(userId, payload, opts)

Restore, with the safety copy taken first.

The copy is the server's own addition: it is a net for an operator asked "can you put Ana back", and the path lands on the audit line the import writes. A device takes a different one — see its page.isolated.ts — because a file beside the database there is a file nobody can reach.

account-profile

The account row itself, for whatever draws a name at the top of a page — and the name on it, which both instances let a person change.

The rest of user handling — sessions, passwords, deletion — is the server's business and stays there. This is the one read that every instance needs: on an isolated instance it is the single seeded account, and the layout builds its user from it.

Functions

profileOf(userId)

renameAccount(ctx, raw)

The name the app calls this account by — in the header, and to the family on what they share. Not a credential: nothing signs in with it, so it takes no password to change.

account

Taking your data out, and closing your account — and what this instance allows of both.

The walk over every table holding user data is in $lib/services/account-data.ts, which a device runs too. What is here is the part that needs a server behind it: how many exports a plan allows in a day, the log that counts them, and the audit line each one writes. Both halves are re-exported from here so the rest of the server keeps one import.

Functions

exportsAllowedFor(userId, now)

How many exports this account's plan allows in a day.

exportAllowance(userId, now)

exportAccount(userId, now, opts)

Types

activities

Categories are the areas of a life; activities are the named recurring things inside them. Both are referenced by planner slots and by history. A category cannot be deleted while something still points at it — history that loses its category stops being readable. An activity can: what named it falls back to its category, or moves to another activity (deleteActivity).

Functions

listCategories(ctx)

listActivities(ctx, opts)

listActivitiesWithUsage(ctx)

The activities page also says what deleting each one would touch.

createActivity(ctx, raw)

updateActivity(ctx, id, raw)

toggleActivityActive(ctx, id)

Retiring an activity keeps its history; deleting it would not.

setActivityActive(ctx, id, active)

Switched on or off by saying which, for a caller that should not have to read first.

activityUsage(ctx)

The usage of every activity on the account, keyed by activity, in four statements.

deleteActivity(ctx, id, opts)

Delete an activity, whatever still names it.

Nothing is left pointing at a row that is gone, and nothing loses the word it was shown under. Folded, every block that named it becomes a block of its category carrying its name as the label, and every occurrence keeps the name as its own; goals that counted it stop counting it. Moved, all of it — goal links included — names the other activity instead, which is the same as having edited each one by hand.

createCategory(ctx, raw)

updateCategory(ctx, id, raw)

ownedCategory(ctx, value)

A category id from a form or a call, or null for none — one of this account's own, or a 404.

ownedActivity(ctx, value)

An activity id from a form or a call, or null for none — one of this account's own, or a 404. The planner's three services each had a copy.

deleteCategory(ctx, id)

Types

admin

Administration: looking at somebody else's account.

Two ways to be one. The instance owner is whoever installed it — the first account, on a self-hosted box. Everybody else has to be given the role, and the giving is itself an audited act.

Not being an administrator is a 404 rather than a 403 (I3): a page you may not see should not confirm that it exists.

Functions

isRole(value)

roleOf(userId)

isAdmin(userId)

canEditInstance(userId)

Who may touch the deployment settings.

Self-hosted: the owner, as always. Hosted: the administrators — the instance page is where registration mode lives, and the person running ontoplano.com flips that more often than anyone self-hosting does.

requireAdmin(userId)

Throws the same thing a missing page would.

claimFirstAccount(userId)

Whether this account should be an administrator by virtue of being first.

Called at registration: an instance with nobody in it hands the first account the keys, because otherwise there is nobody who can hand them to anyone.

searchAccounts(query, limit)

accountById(id)

grantTrial(actorId, subjectId, now)

Hand an account its trial, by an administrator's hand.

For the account that predates billing: created while the instance ran as self-hosted, so it has no subscription row, and the moment plans are enforced it would freeze at "none". Only such accounts qualify — giving a lapsed account another trial is a discount, and discounts belong to the payment provider, not to a button here.

setPlanEnd(actorId, subjectId, endsAt)

End an account's plan on a chosen date, by an administrator's hand.

An operator's tool, not a customer one: set it to yesterday and the account shows exactly what a lapsed user sees, set it ahead and a trial stretches. It moves only the dates this instance keeps — the provider's own billing schedule is not touched, so use it on test accounts.

setRole(actorId, subjectId, raw)

Give or take away the role.

An administrator cannot demote themselves: the instance would be left with nobody who can promote anyone, and the way out of that is a database editor.

deleteAccountAsAdmin(actorId, subjectId, typed)

Erase an account, having been made to type its address.

The friction is the feature. A test account and a real one sit in the same list, look alike, and are one row apart — and this is the button in the app with no undo behind it at all: deleteAccount empties every table the person owns inside one transaction, and there is nothing left to restore from afterwards except a backup of the whole instance.

So the confirmation is not a second click, which lands under the first. It is the address of the account being deleted, typed. Somebody who has the wrong row open types the wrong address and is told so, which is the only kind of confirmation that catches the mistake it is there for. Case and surrounding space are forgiven; nothing else is.

Two accounts are refused outright rather than made harder:

recentEvents(limit)

How many events the instance has recorded lately, for the admin landing.

Types

assistant-chat

The in-app chat: the same assistant surface MCP offers, spoken to a model the person brought a key for.

Everything the model may do goes through handle() — the same dispatcher an external assistant's calls go through — so scope checks, id resolution, the call budget, the write log and the room invalidations all apply here without a second copy of any of them. This file only turns the tool table into the shape the AI SDK wants and picks which company to dial.

Switched off. The chat this serves is parked — CHAT_IN_APP in $lib/features — so nothing calls this today. It is kept, and kept tested, because the decision is pending rather than made.

Functions

chatScopes(ctx)

What the chat may do, which is the account's own answer.

Every grant an assistant is offered, and destructive only where somebody has ticked it on the Chat tab. It is derived rather than written down twice: a tool added next year brings its scope with it through ASSISTANT_SCOPES, and deleting stays the one grant that has to be asked for.

chatResponse(caller, row, messages)

One message in, a streamed answer out, tools and all.

assistant-log

What an assistant did to an account, and the way back.

The MCP layer answers every write with the state it replaced — but that answer goes to whoever holds the transcript, and the person whose data it is has no transcript. This log is their copy: one row per write, with the same before, shown under Settings → Integrations. A row whose call deleted something carries a Put it back that recreates it through the same service the app uses.

Deliberately append-only and deliberately unable to fail a call: a log that breaks a write is worse than a gap in the log, the same rule audit.ts follows. Capped per account, oldest rows pruned, because a log nobody prunes is a disk that fills.

Functions

recordAssistantCall(ctx, entry)

listAssistantCalls(ctx, options)

putBack(ctx, id)

Recreate what a deleting call removed, from the before it recorded.

Through the same create the app uses, so ceilings, validation and ownership are the service's — this cannot make a row the person could not have made by hand. The new row gets a new id; what comes back is the thing, not the exact database row it was.

Only the deleting tools are offered a way back here. A changed row's before is in the log to be read; putting a change back is editing, and editing belongs in the app where the current state is on screen.

Types

assistant-notify

Telling somebody what an assistant just did to their account.

The log under Settings → Integrations is the record — one row per write, with the state it replaced and a way back. This is the part that reaches somebody who is not looking at it: a notification, after the assistant has stopped, saying what changed and pointing at the list.

One per burst, not one per call

"Reschedule my week" is twenty tool calls. Twenty notifications for one instruction is the version that gets switched off in a day, so a burst is collected and said once — and the burst is considered over when the writes stop rather than after a fixed interval, so a long run of them is still one line.

What it is allowed to cost

The sweep runs inside the minute that already runs for reminders. No new timer, no new unit on anybody's box, and a cadence that matches the quiet window exactly: a burst that went quiet a minute ago is a burst this minute will find.

Functions

phraseFor(tool)

What a tool did, as a verb key and a noun.

Returns null for a name this cannot read, and the caller counts those under "things" rather than guessing — a wrong sentence about somebody's data is worse than a vague one.

nounKey(noun)

data_point → dataPoint, which is how the catalogue spells its nouns.

subjectName(write)

What the thing a write was about is called, if anything says.

before first, because it is the row as it stood and a rename would otherwise announce the new name as though it were the old one; then the arguments, which is where a create's title lives since there was no row to read. Null when neither says — a reminder set by time has no name, and counting it is better than inventing one.

summarise(writes, token, t)

A burst of calls, as the line a person reads, in their own language.

Grouped by what was done to what, because that is the shape of the answer somebody wants: "changed 3 blocks, wrote 1 entry" rather than eleven lines. The largest group leads, since with one group it is the whole sentence and with several it is the one worth seeing first.

Active voice, the same way round as the title. "3 blocks changed, 1 entry wrote" was the first shape and the last two words are wrong English: a passive needs the participle. One form per verb is what somebody would say out loud, and it is also the form a translator can work with.

A noun the catalogue has never heard of falls back to the identifier with its underscores opened out. That is not a language, but it is a true sentence about somebody's data, which is the thing that matters most here — and it only happens for a tool added without its noun being added beside it.

whereBurstOpens(calls)

Where a burst's notification opens: the one thing it was about, or the log.

"An assistant added a task" opens that task; the log stays the answer for a burst that touched several things, where no one of them is what happened, and for one that deleted what it touched. A kind $lib/object-links has no room for falls back to the log too.

notifyAssistantBursts(now)

Say what the assistants did, to whoever is not watching.

Called from the minute that already runs. Never throws: a notification that can break the reminder sweep beside it is worse than a notification that does not arrive, which is the same rule the log itself follows.

catchUp(userId)

Start somebody's counter at the newest call there is.

For an account that has never had one of these: without it, switching the notifications on would send a summary of everything an assistant has ever done. Used when the preference is turned on.

Types

attack-watch

What an attack on the app itself looks like from inside the process.

The box already notices the attacks that are visible from outside it: the banning layer reads nginx and jails an address that knocks too often, and the firewall drops the ones the whole internet has already seen. Both of those are per-address, and the interesting attacks on an app are not.

Credential stuffing with a list of leaked passwords comes from hundreds of addresses, a handful of attempts each, against hundreds of accounts. Every individual address behaves impeccably. Nothing below the app can see it, because the shape of it is only visible to the thing that knows an attempt was a sign-in, and that it failed, and whose account it was for.

So this counts three things in a rolling window and says when the shape is wrong. It decides nothing and blocks nobody — the throttle in hooks.server.ts does the blocking, per address, as it always did. This is the sentence that reaches a phone, through the warnings /healthz already publishes and the watcher already relays.

In memory, like rate-limit.ts and for the same reason: this is one node process with a SQLite file beside it, and a table would be writes on the attacker's schedule. What is lost on a restart is a quarter of an hour of counting, and an attack that is still happening is counted again by the next window.

No address or account name is ever in a warning — only how many of each. The counts are what tells you it is happening; the identities are in the logs, where somebody looking for them has to go deliberately.

Functions

recordFailedSignIn(from, account)

A sign-in that was refused. The password is never passed in, or wanted.

recordPasswordReset(from, account)

A password-reset request, whether or not the address has an account.

recordServerError(path)

A request this instance answered with a 5xx.

resetAttackWatch()

Only for tests, and for a suite that must not inherit the last file's counts.

attackWarnings(now)

Whatever the shape of the last quarter of an hour says, as sentences.

Returned in the same form as the resource warnings beside them, because they travel the same way: into /healthz, out through the watcher, into a chat message with no context around it. Each one has to stand alone and say what to do next.

attributes

The attributes an account has actually used, and what they are worth reading.

An attribute is not a row: it is a name somebody typed into an item's own JSON, and the set of them is whatever has been typed so far. That is the point — not every thing shares a shape — and it is also why this file exists: a set with no table has nowhere to keep a colour, nowhere to be renamed from, and no way to answer "what values does length take?" without reading every item.

So the names and values are derived on demand, and the only thing stored is the colour, keyed by the words themselves.

Functions

listAttributes(ctx)

What the account's things say about themselves, gathered.

Sorted by how much they are used rather than alphabetically: a list of attributes is read to find the one worth filtering by, and the one used twelve times is more likely to be it than the one used once.

renameAttribute(ctx, from, to)

Rename an attribute everywhere it is used.

Renaming is the whole reason this is worth a screen: "Colour", "colour" and "color" become three attributes the first time three people — or one person on three days — write the same idea down, and nothing else can merge them. A rename onto a name that already exists is a merge, deliberately: the item's existing value for the destination wins, because that is the one it was last given.

renameAttributeValue(ctx, key, from, to)

Rename one value of an attribute, wherever a thing says it.

removeAttribute(ctx, key)

Take an attribute off everything that has it.

The things themselves are untouched otherwise — this removes a fact about them, not any of them. It is the destructive end of the screen, so the caller asks first.

removeAttributeValue(ctx, key, value)

Take one value of an attribute off every thing that says it.

The rest of each thing's attributes stay, and so does the attribute on the things that say something else — "length: 2m" goes, "length: 5m" does not.

setAttributeColor(ctx, key, value, color, opts)

Give an attribute, or one of its values, a colour.

An empty value colours the attribute itself, which everything of that name wears unless its own value says otherwise — so "length" can be grey while "colour: red" is red. An empty color takes the colour off again.

Types

audio

Recordings: what is accepted, where they go, and who may hear one.

The same three rules media.ts states for pictures, for the same reasons — the type comes from the bytes, nothing is stored under a name the sender chose, and the ceilings are the instance's and are enforced here rather than in a form. What differs is the allowlist and the numbers.

Containers, not codecs. A browser's MediaRecorder hands back WebM or MP4 depending on which browser it is, and neither is negotiable from here. Both are containers whose first bytes are unmistakable, so the check is the same shape as a picture's: read the head, match it, refuse everything else. What is inside the container is not inspected and does not need to be — the bytes are returned with an audio/* content type and a Content-Disposition that forbids the browser treating them as a document, so a file that lies about its insides plays as noise rather than running.

A recording is not a picture. It lives in the same table, because it is the same question — bytes belonging to an account, with a name and a size — but every surface that draws pictures asks for image/* and every surface that plays recordings asks for audio/*. Neither can see the other's rows.

Nothing here is the server's, for the same reason nothing in media.ts is: the digest is WebCrypto's and the ceilings arrive through host, so an instance running on a phone stores a recording the same way.

Functions

audioLimits()

defaultAudioName(at, tz)

What a recording is called when nobody says.

The moment it was made, which is the one thing about an unnamed recording that distinguishes it from the others — and the format sorts the same way it reads. The placeholder in the form is this exact string, so leaving the field alone and typing what it already shows are the same act.

tidyAudioName(raw)

Tidy a name somebody typed.

Control characters out, whitespace collapsed, length capped. Not a path and never used as one — the bytes are served by row id — so the only rules are the ones that keep a list readable.

countStored(ctx)

How many recordings this account already keeps.

store(ctx, input)

Take a recording in.

Everything a caller sends is a claim: the bytes are checked against the allowlist, the name is rewritten rather than trusted, the mime is the one the bytes proved rather than the one the request declared, and both ceilings are counted from the database rather than from anything sent.

read(ctx, id)

The bytes, for the one account they belong to.

Scoped in the WHERE and by kind, so somebody else's id and a picture's id are both a 404: not found, not yours and not a recording are one answer.

list(ctx)

get(ctx, id)

edit(ctx, id, input)

Everything the edit dialog offers, in one statement: the name and the notes land together or not at all.

setNotes(ctx, id, given, onlyIfEmpty)

Replace a recording's notes, with ownership and kind checked in the update.

remove(ctx, id)

Types

audit

What happened to an account.

Deliberately append-only and deliberately dull: a row is a verb, a subject, whoever did it, and enough detail to read it back a year later. Nothing here throws — a log that can fail a sign-in is worse than a gap in the log.

Functions

record(subjectId, event, options)

listForUser(ctx, limit)

The account's own history, newest first.

listForSubject(subjectId, limit)

The same, for an administrator looking at somebody else's account.

hasEvent(subjectId, event)

Whether an account has ever done a thing — used for "first export" style checks.

Types

Which goal a thing belongs to.

Every relation in this app was one-directional in the interface: a goal listed its tasks, and the task had no idea it was serving anything. That is the difference between a set of sections and one app — you write a todo because of a goal, and a week later the todo is the only thing you see.

The links are few (one row per goal-to-thing edge, for one account), so this fetches all of them in one query and the caller indexes what it needs rather than asking per row. A list of forty todos would otherwise be forty queries for something almost always empty.

Functions

goalBacklinks(ctx)

Types

bill-actions

Everything that can be done to a bill, wherever the row is on screen.

The Finance room shows every bill; a notebook shows the ones filed under its subject — the architect's fee, the skip hire — and paying one there has to mean the same thing. The notebook mounts these under a prefix; see $lib/bill-action-names for the names each screen posts to.

billing

Billing, as the rest of the app sees it.

Every name here is the name it always had, and every caller is unchanged — what moved is where the answers come from. The payment provider is behind ../billing/: an interface this repository ships, and an implementation it does not. See ../billing/contract.ts for why.

Two things stayed on this side, because they were never about a provider:

And everything about entitlement — the plan, the seats, the limits — is in subscriptions.ts and never left. A self-hoster still has all of it; what they no longer have in their copy of this repository is somebody else's merchant integration.

Functions

isBillingConfigured()

Whether this instance can actually sell anything.

displayPricing()

What the price is, asked of the thing that will charge it.

checkoutClientConfig()

The token the checkout page needs, when there is a provider with a widget.

hasYearlyPrice()

Whether there is a yearly price to switch to.

createCheckout(userId, interval, tier)

Open a checkout, and answer with the provider's id for it.

That id is what the browser carries to the checkout page and what the webhook will name later, which is how a payment finds its way back to an account even when the webhook is the thing that failed.

portalUrl(userId)

verifySignature(rawBody, signature, now)

handleWebhook(rawBody, fallbackId, now)

currentInterval(userId)

changeInterval(userId, interval)

claimCheckouts(userId, now)

claimAbandonedCheckouts(now)

hasUnsettledCheckout(userId)

chasedCheckouts(since)

reconcile(now)

sendTrialEndingNotices(now)

mapStatus(raw)

checkoutTrialDays(userId)

The trial the next checkout would carry.

The full run for a fresh account, whatever is left of one for a returning account, and zero when there is nothing left to carry. This account's own history, so no provider is asked.

instanceSells()

Whether this instance is meant to sell, whatever it can currently do.

isBillingConfigured() answers "can a checkout be opened right now", which is a different question and the one that was quietly wrong. An instance that intends to charge and cannot — the provider absent from the build, a price id unset, a key that never made it into the environment — looks from in here exactly like somebody's own copy running for free. And the code did the friendly thing with that ambiguity: it started a fourteen-day trial and said nothing, for every account, for as long as nobody looked.

Selling is declared, and nothing else implies it

The first attempt at this read "not self-hosted" as "sells", which is wrong in the direction that matters: almost every copy of this app is somebody's own, most of them never set ONTOPLANO_SELF_HOST because they have no reason to, and the refusal below would have met them on their first registration. The overwhelmingly common instance must be the one that needs no configuration at all.

So there is one setting and it is opt-in: [pricing] sells in config.toml. An instance that says it sells and cannot is broken and says so; an instance that never mentions money is a personal one and gets on with it.

whyItCannotSell()

What is stopping this instance selling, if anything.

For the administration page and for the refusal below, which need the same answer in two registers — a sentence to show somebody, and a reason to stop. Null means it can sell.

billingStatus()

What this instance can do about money, in three fields.

For /healthz, and through it for the deploy: the payment provider is copied into the tree at build time from a checkout that lives outside this repository, so a build made on a machine without that checkout produces an app that cannot sell and looks exactly like one that can. The only place that difference is visible is inside the running process, which is here.

onboardEntitlement(userId, invite, now)

What a brand-new account is entitled to, before it has paid anything.

Three answers and only one of them is about money: an invitation hands over a grant outright, an instance that sells sends them to a checkout, and a self-hosted instance starts a trial because nothing there charges for anything.

Why the fourth case throws

There used to be no fourth case. An instance that sells but cannot fell through to startTrial — the same branch as a self-hosted copy — so a misconfigured production instance handed every new account fourteen free days, silently, for as long as nobody looked. Nothing on any page said so, because from the app's point of view nothing was wrong.

The failure has to be loud and it has to be early: refusing registration on an instance that cannot charge costs the operator the accounts that would have signed up in the minutes before they notice; the alternative costs them the money from every account that ever signs up, and they find out months later. Existing accounts are untouched, and /admin can still grant a trial by hand for anybody who needs one.

playConfigured()

playMissing()

playSkus()

playClaim(userId, input)

playRtdn(rawBody)

playSheetFailed(userId, sku, code)

The store's own sheet said no, as the device reported it on the way back.

checkoutChannel(asking)

checkoutRefused()

The refusal for none, so both pages say the same thing.

Types

bills

Bills: money expected to go out, on a rhythm.

A bill is not a Paddle payment (that is billing.ts) and not a to-do — it is the rent, the water, the streaming subscription: a name, an expected amount, and a rhythm. Marking one paid writes a bill_payments row for that period recording what was actually paid, which may differ from what was expected. That gap — expected versus paid — is the first real number a finance section measures, so the payment is a row of its own rather than a flag on the bill.

A bill is archived, never deleted while it has history: its payments are the point. deleteBill exists for one made by mistake and takes its payments with it, on purpose.

A period can also be skipped — the gym frozen for a month — which is a row of its own with nothing paid, so the period reads as settled rather than overdue. And a bill can be automatic, a subscription on a card: it never asks to be paid, and recordAutomaticPayments writes its payment on each due day so the history is still true.

Functions

periodFor(rhythm, when)

The period key a rhythm settles into for a given instant.

listBills(ctx, opts)

Every bill, active first, newest within each — or one subject's.

notebookId narrows rather than changing the shape: a notebook's Bills tab is this room looking at one subject and draws the rows with the same component, so it needs exactly what the room needs.

summariseHistory(entries)

The numbers under a bill's history, from its rows.

billHistory(ctx, billId)

One bill's history, with automatic payments caught up first.

listBillsThisPeriod(ctx, opts)

The bills, each saying which period it is in and how that one was settled.

The Finance room worked this out in its own load, so anywhere else that showed a bill — a notebook's Bills tab — had the row without the two things the row is about: which period the tick would pay, and whether it is already paid. A row drawn without them offers to pay a bill that is paid. The history rides along because the row expands into it, on both screens.

getBill(ctx, id)

createBill(ctx, input)

updateBill(ctx, id, input)

setArchived(ctx, id, archived)

Archive keeps the history; the bill leaves the active list and its funnel.

Bringing an automatic one back starts its recording from today: the time it spent put away was not paid, and catching up across it would say it was.

deleteBill(ctx, id)

For a bill created by mistake: gone, with its payments.

markPaid(ctx, billId, input)

Mark a bill paid for a period.

The period defaults to the one the rhythm is in now, and paying the same period again corrects it rather than adding a second row (the unique index enforces one payment per period, so this upserts). The expected amount is snapshotted from the bill as it stands, so a later edit to the bill does not rewrite what was actually asked at the time.

updatePayment(ctx, billId, paymentId, input)

Correct the date and amount of an existing paid period without changing its period.

markPaidFromMovement(ctx, billId, movementId, period)

Mark a bill paid by pointing at the line that paid it.

The amount comes from the statement rather than from what was expected, which is the whole point: the gap between the two is the number this room exists to show, and typing it in by hand is how that number becomes fiction. Money out is negative in a ledger and a payment is a positive amount, so the sign is dropped.

unmarkPaid(ctx, billId, period)

Undo a payment for a period — it was never paid, or paid in error.

skipPeriod(ctx, billId, input)

Say a period was skipped on purpose — nothing was owed, nothing was paid.

Refused on a period that is paid: replacing a payment with a skip would lose what was paid, and undoing the payment first is one press.

unskipPeriod(ctx, billId, period)

Take a skip back — the period is open again. The inverse of skipPeriod.

listPayments(ctx, billId)

listAllPayments(ctx)

The payments this account can name through the assistant.

billsSettledIn(ctx, period)

The bills answered for in one period — paid or skipped — in one statement.

The dashboard's card asked listPayments of every monthly bill in turn, two statements each, to learn one fact per bill.

monthSummary(ctx, month, flow)

A month, the way the section's first page reads it: what was expected of the monthly bills, what has actually been paid this month across all bills, and the gap between the two. A monthly bill skipped this month expected nothing.

recordAutomaticPayments(ctx)

Write the payments automatic bills have made since they were last looked at.

A subscription on a card is paid whether or not anybody says so, so its history is written for it: one payment per due day that has come, for the expected amount, marked as the app's rather than a person's. From the bill's mark forward only, and the mark moves to today — so a payment somebody undid stays undone, and a period already paid or skipped by hand is left alone.

Called by every read that shows a bill's settled state, so the device instance, which has no job running in the background, is as true as the server. Idempotent: calling it twice in a day writes nothing the second time.

billsDueBetween(ctx, from, to)

Types

birthdays

Being told it is somebody's birthday, on the morning of it.

An address book that holds a birthday and says nothing on the day is an address book that has the information and none of the point of it. This turns the stored date into an ordinary reminder row, which means it arrives through every channel reminders already arrive through — the card on the planner, the notification on a phone — instead of being a fourth kind of thing that has to be delivered separately.

When

The hour the account's own day starts, from the planner grid. Not a constant: somebody whose day starts at five wants this at five, and being told at nine that it was somebody's birthday since midnight is being told late. The same reasoning as the weekly mail, and the same setting.

Once

The row is the record. A reminder for a person on a date is looked up before it is written, so running this every minute, or twice, or after a restart, writes one row a year per person. Dismissing it does not bring it back: the check is on the date, not on the state.

The year, when it is known

"Ana turns 34 today" where the year was recorded, "Ana's birthday" where it was not — which is the --MM-DD shape an address book needs and a date type cannot hold. Nothing computes an age from a year it does not have.

Functions

birthdayMessage(name, birthday, onDate)

ensureBirthdayReminders(userId, now, tz)

Make sure today's birthdays exist as reminders for one account.

Answers with how many rows it wrote, which is zero on all but a handful of days a year. Cheap enough to call on every delivery pass and from any page that is about to read reminders: one indexed query over an address book.

budgets

Monthly ceilings share statement spending; they never write a bank or stock entry.

Functions

budgetMonth(value)

listBudgets(ctx, filter, spent)

getBudget(ctx, id)

createBudget(ctx, input)

updateBudget(ctx, id, input)

deleteBudget(ctx, id)

copyBudgets(ctx, input)

Copy existing ceilings as ceilings, preserving orphan labels and never overwriting a bucket.

Types

calendar-feed

The plan, published as a calendar anybody's software can read.

calendars.ts is the other direction — reading a feed somebody else publishes. This one hands ontoplano's own week to Google Calendar, Apple Calendar, Thunderbird, a phone's default app: every one of them can subscribe to a URL that returns text/calendar, with no OAuth, no app to install and nothing of ontoplano's in the path but one GET.

Occurrences, not rules. The obvious implementation emits weekly blocks as RRULE:FREQ=WEEKLY and lets the calendar expand them, which is fewer bytes and a great deal more ways to be wrong: every-N-weeks anchors, the monthly rules that land on the 31st of a short month, and above all the occurrences somebody has skipped, which would need an EXDATE for each and would silently come back if one were missed. ics.ts says it in the other direction and it holds here: a meeting drawn at the wrong time is worse than a meeting that is missing. So the server expands the window itself, using the same code the app and the API already use, and publishes what it knows.

The cost is that the feed is a window rather than all of history, which is what a subscription is for anyway — nobody scrolls their calendar to last March to see whether they had a gym block.

Functions

buildFeed(ctx, opts)

The whole feed, as the bytes to serve.

PUBLISH and X-WR-CALNAME are what make a subscription show up named rather than as an untitled calendar; they are not in RFC 5545 but every client reads them, and a feed nobody can tell apart from their others is a feed they turn off.

calendars

Calendars somebody else controls.

One-way and read-only, which is the whole design rather than a limitation: an .ics address needs no OAuth, stores no token that could be stolen, and works for Google, Outlook, Fastmail, Nextcloud and anything else, because it is the one thing they all agree on.

The fetch is deliberately not a background job. A feed is refreshed when the page that draws it notices the copy is stale, which means no scheduler to keep alive on a self-hosted box and no work done for an account nobody is looking at.

Functions

listFeeds(ctx)

addFeed(ctx, raw)

removeFeed(ctx, id)

refreshFeed(ctx, id)

Fetch one feed, whatever its state.

A failure is written to the row rather than thrown: a calendar that stopped answering should say so on the page, not take the planner down with it, and the last good copy keeps being drawn in the meantime.

refreshStale(ctx)

Refresh whatever has gone stale, in parallel, swallowing nothing silently.

subscribedEvents(ctx, from, to)

Everything from every subscribed calendar in a window.

Types

client-errors

Client-side errors, sent in with permission.

A crash in the browser leaves nothing on the server, so bugs that only happen on somebody's phone stay invisible until they give up and leave. The fix is a report — but a stack trace is their data leaving their browser, so nothing is sent until two people have said yes: the instance turns the feature on in its config, and then each person is asked once, in the page, and can say never.

Functions

clientErrorState(userId)

What the page should do with an error: nothing, ask first, send, or drop it.

setClientErrorConsent(ctx, decision)

recordClientError(ctx, input, options)

Write one report — to the log, and to a table the administrator can read.

The log alone was the original answer, on the grounds that a report is operational exhaust rather than the account's data. That first half is still true and is why this table is not part of an export and carries no name once the account has gone. The second half was wrong in practice: the log is journald on the box, so "somebody reported an error" reached nobody who was not already tailing it, and the only way to find out was to be told.

Both, then: the line stays for whoever greps, and /admin shows the last few hundred so a report goes somewhere a person actually looks.

recordVisitorError(input, now)

A crash on a page nobody was signed in to.

The front page is the one a stranger sees, and it was the one page whose failures could never be reported: the endpoint asked for a session, so an error there reached the visitor and nothing else. A 500 on production with nothing in the server log is exactly this shape — the server answered 200 and the page broke afterwards.

There is no stored consent for somebody with no account, so the only way in is an explicit press of the button on the error page. The instance switch still decides whether the feature exists at all.

recordBugReport(ctx, input, kind)

Something somebody wants the operator to read: a problem, or an idea.

Both are recorded the same way and both are mailed if the instance names an address — the difference is one word in the subject, because "the board scrolls wrong" and "the board should scroll the other way" want reading in the same place but sorting differently once read.

recentClientErrors(limit)

The most recent reports, for /admin.

The address is joined rather than stored, so an account that is deleted takes its name out of this view without the row having to be rewritten.

dismissClientError(id)

Forget one, once it has been dealt with.

Types

commitments

Custody, simple debts and reserves are stocks, not bank movements or budgets. A balance is the declared opening plus dated entries in its original currency. Splits and settlements retain their source evidence; nothing here invents money.

Functions

listCommitments(ctx, options)

One aggregate per stock; closed records only appear when explicitly requested.

getCommitment(ctx, id)

createCommitment(ctx, input)

updateCommitment(ctx, id, input)

archiveCommitment(ctx, id, archived)

listCommitmentEntries(ctx, id)

getCommitmentEntry(ctx, id)

addCommitmentEntry(ctx, input)

deleteCommitmentEntry(ctx, id)

closeCommitment(ctx, id, input)

deleteCommitment(ctx, id, options)

commitmentTotals(ctx)

Each kind/currency stays a stock of its own; debts are never netted into funds.

settleCommitment(ctx, input)

consumeReserve(ctx, input)

Types

companions

The processes an instance needs BESIDE the app, and whether they exist.

ontoplano.service is not the whole deployment: reminders fire from a systemd timer that asks /api/jobs/reminders once a minute, the weekly review mail from another, billing reconciliation from a third. A self-hoster who set up the service alone has an app that works perfectly and never reminds them of anything — and nothing anywhere said so. This is what the instance page reads to say so.

Two authorities, in order of honesty:

Functions

markJobRan(job)

lastRanAt(job)

companions()

The rows the instance page shows. Async and shelling out, so it is called from that one page's load and nowhere hot.

Types

ctx

The single argument every service function takes.

Built once per request and passed down. Services never reach for locals, new Date(), or the session — everything they need is here, which is what makes them callable from a form action, a JSON endpoint, a CLI script, or a test with equal ease.

Functions

serverTimezone()

The server's timezone — the fallback for an account that never named one.

buildCtx(userId, opts)

localDateOf(instant, tz)

The civil (calendar) date of an instant in a given timezone.

Data points are stamped with this on write so that "did I weigh myself today" and calendar heatmaps are plain string comparisons rather than per-row timezone maths at read time.

chosenDay(ctx, value)

A day the person chose, or today where they are when they chose none.

Never converted to UTC (I5): the string is kept as the civil date it is. Three services each parsed this for themselves before.

Types

demo

A demo where everybody gets their own copy.

The first version signed every visitor into one shared account and wiped the database hourly. That is simple and it is also the worst of both worlds: two people looking at once each watch the other type, one person can rename everything for everybody, and the only way to make it safe again is to destroy an hour of somebody else's poking about.

So a visitor gets an account of their own, seeded with the same week the development database has, and it is deleted when it has been left alone long enough. Nothing is shared, nothing has to be wiped on a timer, and somebody who comes back within the hour finds their own changes where they left them.

Three things keep that from being a way to fill a disk: an account is only ever made for a page view (never for an asset or an API call), the instance has a ceiling on how many may exist at once, and every one of them has an expiry from the moment it is made.

Functions

demoAccountCount()

How many demo accounts exist right now.

createDemoAccount(host, make)

demoSpareCount()

How many are waiting, for the tests and the operator's curiosity.

fillDemoSpares(host, make)

Top the spares up, one at a time, in the background.

One fill at a time: two visitors in the same second must not start four seeds. A failure is logged and ends the fill — the next request tries again.

resetDemoAccount(userId)

Put one demo account back the way it arrived.

Everything the account owns is deleted and the fixtures are laid down again — the same script, so what somebody resets to is exactly what the next visitor would have been given. The account itself, its address and its session all survive: the point is to undo a mess, not to sign somebody out of a demo they cannot sign back into.

The timer is reset with it, because somebody who just asked for a fresh demo is somebody who intends to keep looking.

isDemoAccount(userId)

Whether an account is one of the demo's throwaway copies.

The expiry stamp IS the distinction: a visitor's account carries one from birth, and an account somebody made deliberately — the operator's — never does. It is also why the sweep cannot eat the operator: it only ever deletes what carries the stamp.

demoExpiry(userId)

maybeSweepDemoAccounts(now)

Sweep, unless one just happened.

This exists because the sweep used to run in exactly one place: the branch that hands a new visitor an account. So a demo nobody new arrived at never cleaned up — the accounts sat there past their expiry, and the one person refreshing the page kept the instance alive without ever triggering the thing that was supposed to end their session. Called from every demo request now, which is the only place that is true whether or not anybody new shows up.

sweepDemoAccounts(now)

touchDemoAccount(userId, now)

Push an account's expiry out, because somebody is using it.

The lifetime is "since last seen" rather than "since created": a visitor reading carefully for two hours should not have the page taken away mid-sentence, and one who left an hour ago is not coming back.

orphanedDemoAccounts(host)

Demo accounts left behind by an older deployment, or by a crash mid-creation.

An account whose address looks like ours but which carries no expiry would otherwise live forever. Swept on the same pass, with the same reasoning: the demo owns every address at this host.

Types

diary

The journal: free text, free-form tags, one running number per account.

Functions

listEveryEntry(ctx)

Every piece of writing this account owns, journal and notebook alike.

listEntries is the journal and deliberately leaves out anything filed against a notebook, which is right for the diary page and wrong for the one question the MCP layer asks before it touches a note: is this note mine? So this is the whole reach in one query — ids and where each note lives, which is all a reference needs, and all a token confined to one notebook needs to be narrowed by.

listEntries(ctx)

The journal, and only the journal.

A note written against a notebook is stored in this table — one kind of writing, one place to keep it — but it is not a diary entry and does not belong in the diary. It appears on its notebook and nowhere else.

A note whose notebook was deleted keeps its notebook number, so it is excluded too: it goes to the orphaned notes on the Notebooks page rather than turning into a journal entry the day the renovation ends.

tagsForEntries(ctx, entryIds)

The tags on each of these entries, keyed by entry id.

One query for a page of notes rather than one per note: a notebook is a list of forty, and the diary is longer than that.

listTags(ctx)

createEntry(ctx, raw)

createWins(ctx, raw)

Three wins for a day, written as one entry and tagged so they can be found.

The wins arrive as win_0, win_1, … from a form that can grow a row, so the count is whatever was sent rather than a fixed three.

updateEntry(ctx, id, raw)

getEntry(ctx, id)

One piece of writing, whole, for a caller that has to read before it writes.

listEveryEntry gives ids and nothing else, and listEntries is the diary — deliberately blind to anything in a notebook. Editing a note over the API needs the note itself: what it currently says, so a change to the title does not have to resend the body, and what notebook it is in, so an edit cannot quietly move it.

archiveEntry(ctx, id, away)

Put a note away, or take it back out.

Hidden, not gone. A notebook kept for a year accumulates notes that have stopped being interesting and are still not things to delete — the trip is over, the flat is rented, the argument is settled — and a list of forty notes where six are current is a list nobody reads. The note keeps its number, its tags and its people, and comes back exactly as it was.

pinEntry(ctx, id, pinned)

Keep a note at the top of its notebook, or stop.

A notebook reads oldest first because it is a subject being worked through, and that is exactly wrong for the two or three notes somebody comes back to every time they open it. As many as you like: what is worth keeping in front of you is not a number anybody else can pick.

deleteEntry(ctx, id)

moveEntry(ctx, id, notebookId)

File a note under another notebook, or under none.

The notebook's number is not carried: the next one free in the new notebook is taken, as fileUnderNotebook does for everything numbered by notebook. A note that lands in the diary gets the diary's next number if it never had one, or it would be the one entry there with no # to be referred to by.

isEntryBatchVerb(value)

tagEntry(ctx, id, change)

Add and take off labels, leaving the others where they are.

updateEntry's tags replaces the whole set, which is right for a form showing all of them and wrong for nine notes that each carry different ones.

batchEntries(ctx, verb, rawIds, what)

Do one thing to each of these notes, or to none of them.

One transaction, as batchTodos is: a note that is not this account's, or a notebook that is not, refuses the whole press rather than leaving half of it done and no way to tell which half.

latestEntry(ctx)

The most recent entry, for the dashboard card.

The diary's own, which is not the same as the most recent row: notes written inside a notebook live in this table too, and the only thing telling them apart is a notebook id. Without that condition the card headed "Diary" showed whatever had been typed last anywhere in the app — a line from a project notebook, under the diary's heading, with the diary's own last entry nowhere. listEntries above has always said isNull(notebookId); this did not.

Types

digest

The content hash a picture is deduplicated by.

WebCrypto rather than node:crypto, because this runs in both worlds: on the server, and inside the worker that is the whole instance on a phone, where there is no Node at all. crypto.subtle is the one digest both of them have — which makes it asynchronous, and that is why store is.

It is a fingerprint, not a secret: the same bytes must reach the same string on every instance, so an export taken from a server and opened on a phone still knows that two rows are one picture.

Functions

sha256Hex(bytes)

entry-actions

Several notes at once, from wherever they are listed.

The diary lists entries and a notebook lists its notes; both are rows of the same table and a selection means the same thing on either, so both screens mount this one handler.

errors

Typed errors thrown by service functions.

Services never import SvelteKit types or return fail() — they throw these, and the route/API adapters map them to the right response shape. That's what lets a form action and a JSON endpoint call the same function.

A refusal is a message key, not a sentence. These reach a person: a form action puts one in form.message and the screen shows it. Written as English in the source, an account set to Portuguese got its screens translated and its refusals in English — which is worse than an untranslated screen, because a refusal is read at exactly the moment somebody is stuck.

So a thrown error carries the key and the values, and the adapter that turns it into a response is what translates, in the language of the request it is answering. A plain string is still accepted: a few of these are read by software rather than by a person.

Functions

toServiceError(e)

Types

family-invite

Inviting somebody to the plan, and the account that makes for them.

An address with no account gets one made on the spot — with a password nobody knows — and a mail whose link verifies the address, signs the new account in and lands it on the set-password step. The link is better-auth's ordinary verification URL, minted directly (familyInviteLinkFor in auth.ts), so there is no second token system.

Functions

passwordPending(userId)

Whether this account has never chosen a password.

True only for accounts minted by a family invitation, from creation until the set-password step (or a password reset) replaces the random one. The /welcome funnel reads it to put the password page first.

markPasswordPending(userId)

chooseFirstPassword(userId, password)

The invited account's first password, chosen on the page the mail opens.

Hashed and stored the way better-auth stores every credential, so the next sign-in is an ordinary sign-in. Clears the pending flag, which is what lets /welcome proceed.

familyOfferMail(t, url, ownerName)

The mail an account that already exists gets: a question, not news.

Nothing has happened to their account when this lands — the seat is an offer sitting in the app, and the link goes to the page with the two buttons on it.

familyInviteMail(t, url, ownerName)

inviteToPlan(ownerId, email)

Put somebody on the plan whether or not they have an account yet.

With an account, this is addToPlan — the seat lands instantly. Without one, the account is made on the spot with a password nobody knows, the seat attached, and the invitation mail carries better-auth's own verification link — which verifies the address, signs the new account in and lands it on the set-password page, and /welcome after that. No second token system; the one the funnel already has.

Creation is only offered where registration is open. On an invite-only or closed instance a payer typing addresses must not be a way to mint accounts, so those fall back to the old rule: the account has to exist.

finance-summary

The four questions, answered once.

What is mine, what is committed, what I spent this month, and what is left against my plans — for every original currency separately, because §4 says currencies never add (D6) and because a total that mixes them is a total nobody can act on.

This module does no arithmetic of its own beyond the cross-room formulas in architecture.md §4: supported funds come from ledgers.ts (D5), spending from statements.ts (D4), budgets from budgets.ts (D7), stocks from commitments.ts (D7). It only puts them beside each other and names the gaps. Every figure is one aggregate query — the inputs are grouped reads; nothing here loads statement rows and re-sums them in JavaScript (D11).

Functions

moneyOverview(ctx, filter)

The money picture for one account at one civil month.

Currencies are the union of the accounts, the open stocks, the month's budgets and the month's spending — a currency is on the screen if it has something to say, and an account in it is the only thing that can make a supported total.

Types

Albums: lists of references over the one media table.

A picture lives once in media, deduplicated by hash; an album holds membership rows. Putting a picture in a second album is a second row — "duplicating" a photo never copies bytes, which is why it is cheap and why removing it from one album leaves the other untouched. A picture whose last reference goes is deleted with it: the gallery never leaves invisible bytes behind on somebody's instance.

Portable, like the media service under it: the bytes are a column in the same database either way, so an instance running on a phone has a gallery for the same reason a served one does.

Functions

albumNameFor(path, under)

The album a chosen file belongs in, from the path the browser sent.

webkitRelativePath looks like a path but it is a string the client wrote, and this is where it stops being one: the segments are split off, . and .. are dropped rather than resolved, separators and control characters are removed, and the em-dash this module uses to mean "inside" is taken out of each segment so a folder literally called a — b names one album rather than forging two levels. Nothing here ever reaches a filesystem — the bytes are a column — but a name that still looks like a path invites somebody later to treat it as one.

Too deep or too long lands in the nearest ancestor that fits, because an import that refuses a photograph over the length of a folder name is worse than one that files it a level up.

albumTree(ctx)

The albums as they belong to each other, roots first.

listAlbums(ctx)

createAlbum(ctx, input)

renameAlbum(ctx, id, input)

deleteAlbum(ctx, id)

Deleting an album lets go of its references, and any picture that was only there goes with them — bytes nobody can see are not kept.

albumsInside(ctx, albumId)

The albums directly inside one, and everything beneath it.

An album's name is its lineage — see ALBUM_SEPARATOR — so "inside" is a string test rather than a join. The nearest existing descendant on each branch, not everything one separator deeper: a folder import can leave Birds — Falconiformes — Hawks with no Birds — Falconiformes, and that album still has to appear somewhere rather than vanishing into a gap.

albumPicturesDeep(ctx, albumId)

Every picture in an album and in everything inside it.

What somebody means by "the Birds album" is the birds, and a folder import puts every one of them in a subfolder — so the album itself holds nothing and the page was a wall of white. A picture that is in two folders under the same parent appears once.

albumPictures(ctx, albumId)

uploadToAlbum(ctx, albumId, input)

A new picture, straight into an album.

planFolder(ctx, files, opts)

What an import would do, before it does any of it.

A folder of two hundred photographs is exactly the case where "18 in, 10 refused" after the fact is useless: by then the ten are lost in a number and nobody knows which. So the browser reads the names and sizes, this says what would happen to each, and the import proper only runs on a second press.

Names and sizes, never the bytes, so looking at a folder costs one small request instead of the whole folder going up twice — and every ceiling the import will be judged against is counted here, so "will import" means it.

importFolder(ctx, files, opts)

addToAlbum(ctx, albumId, mediaId)

The reference duplicate: the same picture, now in another album too. Idempotent — an album holds a picture once.

removeFromAlbum(ctx, albumId, mediaId)

Out of this album; gone entirely if this was its last reference anywhere.

moveBetweenAlbums(ctx, from, to, mediaId)

Move: out of one album, into another, one gesture.

renamePicture(ctx, mediaId, input)

What a picture is called, and what it says to somebody who cannot see it.

The description is optional and separate from the name on purpose: a filename is for finding it again, and alt is what a screen reader speaks and what stands in when the bytes do not load. It is the one field of a picture the gallery could not edit — recipes and people set it on upload and the gallery never asked.

tagPicture(ctx, mediaId, raw)

Types

goal-actions

Everything done to a goal, from wherever a goal is shown.

It used to be one action on the goals page, which is why a notebook's "New goal" was a link that took you out of the notebook you were looking at — there was nowhere else the form could post to. A task made from the same header had always stayed put, so the same press behaved two different ways depending on which tab was showing.

The same arrangement todo-actions.ts has, and for the same reason: one handler, used by every route that offers the verb. Making was the first one to move; the rest followed when a notebook's Goals tab turned out to be a list you could look at and nothing else — no edit, no delete, no way to say a goal was missed — because the verbs lived on one page rather than beside the thing they act on.

Which name each route answers to is $lib/goal-action-names, because a notebook page already uses update and delete for the notebook itself.

Functions

targetsFrom(formData)

The rows of "what this goal is measured by", as the form posts them.

goals

Goals, and the progress that makes them more than a wish list.

A goal linked to tasks has progress that can be counted: how many of the occurrences it covers actually got done inside its period. That is the whole point of linking — a self-reported number tells you what you believe, and the execution log tells you what happened.

What it is measured by lives in goal_targets, one row per thing, because a goal worth making usually wants more than one: three gigs played and five songs recorded is a single commitment with two numbers under it. The goal is as far along as everything measuring it is on average.

Functions

listAreas(ctx)

listGoals(ctx, opts)

listActiveOn(ctx, date)

Goals whose period covers date, for the dashboard card.

linkableSlots(ctx)

The blocks a goal can be linked to, each with the name it goes by.

The name follows the same rule as the grid — activity, then label, then category — because a list that showed the label alone printed "block 47" for every block that had never been given one, which is most of them.

createArea(ctx, raw)

updateArea(ctx, id, raw)

Rename an area or change its colour; a field left out is untouched.

A name another area already has is refused, as it is on create: an area is a place goals are filed, like a shelf, and two shelves with one label would be merged by a typo rather than on purpose.

moveArea(ctx, id, delta)

A move is a reinsertion: every area is resequenced around the one moved.

deleteArea(ctx, id)

Goals keep existing without an area rather than disappearing with it.

createGoal(ctx, raw)

updateGoal(ctx, id, raw)

addGoalTarget(ctx, goalId, raw)

Add one measure to a goal, leaving the ones already on it alone.

removeGoalTarget(ctx, targetId)

And the way back off it. The goal and its other measures stay.

setTargetProgress(ctx, targetId, value)

Self-reported progress on one measure — the number somebody types in.

Refused for a measure counted from the workout register: that number is the sum of what was actually logged, and letting it be overwritten would leave the goal saying something the register contradicts — with nothing on screen to say which of the two is the truth.

addGoalNote(ctx, id, raw)

Words on a goal, written down whenever — a closed one's afterthought, or a line on how an open one is going. They join its history rather than replacing what was said on closing.

closeGoal(ctx, id, raw)

Replace a goal's links wholesale — simpler than diffing, and idempotent.

Add work to a goal without disturbing what is already on it.

setGoalLinks replaces the whole set, which is right for the page — a form that shows every checkbox and posts all of them — and dangerous for anybody else. A caller that knows about three todos and calls it unlinks everything it did not know about, silently, and the progress bar drops with no explanation on screen. An assistant asked to "make tasks for this goal" is exactly that caller.

So: additive, idempotent, and it answers with how many links are new.

And the way back off it, one link at a time.

deleteGoal(ctx, id)

Types

habit-actions

Everything that can be done to a habit, wherever the row is on screen.

The Health room shows every habit; a notebook shows the ones filed under it, and ticking one there has to mean the same thing. So the handlers live here and both routes mount them — see $lib/services/scoped-actions for how the notebook mounts them under a prefix, and $lib/habit-action-names for the names the markup posts to.

habits

Habits are things to do or to avoid, logged one day at a time.

Occurrences carry their own user_id, so every statement here scopes by the account directly rather than reaching through the habit it belongs to (I1). It is still one statement — never a check followed by an unscoped write.

Functions

listHabits(ctx, scope)

The habits, all of them or one subject's.

notebookId narrows rather than changing the shape: the notebook's Habits tab is this room looking at one subject, and it draws the rows with the same component, so it needs exactly what the room needs.

Archived habits are left out unless asked for: they keep their history for the room's archived list, and nothing else — today, a notebook, an assistant ticking by name — should offer them.

listOccurrences(ctx)

A year of history, which is what the heatmap draws.

today(ctx)

createHabit(ctx, raw)

updateHabit(ctx, id, raw)

setHabitArchived(ctx, id, archived)

Put a habit away, or bring it back. Its history stays either way.

deleteHabit(ctx, id)

logOccurrence(ctx, raw)

setDayCount(ctx, raw)

A day's count, set outright.

What the counter on a card sends once the pressing stops: the number the day should hold, never a step, so a repeated or late write cannot count twice. Going up adds blank occurrences; going down takes the newest ones back, which are the ones the presses just added.

toggleOccurrence(ctx, raw)

Clicking a day in the heatmap: log it, or take one back.

One at a time, on a day that holds several: the square goes a shade lighter rather than empty. Which is what a square with a number behind it means, and the only reading under which pressing it twice returns you to where you were.

updateOccurrence(ctx, id, notes)

deleteOccurrence(ctx, id)

computeStreak(habit, occurrences, todayDate)

How long the habit has been going.

A bad habit counts the days since the last slip; a good or neutral one counts consecutive scheduled days completed, walking backwards. Today missing does not break a streak — the day is not over yet.

parseScheduledDays(raw)

scheduledOn(habit, date)

Whether a habit is one of today's, for anything showing a single day.

Types

health

Can this process actually reach the database?

Lives here rather than in the route because routes do not query (I2), and because "listening" and "able to serve" are different questions: a locked or missing SQLite file leaves the port open and every page 500ing.

Returns 'ok', or the reason, trimmed — a probe that prints a stack trace to the public internet is a probe that describes the filesystem to strangers.

Functions

databaseReachable()

resources()

instanceName()

Which instance is speaking, as a host name.

A box runs more than one of these — production, staging, and the demo — and the watcher that carries a warning to a phone knows only which box it polled. So "teleonto: 4 mails failed to send" was a true sentence about one of three instances, and the /admin it points at is whichever one you happen to open.

ORIGIN is where this process is actually served from, which is the only honest answer and already set on every deployment. Empty when it is not — a development run, a test — and then the warnings read as they used to.

warnings(r)

Whatever is currently over the line, as sentences a person can read.

tokenMatches(want, given)

Constant-time token comparison.

A probe token is not a password, but === on a secret leaks its length and then its bytes to anyone patient enough to time the endpoint, and the fix is four lines.

Types

host

What the surrounding instance does for the services.

A handful of things the services fire off are not theirs to implement: delivering a webhook, knowing who shares a family plan, enforcing a paid plan's limits. On the server those are real modules with network access and billing tables behind them; on an isolated instance they have nothing to stand on — and, more to the point, nothing to do.

The defaults below ARE the isolated instance, correct by construction rather than by configuration: one account means the family circle is you; no billing means no limits to enforce; no listeners means an event announced to nobody. The server overrides all of it in $lib/server/host.ts, bound in the same breath as its database, so a server can no more forget these than forget where its data is.

Functions

bindHost(instance)

bindFileCaller(fn)

The file caller, bound on its own rather than with the rest.

Everything else here is bound by $lib/server/db/index.ts the moment the database is opened. This one cannot be: answering it needs the token service and the payment gate, and those reach back into the database — so binding it there makes a cycle, and the first thing that cycle does is run the migration check before anybody has said which database to open. It is bound from hooks.server.ts instead, which is the server's own entry and is imported by nothing below it.

Until then the answer is "no key is asking", which is the truth on a device and the safe answer everywhere: the route falls back to the session.

Types

idea-actions

Everything that can be done to an idea, wherever the row is on screen.

The Ideas room shows every idea; a notebook shows the ones filed under it, and marking one applied there has to mean the same thing. The notebook mounts these under a prefix — see $lib/services/scoped-actions.

ideas

Quick capture: a thought, optionally tagged, optionally marked as applied.

Functions

listIdeas(ctx, scope)

The ideas, all of them or one subject's.

notebookId narrows rather than changing the shape: a notebook's Ideas tab is this room looking at one subject and draws the rows with the same component, so it needs exactly what the room needs.

listTags(ctx)

createIdea(ctx, raw)

updateIdea(ctx, id, raw)

deleteIdea(ctx, id)

toggleApplied(ctx, id, note)

Applied is a toggle, so the current value is read inside the same scope.

It deliberately leaves updatedAt alone. The card writes "· edited " whenever that differs from the creation date, so marking an idea applied added a line of text to the row and reflowed everything under it — an edit marker for something nobody edited.

updateAppliedNote(ctx, id, note)

The note beside an applied idea, which is not the idea. See toggleApplied.

toggleFavorite(ctx, id)

Starring an idea is not editing it either. See toggleApplied.

isIdeaBatchVerb(value)

batchIdeas(ctx, verb, rawIds, what)

Do one thing to each of these ideas, or to none of them: an idea that is not this account's refuses the whole press, as batchEntries does.

Types

import-vault

A vault of markdown becomes notebook entries.

The other importers take a list and make todos, because a list is what Todoist and Google Tasks hold. A vault is not a list — it is writing — so it lands where writing lands: entries, in one notebook, keeping their text.

An import and not a plugin, which is the whole decision here. A plugin is a thing to keep working forever against somebody else's release cycle, and it would have to hold a folder open on a machine this app is not running on. An import is a button somebody presses once, and Obsidian's format is plain files in a folder — the one thing about it that cannot break.

What comes across

The text, as written. Obsidian's markdown is markdown, and entries are markdown. [[wikilinks]] are left exactly as they are: they are not links here, but they are what somebody typed, and rewriting them would be guessing at which note was meant across a hundred files.

The tags, from #tag in the body and from a tags: line in the frontmatter. Tags are the one piece of structure both apps genuinely share.

The title, from the first heading if the file opens with one, and from the filename otherwise — which is what Obsidian itself displays.

The folder, as a tag. A vault's folders carry meaning, and a notebook per folder would make one import into thirty notebooks, which is the opposite of the undo the other importers are careful to preserve.

What does not

Attachments, canvases, plugin data, and the frontmatter beyond tags and a date. Those are Obsidian's, not markdown's, and inventing a home for them here would be inventing a claim to understand them.

Functions

parseVaultNote(file)

One file, read.

Answers null for a file with nothing in it but its frontmatter — an empty note is Obsidian's scratch, and importing a hundred of them is the fastest way to make somebody regret pressing the button.

looksLikeText(text)

importVault(ctx, input, t)

Types

imports

Bringing a list in from somewhere else.

The top reason people do not adopt a planner is that everything they already wrote down is in the last one. So this takes what Todoist and Google Tasks actually hand you when you ask for your data — a CSV per project, and Takeout's JSON — and turns it into todos.

Todos, and not blocks, because that is what these apps hold: a title, some notes, sometimes a day. Neither of them knows what an hour of your week is for, so inventing one here would be putting words in somebody's mouth. What arrives lands in the strip beside the planner grid, ready to be given a time.

Every import goes into a notebook of its own. That is not filing for its own sake — it is the undo: one delete puts the account back, and a notebook that goes takes nothing with it (notebooks.ts disowns rather than cascades).

Functions

detectSource(text)

Which of the two this is, without asking.

Both files announce themselves plainly — one starts with Todoist's own header row, the other is JSON — and a person exporting their tasks should not have to know which radio button matches the file they just downloaded.

parseCsv(text)

A CSV reader that survives what a task manager exports.

line.split(',') is wrong here and not by a little: a Todoist task called "Buy milk, bread" becomes two cells, and a description with a newline in it becomes two rows. So: quotes, doubled quotes inside them, newlines inside them, and CRLF — which is RFC 4180 and nothing more.

parseTodoistCsv(text)

Todoist's own CSV, as its "Export as template → CSV" writes it.

The columns that matter are TYPE, CONTENT, DESCRIPTION, PRIORITY, INDENT and DATE. Three shapes appear in the TYPE column and each is a decision:

Sub-tasks (INDENT above 1) come in flat. Nesting is a shape this app does not have, and inventing a "parent: " prefix would make somebody edit every one of them to get rid of it.

The DATE column is free text — "every day", "tomorrow", "in 3 days" — and only a real date is read. A repeat rule is not a date, and guessing what "every day" meant to somebody else is how an import puts wrong things in a calendar.

parseGoogleTasks(text)

Google Takeout's Tasks.json.

One object with items, each of which is a list that itself has items — the tasks. A completed task carries status: "completed", and due is a full RFC 3339 stamp of which only the date half means anything: Google stores a due date as midnight UTC, so reading the time would move half the world's tasks a day.

parseGoogleKeep(text)

Google Keep, which is not Google Tasks and never was.

Takeout writes Keep as one JSON file per note, which is the awkward part: a person with four hundred notes has four hundred files. So the page reads however many were chosen and hands this a JSON array of them; a single note on its own is accepted too, because that is what one file holds.

What a note becomes:

Both land in the one notebook, so deleting that notebook still undoes the whole import.

Trashed notes are never imported. Archived ones are, because archived in Keep means "dealt with but keep it", which is not the same as deleted.

parseOrg(text)

Org mode, which is plain text with stars.

What a heading becomes:

#+TITLE: names the notebook everything lands in. Nothing else from the preamble is read: org files carry a person's whole configuration, and an import that guessed at the rest would be wrong in interesting ways.

importTasks(ctx, input)

Read the file, then write what it said.

includeDone is off by default, and that is the important default: a Todoist account of several years holds thousands of finished tasks, and importing them fills the board's Done column with somebody's entire history on their first day here. What is worth bringing over is what is still owed.

freeNotebookTitle(ctx, asked, fallback)

A name that does not collide, because a second import must not fail.

createNotebook refuses a duplicate title, which is right when a person types one and wrong here: importing two Todoist projects in a row would refuse the second with a message about notebooks. So the date is added, and then a number, until it is free.

Shared with the vault importer, which has the same problem for the same reason — somebody bringing two vaults in must not meet an error about notebook titles.

Types

instances

The one answer to "what is on, between these dates".

Both kinds of planned block — a recurring weekly slot and a one-off — produce rows in task_records, and this module is the only place that knows how to generate and read them. Before it existed each caller wrote its own union of two tables, and the ones that forgot the second half were quietly wrong: the dashboard omitted one-offs entirely and the tracker's day tabs counted a different set of tasks than the list beneath them displayed.

Functions

generateInstances(ctx, from, to)

generateOneOffs(ctx, from, to)

Just the one-offs in a window, without conjuring any recurring history.

The two halves of generateInstances are not the same kind of fact. A recurring block is a rule about how weeks go, and running it over a week long past invents a history nobody lived — it was answered here first by generating everything for the week a review asked about, and the review of an untouched week in 2020 promptly filled with three blocks a rule written this year says should have happened.

A one-off is the opposite: it exists because somebody wrote it on that day, deliberately, and often afterwards — doing a thing late and saying so is the ordinary case. So a screen that reads the past materialises these and only these.

generateForDate(ctx, date)

Generate for a whole day, the common case for a page that shows "today".

listInstances(ctx, from, to)

Every occurrence in the window, ordered by time. from inclusive, to exclusive, both dates rather than datetimes.

listForDate(ctx, date)

Everything on one date.

recordIdOf(ctx, occurrenceId)

The occurrence's record id, whichever shape of id the caller holds.

A slot:N id already IS a record id; an exceptional:N id names the one-off, whose record may not exist until its day is first looked at — so the day is generated the way opening the board does it, then the one record is read. Reminders hang off records, which is why this exists apart from setOccurrenceStatus.

occurrenceRow(ctx, occurrenceId)

One occurrence's own row, in the shape worth reading back.

This exists for the MCP layer's before/after answers: a mutation reports the state it replaced, so an assistant's bad call is reversible from the transcript rather than from a backup. Null rather than a throw when there is no such row, because "there was nothing there" is itself the honest before.

setOccurrenceStatus(ctx, occurrenceId, rawStatus)

setStatusOn(ctx, kind, refId, dateStr, rawStatus)

Tick a block off — or back on — straight from the plan.

The plan knows a block and a date, not an occurrence id: the occurrence may not exist yet, because records are made when a day is first looked at. So the day is generated first, the way opening the board does it, and then the one record for that block on that date is moved.

setInstanceStatus(ctx, id, rawStatus)

setInstanceLabel(ctx, id, label)

Name this one occurrence.

A recurring block says what you usually do; this says what you are doing today. Empty clears it and the block's own label comes back, so there is no separate "reset".

resolveInstanceActivity(ctx, id, activityId)

Which activity a category-mode block turned out to be.

setInstanceTime(ctx, id, rawTime)

setInstanceDuration(ctx, id, minutes)

Zero means "however long the block says"; anything else overrides it.

deleteInstance(ctx, id)

setInstanceRatings(ctx, id, ratings)

Per-day rating overrides.

On an occurrence these leave the block that produced it — and every other day it produces — untouched.

changeOccurrence(ctx, occurrenceId, changes)

Change one block on one day, by the id the schedule hands out.

Why this exists

An assistant asked to "push the study block to four" had no way to do it — the tools were add, and answer for. So it invented one: it added a second block at the new time and marked the original skipped to clear it off the grid. The day then said something that had not happened. A skip is a fact about a week — it feeds the review's "what did not happen" and the history — and using it as a tidy-up writes a small lie into somebody's record of their own life.

The verb existed everywhere in the app and nowhere in the API. This is it, once, for both kinds of block, taking whichever fields are actually changing.

One day, never the pattern

Moving this Thursday's gym does not move gym. A recurring block's occurrence is changed on its own record — the same thing dragging it in the grid does — and a move to another day becomes what it already is in this app: that day suppressed, and a one-off carrying the same identity at the new time. Nothing here edits the weekly plan, because "push it to four" never means "and every Thursday from now on".

cancelOccurrence(ctx, occurrenceId)

Take a block off a day, because it is not happening and never was.

The counterpart to setOccurrenceStatus(…, 'skipped'), and the distinction is the whole point of having both. Skipped is a fact about a week: you meant to do it and did not, and the review asks about it. Cancelled is the plan being wrong: the meeting moved, the lesson was called off, it was put on the wrong day. One belongs in the record and one does not, and an assistant with only the first will use it for the second — which is exactly what happened.

A one-off is deleted. An occurrence of a recurring block is suppressed for that date only, which is reversible in the app and leaves the pattern alone.

Types

inventory

Two lists that share a table: replenish is stock you keep, someday is a wishlist. The difference is what "bought" means — a replenish item comes back when it runs out, a someday item is done.

Functions

listItems(ctx, scope)

The items, all of them or one subject's.

notebookId narrows rather than changing the shape: a notebook's Inventory tab is this room looking at one subject and draws the rows with the same component, so it needs exactly what the room needs.

listCategories(ctx)

setCategoryShared(ctx, id, shared)

Share a section with the family, or stop. The owner's switch alone.

createCategory(ctx, raw)

renameCategory(ctx, id, raw)

Whether things in this category can be an ingredient.

One tick per category rather than per item: otherwise every tin of tomatoes has to be marked by hand, and the television has to be marked as not.

deleteCategory(ctx, id)

Deleting a category unfiles its items rather than taking them along: the category is organisation, the items are somebody's cupboard, and removing a shelf label must not empty the shelf.

setCategoryColor(ctx, id, raw)

A category's colour, or none (empty) for the neutral one. The owner's alone.

moveCategory(ctx, id, delta)

One place up or down the order the cards are drawn in.

Only the account's own categories are renumbered: a family member's shared shelf is ordered by its owner, and moving one of those is a not-found.

setCategoryFood(ctx, id, isFood)

createItem(ctx, raw)

Adding something already on the list puts it back on it.

Typing "milk" twice used to give two rows named milk with no hint that one was already there, which is never what somebody meant: they either forgot, or they bought it last week and need it again. Either way the answer is one row, marked as needed.

Returns whether it was a name already held, so the page can say so.

createOwnedThing(ctx, raw)

Something you already own, filed where it lives.

createItem is for a thing to buy: it revives a bought row rather than making a second one, and it fires inventory.added so a synced list learns about it. Neither is right here — a tape that has been in the drawer for ten years was never wanted, and putting it on somebody's list would be the opposite of what "I have it" means. So it arrives bought, with an address, and the to-buy half never shows it.

Returns the new item's id, or the existing one when a thing by that name is already known: filing the tape you already listed should move it, not duplicate it.

updateItem(ctx, id, raw)

changeItem(ctx, id, raw)

Rename an item, or change its type or notes, touching nothing else.

updateItem takes the whole row, price and section included, which is the form's shape; a change that names one field sends one. Left out, a field keeps what it had; an empty notes clears them.

setItemCategory(ctx, id, categoryId)

File an item into a section, or out of every one, touching nothing else.

updateItem re-parses the whole row, so filing through it means re-sending name and type just to move a thing — which is exactly the call an assistant gets wrong. One field, one change.

setItemLocation(ctx, id, locationId)

Say where a thing lives, or that it lives nowhere in particular — the inventory half of an item. The location must be the caller's own.

setItemAttributes(ctx, id, attributes)

The item's own fields, replaced wholesale — { length: '5m', kind: 'tailor' }. A string->string map, because an inventory holds things that do not share a shape, and a fixed set of columns is exactly the assumption that fails.

deleteItem(ctx, id)

setQty(ctx, id, wanted, raw)

How many of a thing you have.

The one place qty is written, so bought cannot drift from it: having a thing is having at least as many as you keep, and where you keep none of it on purpose, having any at all. Every reader of bought — the recipes' "already have", the API, the webhooks, an assistant — goes on asking the question it was asking.

toggleBought(ctx, id, raw)

The tick, which is now a shortcut through the count.

One press in a supermarket aisle still means "that's dealt with" — so it fills the thing up to what you keep, and unticking empties it. Somebody who wants two of six says so with the arrows.

setBought(ctx, id, bought)

Set bought to a stated value — the API's verb, where the page's is a toggle.

Idempotent on purpose: a plugin mirroring two lists says "this is bought" and must be able to say it twice. Only a transition fires the webhook, so a pair of synced lists settles instead of ping-ponging.

ensureCategoryId(ctx, name)

A category id for a name, creating the category if it is new.

For the API, where a producer says "Dairy" and should not have to make a second request to find out what number that is.

recordPaid(ctx, id, raw)

What you actually paid.

The item's own priceCents is a last known price and gets overwritten, which answers "what will this shop cost" and nothing over time. A row per purchase answers the other question — milk has gone from 1.20 to 1.60 this year, which nobody else's app will tell you.

Only written when somebody says a number. A chart built out of guesses is worse than no chart.

priceHistory(ctx, id)

Everything ever paid for one item, oldest first.

priceDrift(ctx, id)

How a price has moved, in the one sentence worth reading.

Null until there are two prices to compare, because "it cost 1.60" is already on the item and saying it twice is not insight.

restockItem(ctx, id)

Put a replenish item back on the list; a wishlist item has nothing to restock.

toggleSnoozed(ctx, id)

setSnoozed(ctx, id, snoozed)

Snoozed, or not, said rather than flipped.

A toggle is the right control under a finger and the wrong one for a caller that knows what it wants: "put this back on the list" through a toggle is read-then-flip, which is a race and, worse, silently does the opposite when the read was stale. Everything outside the page itself asks for a state.

listToBuy(ctx)

What is still to buy, for the dashboard card.

shoppingRun(ctx)

Types

item-actions

Everything that can be done to an inventory item, wherever the row is.

The Inventory room shows everything; a notebook shows what is filed under it, and ticking something bought there has to mean the same thing. The notebook mounts these under a prefix — see $lib/services/scoped-actions.

What is not here is the room's own furniture: sections, locations, the attribute vocabulary. Those are the room managing itself, and a notebook has no business offering them.

ledger-actions

Everything that can be done to a ledger, wherever the row is on screen.

The Finance room shows every ledger beside the lines in it; a notebook shows the ones filed under its subject — the account the renovation is being paid from — and naming, archiving or dropping one there has to mean the same thing. The lines themselves stay in the room: a statement is a page, not a panel, and a notebook is not where somebody imports one.

Mounted under the room's older names there and under a prefix inside a notebook — see $lib/ledger-action-names.

ledgers

Ledgers: the places money moves through.

A current account is one, a credit card is another, and keeping them apart is what makes "what did the card cost this month" answerable at all. Everything imported belongs to exactly one, and a ledger carries the export format it usually receives so importing into it is one gesture rather than two choices.

Functions

listLedgers(ctx, opts)

The ledgers, all of them or one subject's.

notebookId narrows rather than changing the shape: a notebook's Ledgers tab is this room looking at one subject and draws the rows with the same component, so it needs exactly what the room needs.

supported(ctx, asOf)

Active covered ledgers only. Unknown currency is an exclusion, not an FX guess.

listBalanceChecks(ctx, ledgerId)

Observations remain evidence even when there is not yet an opening to check them against.

recordBalanceCheck(ctx, input)

getBalanceCheck(ctx, id)

deleteBalanceCheck(ctx, id)

getLedger(ctx, id)

createLedger(ctx, input)

updateLedger(ctx, id, input)

setLedgerArchived(ctx, id, archived)

Put away without losing anything: its lines stay, and its totals with them.

deleteLedger(ctx, id)

Gone, and its lines with it.

The lines go first and explicitly: SQLite cannot attach an ON DELETE to a column added by ALTER TABLE, so the cascade would have been a promise the migration could not keep. Doing it here is also where it belongs — beside the confirmation that says how many lines are about to go.

moveLedger(ctx, id, delta)

A move is a reinsertion: every ledger is resequenced around the one moved.

placeLedger(ctx, id, position)

Put a ledger at a place in the order listLedgers gives, counting from 0. A place past either end is the end.

Types

The facts the policies are written around.

A privacy policy that says "we may share your data with partners" when there are no partners is worse than none — it teaches people that the page is boilerplate. So the pages state what this instance actually does, and the few things that differ between deployments come from here.

The operator's name, address and jurisdiction are the instance's to set. The defaults say so rather than inventing a company.

The price comes from the payment provider rather than from this instance's env, and that is the whole point: the number in the terms is a promise about what a card will be charged, so it has to be the number the provider will actually charge. Reading the env here meant the terms could quote one price while the checkout took another — the one billing disagreement that reaches a stranger's statement. Falls back to the env when the provider cannot be reached, which is also what the billing page does.

Functions

legalFacts()

locations

Locations: the tree an inventory hangs on.

"Where do we keep the measuring tape?" — "Living room, white chest, first drawer." A location has a parent, so locations nest as deep as a house does, and an item points at the one it lives in. This is the half a flat shopping list does not have; the list stays the "I need it" view of the same items.

A location deleted lets its children rise to where it was (the FK is set-null) rather than taking a wing of the house down with it — the same gentleness the rest of the app gives things that took effort to enter.

Functions

listLocations(ctx)

getLocation(ctx, id)

locationTree(ctx)

The whole tree, each node carrying how many items sit directly in it.

pathOf(ctx, id, byId)

The chain of names from the root down to this location, for "Living room › chest › drawer".

locationChoices(ctx)

Every location with its whole path, for a form's location picker.

createLocation(ctx, input)

updateLocation(ctx, id, input)

deleteLocation(ctx, id)

Delete a location. Its children rise to its parent, and any item that lived in it becomes location-less — nothing is destroyed for standing in a room that was removed.

rootLocations(ctx)

The top-level locations, for a first "where does this live" choice.

Types

mail-log

Mail that must not fail silently.

sendEmail is honest but forgetful: it logs a failure and moves on. This wrapper remembers — a failed send becomes a mail_failures row, which /healthz counts as a warning (the off-box watchers alert on warnings) and /admin lists with a retry. A later successful send to the same address for the same kind resolves the row, so the list is what is still wrong, not a history.

A box with no SMTP at all is a deliberate state for a self-hosted install (the log is the transport), so unconfigured is only recorded when the caller says so — the newsletter welcome does, because an instance with the mailing list switched on has said it means to write to people, and a stranger who was told "You're on the list" has no other way to find out that nothing was sent.

Functions

sendLogged(kind, email, options)

sendEmail, with the failure remembered and the recovery noticed.

openFailures()

What is still wrong, newest first — the /admin list and the /healthz count.

retryFailure(id)

Send a stored mail again, as it was.

Only for rows that kept their body. Auth mail cannot be replayed — its link died within the hour — so the fix there is a fresh request, and the row offers dismiss instead.

dismissFailure(id)

Close the row without sending anything — for failures overtaken by events.

openFailureCount()

How many mails are sitting failed — one number, for the health probe.

Types

media-kind

Which kind of thing a media row is.

One table holds both, because it is one question — bytes belonging to an account, with a name, a size and a hash — and because an account's storage is one number whatever it is spent on. What differs is every surface: a gallery draws pictures, a recordings list plays recordings, and neither may see the other's rows.

The discriminator is the mime, which is not a claim: it is written from what sniff proved the bytes to be. So there is nothing to keep in step with a separate column, and a row cannot be one kind by its type and another by its label.

Named here rather than written as like(media.mime, 'image/%') wherever it is needed. A filter spelled out at nine call sites is a filter that gets forgotten at the tenth, and the tenth is a recordings row rendering as a broken picture in somebody's gallery.

Functions

isImageMime(mime)

isAudioMime(mime)

sniffer(signatures)

What these bytes are, by this table, or nothing.

Types

media-limits

What an instance allows a picture to be.

Its own module because two very different things answer the question. A served instance reads [media] out of config.toml and reconciles it with the body its server will actually accept; an instance running on a phone has no config file and no body limit, and its numbers are the ones below.

The type is shared so neither can quietly answer a different question, and the services ask through host.mediaLimits() rather than either directly.

Types

media-permission

Whether a caller may see a file, decided by what the file is used for.

The rule, and the reason it is this rule rather than a new grant: a picture or a recording is never loose. It is in a note, or it is somebody's face, or it is one of a recipe's photographs — and a person who has said "you may read my notebooks" has already said what should happen to the pictures in them. Inventing media:read would ask them the same question twice and let the two answers disagree.

So the permission a file needs is the permission its referrer needs, and a file nothing refers to is reachable by nobody. One readable referrer is enough: a picture in a note you may read is a picture you may see, whatever else it also sits in.

What is deliberately not reachable

A picture that only lives in a gallery album. There is no scope for the gallery — the room has never had one — and this is not the change that invents it. album is listed below with no scope against it so that the omission is a decision somebody can read rather than a kind nobody thought of.

Why it lives here

Two doors ask it: an HTTP request carrying a bearer key, and an assistant over MCP, which never holds a raw key at all — its client keeps the credential and hands out none. This module imports nothing but types, so the MCP tool table can ask it without dragging the token service, the database and the billing provider into a cycle with itself. It did, once, and the dev server died on Cannot access '__vite_ssr_import_10__' before initialization.

Functions

mayReadFile(referrers, scopes, confined)

Types

media-referrers

What points at a picture or a recording, and where it lives.

The question isReferenced asks — is anything still using this — with the answer kept rather than reduced to a yes. It is the same walk, and knowing which thing refers to a file is what lets a caller be told the file is theirs to read: a picture in a note belongs to the notes, so a key that may read the notes may see it.

Nothing here decides anything about permission. It reports where a file is used; $lib/server/api/media-access.ts turns that into a yes or a no, and this file stays portable so the routes that use it still compile into the device's worker.

Functions

pictureReferrers(ctx, id)

Everything of this account's that points at the picture with this id.

recordingReferrers(ctx, id)

Everything of this account's that points at the recording with this id.

Types

media

Pictures: what is accepted, where they go, and who may see one.

Three rules do most of the work here, and each of them is a thing that goes wrong in every other app that stores an upload.

The type comes from the bytes. A browser's Content-Type is a claim by whoever wrote the request, and a filename extension is a claim by whoever named the file. Neither decides anything: the first bytes are read and matched against a short allowlist, and a file that does not look like one of those is refused. Nothing is ever stored under a name the sender chose.

SVG is not an image here. It is a document that can carry script, served from this app's own origin, which is same-origin script execution dressed up as a picture. There is no configuration for it.

The ceilings are the instance's. On a server [media] in config.toml decides how big one picture may be, how many a recipe or an entry may carry, and what one account's pictures may add up to; on a phone they are the numbers in media-limits.ts. Either way they arrive through host.mediaLimits() and are enforced here, in the service, rather than in a form — a limit checked in a form is a limit the API does not have.

Nothing here is the server's. The bytes are a column, the type comes from reading them, and the hash is WebCrypto's — so the same file serves the instance running on a phone, where there is no Node, no Buffer and no config file. That is why store is asynchronous: crypto.subtle is the one digest both worlds have.

Functions

mediaLimits()

tidyFilename(raw)

The filename, reduced to something safe to show and store.

It is never a path here — the bytes are a column, not a file — but it is echoed back into a Content-Disposition header and into markup, so anything that could steer either is removed rather than escaped: no separators, no control characters, no quotes.

bytesStored(ctx)

What this account's pictures already add up to.

store(ctx, input)

Take a picture in.

The same bytes uploaded twice are one row: two entries that quote the same screenshot should not cost twice, and the second upload returns the first row rather than failing on the unique index.

read(ctx, id)

The bytes, for the one account they belong to.

Scoped in the WHERE, so somebody else's id is a 404 rather than a picture: not found and not yours are the same answer.

list(ctx)

isReferenced(ctx, id)

Is anything still pointing at this picture?

Asked of pictureReferrers, which is the one list of the things that can hold a picture. This used to keep a list of its own — albums, faces, recipes and notes — and it did not have todos, ideas or a notebook's own picture in it, so replacing somebody's photo deleted the old file even when it had also been pasted into a todo.

remove(ctx, id)

Remove a picture outright.

removeIfUnreferenced(ctx, id)

Remove it only if nothing points at it any more. Returns whether it went.

referencedIn(content)

How many pictures a piece of writing carries.

Counted from the text rather than from a join table, because that is where the truth is: a picture is in an entry when the entry says ![…](/media/12), and deleting the line is how you take it out again.

assertEntryWithinLimit(content)

Refuse writing that has gone over the instance's per-entry ceiling.

setPersonPicture(ctx, personId, input)

Give somebody a face, replacing whatever was there.

removePersonPicture(ctx, personId)

setNotebookPicture(ctx, notebookId, input)

Give a notebook a picture, replacing whatever was there.

The same shape a person's face has, and for the same reason: one picture, because it is what the notebook is, and the one it replaces goes if nothing else refers to it.

removeNotebookPicture(ctx, notebookId)

setItemPicture(ctx, itemId, input)

Give a thing a picture, replacing whatever was there.

The same shape a face and a notebook's picture have: one picture, chosen in one gesture, and the old one let go of if nothing else points at it.

removeItemPicture(ctx, itemId)

picturesOf(ctx, recipeId)

mainPictures(ctx, recipeIds)

The one picture that stands for each of these recipes, by recipe id.

attachToRecipe(ctx, recipeId, input)

Put a picture in a recipe's gallery.

The first one is the main one without being asked: a gallery of one whose single picture is not the one the list shows would be a bug nobody would think to report.

detachFromRecipe(ctx, recipeId, mediaId)

Take one out, and take the bytes with it when nothing else wants them.

setMain(ctx, recipeId, mediaId)

Which one the list shows.

Two statements, and the clearing has to come first: the database holds "one main per recipe" as a unique index, so setting the new one before clearing the old one is a constraint failure rather than a swap.

Types

model-catalog

What a provider will actually answer to, asked rather than typed.

The model was a text box, which only works for somebody who already has the provider's documentation open — and the answer changes every few months, so a list compiled here would be wrong by the time anybody read it. Every one of these companies publishes what it serves; this asks, with the key the person just pasted, and the form offers what came back.

The key is theirs and the call goes to the company they chose. Nothing is stored by this: it is a question asked while a form is open.

Functions

listModels(provider, key, baseUrl)

Ask one provider what it serves.

Newest first where the provider says so — all four return their list in their own order, and every one of them puts the current generation at the top or near it, so the order is left as given rather than sorted into alphabetical, which would bury claude-sonnet-5 under claude-2.

Types

model-keys

The model-provider key behind the in-app chat.

One row per account, replaced on save: the chat speaks to one provider at a time. The key authenticates this instance to a company the person chose, so it is stored as given (see the schema note) and shown back only as a prefix — the settings screen can say which key it is without being able to say what it is.

Functions

saveModelKey(ctx, raw)

describeModelKey(ctx)

What the settings screen may know: everything but the key.

configuredModelKey(ctx)

The whole row, key included — for the chat backend and nobody else.

removeModelKey(ctx)

modelNameFor(row)

The model the chat will actually ask for, name or default.

Types

newsletter

The one channel nobody else can take away.

Every other way of reaching somebody who liked this is rented. A subreddit changes its rules, a feed changes its ranking, a search position moves, and the audience that took a year to gather is gone in an afternoon. An address somebody handed over is not like that.

It is also the only way to tell the hundred people who tried the demo and did not sign up that the thing they wanted now exists.

Not accounts

A subscriber is an address, a flag, and a token. No password, no session, no join to user. Somebody who subscribed and later signed up is two unrelated facts, and keeping them unrelated is what stops "unsubscribe" from ever being confused with "delete my account".

One step, and what stands in for the second

An address is on the list the moment somebody types it and presses the button. Double opt-in is the safer arrangement and this deliberately is not it: a confirming click loses the people who do not go back to their mail.

A welcome note still goes out, and it is not that click. It asks for nothing; it says the address is on the list, says what will arrive, and carries the way off. Somebody who has handed over an address and been shown a sentence on a web page has no other evidence the thing worked, and an address typed by mistake — or by somebody else — has nowhere to complain to until a message arrives at it.

What stands in its place is the part that actually protects a domain: a hard rate limit in front of the endpoint, and an unsubscribe link in every single message — one click, nobody signed in to anything.

Every row still carries a token, because that link is what it carries.

What it says

Whether the address was new or already on the list — the owner asked for the form to say which. The cost is that the form answers "is this address a subscriber?" for anybody who types one, which is why the rate limit in front of it stays tight. Mail still follows the stricter rule: an address already on the list is sent nothing.

Functions

newsletterEnabled()

Whether this instance keeps a list at all.

newsletterOrigin()

The one other origin allowed to post the form, if there is one.

subscribe(rawEmail, source)

Take an address, and put it on the list.

Says whether it was already there. A welcome goes to an address that has just joined and to nothing else, so the form cannot be used to send anything to an address that did not ask for it twice.

The send is after the write and cannot undo it. A subscriber whose welcome bounced is subscribed — the list is the row — and the failure belongs in the mail log where the operator sees it, not in an answer to a stranger.

unsubscribe(token)

Come off the list.

Kept as a row with a date rather than deleted, so that a later subscribe knows to ask again instead of quietly resuming — and so the same link followed twice says the same thing.

counts()

How many are actually on the list, and how many have not answered yet.

confirmedAddresses()

The list, for the one person who runs this instance.

Confirmed and not unsubscribed, and nothing else — the point of an export is that it can be pasted into whatever sends the issue, and a list that included people who never confirmed would be the thing that gets that sender banned.

announced(version)

Whether this version has already been announced.

The release runs from a make target that is meant to be re-runnable — a publish that died at step six is started again — and the one step nobody wants repeated is the one that reaches two hundred inboxes.

announce(issue)

Tell the list that something shipped.

One message per address, each carrying that person's own unsubscribe link — which is the whole of what keeps this from being the thing mailbox providers exist to stop. Sent one at a time rather than as one message to everybody, because a single mail with two hundred addresses on it discloses the list to every one of them.

A failure is counted and the rest go on: an address that bounces is that address's problem, and stopping the run at the first one would mean the hundred after it never hear. The failures are retryable from /admin, the same as every other mail this app sends, and the count is written down so a partial send is a visible fact rather than something to infer.

Refuses to send twice. Say so rather than silently doing nothing, because "it did not send" and "it had already sent" are different things to the person running it.

listSubscribers()

Everybody on the list now, newest first.

removeSubscriber(id)

Take an address off the list, from the administration page.

The same row the address's own link would write — kept, with a date — so a later subscribe starts again rather than quietly resuming.

sentIssues(limit)

What has gone out to the list, newest first.

announcedVersions()

The versions already announced, for the release that collects what came since.

Types

note-todos

Turning a note that is really a checklist into the todos it describes.

People write lists in notes because that is the fastest way to get one out of their head, and then the list sits somewhere nothing can remind them of it. This is the way across: every - [ ] line in the note becomes a todo, with whatever is written under it as that todo's notes — see $lib/checklist for the shape being read.

The note keeps its words and stops keeping the boxes: each line that crossed over becomes a reference to the task it became — TASK:#4, the task's number inside this notebook — so the note still says what it said and the list is where the work now lives. Leaving the boxes behind left the offer standing over a list that had already been made, and two records of one list to drift apart.

The note itself is not deleted. That is a separate press, because somebody who meant "also put these on my list" and somebody who meant "move these onto my list" both press this button, and only one of them wants the note gone.

Functions

makeTodosFromEntry(ctx, entryId, only)

Make todos of a note's checkboxes.

only names which ones by their position in the note, counting from zero, for the screen that lets somebody leave a few behind; left out means all of them. A ticked box arrives ticked, so a half-done list crosses over half done rather than pretending the finished half never happened.

Each todo is filed under the note's own notebook. That is nearly always what was meant — the list was written about something — and a todo in the wrong notebook is a great deal easier to notice and move than one in none.

Types

notebook-linking

Putting something that already exists under a subject.

Every tab can make a new thing; none of them could take one that was already there. A renovation that starts halfway through a project has its tiles on the shopping list and its account in Finance already, and the only way to gather them was to delete each one and write it again under the notebook.

Linking is the same act for all of them — set notebook_id — so this is one function rather than nine, and one modal draws all of them. What differs is only which table and which column a row is named by, which is the table below.

Functions

linkableInto(ctx, module, notebookId)

What could be filed under this notebook, that is not already.

Both the things filed nowhere and the things filed under another subject: a tin of tomatoes bought for the kitchen is a fair thing to move to the renovation, and refusing that would mean deleting it to re-make it. The ones that are elsewhere say so, so moving one is a choice rather than a surprise.

linkablesInto(ctx, modules, notebookId)

Every module's candidates for one notebook, checked once.

The notebook page asked linkableInto per module, and each asked whether the notebook could be reached — eleven identical statements per load.

fileUnderNotebook(ctx, module, id, notebookId)

File one under this notebook, or take it out.

Scoped by the account as well as the id (I1) — never a check followed by an unscoped write. A null notebook is how a thing is unfiled, which is the same act in reverse.

The number goes with the notebook

A note, a task, a goal and an idea are numbered inside their notebook as well as in the account — #4 on a card, and what TASK:#4 in somebody's writing points at — and (notebook_id, notebook_seq) is unique. So moving one that already had a number into a notebook that already has that number is a constraint failure, and it is the ordinary case rather than a corner: bringing anything into a notebook with more than three things in it hit it. What came back was "Unexpected error", which is the least useful thing this could have said.

The number is therefore not carried. It is dropped on the way out and the next one in the new notebook is taken, which is what the number means: where this sits in that notebook, not where it sat in the last one. seed-dev.mjs has the same note beside its own version of this.

Types

notebook-media

Every picture that is in a notebook, as a gallery album.

A picture in a note — or in a task, goal or idea filed in the notebook — is an ordinary media row that the writing's markdown points at — ![a shelf](/media/12) — and nothing records which notebook it belongs to. That is on purpose: the writing is where the picture lives, so moving a note between notebooks, deleting the line, or pasting the same picture into a second note are all just edits to text, and a table recording "picture 12 is in the kitchen notebook" would be wrong within a week.

So this is derived, every time it is asked. The gallery gets a folder per notebook that has any pictures, inside the shelf folders that notebook is filed in — Countertops in Home/Kitchen on the shelf is Home/Kitchen/Countertops here too.

Derived also decides what can be done to it: pictures can be looked at, named and tagged like any other, but nothing is uploaded into a notebook folder and nothing is dragged out of one. There is no such thing as being in one — a note mentions a picture, and that is the only fact there is.

Functions

picturesMentionedIn(content)

Which pictures a piece of writing points at.

The markdown the app writes is ![alt](/media/12), but somebody editing by hand may well have written the address on its own, so this looks for the address rather than for the whole image syntax.

notebookMediaFolders(ctx)

The notebooks that have pictures, with the pictures in each.

Ordered by name, so the folders sit in the order the notebooks room shows them and a folder's parent always comes before it.

notebookMediaCount(ctx)

How many pictures are in notebooks at all, for the tile on the index.

notebookMediaView(ctx, path)

One folder of the notebooks album: what is directly inside it, and the pictures in it and everything under it.

path is the notebook's title. Empty means the album itself, which holds no pictures of its own — every picture is in some notebook — and shows one folder per notebook at the top of the tree.

picturesById(ctx, ids)

The gallery's own picture shape, for a list of ids in the order given.

Types

notebook-sections

One tab of one notebook, as lines.

What a home-screen widget draws, and what /api/v1/notebooks/:id/:section answers: each row is the tab's own row — read through the same list function the tab is drawn from — reduced to what fits on a line, filtered and ordered by the choices $lib/notebook-widget offers for that tab.

Functions

sectionItems(ctx, notebookId, q)

The lines of one tab, or a NotFoundError for a notebook this account cannot reach — somebody else's and one that does not exist answer alike.

Types

notebook-seq

Numbers inside a notebook — what TASK:#4, GOAL:#2 and IDEA:#7 point at.

A reference somebody types by hand has to be a number they can see, so a thing filed under a notebook is numbered there: the fourth task about the kitchen is #4 rather than #312. One allocator for every kind that has the column, so they cannot come to disagree about what a number means.

The high-water mark rather than max + 1: deleting the newest one would hand its number to the next, and a reference written months ago would silently come to mean something else. A reference that can change what it refers to is not a reference.

Null for a thing filed under nothing: there is nowhere for it to be fourth of.

Functions

nextNotebookSeq(ctx, table, notebookId)

The next number free in that notebook, taken.

notebookSeqFor(ctx, table, id, notebookId)

The number a row should have once it is filed in notebookId.

Kept if it already has one there — a thing edited twice must not change its own reference — and otherwise the next one, or none if it leaves: a number in a notebook it is no longer in is a reference to nothing.

numberedNotebookPatch(ctx, raw, holds, table, id)

notebookPatch, numbered: the notebook a form chose, and the number that goes with it. Left out means left alone, the number included.

Types

notebooks

Notebooks: a subject you write against, with no deadline.

A goal is a commitment with a horizon and a verdict at the end. A notebook is neither — it is a place to put things about one subject, so it owns nothing. Entries, todos and goals point at it and are perfectly fine without it; deleting a notebook leaves every one of them where it is. That is the whole design, and the reason this is not a second task system.

Functions

listNotebooks(ctx)

Open ones first: a closed notebook is history, not a place you are writing.

recentlyEditedNotebooks(ctx, limit)

The notebooks worked on most recently, newest first.

"Worked on" is the newest stamp on anything the notebook holds, not the notebook row's own: writing five notes into the kitchen today has to put it above a notebook that was renamed last week, or the word means the opposite of what somebody reading it expects. The row's own stamp still counts, so a notebook that has just been made and not yet written in is recent.

Closed ones are left out. A closed notebook is history — a trip that happened — and the last thing that happened in it was closing it, which would put it at the top of exactly the list nobody wants it in.

listOrphanedNotes(ctx)

Notes whose notebook was deleted.

They have a notebook number and no notebook, which is exactly what being orphaned means, so no extra column records it. The page shows them as a notebook of their own, and only when there are any.

getNotebook(ctx, id)

moduleChoices(ctx, id)

What the Edit dialog offers: every module, with this notebook's answer.

The whole list rather than only what is on, because the dialog's job is switching them, and each row carries how much is already filed under it — the number is what makes turning one off legible as "this tab goes" rather than "this is deleted".

A module whose room this account has put away is left out altogether. It would be a switch that changes nothing on screen, and the honest place to answer for it is Preferences, where the room itself was put away.

entriesOf(ctx, id)

The notes in one notebook this account may reach. See notebookEntries.

contentsOf(ctx, id)

Everything pointed at this notebook, in the three shapes it can arrive in.

createNotebook(ctx, raw)

updateNotebook(ctx, id, raw)

defaultTagsOf(ctx, notebookId)

The labels a new note in this notebook should start with.

Empty for a note filed nowhere, and empty for a notebook nobody set any on, which is the same answer and wants no distinction. Reads the column rather than the whole notebook: this runs on every note written.

defaultCategoryOf(ctx, notebookId)

The category a new task in this notebook starts with, or null.

Only a category of the writer's own: a notebook shared into the family carries its owner's, which means nothing on somebody else's week.

setNotebookClosed(ctx, id, closed)

Close a finished subject, or reopen one you went back to.

deleteNotebook(ctx, id)

Deleting a notebook deletes only the notebook.

The links are cut here rather than left to the foreign key: the columns were added by ALTER TABLE, which SQLite gives no delete action, so an unlinked delete would simply fail. Cutting them explicitly also says what should happen — the entries and tasks survive, they just stop belonging anywhere.

notebookSeq is deliberately left behind. An entry with a notebook number and no notebook is one whose notebook was deleted, and that is what puts it in listOrphanedNotes rather than back in the diary — a note about a renovation does not become a journal entry because the renovation is over.

ownedNotebookId(ctx, value, holds, was)

A notebook id from a form, or null.

Every service that lets something belong to a notebook goes through here, so "somebody else's notebook" and "no notebook" cannot be confused: an id you do not own is a 404, not a silent null (I3).

assertNotebookHolds(ctx, notebookId, module)

A notebook that holds this kind of thing, or a refusal naming the tab it lacks.

Filed into a notebook without the tab, a thing is shown nowhere inside it — which is worse than refusing, because nothing says where it went. Read from what the notebook was told to hold, the same list its tabs come from; a room the account has put away does not count against it, since putting a room away hides a tab rather than emptying it.

notebookHolds(ctx, notebookId, module)

Whether a reachable notebook has this tab — for a caller with somewhere else to go.

notebookPatch(ctx, raw, holds, was)

{ notebookId } when the caller named one, and nothing at all when it did not.

Spread into the values a room writes. The distinction matters because these rooms are reached two ways: a form posts every field it has, including an empty notebook meaning "none", while an assistant changing a habit's name over MCP says nothing about the notebook and must not be read as taking the habit out of its subject.

pickableNotebooks(ctx)

The open notebooks, for the selector on every form that can point at one — the favourites first, the way the shelf has them.

renameFolder(ctx, rawFrom, rawTo)

Rename a folder, or move it: every notebook of this account's in from or anywhere inside it has that prefix rewritten to to.

A folder has no row, so this is the whole of renaming one. to may be '' to take its contents to the top of the shelf, or the folder's own parent to dissolve it into that. Refused when a moved notebook would land on a name already in its new folder, and when the folder would go inside itself. Answers how many notebooks moved.

setNotebookFavourite(ctx, id, favourite)

Star a notebook, or take the star off.

Reachable rather than owned: a notebook shared into the family can be one somebody reaches for every day, and the star is theirs, not the owner's. Starring twice is one star.

assertReachableNotebook(ctx, id)

A notebook this account may reach, or a loud refusal.

Exported because linking something into one has to make the same check — see notebook-linking.ts. Reachable, not owned: a family member's shared notebook is one you may file things under.

setNotebookShared(ctx, id, shared)

Share a notebook with the family, or stop. The owner's switch alone.

Types

notifications

Everything the app will tell you about, in one list.

These grew one at a time and each one decided for itself whether to happen: the weekly review nag always did, bills always did, birthdays always did, the Monday mail was a checkbox on a different page, and a block could only notify you if you gave that block a lead time by hand. So there was no screen anywhere that answered "what will this app interrupt me for", which is the first thing somebody wants to know and the only thing they can act on.

One list, then, and the settings screen is drawn from it rather than being a hand-written row per notification — a new one appears there by being added here, which is the only way a list like this stays complete.

Off or on is an account's business and lives in user_settings. It is not the same question as sound, which is per device and per kind and belongs to the reminders page: this decides whether the thing is said at all.

Functions

notifies(userId, id)

Whether one of these is on for an account.

Stored as the same two words the mail setting has always used, so the one key that predates this reads the same way as the rest.

notifyAt(userId, id)

The time one of the timed ones goes off, as HH:MM.

notificationSettings(ctx, instance)

setNotification(ctx, id, choice)

Types

oauth

Connecting an assistant without anybody handling a key.

The flow is the one Claude, ChatGPT and the MCP clients already walk: the client discovers this instance is protected, registers itself, sends the person here to say yes, and swaps the code it gets back for a token. What this file owns is the middle — clients, codes and the rules about both. The token at the end is an ordinary api_tokens row minted by tokens.ts, so revoking a connected assistant is the same button as revoking a key.

Deliberately small: authorization code grant with PKCE and nothing else. No implicit grant (removed in OAuth 2.1), no client secrets (every client here is a public one — a desktop app cannot keep a secret), no refresh tokens, because the token this issues does not expire and a refresh of a token that never goes stale is a round trip that buys nothing.

Functions

isUsableRedirect(raw)

Where a code may be sent back to.

https anywhere, and http only on this machine — which is not a loophole but the ordinary case: a desktop client listens on http://127.0.0.1:PORT because there is nothing to encrypt between a process and itself. Anything else is a native app's own scheme (cursor://, com.example.app:/cb), allowed because the protection here is the exact match at both doors, not the scheme.

registerClient(input)

A client registering itself, which happens before anybody has signed in.

Anonymous by design (RFC 7591): the assistant has no account here and the person it belongs to has not arrived yet. That makes this the one write in the app an unauthenticated caller can cause, so the route in front of it rate limits, and everything stored is bounded and never trusted as words: the name lands on a consent screen and is the client's claim about itself, not ours about it.

findClient(clientId)

scopesFor(asked, mayDelete)

What an assistant connected this way may do.

The same set the key form offers — everything an assistant reads and writes — with deleting left out unless the person ticked it on the consent screen. A client may ask for less by naming scopes; it may not ask for more than this, and a scope nobody recognises is dropped rather than refused, because a client guessing at names should still connect with what it can have.

issueCode(input)

The code handed back through the browser, and its hash left here.

redeemCode(input)

A code spent, once.

Every refusal here is the same refusal on purpose — a token endpoint that says which half was wrong is a token endpoint that can be asked. Marked used before anything is minted, so two requests racing the same code cannot both come out the other side with a token.

forgetStaleCodes(now)

Codes nobody came back for. Cheap, and run wherever one is issued.

protectedResourceMetadata(origin)

What this instance says about itself, to a client that has not met it.

Two documents at two well-known addresses, and between them they are the whole of "no configuration": the client is told where the MCP endpoint is, which authorization server guards it, where to register, where to send the person, and where to bring the code back. Nobody types anything but the instance's address.

authorizationServerMetadata(origin)

Types

onboarding-templates

The starter weeks, as data.

Separate from onboarding.ts because that module reaches the database at import time, and these are three literals that a test — or anything else — should be able to read without one. onboarding.ts re-exports them, so nothing that already imports from there has to change.

Types

onboarding

First run.

An empty grid is what a new account churns on: there is nothing to react to and no example of what a block is meant to be. So the first screen asks two questions it cannot guess wrong — where you are and when your week starts — and offers a week to start from.

The starter categories are deliberately not duty / skill / money. That is the author's ontology; a stranger should meet words that mean something to them and rename them later.

Functions

needsFirstRun(userId)

templateFor(key)

completeFirstRun(ctx, raw)

Everything first run decides, applied at once.

Idempotent by construction: an account that already has categories keeps them, so re-submitting cannot double-seed a week.

applyTemplate(ctx, key, options)

Put a starter week in, whenever somebody wants one.

These templates were only ever offered during first run, and then never seen again — so somebody who skipped onboarding, or whose life changed in March, had no way back to them. This is the same application, callable later.

Everything is matched by name, so applying a template twice does not give you two categories called "work" and two activities called "Study block". The blocks are the exception: replacePlan clears the weekly plan first, because merging one timetable into another produces a week that is neither.

Types

people

The people in your life, and where they turn up.

Deliberately thin: a name, how you know them, and a note. The value is not the record — it is that every entry mentioning them collects into one page, which a free-form tag cannot do because a tag has no identity beyond its spelling.

Functions

listPeople(ctx)

entriesAbout(ctx, personId)

Everything written that mentions this person, newest first.

createPerson(ctx, raw)

updatePerson(ctx, id, raw)

deletePerson(ctx, id)

Deleting a person leaves the entries; only the mentions go.

setEntryPeople(ctx, entryId, raw)

Replace the people an entry mentions, creating any that are new.

Written as names rather than ids because it is typed inline with the entry — the same gesture as tags, and stopping to open a picker is how a journal stops being written in.

peopleForEntries(ctx, entryIds)

The people each of these entries mentions, keyed by entry id.

Types

phone-widgets

Home-screen widgets that show one tab of one notebook.

Each widget is a row here and a key of its own. The key is confined to the notebook and granted the single read its tab needs — a widget on the lock screen showing the renovation's shopping cannot read the diary, nor another notebook's shopping. Editing a widget moves its key; deleting one revokes it.

Functions

listPhoneWidgets(ctx)

createPhoneWidget(ctx, raw)

A new widget and the key it reads with. The key is in the answer once and never again — it goes straight to the phone.

updatePhoneWidget(ctx, id, raw)

Point a widget somewhere else. Its key moves with it; the phone keeps the same one.

deletePhoneWidget(ctx, id)

Remove a widget and revoke its key, so the phone's copy stops working.

widgetFor(ctx, token)

What the widget holding this key should draw.

The key names the widget: the phone never says which notebook it wants, so it cannot ask for another one. Not found for a key that is no widget's.

Types

plan-intent

Which plan somebody said they wanted, carried from the front page to the card.

The choice is made before the account exists — "Ontoplano for the family" is a button on ontoplano.com — and the card step is three pages later, after registering and confirming an address. Nothing in between has anywhere to put it: there is no account row yet at the moment of the click, and adding a column to hold an intention that expires in a minute is worse than a cookie that does.

It is a preference, never an authority. /start offers both plans however this reads, and what an account is actually on comes from the provider's webhook — so a stale or forged cookie can preselect a button and nothing more.

Functions

wantedPlan(cookies)

What the cookie says, treated as a suggestion. Anything unknown is solo.

forgetWantedPlan(cookies)

Spent the moment a checkout opens: the choice is the provider's now.

plugins

Plugin manifests: what a plugin says it understands.

A task's attributes accept any key, which is what lets a plugin define its own vocabulary without a schema change here. The price is anonymity — a list of keys with nothing saying who reads them. A manifest buys the provenance back without closing the vocabulary.

Functions

parseAttributeKeys(input)

Validate a declared vocabulary.

Keys must look like attribute names, because a manifest that describes keys nobody can actually set is worse than no manifest — it documents something that will be rejected on save.

listManifests(userId)

upsertManifest(userId, input)

Record what a plugin declares, replacing whatever it declared before.

Replace rather than merge: a plugin that stops using a key should be able to drop it, and the manifest it sends is the whole truth about that version.

deleteManifest(userId, source)

attributeKeyOwners(userId)

Which plugin claims each key, for the attributes editor.

A key claimed by two plugins lists both — that is real, and hiding one would misrepresent what happens when it is set.

Types

preferences

The settings a person chooses about themselves.

Thin, but here rather than in the route: these are the last four writes that parsed their own input in an action, and a route that validates is a route that can forget to (I2). Every value is checked against something closed — a list, a range, or a timezone the platform recognises.

Functions

setUserTheme(ctx, value)

setUserLanguage(ctx, value)

setUserClock(ctx, value)

Which clock this account reads.

auto is a real answer, not the absence of one — it means "whatever my language does" and has to survive being chosen deliberately after a person has tried 12 and 24 and decided the default was right.

setUserStyle(ctx, value)

saveWeekPreferences(ctx, raw)

The week, and where the user is.

The timezone is optional here because the week can be saved without touching it — first run is what captures it, and this screen is where it is corrected.

saveGridHours(ctx, raw)

The stretch of the day the planner grid draws.

Whole hours, and the end has to be after the start — a grid from 18 to 6 is not a short day, it is a pair of numbers that renders nothing.

parseTimezone(value)

Rejected here rather than stored and thrown on every date afterwards.

setAssistantMayDelete(ctx, value)

Whether the chat inside the app may delete things.

A checkbox, so its absence from the form is the answer "no" rather than a missing field — which is why this takes the posted value and not a boolean.

saveCaptureSettings(ctx, raw)

The capture wheel: its wedges, their order, and the notebooks it writes into.

At least one wedge, because a wheel with none is a button that does nothing. The notebook has to be one this account can file things in — its own, or one the family shares — and one it cannot is the same answer as one that does not exist.

push

Telling somebody something while the app is closed.

Until now the app could only interrupt you if you were already looking at it: a page open in a visible tab polled once a minute and raised a notification from the page itself. That is the wrong shape for the thing reminders are for. A reminder you only see when the tab is in front of you is a reminder for somebody who did not need one, and on a phone it never worked at all — Android refuses new Notification() outside a service worker, and with the app closed nothing was running to call it anyway.

Web push is the mechanism that does work: the browser keeps a connection to its vendor's push service, and that service wakes the service worker whether or not the app is open. So a phone with a signal gets the reminder with everything closed and the screen off, which is what anybody means by a reminder.

The relay, and why it is acceptable here

There is a third party in the path and it cannot be removed: the push service belongs to whoever made the browser, and only that browser can be reached through it. It is not, however, in the trust path. The payload is encrypted with the subscription's own keys before it leaves this process, so the relay carries ciphertext addressed to one browser; it learns that a message went to an endpoint and nothing about who or what it is for. Nothing about this instance is disclosed either — VAPID identifies the sender to the relay by a public key, not by a domain it phones home to.

An instance that would rather not use it simply does not set up keys, and everything else keeps working: configured() is false, the browser is never asked for permission, and reminders stay in-page as before.

The keys

VAPID is one keypair per instance, not per user. Taken from the environment when it is set — an operator who wants them in their secret store can put them there — and otherwise generated once and kept in the config directory, because an instance that has to be told to run a keygen before notifications work is an instance where notifications are broken by default.

They are not rotated automatically. Changing them invalidates every existing subscription: browsers pin the key they subscribed with, and the push service rejects a message signed with another. Rotating means every device has to be asked again, so it is a thing an operator does deliberately, by deleting the file.

Functions

vapidKeys()

The instance's keypair, made on first use.

Written with the mode of a secret, and read back on later starts: a keypair that changed on every restart would silently unsubscribe every device that had ever said yes.

forgetKeys()

Only for tests, which make and throw away config directories.

pushConfigured()

Whether this instance can push at all.

publicKey()

What the browser needs to subscribe. Null means "do not ask for permission".

The private half never leaves this module; the public half is meant to be public — it is what the browser hands to its push service so that only this instance can address the subscription it gets back.

saveSubscription(ctx, subscription, label)

Remember a browser, or remember it again.

Upsert on the endpoint: a browser that re-subscribes — after a service worker update, or because the push service rotated the address — hands back the same endpoint, and inserting would either fail on the unique index or grow a row per visit. Re-subscribing also clears the failure count, since the thing that was failing has just proved it is there.

Taking the account from ctx rather than the body: a subscription belongs to whoever was signed in when the browser said yes.

removeSubscription(ctx, endpoint)

A device saying it does not want these any more.

subscriptionsFor(userId)

The devices signed up for one account, newest first.

pushToUser(userId, payload)

Types

quotes

The quotes shown one-per-day on the dashboard.

Small enough that it would be tempting to leave in the page, which is how fourteen route files ended up owning their own queries. Ownership lives in the WHERE here (I1), so a wrong id and someone else's id are the same 404.

Functions

listQuotes(ctx)

createQuote(ctx, raw)

importQuotes(ctx, raw)

Many quotes at once.

One per line, not CSV: half of all quotes contain a comma, so a comma- separated file is a parsing argument waiting to happen. The author is whatever follows the last em dash or double hyphen on the line, which is how people already write them:

Plans are worthless, but planning is everything. — Eisenhower What gets measured gets managed -- Drucker A quote with no attribution at all

Duplicates are skipped rather than refused: pasting the same list twice should be boring, not an error.

deleteQuote(ctx, id)

Types

recipes

Recipes, and the loop they close.

A recipe here is not a cookbook entry. It is a list of shopping items with amounts, which is what makes "what does this week's food need" a join rather than a text-matching problem: put a recipe on a day, and the ingredients of every meal in the week minus what is already in the cupboard is the shopping list.

Which is why an ingredient points at inventory_items and never holds a name of its own, and why writing a recipe creates the items it mentions. The list stays current because keeping it current is a side effect of cooking.

Functions

edibleItems(ctx)

Items in a category the account has said holds food.

foodCategories(ctx)

listRecipes(ctx, options)

The recipes, all of them or one subject's.

notebookId narrows rather than changing the shape: a notebook's Recipes tab is this room looking at one subject and draws the rows with the same component, so it needs exactly what the room needs.

getRecipe(ctx, id)

ingredientsOf(ctx, recipeId)

createRecipe(ctx, raw)

updateRecipe(ctx, id, raw)

setArchived(ctx, id, archived)

deleteRecipe(ctx, id)

Deleting a recipe leaves the shopping items alone.

They are things you buy, not parts of the recipe — throwing away a recipe should not take cumin off the list.

cooked(ctx, id, ranOutOf)

You cooked it. What that means for the cupboard is a separate question.

The tempting version marks every ingredient as used up, which puts salt on the shopping list after every meal and teaches people to ignore the list. So this only records that it happened; ranOutOf is the second half, and the screen asks which ones actually ran out — usually none, sometimes the milk.

listIngredientItems(ctx, options)

The inventory items that are an ingredient of some recipe, as { id } rows keyed by the item's id — optionally only the recipes filed in one notebook.

markOutOfStock(ctx, itemIds)

Out of the cupboard is onto the list — the two are one state.

addIngredient(ctx, recipeId, raw)

importIngredients(ctx, recipeId, text)

A pasted ingredient list, added in one go.

Every recipe on the internet is a list of lines, and typing them back one combobox at a time is the reason a recipe never gets written down. Anything the parser cannot make sense of is skipped rather than guessed at, and anything that is not already a shopping item becomes one — which is the same thing typing a new name into the field does, and the reason the shopping list stays current without anybody maintaining it.

Returns how many lines became ingredients, so the page can say so.

recipesByItem(ctx)

Which recipes use each shopping item.

The other half of the link. A recipe has always listed its ingredients; the ingredient had no idea it was one, so the shopping list could not tell you why the olive oil is on it — which is exactly the question you have while deciding whether to buy more.

removeIngredient(ctx, id)

neededBetween(ctx, from, to)

The ingredients of every meal between two dates, minus what is in the cupboard.

Amounts are listed rather than added up. Two tablespoons plus a hundred millilitres is not a number, and a total that pretends otherwise is a lie told in a shop.

mealsBetween(ctx, from, to)

The meals planned between two dates.

A meal is a one-off block with a recipe on it. Weekly blocks with recipes exist too, but they repeat forever and putting them on a dated calendar would mean expanding them; neededBetween counts them, and this lists what was deliberately put on a day.

withMissingCounts(ctx, options)

Recipes ordered by how much of them you already have.

Types

regex-safety

Patterns somebody types, and the ones that never finish.

A finance rule is a regular expression written by a person and then run against every line of every statement, on the server, every time a page is drawn. JavaScript's engine backtracks, so a pattern like (a+)+$ takes exponential time on an input that nearly matches: a few dozen characters is already longer than the heat death of the sun, and there is no way to interrupt a running match — it holds the whole process, which on a shared instance means everybody's.

The engine gives no timeout, so the check has to happen before the pattern is ever run. What makes a pattern explode is always the same shape: a repetition wrapped in another repetition, where the inner one can match the same text in more than one way. That is what is looked for here, plus the two other things that cost more than they are worth — backreferences, which force the backtracking engine, and counted repetitions with enormous counts.

This refuses some patterns that would in fact have been fine. That is the right direction to be wrong in: the answer names the shape and the person writes [a-z]+ instead of (a+)+, which is what they meant anyway.

Functions

unsafePattern(pattern)

Why this pattern must not be run, or null if it may be.

The reason is a sentence for whoever typed it, not a diagnostic.

registration

Who is allowed to create an account here.

The instance decides, not the person signing up, so the mode lives in the config file beside the other deployment settings. Enforcement is in hooks.server.ts rather than in a route, because sign-up is better-auth's endpoint and the answer has to be the same however it is reached.

Functions

defaultGrantUntil(now)

A month from now, as the date the invite form opens on.

registrationMode()

Who may create an account here.

The environment wins over the config file. That exists because the config file is written from the web page: on a box you administer over ssh, being able to open registration without logging in — or before there is anybody to log in as — is the difference between a deploy and an afternoon.

ONTOPLANO_REGISTRATION=open|invite|closed is the whole of it.

Staging does not imply open, and no environment implies anything. It used to: ONTOPLANO_STAGING=true quietly returned open, which meant the copy people test on was answering a question differently from the instance it is a copy of. A staging instance exists to behave exactly like production and be labelled as not being it; the moment a code path asks which one it is running on, it has stopped testing the thing it is standing in for. If a staging box should take sign-ups, its env file says ONTOPLANO_REGISTRATION=open out loud, and that is a sentence somebody wrote rather than a consequence.

setRegistrationMode(mode)

instanceIsEmpty()

Whether anybody has an account yet. The first one is always allowed in.

checkSignUpAllowed(code, now)

May this sign-up proceed?

Returns the invite it consumed, if any, so the caller can mark it used once the account actually exists. Throwing here is what a refused sign-up looks like. A closed instance answers flatly; an invite-only one says what was wrong with the code — the register form already announces that codes exist, so "not accepting new accounts" to somebody holding a mistyped one was secrecy about a fact the same page states, and read as "your invitation is worthless".

consumeInvite(id, userId, now)

Called once the account exists, so a failed sign-up does not burn a code.

Returns whether this call is the one that spent it: two sign-ups can arrive holding the same code, and the usedAt IS NULL in the update is what decides between them — the caller hands the code's grant only to the account this returns true for.

listInvites()

createInvite(createdBy, raw, now)

revokeInvite(id)

Revoking an unused invite deletes it; a used one is history and stays.

openInviteCount()

How many invites are outstanding, for the settings page.

Types

reminder-clock

The thing that makes a reminder arrive when it says it will.

Why not a timer every minute

There was one: a systemd unit that asked the app, once a minute, whether anything was due. It works, and it is late by up to fifty-nine seconds every single time. A reminder is a time — being told at 09:00:47 that something starts at 09:00 is being told late, and an alarm for seven in the morning that goes off at some point during the seventh minute is not an alarm.

Why not "work out the next one and sleep until then"

Because the database changes underneath you. Sleep until 15:00 because that is the next thing, have somebody set an alarm for 14:10, and you wake at three to a reminder that was fifty minutes late. That is the failure mode that makes the obvious design wrong, and it is the reason for both halves of what is here:

  1. Every write wakes it. wake() throws the current sleep away and asks the question again. Anything that creates, moves or removes a reminder calls it, so a new earliest reminder reschedules the clock the moment it exists rather than the next time the clock happens to look.

  2. It never sleeps longer than a minute anyway. A write path that forgets to call wake() costs at most sixty seconds, not the hours until whatever it was sleeping for — and the same ceiling covers the things no write can notify us about: the machine suspending, the clock being stepped, a daylight-saving change moving every wall-clock reminder in a zone at once.

So the fast path is exact and the slow path is the behaviour it replaced. There is no arrangement of events that makes this later than the timer it replaces, and in the ordinary case it is exact to the second.

One process

This is a single node process with a single sqlite file, so this is a timer in it — no queue, no second daemon, nothing to keep alive. The systemd unit stays as a belt: it costs one request a minute and covers the case where the app was restarted between a reminder falling due and anybody noticing.

Functions

nextDueAt(now)

The instant the next unsent reminder falls due, or null if there is none.

remind_at is wall-clock in the account's own zone — "remind me at ten to nine" means ten to nine wherever that person is — so each account's earliest has to be converted with that account's offset before they can be compared. The candidates are exactly the ones a pass would deliver (pushCandidates): one it would skip must not be what the clock wakes for, or it wakes for it forever.

wake()

Throw away the current sleep and ask again. Cheap; call it freely.

startReminderClock()

Start it. Idempotent, because SvelteKit imports its hooks more than once.

The first look happens on the next turn of the loop rather than here, so importing the hooks never touches the database. It did, and that made every test that loads them pay for a query against whatever database that test had — which under a parallel run was enough to time two of them out.

reminder-delivery

The pass that makes a reminder arrive with the app shut.

Everything else about reminders is written for somebody who is looking: the planner card, the poll, the notification raised by an open page. That covers the person who did not need reminding. This is the other half — a phone in a pocket, a laptop asleep — and it is the reason the feature exists.

Run it every minute. It is cheap on the ordinary minute: one indexed query over reminders that are due and unpushed, and on most minutes that answers nothing and the pass ends. Safe to run twice — pushed_at is stamped only after the push actually left, so a crash halfway repeats at most one message rather than losing one.

Birthdays

Written here as well, because the row has to exist before the minute it is due — nobody is looking at the app at six in the morning, which is the whole point. Idempotent per person per day, so this and the poll cannot make two.

Functions

deliverDueReminders(now)

reminder-sources

The reminders nobody types.

A reminder somebody set is easy: they said a time and it fires. These are the other kind — the things the app knows and the person does not, which is the only reason to have an app that keeps them. Each one is written into the ordinary reminders table well before it is due, because the row has to exist for the clock to find it and nobody is looking at six in the morning.

Every one of these is idempotent per account per day, keyed on the exact row it would write, so running the pass twice cannot say a thing twice. That matters more here than anywhere: the pass runs on a timer that is allowed to be woken, so "twice" is the normal case and not the accident.

Functions

ensureReviewReminder(ctx, now, tz, t)

"Your weekly review is pending."

Once, on the morning of the day the week turns over, and only while there is actually something to review — reviewPending already refuses to ask about a week nobody planned, and about a week whose blocks have all been answered for. Said again the following week if it is still open, because the number in it will have changed and so will the sentence.

ensureBillReminders(ctx, now, tz, t)

The three things worth saying about a bill.

They are three different sentences because they are three different situations, and one of them is an emergency:

Only for bills that are actually unpaid, and only inside a fortnight, so a year's worth of yearly bills is not written into the table in advance.

ensureBlockReminders(ctx, now, tz)

Every block on the plan says so as it starts.

A reminder about a block used to need a lead time set on that block, one block at a time — which is right for "ten minutes before gym" and useless as the answer to "tell me when things start". This is the account-wide version: on, and every occurrence left today gets a nudge at its own time.

Through createReminder rather than by writing rows, so it is the same kind of reminder a lead produces, carries the block's own name, and is deduped on (block, minute) the way regenerating a week already is — a block that has a lead of zero, or that has already been given one for this minute, does not get a second.

Only what is still ahead. A block at nine, with the app opened at eleven, is not something to be told about: the row would be due the moment it existed and would fire as though it were news.

ensureEndOfDayReminder(ctx, now, tz, t)

What the day turned out to be, at the hour it ends.

The one reminder that is about a day rather than about a thing in it, which is why it is its own kind. It says the count and nothing else: a day that went badly does not need a paragraph about it, and a line somebody reads in a second is a line they keep letting through.

Written ahead of its time like everything else here — the row has to exist before the clock looks for it, and on a phone it has to exist before the app is closed, which is hours earlier.

passesRunOnAClock()

Said by the clock when it starts: from now on a pass runs without the polls.

forgetReminderPass(userId)

Something of this account's changed: its next poll runs the pass in full.

ensureOwnReminders(ctx, now, tz, t, { always = false })

Every reminder nobody types, written for one account.

Five sources, one call, because the list of them is a thing that grows and every caller was keeping its own copy of it: the delivery job wrote the review nag and the bills, and the two places a page reads reminders wrote only the birthdays. So an instance that runs on the device — which has no delivery job at all, because there is no server to run one — got birthdays and nothing else, and the difference was invisible: a reminder that is never written is indistinguishable from a day with nothing on it.

Every one of them is idempotent per account per day, keyed on the exact row it would write, so being called from a page poll, a job and a phone waking up cannot say a thing twice.

upcomingWindow(raw)

A window somebody asked for, clamped to something the list can be.

windowEnd(now, tz, days)

The last day a window covers.

The page shows two kinds of row in one list — the ones stored and the ones worked out here — and they have to agree about where the list ends, or "the next day" answers with something in December.

upcomingDerived(ctx, now, tz, t, days)

Types

reminders

Something that reaches out.

The app only helps on the days you remember to open it, which is why most people who try a planner stop in week two. Everything else here waits to be visited; a reminder is the one thing that does not.

A reminder belongs to a block, and to nothing else

There used to be three kinds: one on an occurrence, one on a todo, and a "free" one that was a message and a clock reading and nothing else. The free one was a mistake — it made reminders a thing of their own, with a list of their own to keep, when what anybody actually means is tell me before this starts. So there is one kind now, and it hangs off an occurrence.

Which also settles the todo. A todo has no time; there is nothing to be before. Wanting to be reminded of one is wanting it to happen at a time — give it one, which makes it a block, and the block takes the reminder.

The lead lives on the block (remind_lead_minutes) and generateForDate writes a row here per occurrence, so "ten minutes before gym" is said once and applies to every gym. The rows below are those occurrences.

Times are wall-clock, like a block's, because "remind me at ten to nine" means ten to nine wherever you are. Delivery is deliberately somebody else's job: a row that is due is a row anything with the database can deliver — the page you have open, or the push delivery job while the app is closed.

Functions

localNow(ctx)

Now, as the wall-clock string reminders are stored in.

startOfDay(userId)

The hour the account's day opens on, as HH:MM.

Exported because the form has to say it: a field somebody may leave empty has to name what leaving it empty means.

reminderClock(ctx)

The account's clock, as a form setting a reminder needs it.

Its own wall clock rather than the browser's, because "now" means now where the account is — the machine's zone is the account's only by luck. The reminders page and the quick add sheet both ask this, and the rules they apply to it are $lib/reminder-clock.

upcomingReminders(ctx)

What has not gone off yet and is close enough to be worth an alarm.

One definition, used by the page that books alarms for the instance it is part of and by /api/v1/reminders/upcoming, which hands the same list to a phone pointed at a server. Two copies of this filter would be two answers to "will my phone ring", and the difference would only ever show up as silence.

listReminders(ctx, options)

dueReminders(ctx)

Everything that should have gone off by now and has not.

deliveredAt is stamped by whoever shows it, so two channels cannot both announce the same thing — and one that fell due while the app was shut still arrives the next time it opens, rather than being silently skipped.

createFreeReminder(ctx, raw)

createTodoReminder(ctx, raw)

A reminder about a todo, at a time.

A todo has no time of its own, so this is an alarm that names what it is about: the todo's title when nothing else is said, and the todo's own page when it is pressed. It is also the one reminder a key confined to a notebook can set — an alarm names nothing, and so belongs to no notebook.

createReminder(ctx, raw, { chosen = false })

markDelivered(ctx, ids)

Stamped by whoever showed it, so nothing announces the same thing twice.

editReminder(ctx, id, raw)

Change one that is already set.

Everything the form that made it asked for: when, what it says, whether it makes a noise and which noise. Without this, a reminder was a thing you could make and unmake and nothing in between — so wanting an alarm five minutes later, or wanting the one you set silently to actually wake you, meant deleting it and typing it again.

Any row, not only the ones somebody typed. A nudge before a block is a real row with a real time on it, and "not this one, ten minutes earlier" is the commonest thing anybody wants to say about one; what it must not do is change the block, which is why only these four fields are here.

Absent fields are left alone, so a caller that only cares about the sound sends the sound. audible: null is a real answer — it means "whatever this kind of reminder does", which is what a row says before anybody overrides it.

dismissReminder(ctx, id)

deleteReminder(ctx, id)

remindersFor(ctx, id)

The reminders already set on one block, so its editor can show them.

pushCandidates(now, lookBackHours)

The reminders a pass can still deliver: unsent, not dismissed, and between the catch-up floor and a day ahead.

One definition, because the reminder clock asks the same question to decide when to wake. It used to ask it without the floor, so a reminder more than CATCH_UP_HOURS old — never rung, so never stamped — was forever "the next one due", and the clock ran the whole pass again every quarter of a second.

pushableReminders(nowByUser, now, limit, lookBackHours)

markPushed(ids)

Say a reminder left for somebody's devices.

Separate from markDelivered and stamping a different column — see the note on the schema. Nothing here is per account: the job that calls it has no signed-in user and the ids come from its own query.

Types

request-replays

A create sent twice, answered once.

An assistant that did not hear back from add_task cannot know whether the task was made. If it sent a requestId, it can send the same call again: within the window the first answer comes back and nothing runs a second time. After the window the id is forgotten and a resend is a new call — a retry is a matter of minutes, and a table of every answer ever given would be a second copy of the account.

Keyed per account, not per key: two assistants on one account that pick the same id are the same caller as far as the data is concerned, and the fingerprint stops either of them being answered for a call it did not make.

Functions

fingerprintOf(tool, args)

The call a requestId was used for: the tool, and its arguments without the id.

requestIdOf(value)

The id as sent, refused when it is not one.

replayOf(ctx, requestId, fingerprint)

The answer this id was given, when it was given within the window — or null.

The same id with a different call is refused: answering "add milk" with the answer to "add eggs" would say something was made that was not.

rememberAnswer(ctx, requestId, tool, fingerprint, answer)

Remembers the answer, and forgets this account's ids that have run out.

review-mail

The morning a week begins: what last week actually was, in the inbox.

The review page has held these numbers since the beginning and nothing ever asked anybody to look at them — the app only helps on the days you remember to open it, which is why most people who try a planner stop in week two. A report over data already collected is the cheapest thing there is that brings somebody back, and unlike a notification it is not asking for anything: it says what happened and leaves the door open.

Two mails, because there are two situations

A week with blocks nobody has answered for and a week already settled are not the same message. The first is "Review your week": what you did, and what you have not said happened yet, with the page that closes it one press away. The second is "Your week": what you did, and nothing to do about it — a report, not a chore.

It used to send only the first, and only while the week was open, so somebody who keeps their week tidy got no Monday mail at all. That is backwards: they are the ones the report is worth reading for.

What it will not do

Send about a week that had nothing in it. A mail full of zeroes about a week somebody never planned is how a lifecycle mail teaches people to filter it.

Send twice. The week it last wrote about is stored, so a timer that fires hourly, a box that reboots, and a run somebody starts by hand all produce one mail.

Send to an address nobody confirmed. An unverified address is one somebody typed, possibly somebody else's.

Send to anybody who did not ask. Off unless the account turns it on, under Settings → Account. Mail somebody did not ask for is spam however useful it is, and the fact that it is about their own data does not change whose inbox it lands in. Every message carries a link that stops them in one click with nothing to sign in to, which is the other half of the same rule.

When

The hour is the account's own: the start of its planner grid, plus an offset. Somebody whose day starts at 06:00 is up an hour before somebody whose day starts at 09:00, and both of them want this over the first coffee rather than at a time the app chose. The offset is one setting for the whole instance rather than a number in this file — see REVIEW_MAIL_OFFSET_HOURS.

Functions

reviewMailOffsetHours()

Hours after the planner's own start of day.

An hour, unless the instance says otherwise. Not a constant in the middle of a function: "why does mine arrive at eight" has an answer somebody can change without editing this file, and the number is a judgement rather than a fact. Bounded to a day, since past that it is no longer the same morning.

weeklyReviewMailEnabled(userId)

Off unless the account said otherwise. Nobody is mailed unasked.

reviewMailHour(userId)

The hour this account's mail goes out, in its own timezone.

The planner's first hour plus the offset, clamped to the day: a grid that starts at 23:00 would otherwise send at midnight tomorrow, which is not the morning of anything.

setWeeklyReviewMail(ctx, on)

unsubscribeToken(userId)

unsubscribeTokenValid(userId, token)

weeklyReviewMail(t, ctx, weekStart)

A week, in the four sentences worth reading over breakfast.

Not a rendering of the review page. The page is where you do something about a week; the mail's job is to make somebody want to open it, so it says the shape of the week and stops. The one number that carries the feeling is how much of what you meant to do you did.

sendWeeklyReviews(now)

Write to everybody whose Monday it is. Answers with how many went.

Run hourly: the account decides the hour, because the account decides the timezone, and a job that ran once a day could only ever be right for one of them.

review

Closing a week.

The planner has recorded these numbers since the beginning and nothing ever asked anybody to look at them. That is the difference between a tracker and a habit: the app records what happened and never once says "that was your week, what do you want to do about it".

A review is three questions. What did you plan against what you did. What did not happen, and does it still need to. And what you would say about the week, which is the part that is actually worth reading in a year.

Functions

weekStartOf(ctx, value, fallback)

Which week a review is for.

Snapped to the day this account's week begins on, so "the week of the 14th" and "the week of the 16th" cannot become two reviews of the same seven days — and so the review and the planner name the same seven. It used to snap to Monday whatever the account had been told, which meant that for anybody whose week starts on a Saturday, a Saturday block led one week on the plan and closed the week before it in the review. 0085 re-keyed what was already stored.

readWeek(ctx, weekStart)

goalsTouched(ctx, weekStart)

Goals you moved this week.

A goal's measures are single numbers with no history behind them, so this cannot say how much one moved — only that the goal was touched inside the week, which is the honest version and still answers "did any of this go anywhere". Where it stands comes along, one line per measure.

saveNote(ctx, raw)

Replace a week's note.

Keyed by (week, position) like the daily wins, so re-saving edits the same row instead of accumulating a new review every time somebody fixes a typo, and an emptied box removes it rather than storing a blank. Position 1 is where the first of the old three lines lived, and the migration folded the other two into it, so nothing anybody wrote was lost.

readNote(ctx, weekStart)

The week's note, or an empty string where there is not one yet.

pastNotes(ctx, options)

Everything ever written, newest week first.

Three lines a week is the only running account of a year that this app keeps, and until now they went into a row and stayed there: you could read last week's by looking at last week, and everything before that by clicking back fifty times. A thing you write and never see again is a thing you stop writing.

carryIntoTodos(ctx, weekStart, rawIds, scheduledDate)

Carry what did not happen into the todo list.

A block that did not happen is a block that is gone: its occurrence belongs to a day that has passed, and next week generates its own. So the honest carry is not "move it" but "make a todo out of it" — something with no day on it, which is exactly what a thing you still intend to do but did not schedule is.

The ids are checked against the week's own loose list rather than trusted, which makes this ownership-safe by construction: an id from another account is not in that list, so it is not carried and nothing says so (I3).

reviewPending(ctx)

How far back the open weeks go.

The whole reason the review exists is that nothing ever asked. This is what the dashboard asks with — and it only asks about a week that is actually over and had something in it, because prompting somebody to review a week they did not plan is how a prompt becomes noise you learn to ignore.

It used to look at last week and stop, so somebody who let three go by was told the same thing as somebody who let one — "last week is still open" — which is both wrong and comforting. It walks back now and reports the oldest one and how many there are, because the oldest is where you would start and the number is the thing worth knowing.

What closes a week

The blocks, not the note. It used to be the note: a week with an unanswered Tuesday in it went quiet the moment you typed a sentence about it, and a week where every block had been answered kept asking forever because nobody felt like writing. Both halves of that were wrong, and the second is the one that gets a prompt ignored — being told to fix something already fixed.

So a week is open while a block on it is still waiting for an answer, and every one of the four answers — it happened, skipped, onto the todo list, on this day — takes a block out of that count. The note is writing, and writing is not a chore anything here nags about.

resolveLoose(ctx, weekStart, rawIds, status)

Say what actually happened to the ones that did not.

Carrying into the todo list was the only answer on offer, and it is the least common one. Most of what is sitting in that list on a Sunday either happened and was never ticked, or was never going to happen and you have made your peace with it. Offering only "carry it" made the review a chore with one wrong answer.

Ids are checked against the week's own loose list rather than trusted, which makes this ownership-safe by construction: an id from another account is not in that list, so nothing happens and nothing says so (I3).

settleWeek(ctx, weekStart, verdicts)

Types

ringtones

The sounds a reminder can make.

Every reminder shows. Only the ones somebody asked to hear make a noise — an app that beeps the first time you use it is an app whose sound you turn off and never turn back on. So silence is the default for every kind, and audible is a thing you switch on for the kinds that are worth interrupting you: an alarm, probably; a birthday, probably not.

Functions

listRingtones(ctx)

readRingtone(ctx, id)

The bytes themselves, for the route that plays one. Owned or nothing.

addRingtone(ctx, input)

removeRingtone(ctx, id)

soundChoices(ctx)

What every kind sounds like, including the ones never chosen.

A kind with no row is silent, and returning the full set rather than what happens to be stored means the page renders the same shape whether somebody has touched this or not.

setSoundChoice(ctx, kind, choice)

soundFor(ctx, reminder, choices)

What this particular reminder should sound like, if anything.

The reminder's own answer wins where it has one — an alarm set for one morning is audible whatever free reminders do in general — and otherwise it is whatever its kind says. null means show it and say nothing.

Types

saved-filters

A narrowing somebody wants back, under a name they chose.

"Everything urgent about the house that nobody has done" is four controls set four ways, and setting them again every Monday is the work the controls were supposed to save. So a filter can be kept: a name, and the query string the list is already writing into the address.

The query string is the whole of it. Since $lib/filters-in-url put a list's narrowing into the address, applying a saved filter is navigating to it — so there is no shape here to keep in step with the controls, and a filter saved today still means something after a control is added or renamed. What it cannot express is anything a list keeps outside the address, which is also exactly what is not worth saving.

Stored per surface, because the task list's filters are not the diary's and a name that means one thing on one screen means nothing on another.

Functions

savedFilters(userId, surface)

One surface's saved filters, oldest first, or nothing at all.

saveFilter(userId, surface, rawName, rawQuery)

Keep this narrowing under this name, replacing one of the same name.

Replacing rather than refusing: somebody saving "This week" twice has adjusted it, and being told the name is taken sends them to delete the old one first for no reason.

deleteFilter(userId, surface, rawName)

Types

schedule

Read-only view of what's coming up.

This is what lets an external app schedule against the plan — an alarm clock turning a "wake up 07:00 Tuesday" slot into an alarm, for instance. It is deliberately read-only and deliberately generic: ontoplano exposes what is scheduled, and the consuming app decides what to do about it. Ontoplano knows nothing about alarms, ringtones, or wifi.

Functions

getUpcomingSchedule(ctx, opts)

Types

schemes

Saved weeks.

A scheme is a snapshot of the weekly plan that can be put back later — a term timetable, a holiday week. Applying one replaces the plan wholesale, which is why it happens inside a transaction: a half-applied week is worse than either of the two it sits between.

Functions

listSchemes(ctx)

saveScheme(ctx, rawName)

applyScheme(ctx, schemeId)

Replace the weekly plan with a saved one.

deleteScheme(ctx, schemeId)

renameScheme(ctx, schemeId, rawName)

scoped-actions

What a form handler needs of the request: the body, and who is signed in.

Functions

formAction(run)

A form action: the body read, the account's context built, the service called, and a service's refusal turned into the fail() the page shows.

Every handler in the app did these four things by hand around one line of its own — two hundred and eighty copies of the same try/catch. What is left to write is the line: which service, with which fields. Returning nothing answers { success: true }; returning something answers with that.

under(prefix, handlers)

A room's handlers, mounted under a prefix.

The notebook page answers for every module it can hold, and each module's handlers are the room's own — the Habits tab inside a notebook ticks a habit with the same code the Health room does, or the two screens mean different things by the same button. The plain names belong to the notebook itself, so they are mounted prefixed: create becomes habitCreate.

What the markup posts to is the matching *-action-names.ts beside each card — $lib/habit-action-names, $lib/bill-action-names and the rest — which spell the same names out for the two screens that draw the card.

Types

One box over everything the account owns.

The app had grown nine places to put a sentence and no way to find one again: remembering which section something is in is not a feature. This queries each table for the same substring and returns the hits grouped by what they are.

LIKE rather than FTS5 on purpose. It is one index scan per table on a few thousand rows, which is nothing, and it keeps the schema free of a second copy of every piece of text. When somebody has fifty thousand notes this becomes an FTS5 table and the shape of this file does not change.

Functions

search(ctx, raw)

grouped(hits)

The same hits, in the order the kinds are listed, for rendering.

sent-notifications

What the app has told somebody, kept so they can read it again.

A push happens once. It lands on whichever device was awake, somebody clears a lock screen, and it is gone — so "what did it tell me while I was out" had no answer. This is the answer, and the reason the table exists.

Everything the server sends is written here on its way out, at the one function that sends: a new kind of notification is recorded without anybody remembering to add it. See pushToUser.

Functions

record(userId, what, now)

Write one down.

Takes a plain user id rather than a Ctx, because the thing that calls it is a delivery pass working through a list of accounts and has no context for any of them.

list(ctx, limit)

unreadCount(ctx)

markRead(ctx, id)

Mark one read.

Already-read stays as it was: the first time somebody saw a thing is the useful fact, and re-reading it does not move that.

markAllRead(ctx)

Mark every one of them read.

What opening the list means: they have been seen. The count is about "is there anything I have not looked at", and looking at them answers it — a badge that survives being read is a badge people stop believing.

Types

sessions

The sessions an account currently has open.

Session tokens never leave the server: the page addresses a session by its id, and this module is the only thing that turns an id back into the token better-auth needs to revoke it. Putting the tokens in the HTML would make the page a list of working credentials.

Functions

listSessions(ctx, currentToken)

sessionTokenById(ctx, id)

The token for one of this account's sessions, or a 404.

describeUserAgent(ua)

A user agent as something a person recognises.

Deliberately crude — the string is a self-reported free text field, and the job here is only to help someone tell "my phone" from "the laptop I left at the office", not to build a device database.

Types

settings

A person's own settings, kept in their rows.

Everything here is one account reading and writing user_settings, which is why it can run on any instance — the server, or the device itself. What the deployment is (self-hosted, staging, local) stays in $lib/server/settings.ts, because a browser has no environment to ask.

Functions

forgetUserSettings(userId)

An account's settings were written around this module: read them afresh.

getUserSetting(userId, key)

setUserSetting(userId, key, value)

isTheme(value)

getTheme(userId)

The user's stored theme, or system if they have never chosen one.

system is resolved in CSS from prefers-color-scheme, not here — the server has no way to know what the device is set to, and guessing would make the first paint wrong for half of visitors.

setTheme(userId, theme)

isMoneyView(value)

getMoneyView(userId)

Whether the money picture shows every account balance, including money held for somebody else, or only the part that is the reader's. The default is all so an existing picture does not change under a person who never chose; the money screen offers the switch and stores the answer.

setMoneyView(userId, view)

getLocale(userId)

The language this account reads the app in.

Null rather than a default when nothing has been chosen, because "no answer" and "English" are different states and only the caller knows which fallback belongs where: a page falls back to what the browser asked for, an email falls back to the instance's own language, and neither can be decided here.

An unknown tag reads as no answer. A language that is removed from the app therefore lets everyone who chose it fall back cleanly instead of rendering a screen of keys.

setLocale(userId, locale)

getClock(userId)

Whether this account reads a 12- or a 24-hour clock.

auto — the default, and what almost everybody should be on — asks the language: English says four in the afternoon, Portuguese and German say sixteen. It is a setting because the language is a good guess about a person and not a statement about them: plenty of people read English and think in 24, and the app has no business arguing.

setClock(userId, clock)

getHiddenSections(userId)

The sections this account has put away — out of every menu, still there at their URLs. Stored as a JSON array; unknown ids are dropped on read, so a section that stops existing disappears from the setting by itself.

setHiddenSections(userId, hidden)

hasSeenTutorial(userId)

Whether this account has been shown around.

The tour runs unasked exactly once — on the first screen after first run — and lives on a button in the corner from then on. One flag rather than one per screen: being walked through the app is a thing that happens to a person, not to a page, and an account that meets the same welcome on every new room has been nagged rather than helped.

The demo does not consult this. Every visitor there is somebody's first visit and the account is shared with nobody, so the demo remembers a dismissal for the length of the tab and forgets it afterwards.

setTutorialSeen(userId, seen)

getNavOrder(userId)

The order this account wants its rooms in, as a list of keys.

Stored thin and validated on the way out rather than on the way in: what is a room changes as the app grows, so a list that was valid when it was saved can name something that no longer exists. applyOrder in $lib/nav-order.ts is where a missing key is dropped and a new room is kept, and it is the only place that decides either.

setNavOrder(userId, order)

getSectionColors(userId)

The colours this account has changed, keyed by section. The rest are the app's own; $lib/colors.ts still holds those and is still the default.

Only #rrggbb is stored and only #rrggbb is returned. A colour reaches a style attribute, which is one of the very few settings that does, so it is checked at both ends rather than trusted at either.

setSectionColors(userId, colors)

getStyle(userId)

setStyle(userId, style)

getWeekSettings(userId)

setWeekSettings(userId, week)

getGridHours(userId)

setGridHours(userId, hours)

getCurrency(userId)

setCurrency(userId, currency)

getTimezone(userId)

setTimezone(userId, tz)

isOnboarded(userId)

markOnboarded(userId)

panelWidthFallback(key)

Where this particular panel starts. See PEOPLE_PANEL_FALLBACK.

panelWidthMin(key)

The narrowest this particular panel goes. See NOTEBOOK_PANEL_MIN.

getPanelWidth(userId, key)

setPanelWidth(userId, key, rem)

getChatMayDelete(userId)

Whether the chat holds the destructive grant.

Off until somebody says otherwise, the same default the key form ticks — an assistant that can remove a person or a habit's history is a bad trade for most people most of the time. Off is not "never": the grant exists, it is theirs to give, and deciding it for them in a route would make the permissions screen a lie in one place.

setChatMayDelete(userId, may)

getCaptureSettings(userId)

Which wedges the capture wheel holds, in what order, and the notebook its forms start in. Read leniently — see $lib/capture-settings; the notebook is checked when it is written, and a form offered one that has since gone simply does not find it among the notebooks it lists.

setCaptureSettings(userId, settings)

getNoteView(userId, notebookId)

setNoteView(userId, notebookId, view)

Types

slots

The plan itself: blocks that repeat (recurring_tasks) and blocks that happen once (exceptional_tasks), plus the skips that cancel a single occurrence.

What any of it produces on a given day is services/instances.ts. This module owns the shape of the plan; that one owns what the plan means for a date.

Functions

listActiveWeeklySlots(ctx)

listWeeklySlots(ctx)

Every weekly block, active or not — the grid draws the inactive ones faded.

listSuppressions(ctx, from, to)

listExceptionals(ctx, from, to)

createSlot(ctx, raw)

updateSlot(ctx, id, raw)

Arm the days that already exist, when the block's lead changes.

remindFor runs at generation, so a reminder set on a block reached only the occurrences generated after it — and the ones somebody cares about are this week's, which were generated days ago. Setting "ten minutes before" on a block you already have therefore did nothing at all, silently: the lead was stored, the form said saved, and no reminder ever appeared.

So the block's own future occurrences are re-armed here whenever the lead is written. Its reminders go first — a changed lead has to move them, not add a second one — and only the untouched days are re-armed: an occurrence somebody has marked done or skipped is a record of their day, not something to ring about. Only from today: arming yesterday is arming nothing.

toggleSlotActive(ctx, id)

setSlotActive(ctx, id, active)

Paused or running by saying which, for a caller that should not have to read first.

deleteSlots(ctx, ids)

copySlotsToWeekdays(ctx, ids, days)

clearWeeklyPlan(ctx)

Empty the weekly plan, and everything the blocks produced.

clearWeeklyPlanIn(tx, ctx)

The same, inside a transaction someone else opened.

createExceptional(ctx, raw)

updateExceptional(ctx, id, raw)

deleteExceptional(ctx, id)

suppressOccurrence(ctx, slotId, rawDate)

Skip one occurrence of a recurring block. Skipping twice is not an error.

unsuppressOccurrence(ctx, slotId, rawDate)

moveOccurrence(ctx, raw)

Move one occurrence of a recurring block without moving the block.

Modelled as the two things that already exist: the occurrence is skipped on its own date, and a one-off carrying the same identity is created at the new time. No new table and no third kind of thing — "this week is different" is exactly a skip plus a one-off, and both halves stay individually reversible.

convertRepeat(ctx, id, raw)

Turn a one-off into a recurring block, or a recurring block into a one-off.

The two differ only in which day they name — a weekday versus a date — so changing your mind should not mean deleting one and retyping the other. Everything else about the block travels with it, labels included.

They are two tables, so the block comes out with a new id, and that id is what is returned: whatever goes on editing it — saving, switching back — has to name the new row. The old one no longer exists.

A recurring block becoming a one-off keeps only the occurrence in the visible window; its other occurrences were never separate things, so there is nothing else to preserve.

importWeekCsv(ctx, raw)

A week as a grid: start time, duration, then one column per weekday.

A cell naming an activity becomes an activity block; anything else becomes a category block carrying the text as its label, and is reported back so the user knows what was not recognised rather than silently losing it.

readRecurrence(formData, now)

The recurrence rule from a block form.

Every shape has a day it starts from, which the form supplies as the block's own date — so "every 2 weeks" counts from the occurrence you were looking at, and "every week on Saturday" is a rule about the Saturdays from here on rather than about every Saturday there has ever been.

The every-N shapes are counted from the anchor and cannot do without one, so they fall back to today. Weekly and monthly can: a rule written before this existed has no anchor, and an edit that leaves the field empty must not quietly give it one — that would cut a year-old routine off at today.

Types

stale

Things that never ended.

Todos, ideas and someday-items only ever accumulate. Nothing in the app has ever asked "these nine are four months old — are they real?", so a list that started as a plan becomes a monument, and eventually somebody stops opening it rather than face it.

This is deliberately part of the weekly review rather than a page of its own. A second ritual is a second thing to remember, and the whole problem here is that nobody remembers.

Functions

listStale(ctx, months)

keepStale(ctx, sort, id)

"Still real."

Touching the row is the whole action: it resets the clock, so the thing stops being offered for another three months. Nothing else about it changes, which is the point — you are answering a question, not editing anything.

completeStale(ctx, sort, id)

"Done, actually."

Half of what has been sitting there for three months is not undecided — it is finished and never ticked. Offering only "still real" and "delete" made the honest answer impossible, so the list quietly taught you to lie about it.

Only a todo can be done; an idea and a someday-item have no such state, so for those this does nothing and the caller keeps its other two answers.

dropStale(ctx, sort, id)

"Let it go."

Scoped to the account in the same statement, so an id belonging to somebody else deletes nothing and reports nothing (I3).

Types

statements

What moved through a ledger, and the rules that make sense of it.

Lines are kept as the bank said them — see the schema note on finance_transactions — and the sorting happens at read time: a category is the first rule whose pattern matches, in position order, so the partition is deterministic and a month's categories add up; tags are every rule that matches, so they overlap freely and are a lens rather than a sum. A rule written today therefore sorts last year's lines, with no resweep and nothing stored to drift.

Functions

splitMovement(ctx, input)

A split never rewrites the bank line; custody entries and all parts land together.

unsplitMovement(ctx, id)

Remove parts only after the owner explicitly reverses their stock entries.

availableParsers()

The parsers an import can offer, by key and friendly name.

importStatement(ctx, input)

Import one export's text into one ledger. Idempotent: the same file twice adds nothing, and the same file into a different ledger is a different set of lines, because a ledger is part of what a line is.

flip negates every amount, for an export whose signs mean the opposite of what the parser expects — a statement kept from the card's point of view, say.

mapping is for the generic CSV reader, and only for it: which column holds the date, which the description, which the money. Absent, that reader falls back to what it guessed from the header, which is the ordinary case — the mapping arrives when somebody has corrected the guess on the screen.

recordMovement(ctx, input)

One line, recorded by hand or pushed in by a plugin.

The same idempotency as an import: a plugin that re-sends a movement it already sent adds nothing, provided it names the same externalId.

updateMovement(ctx, id, input)

classifyMovements(ctx, input)

Say what several movements were at once, in one write.

The point is a hundred lines in one gesture, not a hundred round trips, so the whole selection is checked first: a single leg of a transfer or an id from another account refuses the batch rather than half-applying it.

linkTransfer(ctx, input)

Two lines are one move between my own accounts (D2).

A group of one is a pending counterpart and is allowed to stand: the destination statement often arrives later. Two legs in the same ledger are refused — they would balance to zero and hide two real expenses — and legs in one currency must add up to nothing, because money that left one place arrived in the other.

unlinkTransfer(ctx, id)

Undo a pairing: both legs return to the sign's guess, and the group is gone.

linkMovement(ctx, input)

Attach a line to the one it belongs to (D8).

A refund reverses one expense and a fee belongs to the payment it was charged on. One parent only: a credit returned against two expenses is two credits. The currencies must match — the ledgers may differ, the money may not — because netting unlike money is arithmetic, not reconciliation.

detachMovement(ctx, id)

Undo a refund or fee link: the line stands alone again, back to the sign's guess.

getMovementRow(ctx, id)

One movement as stored, or null — how an id somebody named is resolved.

listMovementRows(ctx, opts)

Every movement as stored, or only those in some ledgers — what a key tied to a notebook reaches.

deleteMovement(ctx, id)

Gone.

A leg of a transfer is refused while the group stands: deleting one leg is how a pair becomes a single line that still calls itself a transfer (D2). Anything that pointed at the line is unlinked first, so a refund survives its parent as an unmatched credit rather than disappearing with it.

listRules(ctx)

createRule(ctx, input)

updateRule(ctx, id, input)

moveRule(ctx, id, delta)

Move a rule within its kind. A move is a reinsertion and every sibling is resequenced around it, so two rules can never share a position and "up" always actually moves.

placeRule(ctx, id, position)

Put a rule at a place among the rules of its kind, counting from 0 — the order listRules gives them, which for categories is the order they win in. A place past either end is the end.

deleteRule(ctx, id)

sortByRules(ctx, description)

What a description would be filed as, for anything that needs to ask.

listMovements(ctx, filter)

listMovementPage(ctx, opts)

One bounded page of statement lines, newest first.

A page, not a figure: limit is clamped and beforeId is the cursor the previous page answered with, so walking the history never loads all of it. The values are listMovements' own — same category, tags, allocations and counterpart state — because the API and the screen must tell one story.

beforeId is the id of the last line already read; the keyset is that line's place in the order, not the id alone, so an import backdating a line cannot make a page skip or repeat a row.

recentForLinking(ctx, limit)

The few recent lines one meaning can be linked to.

Deliberately not listMovements: that loads the whole history and runs every sorting rule over each row, which a picker of the last hundred lines has no use for and would pay for on every page load.

uncategorizedCount(ctx, filter)

How many lines no category rule claims — the number that says "more rules".

monthKeys(now, months)

'YYYY-MM' keys ending at the month now is in.

statementWindow(ctx, months, filter)

One inclusive window for every finance series, in the account's civil month.

categorySlices(ctx, filter)

Personal spending by category over a window — the pie, and the table beside it. Expenses and fees count, net of refunds, and only the personal part of a split line. Transfers, cash withdrawals and custody shares stay out; a month whose refunds outweigh its spending has no slice rather than a negative one.

spending(ctx, filter)

Personal consumption and its exclusions, grouped by original currency without a row cap.

monthlyTotals(ctx, months, filter)

In, out and net, month by month.

This is cash flow as the bank shows it: a linked refund arrived, so it is money in. Personal spending — categorySlices, categoryMonths — nets that refund against the expense it reverses and leaves transfers, cash and custody shares out, so the two totals are not meant to match.

categoryMonths(ctx, months, filter)

Each category's spending across the months — the stacked bars on Insights.

tagMonths(ctx, tag, months, filter)

What one tag costs, month by month, with its average.

A tag is a lens and tags overlap, so this is deliberately one tag at a time: summing several would count a line that carries two of them twice, and a chart that lies is worse than a chart that answers one question.

filterOptions(ctx)

Everything the movement filters can offer, for the pickers.

monthsWithLines(ctx, ledgerId)

The months this ledger actually has lines in, newest first.

A list rather than a date field. input type="month" is a picker in Chromium and a bare text box in Firefox — where somebody typing "2" gets a filter that matches nothing and a box that explains nothing — and even where it is a picker it offers every month since the calendar began, all but a handful of which are empty. These are the months there is something to look at.

movementsIn(ctx, ids)

The ledgers a set of transaction ids belongs to — for bulk moves later.

Types

streams

Declare a stream. Idempotent per (user, slug) so producers can call it at every startup.

Functions

upsertStream(ctx, input)

listStreams(ctx, opts)

getStreamBySlug(ctx, streamSlug, opts)

updateStreamDisplay(ctx, id, input)

deleteStream(ctx, id)

Delete a stream and all its points (points cascade).

pushPoints(ctx, streamSlug, rawPoints)

Push a batch of points.

Partial success is deliberate: an offline phone draining a week of readings must not have the whole batch rejected because one point is malformed, or it will retry the good ones forever. Re-sending an already-stored point is reported as a duplicate, not an error — that is the designed behaviour and producers should treat it as success.

listPoints(ctx, streamSlug, opts)

deletePoint(ctx, streamSlug, externalId)

streamStats(ctx, streamId)

sweepAllStreams(now)

The nightly sweep: every stream with a retention window, all accounts.

Instance-wide by design — retention is the account's own setting, but enforcing it cannot depend on the account visiting. Called from the server's daily timer, and cheap when nothing is due: one indexed select, then one bounded delete per stream that keeps a window.

serialisePoint(p)

Serialise a point for the API — snake_case, matching the documented contract.

serialiseStream(s)

Types

subscriptions

What an account may do, and until when.

Everything asks resolvePlan; nothing asks "is this account paying". A self-hosted instance is not a customer at all — it answers everything, forever, no billing anywhere in the interface, exactly as the deployment settings work.

Functions

resolvePlan(userId, now)

startTrial(userId, now, actorId)

Give a new account its trial.

Fourteen days without a card, because a planner is not something you can judge in an afternoon — the point of it only shows up in the second week.

applySubscription(userId, input, now)

Write what the provider says.

The webhook is the source of truth: this never decides anything, it only records what came back and audits the change.

hasPlanHistory(userId)

Whether this account ever held any plan — trial, invite or subscription.

activeProviderSubscription(userId)

The provider subscription still standing, if any — id and its customer.

trialCarryover(userId, now)

What a returning account still has of its trial.

Cancel with four days left and come back: the four days carry over. Come back after they ran out — or after a paid year — and there is no trial at all; fourteen fresh days per card would make cancelling a renewal ritual.

userIdForSubscription(provider, subscriptionId)

The account a provider subscription belongs to, for a webhook.

accountExists(userId)

Whether an account with this id exists — for a provider naming an account the app stamped on a purchase, before anything is written against it.

usage(userId)

How many of a thing this account already has.

limitOf(plan, key)

assertWithinLimit(ctx, key, adding)

Refuse a create that would go over the plan's ceiling.

Called by the service that owns the thing, not by the route: a limit checked in a form is a limit that the API does not have.

seatsFor(userId)

How many accounts this subscription is allowed to cover.

membersOf(ownerId)

The accounts on somebody's plan, the payer excluded. Accepted seats only.

invitesOf(ownerId)

The people this plan has offered a seat to, who have not answered yet.

invitationFor(memberId)

The offer waiting for this account, if there is one.

What the band at the top of every page is made of: who is offering, and whether saying yes would cost this account a subscription it is paying for itself. Both have to be on the screen where the button is.

familyUserIds(userId)

Everybody on this account's family plan, this account included.

The circle that "share with family" shares into: the payer and every seat, whichever of them is asking. An account on no family plan is a circle of one, which is what makes the sharing predicates below safe to apply unconditionally — alone, they reduce to the ordinary ownership check.

forgetFamilies()

Somebody joined, left or was offered a seat: every circle is read afresh.

seatOwnerOf(memberId)

seatsTaken(ownerId)

Seats spoken for: the members plus the offers still out.

An unanswered offer holds its seat. Otherwise a five-seat plan could have twenty invitations out and the fifth acceptance would be the one that fails, which is a rule the payer meets at the worst possible moment.

seatOwnerAccount(memberId)

Who is paying, by name, for a page that has to say so.

seatOwnerOf answers with an id, which is the right answer for a check and the wrong one for a sentence. Nothing private crosses: the payer put this account on their plan by typing its address, so the two already know each other.

addToPlan(ownerId, email)

Put an account on somebody's plan.

By address, and the account has to exist already: this hands somebody a paid plan, so it is not a way to create accounts, and an instance with closed registration must not gain a back door because a payer typed an address.

Refuses when the plan has no room, when the account already has a plan of its own — being on two at once is a question with no good answer, and the second payer would be paying for nothing — and when the payer is not paying.

acceptPlanInvite(memberId)

Say yes to an offer.

Refused while the account pays for itself, and deliberately: accepting would leave them on somebody else's plan and still being charged by the provider for their own. Cancelling somebody's subscription as a side effect of pressing Accept is not a thing this app will do to a card, so the answer is to cancel it themselves first, which the message says.

declinePlanInvite(memberId)

Say no to it. The row goes; the payer sees the seat free again.

cancelPlanInvite(ownerId, memberId)

And the payer can take the offer back while it is still unanswered.

removeFromPlan(ownerId, memberId)

Take an account off a plan. Their data is untouched; only the seat goes.

Types

tags

Tags, and the rows that join them to what they tag.

Every statement here carries the account in its own WHERE (I1). The join tables used to have no user_id at all, so "delete this entry's tags" was a statement scoped by an id that arrived from a form — correct only for as long as the caller remembered to check first.

Functions

optionalTagInput(value)

Tag input as it arrives from a form or a tool, checked and nothing else.

Shared rather than written per room: ideas had a private copy of this, and the moment todos wanted tags too there would have been two ceilings that could drift apart.

parseTags(raw)

Normalizes raw tag input. Strips leading #, splits on commas/spaces, lowercases, dedupes. All these produce ["tagfoo", "tagbar"]: "tagfoo, tagbar" | "tagfoo tagbar" | "#tagfoo #tagbar" | "#tagfoo, #tagbar"

ensureTagIds(tagNames, userId)

linkDiaryTags(entryId, tagIds, userId)

replaceDiaryTags(entryId, tagNames, userId)

Set a note's labels to exactly these, without forgetting when the old ones went on.

The same diff replaceTodoTags does, and for the same reason: deleting every row and writing them back gives the same answer and a different history, so a label that had been there a week came back dated today and "what went into review since I last looked" became "what has been edited since".

cleanupOrphanTags(userId)

Drop the labels nothing carries any more.

Read from CARRIERS rather than from four hand-written queries: the four were diary, ideas, media and todos, so a word used only on a block of the week counted as referenced by nobody and was deleted the next time an unrelated note was edited. Now a join table cannot be left out of this without being left out of every other verb here too.

linkIdeaTags(ideaId, tagIds, userId)

replaceIdeaTags(ideaId, tagNames, userId)

tagsForBlock(kind, taskId, userId)

replaceBlockTags(kind, taskId, tagNames, userId)

Set a block's labels to exactly these, keeping the dates of the survivors.

linkTodoTags(todoId, tagIds, userId)

replaceTodoTags(todoId, tagNames, userId)

Set the labels to exactly these, without forgetting when the old ones went on.

This used to delete every row and write them all back, which is the same answer and a different history: a label that had been there a week came back dated today, so "what was tagged since I last looked" was whatever had been edited since. Now only the difference moves — the ones going away are dropped, the new ones are dated, and a label that was already there is left exactly as it was.

linkMediaTags(mediaId, tagIds, userId)

replaceMediaTags(mediaId, tagNames, userId)

listTagsWithUses(userId)

Every label the account has, alphabetically, with how many things carry each.

recolorTag(userId, id, color)

Give a label a colour, or take its colour away.

Null is a real answer and not a missing one: most labels are words rather than colours, and a tag with no colour is drawn as the plain chip it has always been.

renameTag(userId, id, name)

Rename a label — and if the new name is one the account already uses, merge into it rather than refusing.

The unique index on (account, name) is what makes the vocabulary one vocabulary, and hitting it is exactly what somebody fixing a typo does: worik should become work, and work already exists. Refusing leaves two words meaning one thing, which is the state they were trying to get out of.

Merging means: everything that carried the old label now carries the surviving one, and the old label stops existing. For a thing that carried both there is nothing to move — it is already labelled — so that join row goes, and the one that stays takes the earlier of the two dates, because that is when the thing actually started carrying this idea. The survivor keeps its own colour unless it never had one, in which case it inherits the colour of the label that is being folded into it: a colour that was chosen beats no choice at all.

deleteTag(userId, id)

Take a label out of the vocabulary, and off everything that carried it.

The join rows go explicitly rather than by cascade: the same statement then does the same thing whether or not foreign keys are on, which they are not during a migration.

tagByName(userId, name)

The label the account calls this word, if it has one.

untagNotebook(userId, notebookId, tagId)

Take a label off everything in one notebook, and nothing outside it.

The notebook's own tag list could rename and recolour, which are account- wide, but not remove — and removing across the whole account is the Tags tab's job, not something to do from inside one subject. A label nothing carries any more afterwards goes, as it does when the last thing drops it.

describeTag(userId, id, description)

What a label means here, in the account's own words.

Empty takes the meaning off again, which is a real answer: most labels are a word that explains itself.

tagsInNotebook(userId, notebookId)

One notebook's labels, with what carries each of them in there.

The Tags screen counted a word across the whole account, which answers a question nobody has: #home doing forty things somewhere is not why it is on this renovation. Here the count is the subject's own, and it is broken down by kind — two notes and one task — because "three things have it" does not say where to go and look.

A label the notebook hands new notes by default is listed at nought, once it exists at all: it is part of this subject's vocabulary whether or not anything in here wears it yet. A suggestion nobody has ever used anywhere is not listed, because there is nothing to list — default tags are stored as the text somebody typed, and a word becomes a label by being put on something.

tagsWithUses(userId)

The same reading of the whole account: every label, and what carries it.

The Tags screen had a bare number per label — "3 things carry it" — which says how much a word is doing and not one thing about where. The kinds are the same words the notebook version uses, plus the pictures, which are the one carrier that is never filed under a subject.

tagsByNotebook(userId)

The labels each notebook uses: the ones on the notes, tasks and ideas filed in it, and the ones it hands a new note by default.

What a tag field suggests once a notebook is chosen — a subject's own few words rather than the whole account's vocabulary. Every row read is this account's own, so a notebook somebody else filed things in contributes only what this account filed there.

notebookTags(userId, notebookId)

One notebook's labels — nothing, for a notebook this account never filed anything in.

Types

task-attributes

A task's attributes: user-defined key/value pairs on a task block or a todo.

Ontoplano stores these and never interprets them. Plugins read them from the schedule API and decide what they mean — alarm: true and remind_min: 5 make an alarm app ring five minutes early, and a future ontoplano app can act on the same pairs without a schema change.

The same idea as an inventory item's attributes, with a stricter key: a plugin reads these by name, so a key is an identifier rather than a word — remind_min, never Remind min — and a typo like remind_mins is at least refused in the same shape every time. Flat string→string, validated keys, hard caps: an unbounded blob turns into a dumping ground.

Called meta until 0.184; the schedule API and the plugin manifest still answer to the old names until META_REMOVED_IN.

Functions

parseAttributes(raw)

serialiseAttributes(input)

Validate and serialise attributes for storage.

Accepts a plain object, or a JSON string of one.

withAttribute(stored, key, value)

One attribute set, changed or removed, the rest left as they were.

What the ⓘ dialog's pencil posts: one pair, not the whole set, so editing a value cannot drop a key somebody else wrote in the meantime. An empty value removes the key, the same rule as everywhere else.

attributesFromFormData(formData)

Attributes from parallel form fields.

Forms submit attributeKey and attributeValue as ordered parallel lists, which is the shape a repeatable key/value editor produces.

attributesPatchFromFormData(formData)

Attributes for an update, distinguishing "not submitted" from "cleared".

Drag and resize in the grid post to the same update action with only the placement fields. Those requests must leave attributes alone — returning {} would silently wipe a block's alarm settings every time it was moved. A form that genuinely clears the last pair still carries the editor's attributesPresent marker, and so reads as an explicit {}.

attributesArg(args)

The attributes an API or MCP caller sent, from the current spelling or the old one, and whether the old one was used.

attributes wins when both are given. Undefined means neither was sent, which an update reads as "leave them alone".

Types

time

Time, in the two shapes this app actually has.

Instants — created_at, updated_at, completed_at, and friends — are moments that happened. They are stored as UTC ISO-8601 with a trailing Z, so a reader in any zone can render them correctly and two rows can be compared without knowing where either was written.

Wall-clock values — task_records.scheduled_at, recurring_tasks. start_time — are not instants. "Gym at 18:00 on Thursday" means six in the evening wherever you are, not a fixed point on the timeline, and converting it to UTC would move it when you travel. They stay naive and are resolved against ctx.tz at the moment a comparison needs a real instant.

Getting these two confused is finding S7: everything used to be the server's local time, which was right for exactly one person.

Functions

stamp(ctx)

The timestamp services write into instant columns.

instantOfLocal(local, tz)

A naive YYYY-MM-DDTHH:MM(:SS) in this timezone, as a real instant.

offsetAt(instant, tz)

How far ahead of UTC tz is at this instant, in milliseconds.

localOfInstant(instant, tz)

An instant as the wall-clock time it shows in this zone.

minutesOfDay(instant, tz)

Minutes since midnight where the person is, not where the server is.

instant.getHours() answers in the zone the Node process happens to run in, which on the box is UTC. Every screen that compares "now" with a time somebody typed — a block at 11:45, the next thing on the dashboard — was therefore three hours out for an account in São Paulo, and said so with confidence: "7 hours left" where the honest answer was ten.

clockOfDay(instant, tz)

The hour and minute where the person is, as two numbers.

stamps(ctx)

The timestamps an insert sets.

The columns have SQL defaults, but CURRENT_TIMESTAMP writes 2026-08-25 02:19:01 while application code writes ISO-8601 — two formats in one column, which sorts wrong the moment both appear. Services set them, so there is one shape.

created(ctx)

For tables that record when a row appeared and never when it changed.

dayInWords(day, locale)

A day written the way a person says it, in their language and without the year.

"2026-10-05" is a machine's answer. Somebody reading "Rent — R$1,800.00, due 2026-10-05" has to parse a date to learn something they already knew, which is that rent is due next month.

No year, deliberately. These sentences are about things weeks away at most — a bill due, a birthday coming — and the year in them is always this one or the next. Printing it spends four characters saying nothing and makes the line read like a database row. The year is right in a place that shows history; it is wrong in a place that shows what is about to happen.

en-CA is not a locale here: the format comes from the reader's, which the translator carries.

localDay(d)

A Date as the day it is, where it is — 2026-09-19.

toISOString is UTC, so a date built from local parts comes back as yesterday for anybody west of Greenwich in the evening. Written out of the local parts instead, which is what every table keyed by day holds.

pad2(n)

Two digits, for a month, a day or a clock.

addDays(date, days)

The same instant on another calendar day, by the local calendar.

toLocalISOString(d)

A Date as a naive YYYY-MM-DDTHH:MM:SS, with no zone.

For wall-clock values only — task_records.scheduled_at and the day bounds compared against it. Instants are UTC and come from stamp(); writing one of those with this is finding S7 all over again.

startOfToday(ctx)

The start of today where the person is, as a Date the civil-day helpers here read by its local parts.

startOfDay(ctx.now) is the server's today, and the server is UTC: for an account three hours west, tomorrow began at nine in the evening.

startOfDay(d)

Local midnight of the day the instant falls on.

shiftDay(day, days)

A civil day moved by so many days — 2026-03-31 + 1 is 2026-04-01.

daysBetween(from, to)

Whole calendar days from one civil day to another; negative when to is earlier.

dayOf(local)

The civil day of a local timestamp — 2026-03-31T18:00:00 → 2026-03-31.

instantInWords(iso, when, now)

An instant, said where the reader is.

created_at and its friends are UTC — see the note at the top of this file — and two screens were printing them raw: 2026-09-19 05:25 for something that happened at twenty-five past two in the morning three hours west, written as a database row rather than as a time. Today's are the clock alone, because the day is the one you are in; anything older says which day. The locale decides the order of the parts and what goes between them, so nothing is joined by hand here.

today

One day, in one request.

The home-screen widget draws blocks, habits and tasks together and refreshes on a timer over mobile data, so it asks once rather than three times. Nothing here is new: it is the three services the dashboard already uses, narrowed to today and flattened into the shape a RemoteViews list can read without thinking.

Functions

getTodayBoard(ctx, allowed)

Types

todo-actions

Everything that can be done to a todo, wherever the row is on screen.

In services rather than server, where it used to be: nothing here needs a server. It is form handlers over the todo service, and the to-do room is one of the rooms a phone-only instance carries — so a file under $lib/server was a route compiled into the device's worker importing from a directory that must never reach it. It happened to work because this file touches no Node API; the next thing added to it would not have.

The to-do room shows every todo; a notebook shows the ones filed under it, and operating on one there has to mean the same thing — tick it off, put it on a day, edit it, put it away, delete it. The handlers live here so the two screens run the same code rather than two copies that drift.

They are mounted under different names on the two routes: a notebook page already has a delete and an update of its own, so there they are todoDelete and todoUpdate. TODO_ACTIONS below names them for the markup, so a form never spells an action out.

todos

Todos: tasks that have no date yet.

A todo and a scheduled block are the same kind of thing at different stages. The difference is scheduledDate: null means it lives in the general list, a date means it has been pulled onto that day's board. Setting it is what dragging a card onto Today does — the same row acquires a day rather than being copied into a second table, so nothing has to be kept in sync.

Functions

tagsForTodos(ctx, todoIds)

The tags on a set of todos, in one query rather than one per row.

The same shape the notebooks use for a note's tags and people: a list comes back, its ids go out in a single IN, and the rows are handed back by id.

archiveTodo(ctx, id, away)

Put a todo away, or take it back out.

Neither done nor gone: a task somebody is not going to look at for a while and is not willing to delete. Its own column rather than a fifth status, because archived and unfinished are different answers to different questions — coming back to it has to find it exactly as it was, and a status would have had to remember what it used to be.

getTodo(ctx, id)

One task, as the list would have shown it.

listTodos(ctx, options)

Every todo, or the ones a tag filter lets through.

queryTodos(ctx, query)

The one query behind every listing of todos a caller can shape.

Filters, order and the page are all SQL: the page is LIMIT/OFFSET and total is a COUNT over the same WHERE, so a list of thousands costs the rows asked for rather than all of them. Labels are read for the page alone. Everything is scoped to ctx.userId in the statement itself.

listTodosIn(ctx, notebookId, options)

Everything filed under one notebook.

The same rows listTodos returns, narrowed — the notebook's Tasks tab is the to-do room looking at one subject, so it needs the whole todo rather than a title and a status.

listUnscheduled(ctx, options)

The general list: todos not pulled onto a particular day.

openOnly drops the ones already finished or skipped. The board wants them — its Done column is where they live — but anywhere that offers a todo to be scheduled wants only the ones still waiting, because asking somebody when they will do a thing they already did is nonsense.

A task delegated to a block is left out too, while the block exists: its day is decided, and the block is what the board and the plan show for it. The full list (listTodos) still has it, with the day on its card.

listForDate(ctx, date)

Todos sitting on one day's board.

Anything still open from an earlier day is included, because a todo you pulled onto Monday and did not finish has not stopped needing doing — it would otherwise vanish silently at midnight.

nextSortOrder(ctx)

Next free slot at the bottom of a column, so a new card lands last.

promoteTodo(ctx, input)

Turn a todo into a scheduled block.

A todo pulled onto a day stops being a todo: it becomes a one-off with a time, which is what puts it on the grid, in the tracker, and against a goal. The row moves rather than being copied, so there is never a todo and a task that are secretly the same thing.

Shared by the board, where it is a drop into a status column, and the plan grid, where it is a drop at a particular hour.

demoteToTodo(ctx, slotId)

Put a block back on the list, with no time.

The exact reverse of promoteTodo, and it exists because the forward move was one-way: a todo dragged onto Tuesday at nine stopped being a todo, and changing your mind meant deleting the block and typing it in again. The week is a plan, and a plan you cannot back out of is one people stop making.

One-off blocks only. A weekly block is a shape of the week rather than a task — dragging one off the grid would quietly delete every future occurrence, which is not what "not today" means. Refused, in words.

What survives is what a todo can hold: the name, the notes, the category, the notebook, the three ratings and the attributes. The date and the hour are what is being given up, and the status comes with it — a block ticked off and then pulled back is still done.

createTodo(ctx, raw)

updateTodo(ctx, id, raw)

tagTodo(ctx, id, change)

Put labels on a todo, or take them off, without disturbing the rest.

updateTodo replaces the whole set, which is right for a form that shows every tag it is about to save and wrong for everything else: marking one task done-by-ai should not need the caller to read its tags first and write them all back, and a caller that forgets to is a caller that quietly deletes labels somebody else put there.

Both lists are optional and both are applied — adding and removing in one call is how "this is not blocked any more, it is done-by-ai" is said once rather than twice. Removing wins a tie, because a caller that names the same word in both has said something contradictory and the safer reading of it is the one that does not leave a label behind.

setTodoStatus(ctx, id, status)

scheduleTodo(ctx, id, date)

Pull a todo onto a day, or push it back to the general list.

One column changes. Nothing is copied, so there is no second row to keep in sync and no way for the two to disagree.

deleteTodo(ctx, id)

delegateTodo(ctx, id, raw)

Give a todo a time on a day, keeping the todo.

Unlike promoteTodo, which moves the row, this leaves the todo in place and marks it done — it is the "I did this at 3pm" gesture rather than "this is now a planned block".

reorderTodos(ctx, ids)

Where the cards sit in a column, after a drag.

Ids the account does not own simply do not match, so a posted list can reorder nothing but its own todos.

swapTiedTodos(ctx, id, otherId)

Two tasks the three ratings cannot tell apart, put the other way round.

Tasks rated alike are queued by the order arranged by hand and then by age, so a tie always went to the older one. This lets somebody say otherwise.

Only the tie is renumbered — every task of the account with the same three answers — and with the values it already held, so nothing grows each time: the same numbers, handed out in the new order. Where the tie was still sharing one value, it is spread over consecutive ones from its lowest, which is as far as it ever grows.

setTodoAttribute(ctx, id, key, value)

One attribute on a todo, set or — with an empty value — removed.

Its own verb because the ⓘ dialog edits one value in place: sending the whole set back would need the dialog to hold every other pair as it was, and would overwrite one somebody changed in the meantime.

setTodoRatings(ctx, id, ratings)

demoteInstance(ctx, instanceId)

The reverse of promoteTodo: a one-off block goes back to being a todo.

Only one-offs can go back. A weekly block is a standing commitment, not a todo that happens to have a time.

isBatchVerb(value)

batchTodos(ctx, verb, rawIds, what)

Do one thing to each of these, or do it to none of them.

In a transaction on purpose. A batch that half worked is the worst of the three outcomes: the list comes back showing four of nine done and there is no way to tell which four without reading them, and no way to ask for "the rest" except by hand. Whichever row is refused — a notebook that is not this account's, a status that is not a status — takes the whole press with it, and the answer says so.

move is fileUnderNotebook's job rather than an update: a task moving into a notebook takes the next number free in it, and carrying the old one across collides with whatever already holds it.

Types

tokens

Scopes an API token can hold.

Deliberately narrow: a scale app on a phone needs to push weight readings and read the schedule to set alarms. It must not be able to read the diary if that phone is ever compromised.

Each description is the sentence the person agrees to — "read everything on your calendar", not schedule:read. A grant is consent, and consent given to a string of jargon is not informed; the key is for the developer and the docs, the sentence is for the owner of the data.

Functions

isCalendarLink(scopes)

A calendar link is exactly this one scope — see the note beside it.

createToken(ctx, input)

reshapeToken(ctx, id, input)

Change what a live key may read and where, keeping the key itself.

For a key somebody cannot re-enter: a home-screen widget holds its key and nothing else, so pointing the widget at another notebook has to move the key rather than mint a new one the phone would never hear about. The scopes and the confinement are checked exactly as createToken checks them.

freeName(ctx, base)

base, or the first numbered version of it nobody is using.

For the places that mint a key without asking what to call it. Two live keys may not share a name — a key is shown once and picked out of the list by what it is called afterwards — and a calendar link is deliberately something you can have several of, one per device. So the second one cannot simply be "Calendar link" again, and refusing it would be refusing the feature.

A name somebody typed is not touched: they get the error, which tells them something they can act on.

listTokens(ctx)

revokeToken(ctx, id)

authenticateToken(plaintext, now)

Resolve a bearer token to its owner.

Lookup is by hash, which is indexed and unique, so this is a single indexed read. The timingSafeEqual below is belt-and-braces: the index lookup has already made the comparison constant-ish, but the explicit check documents the intent and costs nothing.

requireScope(token, scope)

Types

unused-media

The pictures nothing points at any more.

A picture pasted into a todo is uploaded the moment it is pasted, and the todo only holds the link — ![shot](/media/12) — in its text. Delete the line and the file is still there, with an id, taking up room, and reachable from nowhere in the app. The same happens to a note whose words are rewritten, a person whose photo was replaced by hand, a draft never saved.

Derived, like the notebooks album: a picture is unused when no album holds it, no person, notebook or recipe wears it, and no writing mentions it. The writing is read wider than pictureReferrers reads it — goal notes and a recipe's method can carry a link typed by hand — because the cost of the two mistakes is not the same: a picture wrongly called used sits here unlisted, while one wrongly called unused is offered for deleting.

Functions

unusedPictures(ctx)

The unused pictures, in the gallery's own shape.

unusedPictureCount(ctx)

How many there are, for the tile on the gallery's index.

removeUnused(ctx, ids)

Delete unused pictures, and only those.

Asked again at the moment of deleting rather than trusted from the page: a picture listed here an hour ago may have been pasted into a note since. One still in use is refused and nothing is deleted, so a stale page cannot take a picture out of somebody's writing.

validate

Small hand-rolled validators.

The convention here prefers the standard library over new packages, and the surface we need is narrow enough that a schema library would be more dependency than value. Every validator enforces a bound — no unbounded strings reach the database.

Functions

tooLong(field, length, max)

Too long, said with how long it is.

"Has to be 4000 characters or fewer" leaves somebody guessing how much to cut; the count and the excess are the instructions.

str(value, field, opts)

optionalStr(value, field, opts)

optionalColor(value, field)

A colour somebody chose, or none.

Empty, null and absent all mean "no colour", because a colour input has no empty of its own and the forms say "none" by sending nothing. Lower-cased so one colour is stored one way.

num(value, field, opts)

civilDay(value, field)

A real civil day, preserved exactly rather than normalised into another date.

minorAmount(value, field)

Money must remain exact at both the write boundary and the aggregate result.

oneOf(value, field, allowed)

isoInstant(value, field)

Parse an ISO-8601 instant and normalise it to UTC with a trailing Z.

jsonObject(value, field, maxBytes)

Validate that a value is a JSON object and serialise it within a size cap.

slug(value, field)

chosenIds(raw, max, keys)

The ids a batch press chose, each a positive whole number, none twice.

Refused as nothing chosen or as too many at once, in the room's own words — the notes, the ideas and the todos each said this for themselves.

version

What is running here, and since when.

The version, the commit and the build time are baked into the bundle by define in vite.config.ts — a build that carries its own identity cannot disagree with itself, which a file beside the bundle could. The start time is this process's own, so "built at 14:02, started at 14:03" says the deploy landed and "built at 14:02, started three days ago" says it did not.

In yarn dev the defines are still applied, so this works there too; the commit simply moves as you commit.

Functions

build()

Types

webhooks

Webhooks: plugins that listen instead of push.

"I want a plugin that reacts to my data" almost never needs code running inside the process — it needs to be told when something happened. A subscription is an address and a list of events; when one fires, the payload is POSTed there, signed, and the plugin does whatever it does on its own machine. Same trust model as tokens: an external program, a scoped grant, no code inside.

Payloads are deliberately thin — the id and, where the thing is its one-line label (a todo's title, a shopping item's name, an idea), that label. Never a body: a diary entry announces its id and nothing else. A webhook address is the least-trusted place the server ever writes to.

Functions

createSubscription(ctx, input)

listSubscriptions(ctx)

deleteSubscription(ctx, id)

reviveSubscription(ctx, id)

A disabled address can be tried again after the receiver is fixed.

serialiseSubscription(s)

A subscription as the API and the page describe it.

Without its secret, deliberately: the secret is a credential, and this is the shape a list answers with. It used to be here, so every GET of /api/v1/webhooks, every load of the Integrations page and every account export carried the signing key for every hook — a read-only leak anywhere handed over the ability to forge signed deliveries for ever, and there is nothing to rotate. The API tokens beside it have always worked the other way: shown once, hashed after. withSecret below is that one showing.

serialiseNewSubscription(s)

The one answer that carries the secret: the reply to the call that minted it.

emit(ctx, event, data)

Fire an event: find who listens, deliver to each, never block the caller.

Best-effort by design and said plainly: one attempt per delivery, five seconds, no queue. A receiver that keeps failing is disabled after ten consecutive misses rather than being hammered forever. The caller's write has already happened — nothing here may throw into it or slow it down.

Types

week-generator

Format a Date as 'YYYY-MM-DDTHH:MM:SS' in local time (no UTC conversion).

Functions

getISOWeekNumber(date)

Get ISO 8601 week number for a date.

getISOWeekYear(date)

Get the ISO week year (may differ from calendar year at year boundaries).

getMonday(date)

startOfWeek(date, firstDay)

The first day of the week this date is in, for an account that begins its week on firstDay — 0 for Monday through 6 for Sunday, as settings.ts stores it.

getMonday is this with firstDay fixed at 0, and stays because the week generator is about the ISO week whatever anybody's preference is. What moved is the review: a plan that starts on Saturday and a review keyed on Monday disagreed about which week a Saturday belonged to.

generateWeekInstances(ctx, weekStart)

Idempotently generate instances for a week, for both kinds of block.

The work now lives in services/instances.ts, which is also what reads them back; this stays as the week-shaped entry point the pages already call.

generateCurrentWeek(ctx)

wins

Three things that went well today.

Stored as their own rows rather than as diary text so a streak or a monthly tally is a query rather than a parse.

Functions

listWins(ctx, date)

saveWins(ctx, raw)

Replace a day's wins.

Keyed by (date, position) so re-saving edits the same three rows instead of accumulating duplicates, and an emptied box removes its win rather than storing a blank.

workout-actions

Everything that can be done to a workout, wherever the row is on screen.

The Health room shows every workout; a notebook shows the ones filed under its subject — a training block, a race somebody is working towards — and writing a session down there has to mean the same thing. The notebook mounts these under a prefix; see $lib/module-actions.

The categories are not here: those are the room's own vocabulary, managed where they are used everywhere rather than from inside one subject.

workouts

Workouts: workouts you plan like meals.

A workout lives under Health, beside habits and the numbers. It is a named session with a plan (Markdown, like a recipe's method) and a kind, and you put it on the week the way you put a meal there: by attaching it to a block. Scheduling is not modelled here twice — recurring_tasks.workoutId and exceptional_tasks.workoutId carry it, exactly as recipeId carries a meal — so "what does this week ask of me" is one join over the grid, not a separate calendar for exercise.

Archived, not deleted, while it has been done: lastDoneAt and the blocks that pointed at it are its history. deleteWorkout is for one made by mistake and clears itself off any block (the FK is set-null).

Functions

listWorkouts(ctx, opts)

The workouts, all of them or one subject's.

notebookId narrows rather than changing the shape: a notebook's Workouts tab is this room looking at one subject and draws the rows with the same component, so it needs exactly what the room needs.

listWorkoutCategories(ctx)

The kinds this account keeps, and the five it starts with.

Made on first sight rather than at registration: an account that predates the table, or one whose list somebody emptied and wants back, gets them the next time the page is opened. Adding by hand afterwards is the point of the table, so this only fills an empty list — it never puts back one that was deliberately shortened.

createWorkoutCategory(ctx, name)

renameWorkoutCategory(ctx, id, name)

deleteWorkoutCategory(ctx, id)

Removed; the workouts in it keep existing and simply have no kind.

getWorkout(ctx, id)

createWorkout(ctx, input)

updateWorkout(ctx, id, input)

setWorkoutMeasures(ctx, workoutId, raw)

Declare what this workout measures: names and units, no numbers.

Replaced wholesale rather than diffed, for the same reason a session's lines are — it is one short list somebody edits as a block, and matching rows up by id would be work in aid of nothing. A blank row is one that was opened and abandoned, not an error.

setArchived(ctx, id, archived)

deleteWorkout(ctx, id)

For one made by mistake: gone, and cleared off any block it was on.

Refused once it has been done, because the sessions behind it are the record of what somebody actually did and deleting the plan would take them with it. A plan with history is archived — it leaves the list and keeps everything it knows.

done(ctx, id, on)

Record that a session happened — the "cooked" of the gym.

Writes a session with nothing measured: ticking Done says it happened, and saying how much of what is logWorkout. lastDoneAt is kept in step because half the app reads it to answer "when did I last do this", and a count over the sessions would be the same answer at more cost.

ensureSession(ctx, workoutId, doneOn)

A session for this workout on this day, made only if there is not one.

Two paths say a workout happened — the Done button in Health, and ticking its block off on the week — and the first calls the second, so an unconditional insert would write the same session twice for one press. The explicit form (logWorkout) always inserts, which is what somebody recording two runs in a day wants; this is the quick tick, and a tick is about a day.

scheduleWorkout(ctx, id, input)

Put a workout on a day.

The same gesture a todo has, and the same result: a one-off block on the grid whose mode says it IS this workout. Nothing is copied — the block points at the workout, so the plan and the workout cannot drift, and finishing either finishes both.

doneToday(ctx, id)

The workout was done — and so, if it was on today's plan, was the block.

The inverse of the binding in setInstanceStatus: ticking Done in Health must not leave the week still asking for it. Only today's occurrence, and only one: a workout done on Tuesday says nothing about Thursday's.

logWorkout(ctx, workoutId, input)

Write down a session: the day, anything noted, and the lines.

getSession(ctx, id)

updateSession(ctx, id, input)

Correct one: the day, the note, and every line, in one go.

deleteSession(ctx, id)

For a session logged by accident. Its lines go with it.

countSessions(ctx, opts)

How many sessions match, ignoring any cap.

A capped list that cannot say what it capped reads as a list that has stopped being updated, so anything handing out a page of these hands out this number beside it.

listSessions(ctx, opts)

Sessions, newest first, with their lines already attached.

Two queries rather than one per session: the lines come back in a single IN and are handed out by id, which is the shape the notebooks use for a note's tags and people.

measureHistory(ctx, activity, opts)

One activity over time, ready to be drawn.

Grouped by the person's own word for it rather than by workout: "ran" is one line whether it happened in the morning session or on a Sunday, which is what somebody asking "am I running more" means. Oldest first, because that is the direction a chart's x-axis runs.

measureTotal(ctx, activity, from, to)

How much of one measure was done between two days, inclusive of the first and exclusive of the last.

What a goal counting a workout measure reads. A line with no amount — "went for a swim", recorded without a number — contributes nothing rather than breaking the sum, which is why the amounts are filtered rather than coerced.

measuredActivities(ctx)

Everything this account has ever measured, for a picker or a chart's menu.

Types