Services

Every module in src/lib/server/services, 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.

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-import Putting an exported account back.
account Taking your data out, and closing your account.
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, so neither can be deleted while something still points at it — history that loses its category stops being readable.
admin Administration: looking at somebody else's account.
audit What happened to an account.
backlinks Which goal a thing belongs to.
billing Billing, as the rest of the app sees it.
birthdays Being told it is somebody's birthday, on the morning of it.
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.
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.
errors Typed errors thrown by service functions.
family-invite Inviting somebody to the plan, and the account that makes for them.
goals Goals, and the progress that makes them more than a wish list.
habits Habits are things to do or to avoid, logged one day at a time.
health Can this process actually reach the database?
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".
legal The facts the policies are written around.
mail-log Mail that must not fail silently.
media Pictures: what is accepted, where they go, and who may see one.
meta User-defined key/value metadata attached to planner slots.
newsletter The one channel nobody else can take away.
notebooks Notebooks: a subject you write against, with no deadline.
onboarding-templates The starter weeks, as data.
onboarding First run.
people The people in your life, and where they turn up.
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.
protection What the box has blocked, read from fail2ban's own log.
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.
registration Who is allowed to create an account here.
reminder-delivery The pass that makes a reminder arrive with the app shut.
reminders Something that reaches out.
review-mail Monday morning: what last week actually was, in the inbox.
review Closing a week.
schedule Read-only view of what's coming up.
schemes Saved weeks.
search One box over everything the account owns.
sessions The sessions an account currently has open.
shopping 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.
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.
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.
time Time, in the two shapes this app actually has.
today One day, in one request.
todos Todos: tasks that have no date yet.
tokens Scopes an API token can hold.
validate Small hand-rolled validators.
version What is running here, and since when.
webhooks Webhooks: plugins that listen instead of push.
wins Three things that went well today.

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-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 shopping_items row names a shopping_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.

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.

parseExport(raw)

The shape exportAccount produces, checked rather than trusted.

importAccount(userId, payload)

Replace everything in this account with what is in the file.

One transaction: it all lands or none of it does. Foreign keys are left on — the parents-first order is what makes that possible, and a failure here means the file is inconsistent, which is a thing worth hearing about rather than working around.

Types

account

Taking your data out, and closing your account.

Both are launch requirements, and both are easy to get subtly wrong: an export that quietly omits a table is worse than none, and a "deletion" that only hides rows is a lie. So both work from 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.

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.

exportsAllowedFor(userId, now)

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

hoursUntil(iso, now)

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

exportAllowance(userId, now)

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.

exportAccount(userId, now)

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.

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, so neither can be deleted while something still points at it — history that loses its category stops being readable.

Functions

listCategories(ctx)

listActivities(ctx, opts)

listActivitiesWithUsage(ctx)

The activities page also wants to know what may be deleted.

createActivity(ctx, raw)

updateActivity(ctx, id, raw)

toggleActivityActive(ctx, id)

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

deleteActivity(ctx, id)

createCategory(ctx, raw)

updateCategory(ctx, id, raw)

deleteCategory(ctx, id)

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

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

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 variable and it is opt-in: ONTOPLANO_SELLS=true. 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.

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.

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.

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

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.

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)

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

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.

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)

deleteEntry(ctx, id)

latestEntry(ctx)

The most recent entry, for the dashboard card.

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.

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(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(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.

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.

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)

deleteArea(ctx, id)

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

createGoal(ctx, raw)

updateGoal(ctx, id, raw)

setGoalProgress(ctx, id, value)

Self-reported progress, for goals with a target and no linked tasks.

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

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)

listOccurrences(ctx)

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

today(ctx)

createHabit(ctx, raw)

updateHabit(ctx, id, raw)

deleteHabit(ctx, id)

logOccurrence(ctx, raw)

toggleOccurrence(ctx, raw)

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

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()

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

ideas

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

Functions

listIdeas(ctx)

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.

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.

