Pages and actions
Every address the app serves a page on, and every form action behind it — read from the route tree, so a page added or an action renamed shows up here on the next build.
An action is what a <form method="post"> posts to. They are the app's
own write surface, the way the endpoints in the API are the
write surface for everything else; both end up calling the same
services.
71 pages, 243 actions.
| Page | Actions |
|---|---|
/ |
createDiaryEntry, saveWins, setLayout, setCardOption, resetLayout |
/admin |
— |
/admin/[id] |
— |
/assistant |
— |
/buy |
— |
/calendar/activities |
create, update, toggleActive, delete, createCategory, updateCategory, deleteCategory |
/calendar/board |
setStatus, reorder, schedule, promote, demote, createTodo, setRatings, remind, unremind, editInstance, resolveActivity, deleteInstance, deleteTodo |
/calendar/calendar |
create, update, toggleActive, delete, bulkDelete, copyToWeekdays, addCalendar, removeCalendar, applyTemplate, saveScheme, loadScheme, deleteScheme, renameScheme, scheduleTodo, unscheduleBlock, convertRepeat, moveOccurrence, setTodoStatus, setStatus, suppress, unsuppress, createExceptional, updateExceptional, deleteExceptional, importCsv |
/calendar/review |
saveNote, keepStale, completeStale, dropStale, resolve, reopen, settle, carry |
/calendar/todo |
create, update, archive, setStatus, schedule, tag, nudge, rate, attribute, batch, delete, delegate |
/data/[slug] |
— |
/demo |
default |
/demo/isolated-db |
— |
/finance |
setView |
/finance/bills |
— |
/finance/budgets |
create, update, delete, copy, createCategory |
/finance/commitments |
create, update, archive, close, delete, addEntry, deleteEntry, settle, consume |
/finance/insights |
— |
/finance/ledgers |
splitMovement, unsplitMovement, createLedger, updateLedger, moveLedger, archiveLedger, deleteLedger, recordBalanceCheck, deleteBalanceCheck, import, addMovement, updateMovement, classify, linkTransfer, unlinkTransfer, detachMovement, linkMovement, deleteMovement |
/finance/rules |
create, update, move, delete |
/goals |
createArea, updateArea, moveArea, deleteArea, create, update, setProgress, close, note, setLinks, setTodoStatus, remove |
/goals/history |
setProgress, close, note, setTodoStatus, remove |
/health/habits |
— |
/health/recipes |
— |
/health/recipes/[id] |
— |
/health/workouts |
createCategory, renameCategory, deleteCategory |
/instance |
— |
/inventory |
— |
/inventory/stock |
— |
/inventory/wishlist |
— |
/legal/privacy |
— |
/legal/refunds |
— |
/legal/terms |
— |
/login |
signIn, signUp, requestReset, signOut, resetDemo |
/login/reset |
reset |
/login/verify |
resend |
/mail/weekly-review/off |
— |
/media/audios |
edit, remove, toIdea |
/media/gallery |
create, planFolder, importFolder, rename, delete |
/media/gallery/[id] |
upload, addTo, move, remove, rename, tag |
/media/gallery/notebooks/[...path] |
— |
/media/gallery/unused |
remove |
/newsletter/off |
— |
/notebooks |
— |
/notebooks/[id] |
— |
/notebooks/diary |
create, createWins, update, batch, delete |
/notebooks/ideas |
batch |
/notebooks/people |
create, update, setPicture, removePicture, setPanelWidth, delete |
/notebooks/tags |
save, delete |
/notebooks/weekly |
save, remove |
/oauth/authorize |
allow, deny |
/oauth/connected |
— |
/offline |
— |
/out |
— |
/play |
— |
/reminders |
create, edit, dismiss, remove, addSound, removeSound, setSound |
/ring |
— |
/search |
— |
/settings/account |
rename, setWeeklyReviewMail, changeEmail, changePassword, revokeSession, signOutEverywhere, empty, delete |
/settings/account/import |
importTasks, importVault, previewImport, importAccount |
/settings/billing |
acceptFamilyOffer, declineFamilyOffer, checkout, switchInterval |
/settings/family |
addSeat, withdrawInvite, removeSeat |
/settings/instance |
exportSubscribers, setRegistration, setEmailChange, setClientErrors, createInvite, revokeInvite |
/settings/integrations |
save, models, permissions, remove, ringOnThisPhone, createKey, putBack |
/settings/integrations/connections |
createToken, calendarLink, notifyAssistant, putBack, revokeToken, updateStream, deleteStream, createWebhook, deleteWebhook, reviveWebhook |
/settings/integrations/widget |
connect, createWidget, updateWidget, deleteWidget |
/settings/preferences |
setNotification, setErrorReports, saveCurrency, saveCapture, saveGridHours, saveMenu, resetMenu, setLayout, resetLayout, addQuote, importQuotes, deleteQuote, setStyle, setLanguage, setClock, setTheme, saveWeek |
/start |
checkout |
/welcome |
assistantToken, setLanguage, finish |
/welcome/password |
default |
/widget |
— |
What these pages do
Only the ones whose code says — the comment at the top of the route file, and the comment above each action. Write it there and it turns up here; the rest are named for what they do.
/
How far ahead the planner card looks.
Today and two more. Three columns is what fits side by side on a phone with the block's own name still legible in them, and past the day after tomorrow a plan is a guess anyway.
setCardOption
One of a card's own settings — how it shows what it shows. See CARD_OPTIONS.
/admin
What the demo may see here, and what it may not.
The demo signs every visitor into its one account, which is an administrator — so this page is public there, deliberately: somebody deciding whether to run this themselves should see what administering it looks like. What they must not see is anything about other people: a failed mail carries a real address, and a client error carries a stack from the server.
Writes are refused in hooks.server.ts, in one place, for the same reason
this list is here rather than spread through the page.
/buy
The one page that loads the payment provider's script.
A checkout action mints a transaction and lands here with ?_ptxn=…;
Paddle.js reads that itself and opens its overlay. The page is only the
overlay's backdrop — the terms were already read on /start or the
billing page, so nothing here competes with the payment window.
/calendar/board
Next sensible start time on a day.
Today gets the next half hour from now so a promoted todo lands ahead of you rather than in the past; another day starts at nine.
reorder
Where a card sits within its column.
Only todos have a position to remember — an occurrence's place is its time of day, and letting a drag override that would put the board and the calendar into disagreement over the same task.
schedule
Dragging between the two tabs: a todo gains a day, or gives one up.
promote
Turn a todo into a real scheduled task.
A todo pulled onto a day stops being a todo: it becomes a one-off block with a time, which is what makes it show up 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.
demote
The reverse: a one-off goes back to being an undated todo.
remind
"Remind me before this one." A lead time, not a clock reading.
resolveActivity
Which activity a category-shaped block turned out to be.
/calendar/calendar
What the browser last knew about its own width.
Not a preference and not a setting — a fact about the screen, written by the page that can see one, read by the render that cannot.
scheduleTodo
Drop an undated todo onto the grid: it becomes a block at that hour.
unscheduleBlock
A block dragged off the grid and back onto the todo strip.
The reverse of scheduleTodo, and the reason the strip is a drop target:
scheduling used to be one-way, so changing your mind meant deleting the
block and typing it in again.
setTodoStatus
A todo in the tray ticked off where it stands — the list's own handler.
/calendar/review
This week, by default.
It used to open on last week, on the reasoning that a week is reviewed once it is over. But the page you land on is the page you think you are looking at, and landing a week behind means reading Monday's numbers as though they were today's — every arrival started with working out which week this is. Last week is one press of the arrow, and the dashboard links straight to it when it is still open.
resolve
Done, or skipped — the two answers that are not "carry it forward".
reopen
"I did not actually do that."
A block answered for is not a block answered correctly, and the review is where somebody notices. Back to open rather than straight to skipped: it rejoins the list of open questions, where the four ordinary answers are — including the ones that make a todo out of it.
settle
Everything marked up on the page, applied at once.
The verdicts arrive as one field per answered block —
verdict=<id>:<verb>[:<date>] — because a form posts repeated names as a
list and this is one list rather than four parallel ones that could get
out of step with each other.
/data/[slug]
Half a year by default, ten years at most: a window, not an argument.
/demo
The demo's front door.
Making a demo account is not free: a row, a password hash, and a week of seeded data written to SQLite. That took a few seconds while the browser showed nothing at all — a white page, then the dashboard — which reads as a slow site rather than as something being built for you.
So the wait has a screen. It also has somewhere to go: with the work behind a page the visitor is already looking at, the seed can be made slower under load without anybody experiencing a hang.
/finance
The money picture.
One month at a time, every original currency separately. The month is the one asked for; with none, the account's own current month, not the box's.
view chooses whether the totals include money held for other people. It is
a query when a link asks for one mode, and the account's stored answer
otherwise — the switch on the screen writes that answer.
/finance/bills
Bills are not the finance section any more — transactions are, and they come from the bank rather than from a form. What a bill still is: a thing that wants paying on a day, which is why it rides the week and the reminders. So the page stays, reachable from Ledgers, and the money it describes is the money the statements will show arriving and leaving.
/finance/budgets
The month before a YYYY-MM key, without going through a Date.
createCategory
A category made without leaving the budget form. It is a normal rule: the Rules tab lists it, imports can match it, and it survives reloads.
/finance/commitments
Stocks and their evidence, kept separate from bank balances and monthly budgets.
/finance/insights
The questions a year of statements can answer.
Three, deliberately: what went in and out each month, where the spending went by category, and what one tag costs — the last on its own because tags overlap, and adding two of them would count a line twice.
/finance/ledgers
Ledgers, and what moved through the one being looked at.
The ledger in the URL is the one on screen; with none named it is the first. Everything the page can do to a line — file it elsewhere, correct it, drop it — is here, because a statement that can only be imported and never corrected is a statement somebody stops trusting.
classify
What several lines were, in one write — the checkbox column's verb.
detachMovement
A refund reverses an expense; a fee belongs to the payment it was charged on.
/finance/rules
The most lines a rule's "which lines" panel lists.
/goals/history
The goals that are over — reached, missed or let go — newest first, each with what happened to it on the way. The room's other tab is what is still under way; this one is the record.
/health/recipes
Every recipe, with the picture that stands for it.
One query for the whole list rather than one per card: a cookbook is a page of forty cards, and forty round trips to ask "does this one have a picture" is how a list stops being instant.
/health/workouts
How much of the register the page carries.
The history under a workout is read, not paged: somebody wants to see the last few months of a thing they do twice a week, and a year of that is a couple of hundred rows. Far enough back to be useful, short enough that the page is not a database dump.
deleteCategory
The workouts filed under it keep existing, without a kind.
/inventory
The room's address, which is now a pair of them.
Inventory is two lists — the cupboard and the wishlist — and each is a tab with a page of its own. This is the name everything already points at: bookmarks, the search, a link in a recipe, the installed shell. Opening it goes to the cupboard, which is what the room is mostly for.
/login
Where to go after signing in, when something sent them here to do it.
Only ever a path on this instance: a next that could name another site is
an open redirect, and a sign-in page is exactly where one is worth having.
So it must start with a single slash — //host is a URL with the scheme
left off, and was the first thing tried against this.
signUp
Register, if this instance is taking anybody.
The check is repeated here rather than left to the hook in
hooks.server.ts: this action calls better-auth in-process, so the
request never passes the hook. Two doors, one rule.
requestReset
Ask for a reset link.
Always reports the same thing whether or not the address exists — the response is otherwise a way to enumerate who has an account here.
/login/verify
Signed in, address unconfirmed — the one page such an account can reach
while ONTOPLANO_REQUIRE_VERIFIED_EMAIL=true (the gate is in
hooks.server.ts). It says so plainly and offers exactly one act: sending
the mail again.
/mail/weekly-review/off
The link at the bottom of the weekly review, and the only thing it does.
Reachable with no session on purpose: somebody who has stopped opening the app is exactly who this mail is for, and asking them to sign in before they can make it stop is how a lifecycle mail becomes a complaint. The signature in the URL is what makes naming an account in it safe — without one, this would be a way to turn off anybody's mail by guessing an id.
It turns off one thing and says so. It cannot turn anything on, so a link that leaks does no more harm than the click it was already for, and there is nothing here worth replaying.
/media/audios
What the recordings tab needs, and the two things it can do without bytes.
The bytes never come through a form action: a MediaRecorder hands back a
blob that is already exactly what should be stored, and wrapping it in a
multipart body to unwrap it again buys nothing. Recording posts to
/media/audio; editing and deleting are here, where they have no payload.
toIdea
The recording, as an idea.
Saying something into the phone and then having to go to another room, open a form and reach back for the file is three steps between having a thought and writing it down — which is the whole thing ideas are for. The recording is not copied: the idea carries the same markdown link the note and idea forms already write, so it is one recording with something pointing at it.
/media/gallery
The gallery opens on albums, because that is how anybody actually keeps pictures.
/newsletter/off
The link at the bottom of every issue.
One click, no session, no confirmation step. A way out with a form in front of it is the thing that makes somebody press the spam button instead, which costs the domain far more than the address ever did.
/notebooks
The query value that stands for the orphaned notes rather than a notebook.
/notebooks/[id]
One notebook, with nothing else on the page.
The index shows a notebook beside the list of them, which is the right shape for moving between subjects and the wrong one for sitting inside a single one. This is the same notebook with the whole width.
/notebooks/people
setPanelWidth
Where the divider between the list and the person was left — once, on release.
/notebooks/tags
The labels themselves, rather than the things wearing them.
Every other room reads tags as a property of what they are on. This is the one page where the vocabulary is the subject: what words the account has, how much work each is doing, and the three things that can be done to one.
/notebooks/weekly
A civil date. Anything else would quietly fall back to this week.
/oauth/authorize
"Something wants to connect to your ontoplano."
The only screen in the flow, and the only place anybody decides anything. An assistant sent the person here; this says who is asking, what they would be handing over, and offers the one grant that is not included — the same shape the key form uses, because it is the same decision.
Everything the client sent is checked before a word of it is drawn. Two rules do the real work: the redirect address must be one the client registered, matched whole, and PKCE is required — so a code intercepted on the way back is worth nothing to whoever took it.
allow
Yes — and the code goes back to the address the client registered.
deny
No, said in the words the client understands.
/oauth/connected
The last step, and the only reason it exists is the browser's rules.
form-action 'self' — right for every form in the app — also governs where
a form's redirect may land, so the consent screen cannot send anybody
straight back to the assistant. This page is what it sends them to instead,
and the hand-back from here is an ordinary navigation.
Which makes this an open redirect unless it is careful, so it is: the address must be one the named client registered, compared against the list rather than sniffed for a hostname.
/offline
Public on purpose.
The layout guard sends signed-out visitors to /login, but this page is what a failed navigation falls back to — including one where the session could not be checked because there is no network.
/reminders
Everything with a time on it.
Reminders were scattered: a block carried one, a birthday made one, and the only way to see what was coming was to wait for it. This is the list — and the place to set one that is about nothing at all, which is what an alarm clock is.
edit
The same four questions the form above asked, asked again about a row.
audible arrives as a word rather than a checkbox: a row can say "make a
noise", "stay silent", or neither — and neither is what a nudge before a
block says before anybody overrides it, meaning "whatever this kind of
reminder does". A checkbox has no way to say the third thing.
/settings/account
better-auth's messages are already user-facing; anything else is a bug.
rename
The name the app calls you by. Not a credential, so no password.
changeEmail
Ask to move the account to another address, where the instance allows it.
Nothing changes here: better-auth mails a confirmation and the swap happens when the link is followed. The current address stays the one that signs in until then.
changePassword
Change the password, and sign every other session out.
Someone changing their password is often doing it because they think somebody else is logged in, so the other sessions go with it.
revokeSession
Sign one device out. Its next request finds nothing to authenticate with.
signOutEverywhere
Sign out everywhere, this browser included.
Keeping the current session alive would be friendlier and wrong: someone clicking this has usually decided they do not know who else is logged in, and the answer to that is nobody.
empty
Empty the account, and keep it.
The password, because this is the other irreversible button on the page and the only thing standing between it and a borrowed laptop is that the person at the keyboard knows something. The typed word on top of it is the same idea as the address below: a thing you cannot do by reflex.
It is the account's own data and nobody else's — emptyAccount walks the
tables by user_id, so there is no id to pass in and nothing an
attacker could aim somewhere else. The session says who this is.
delete
Deleting an account is irreversible, so it asks for the account's own email address rather than a yes/no — the point is to make it impossible to do by reflex, not to add a step. And for the password on top of that: the address is written on the screen above the box, so on a machine somebody else is signed into it is a step rather than a barrier.
/settings/account/import
Bringing things in, on a page of its own.
It used to be two cards at the bottom of the account page, below the sessions and above the delete button — which put "restore an export over everything you have" three inches from "change your password". Moving in is its own act, done once, and it reads better as its own page than as the tail of somebody else's.
importTasks
Take a list out of Todoist, Google Tasks or Google Keep and put it here.
The parsing and the writing are services/imports.ts; this reads the
form. The text arrives in the textarea whether it was pasted or read from
a chosen file — the page reads the file itself, so what is about to be
imported is visible before the button is pressed, and no file is ever
uploaded.
importVault
A vault of markdown becomes notebook entries.
The files arrive as one JSON array of { path, text }, read in the page:
the browser can hand over a whole folder, and nothing is uploaded as a
file — the same arrangement the task import uses, for the same reason.
The path matters as well as the text, because a vault's folders are
structure and they come across as tags.
importAccount
And the restore, with this server's own safety copy taken first.
Written beside the database rather than handed to the browser: it is a
net for an operator asked "can you put Ana back", it has to exist whether
or not anybody is still looking at the page, and the path lands on the
audit line the import writes. A device cannot do that and does something
else — see its page.isolated.ts.
/settings/billing
What this account is on, and what it is using.
A self-hosted instance sells nothing, so the page says so and stops — the same answer the deployment settings give.
/settings/family
Who else is on this plan.
This was a card at the bottom of Billing, which put the one thing a family payer comes back for — adding the fourth person, months later — underneath the prices and the usage bars. It is its own tab now, and the tab only exists for an account that is on a family plan: the payer, or somebody whose seat is on somebody else's.
/settings/instance
Deployment settings: bind host, port, database path, and who may register.
These describe the machine, not the account, so they are only readable and writable on a self-hosted instance by its owner. Anyone else gets a 404 — "not yours" and "not there" are the same answer. This page moves to /admin once roles land.
/settings/integrations
The name the phone's own key wears, so making a second one replaces it.
Shared with the reminders page, which reads it to know whether this phone
is set up to ring at all — see services/tokens.ts.
models
What this provider will answer to, asked with the key on the form.
The key may not be saved yet — somebody pastes one and wants to see the models before committing to it — so the form sends what it is holding. Where the field is empty and a key is already stored, the stored one is used, which is how "Replace" can browse without retyping.
permissions
The one grant the chat has to be given rather than born with.
/settings/integrations/connections
calendarLink
Mint another calendar link.
Several are allowed — a phone, a laptop, a partner's calendar — because one per account meant that wanting it in a second place cost you the first. Each is revoked on its own, in the list below, which is what makes "I pasted that one somewhere I should not have" recoverable without breaking the calendars that are fine.
notifyAssistant
Whether a burst of assistant writes buzzes the phone.
Turning it on starts the counter at the newest call rather than at zero: otherwise the first sweep after switching it on summarises everything an assistant has ever done, which is a notification about last month.
/settings/integrations/widget
Where the phone's widgets are set up, and listed.
Two kinds. The Today widget connects itself with one key that reads today. A notebook widget shows one tab of one notebook, and its choices live here — the phone holds only its key — so they can be changed from any screen.
createWidget
A notebook widget, and its key — handed back once, for the phone that
asked (slot is the widget's number on that phone's home screen).
/settings/preferences
Everything on this page belongs to the account, never to the instance (I9).
setNotification
One row of the notifications list: whether it happens, and when.
saveCapture
The capture wheel's wedges and notebook. Posted from this page and from the gear beside the wheel itself, which is the same form in a dialog.
saveMenu
The menu: its order, what is put away, and the colours. One form.
Three settings, saved together, because they are three answers about the same eight rooms and splitting them into three forms is what made the page list everything three times.
room arrives in the order the list showed, hidden names the ones put
away, and color.<section> carries a hex per section. Each is validated
where it is stored — see $lib/nav-order.ts and server/settings.ts —
because what counts as a room and what counts as a colour both change
independently of this form.
resetMenu
Back to the order, the colours and the sections the app ships with.
/start
The card step of the funnel: register → confirm → here.
The billing-hold gate in hooks.server.ts routes a verified account with no plan yet to this page, so the terms are read BEFORE any payment window opens — /buy is just the overlay's backdrop. Yearly leads; it is the one worth taking.
/welcome/password
The one page between a family invitation's link and the app.
The account it opens was made with a password nobody knows, so the first thing to do with it is choose one — before the welcome wizard, because a person who closes the tab after setup would otherwise own an account they cannot get back into except through a password reset they don't know to ask for.