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.