importVault(ctx, input)

Bring a vault in. All of it or none of it, like every other import.

Half a vault arriving is the worst outcome available: nobody can tell which half is missing without comparing against the app they just left, and pressing the button again would duplicate whatever did land.

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

formatDate(d)

generateInstances(ctx, from, to)

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.

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

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()

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 trial notice does, because an instance that sells subscriptions has no business dropping the one mail money depends on.

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

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 operator's. [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. Every one of them is enforced here, in the service, rather than in a form — a limit checked in a form is a limit the API does not have.

Functions

mediaLimits()

What the operator currently allows. Read per call: the file can change.

The per-picture ceiling is the smaller of what config.toml asks for and what the server can actually receive — BODY_SIZE_LIMIT belongs to the Node adapter and rejects a larger body before this app runs, with an answer no page can read. One effective number, honestly reported: the pages quote it, the browser refuses against it, and the service enforces it.

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?

Two kinds of reference exist and both are checked: a recipe's gallery, which is a row, and a mention inside somebody's writing, which is the string /media/<id> in the text. The LIKE is bounded by the characters that can follow an id, so /media/1 does not count /media/17 as a reference to it.

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)

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

meta

User-defined key/value metadata attached to planner slots.

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.

Deliberately constrained rather than free-form JSON: an unbounded blob turns into a dumping ground, and a typo like remind_mins would silently do nothing forever. Flat string→string, validated keys, hard caps.

Functions

parseMeta(raw)

serialiseMeta(input)

Validate and serialise a metadata object for storage.

Accepts either a plain object or the paired metaKey[] / metaValue[] form a form submission produces.

metaFromFormData(formData)

Build a metadata object from parallel form fields.

Forms submit metaKey and metaValue as ordered parallel lists, which is the shape a repeatable key/value editor produces.

metaPatchFromFormData(formData)

Metadata patch 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 metadata alone — returning {} would silently wipe a slot's alarm settings every time it was moved. A form that genuinely clears the last pair submits an empty metaKey, which is still present in the payload and so reads as an explicit {}.

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".

Double opt-in, and what that buys

A row is created unconfirmed. Nothing is ever sent to it but the one confirmation, and if the link is never followed the row stays a dead address that costs nothing. So typing somebody else's address into the form subscribes nobody, which is both the law here and in the EU and the reason a list is worth having: everyone on it asked twice.

The confirmation token is not cleared afterwards, because it is also what the unsubscribe link in every issue carries. A way in that becomes no way out is precisely how a domain gets filed as spam.

What it never says

Subscribing answers the same thing whether the address was new, already confirmed, or previously unsubscribed. The form must not be a way to ask "is this person on your list", which it would be the moment the answers differed.

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 send exactly one confirmation to it.

Answers true whatever happened, because the caller is a public form and the difference between "new" and "already on the list" is not the form's to disclose. A send that fails is a mail-log row like any other; the person is told the same thing either way, because "check your inbox" is true and "our SMTP is down" is not their problem to act on.

confirm(token)

Follow the link. Answers the address, or null if the token is not one.

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.

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.

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)

contentsOf(ctx, id)

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

createNotebook(ctx, raw)

updateNotebook(ctx, id, raw)

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)

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).

pickableNotebooks(ctx)

The open notebooks, for the selector on every form that can point at one.

setNotebookShared(ctx, id, shared)

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

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

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.

Slot metadata accepts 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

parseMetaKeys(input)

Validate a declared vocabulary.

Keys must look like metadata keys, 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)

metaKeyOwners(userId)

Which plugin claims each key, for the metadata 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)

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.

protection

What the box has blocked, read from fail2ban's own log.

The administration page can say who has been signing in and who registered, because the app did those things itself. It could say nothing at all about the layer in front of it — which is where most of what happens to a public instance actually happens.

fail2ban's socket belongs to root and fail2ban-client is a command this process has no business being able to run. Its log is root:adm and read-only to the group, so the answer is a group membership and a file read: nothing to escalate, no shelling out.

