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 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 Paddle, and the rules for talking to 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.
ctx The single argument every service function takes.
diary The journal: free text, free-form tags, one running number per account.
errors Typed errors thrown by service functions.
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.
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.
meta User-defined key/value metadata attached to planner slots.
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.
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.
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.
reminders Something that reaches out.
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 (weekly_slots) and blocks that happen once (exceptional_slots), 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

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)

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.

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

backlinks

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

Paddle, and the rules for talking to it.

They are merchant of record, which is the whole reason: a solo founder selling worldwide does not want to be the one who owes VAT in twenty countries. (Lemon Squeezy came first and could not pay out to Brazil — it pays sellers through Stripe Connect, which does not reach here.)

Three rules, and they are the ones that make billing survivable:

Sandbox and live are entirely separate Paddle accounts; which one this instance talks to is decided by the API key alone (pdlsdbx… keys reach sandbox-api.paddle.com), so there is no mode flag to forget.

Functions

isBillingConfigured()

Whether this instance can actually sell anything.

displayPricing()

paddleClientConfig()

What the /buy page needs to start Paddle.js — null when there is no selling.

hasYearlyPrice()

createCheckout(userId, interval)

Mint a checkout for one account, right now.

Paddle has no static buy link that can carry our account id, so the buy button is an action: a transaction is created with the id in custom_data — which is what every later webhook matches on, because the address on the receipt is the provider's business and may not be the one they signed in with — and the customer lands on /buy with that transaction loaded. Our own page, not Paddle's hosted checkout: the hosted one is gated behind approval on live accounts, and /buy is the same overlay without the gate.

portalUrl(userId)

Where an existing customer manages their card or cancels.

Portal links carry a short-lived token and are not to be stored, so a fresh session is created each time somebody looks at the billing page. Null when there is nothing to manage or the provider does not answer — the page just drops the button.

verifySignature(rawBody, signature, now)

Is this really from them?

Paddle-Signature: ts=…;h1=… — HMAC-SHA256 of ts:rawBody with the endpoint's secret, compared in constant time. The raw body: re-serialising the JSON first would change a byte somewhere and the comparison would fail for a reason nobody could see. More than one h1 can appear while a secret is being rotated; any of them passing is a pass.

mapStatus(raw)

What Paddle's statuses mean here. Paused is not entitled to anything.

onboardEntitlement(userId, invited, now)

What a brand-new account is entitled to, decided once at registration.

Invited: the alpha deal — Pro, no billing UI, until the operator changes it. Open registration on a selling instance with card-first trials: nothing yet — the fourteen days start at the provider's checkout, card in hand, and the caller sends the person there. Everything else (an instance that sells but does not require the card, mainly): the internal no-card trial, as before.

checkoutTrialDays(userId)

The trial the NEXT checkout would carry, for the pages that sell it: the full run for a fresh account, the carried-over remainder for a returning one, zero when there is nothing left to carry.

currentInterval(userId)

Which cycle the standing subscription bills on, asked of the provider.

changeInterval(userId, interval)

Move the standing subscription to the other cycle, in place.

The one-click upgrade: no cancel-and-rebuy, no second trial. Mid-trial nothing is billed (the new cycle starts when the trial does); on a paid subscription the difference is prorated immediately, which is the honest way to sell an upgrade. The webhook writes the outcome back as always.

handleWebhook(rawBody, fallbackId, now)

Apply one webhook, exactly once.

The event is written down before it is applied, and the unique index on (provider, event id) is what makes "exactly once" true rather than intended.

Two event families matter. subscription.* carries the subscription entity itself. transaction.completed is handled too because it is the one place our own custom_data is certain to arrive — it is set on the transaction the checkout was minted from — so the very first payment can bind subscription to account even if the subscription events carry no custom_data of their own.

reconcile(now)

The nightly pass.

Two jobs. Anything whose period has run out is marked expired, which is what a missed "subscription_expired" webhook would have done. And, when an API key is configured, every subscription the provider still knows about is fetched and compared — a webhook that never arrived leaves nothing to notice, and this is the noticing.

sendTrialEndingNotices(now)

Types

calendar-feed

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

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

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

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

Functions

buildFeed(ctx, opts)

The whole feed, as the bytes to serve.

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

calendars

Calendars somebody else controls.

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

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

Functions

listFeeds(ctx)

addFeed(ctx, raw)

removeFeed(ctx, id)

refreshFeed(ctx, id)

Fetch one feed, whatever its state.

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

refreshStale(ctx)

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

subscribedEvents(ctx, from, to)

Everything from every subscribed calendar in a window.

Types

client-errors

Client-side errors, sent in with permission.

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

Functions

clientErrorState(userId)

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

setClientErrorConsent(ctx, decision)

recordClientError(ctx, input, options)

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

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

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

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

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

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

toActionFailure(e)

Map a thrown service error onto a SvelteKit form-action failure.

toJsonError(e)

Map a thrown service error onto a JSON API response.

Types

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)

setGoalLinks(ctx, id, links)

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

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.

updateAppliedNote(ctx, id, note)

toggleFavorite(ctx, id)

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

Create any missing instances for the window, for both kinds of block.

Idempotent: an occurrence that already exists is left exactly as it is, so this can run on every page load without disturbing recorded status. from is inclusive, to exclusive.

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.

setInstanceTiming(ctx, id, raw)

Say when it actually happened, rather than when the clock says you said so.

Marking a day's work done at the end of the day makes everything "late", which is true of the tick and false of the doing. This is the correction, and it is one click on the badge rather than an edit form.

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.

Types

legal

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.

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

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

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.

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

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

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

registrationMode()

Who may create an account here.

The environment wins over the config file, and staging wins over the default, in that order. Both exist 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 explicit form and beats everything. ONTOPLANO_STAGING=true implies open, because a staging instance nobody can sign up to is not staging anything.

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 — the message is deliberately the same for "closed" and "no code", because a stranger learning why they were refused learns how the instance is configured.

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

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.

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, and on a self-hosted box the Telegram bot, which is the only channel that works while the app is closed without putting a stranger in the path.

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)

markDelivered(ctx, ids)

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

dismissReminder(ctx, id)

deleteReminder(ctx, id)

remindersFor(ctx, kind, id)

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

Types

review

Closing a week.

/planner/history has held 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 — massalarme 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)

Upcoming occurrences over the next days days, ordered by time.

Includes both generated task instances (from the weekly plan) and one-off exceptional slots. Suppressed slots never produce task instances, so they're excluded for free.

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)

search

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)

createCategory(ctx, raw)

setCategoryFood(ctx, id, isFood)

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.

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)

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)

listToBuy(ctx)

What is still to buy, for the dashboard card.

Types

slots

The plan itself: blocks that repeat (weekly_slots) and blocks that happen once (exceptional_slots), 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 Pro, forever, with no billing anywhere in the interface, exactly as the Telegram bot and the deployment settings work.

Functions

resolvePlan(userId, now)

startTrial(userId, now, actorId)

Give a new account its trial.

Fourteen days of Pro 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.

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_instances.scheduled_at, weekly_slots. 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)

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: massalarme running 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.