sudo usermod -aG adm

Unreadable is a first-class answer. "No bans" and "cannot see bans" look identical in a list and mean opposite things, so the page is told which it is looking at.

Functions

protection(limit)

banControlEnabled()

unban(jail, address)

Let an address back in now, rather than when its bantime runs out.

blockForever(address)

Out for good.

fail2ban has no "forever" — every jail has a bantime and the timer wins — so this is an entry in the banned nftables set the box already keeps, which survives a fail2ban restart and a jail expiry.

unblockForever(address)

permanentlyBlocked()

Which addresses are out for good, so the page knows which button to offer.

Types

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 shopping_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)

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.

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)

Recipes ordered by how much of them you already have.

Types

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.

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-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)

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.

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.

createReminder(ctx, raw)

A nudge before one occurrence starts.

at is a lead in minutes, not a clock reading — "ten minutes before" is how anybody describes a reminder about something already on a calendar, and it is the only thing this takes. There is no way to make a reminder about nothing, on purpose: see the note at the top of this file.

markDelivered(ctx, ids)

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

dismissReminder(ctx, id)

deleteReminder(ctx, id)

remindersFor(ctx, id)

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

pushableReminders(nowByUser, limit)

Everything due that no device has been told about, for every account.

The counterpart to dueReminders, and deliberately not the same query. That one answers "what should this open page show me", is per account, and is gated on delivered_at. This one answers "whose phone should ring", runs from a job with no signed-in user, and is gated on pushed_at — the two channels have to be able to reach the same reminder, because being at a laptop is not a reason for a phone to stay quiet, and having a phone is not a reason for the planner to look empty.

Times are wall-clock in each account's own zone, so the comparison cannot be done in SQL against one clock. The rows are filtered here instead: due, in their own zone, and not yet pushed.

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

review-mail

Monday morning: 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.

What it will not do

Send about a week that had nothing in it. The rule is reviewPending(), the same one the dashboard's own prompt uses, so the mail and the app never disagree about whether there is a week worth looking at. Somebody who did not plan gets no mail at all rather than a mail full of zeroes.

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(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 three lines about the week, which is the part that is actually worth reading in a year.

Functions

weekStartOf(value, fallback)

Which week a review is for.

Always snapped to its Monday, so "the week of the 14th" and "the week of the 16th" cannot become two different reviews of the same seven days.

readWeek(ctx, weekStart)

goalsTouched(ctx, weekStart)

Goals you moved this week.

A goal's value is a single number with no history behind it, so this cannot say how much it moved — only that it was touched inside the week, which is the honest version and still answers "did any of this go anywhere".

listLines(ctx, weekStart)

saveLines(ctx, raw)

Replace a week's three lines.

Keyed by (week, position) like the daily wins, so re-saving edits the same rows instead of accumulating a new review every time somebody fixes a typo, and an emptied box removes its line rather than storing a blank.

pastLines(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)

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)

Is last week still waiting to be looked at?

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

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).

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)

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.

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

shopping

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)

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.

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.

updateItem(ctx, id, raw)

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.

deleteItem(ctx, id)

toggleBought(ctx, id, raw)

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.

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)

toggleSlotActive(ctx, id)

deleteSlots(ctx, ids)

copySlotsToWeekdays(ctx, ids, days)

Same block, other days. A day the block already sits on is skipped.

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.

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-N shapes need an anchor to count from; the form supplies the block's own date when it has one, and today otherwise, so "every 2 weeks" starts counting from the occurrence you were looking at.

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

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.

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.

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

time

Time, in the two shapes this app actually has.

Instantscreated_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 valuestask_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.

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.

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

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

listTodos(ctx)

Everything, ordered the way the board wants it.

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.

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 and the three ratings. 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)

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.

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.

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)

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

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

str(value, field, opts)

optionalStr(value, field, opts)

num(value, field, opts)

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)

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)

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

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.