Oracle3
Oracle3: 예측시장 거래 엔진·MCP 서버
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Financial Plan — a personal budget planner, successor to a Numbers spreadsheet kept since 2011: a monthly grid that projects itself forward, savings goals, net worth, retirement drawdown with your own tax tables, investments and giving. This repo holds code only, never data. Single-file web app, works offline, optional Google sync.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Charlie's personal financial planner — the web-app successor to a Numbers
spreadsheet kept since 2011. A monthly budget that projects itself forward,
with the tabs that hang off it: savings goals, retirement, tax, investments,
giving, trips and the household the plan is for.
No account. No install. Nothing uploaded. Your figures live in your own
browser and stay there unless you deliberately sign in or send a link.
Beyond One Browser
Backups and Starting Over ·
Comparing Two Plans ·
Share a Read-Only Link ·
Cross-Device Sync ·
Installing It as an App ·
Working OfflineREADME 전문 보기 ↗Charlie's personal financial planner — the web-app successor to a Numbers spreadsheet kept since 2011. A monthly budget that projects itself forward, with the tabs that hang off it: savings goals, retirement, tax, investments, giving, trips and the household the plan is for.
Live at https://eagleadams86.github.io/financial-plan/
No account. No install. Nothing uploaded. Your figures live in your own browser and stay there unless you deliberately sign in or send a link.
Using It Start Here · Where Your Data Lives · Getting Around · Preferences · Themes
The Tabs Budget · Progress · Vacations · Giving · Investments · Retirement · Household · Compensation · Tax
Beyond One Browser Backups and Starting Over · Comparing Two Plans · Share a Read-Only Link · Cross-Device Sync · Installing It as an App · Working Offline
Under the Hood Architecture · Tests · The Migration Scripts
The tabs are not there yet, and that is deliberate. Nine tabs above a welcome card would every one of them lead to an empty state explaining that there is nothing there — so nothing is offered until there is something behind it, the same rule that shows no net-worth tile for a part you have never recorded. They appear together the moment a plan exists. (A read-only link is the exception: its tabs are what the sender chose to send.)
Open the page and press Start Fresh. That sets up the current year's budget with a starter set of rows — all renameable, deletable and reorderable — and every tab's + Add button does the rest. No import, no account, nothing to configure first.
Because each person's data stays in their own browser, sharing the plain URL shares the app, never the numbers. (A share link is the deliberate exception: it carries the figures you picked inside the link itself.)
Three things worth knowing on day one:
This repo is public and holds code only — never data. Everything you see in the app lives in your browser's localStorage.
#fragment, which browsers never send to a server. Creating one
and opening one are both entirely local, and opening one leaves whatever
the reader already had saved untouched.financialplan-60c6e Firebase project.
Security rules confine every account to its own document, sign-in goes
through Google Identity Services (so it works on corporate networks that
block firebaseapp.com), and "Delete all data" empties the synced copy too.
See Cross-Device Sync.Full detail in privacy.html. The app's footer links to it, beside a How it works link back to this README on GitHub (the repo front page renders it) — the manual for anything the in-app ⓘ dialogs don't cover.
The monthly grid, 2011–present — the heart of the app, and the part that was a spreadsheet longest.
Rows are grouped into Income, Expenses, Transfers and Accounts, with per-month subtotals (a section holding a single row skips its subtotal) and a year-total column pinned to the right (on a phone it unpins and waits at the end of the row, leaving room for the months).
A transfer row's figure is what the move did to the hub — the account your pay lands in — which is the same rule the rest of the grid follows, where a figure below zero is money that left. So −$500 on a Savings row takes $500 out of the hub and puts it into Savings, and +$500 brings it back the other way.
Which is why a transfer row reads best named for the other account rather
than for a direction. One row carries money out in one month and back in the
next, so "To Savings" is right only half the time and "From Savings" is wrong
the other half; the section heading has already said these are transfers, and
the sign says which of the two a given month was. The starter budget and the
sample plan both name their transfer rows exactly as the account is named, and
an i on the Transfers heading says so on every plan — the one thing about a
transfer row you cannot work out by looking at it. The exception is a sweep,
which only ever runs one way, so Sweep to emergency keeps its direction.
The Budget has two lenses on the same figures, and a Year / Month switch at the left of the strip along the top picks between them. Whichever you leave it on is where it opens next time, on every device you sync to.
Year is the grid: twelve columns of rows, the shape the spreadsheet had, and the one for planning a whole year on a wide screen. Under it sits Left Over, Month by Month — the same bar chart the Month page ends on (below), with a line over it that adds the year up: what has been left over so far, the average month, and roughly where the year ends once the months still to come are counted. The average counts only the months that have happened, so the plan's own estimates cannot pull it about, and it is drawn across the bars as a dotted line (dotted because a dash already means "still to come"). Here no bar is singled out, and clicking one opens that month on its own page.
Month is one month laid out as a page — the shape for a phone, and for fixing a single row without pinching into a thirteen-column table. It opens on the current month and steps back and forward with the ‹ › arrows, across a year end as easily as within one; a This month button appears once you have wandered off it, and the strip itself lists every month the plan holds — this month underscored wherever it has got to, and every month still to come underlined with dots, the same mark a year that hasn't begun wears. It draws:
Every figure and every heading on the page carries its own i, and each opens a window about that one thing rather than about the page.
You can act on the month from here, not only read it — which matters because this is the phone lens. The actions sit where the thing they act on is, rather than in a strip of buttons at the top:
Each of those two appears on exactly the month it acts on and nowhere else: months are entered in order, so the next un-entered one offers "Mark entered", the last entered one offers "Re-open", and a month further ahead offers neither — October cannot be entered before September. The rest of the grid's buttons stay on the grid, because they act on the whole year: freezing it, building the next one, the CSV, and deleting it are not things to be offered while you are reading one month.
Nothing is worked out twice — every figure on the month page comes out of the same engine the grid draws from, so the two lenses cannot disagree about a month.
No transfer counts against what was left over, wherever you moved the money to. Expenses means gone and transfers means moved, and filing a row under one or the other is how you say which — so paying into your own retirement account is not a loss just because that account is kept on a different tab. The Moved tile shows exactly what the Transfers card totals, sign and all, and its own line says how much of that stayed inside accounts the budget tracks: that part leaves your closing total untouched, and the rest is why the closing total can fall in a month you came out ahead. The other side of the rule: a transfer to something that genuinely stops being yours belongs under Expenses, which is what that section is for.
Picking a month moves the Year lens to that month's year, and opening the Month lens over a year you are not already inside lands you in it — so switching lenses shows you the same place differently rather than losing it.
A year kept as a yearly summary shows no months, so the Month lens sends you back to Year for those. A year you folded yourself still HAS its months — they are kept, and re-opening the year brings them back — but while it is folded the Month lens has nothing to draw.
Each row has a type (which section it lives in), a projection rule, and a behaviour — ordinary money, a transfer with another account, or a pass-through that never touches the main account.
The rules: repeat last month · repeat rising a set % a year · cycle · average so far · average of last year · same month last year · interest · per-check × paychecks · overflow sweep.
A number you type into a month always beats the rule, and moves the same way.
Both averages say which months they're over, because neither is quite what its name suggests. Average of the months so far falls back to last year's average while this year has no months of its own — which is every month of a year you've built ahead. Average of last year counts only the months actually recorded in it, so on a plan entered through August it's an average of eight months, not twelve — until a year has no recorded months at all, which is every year two or more ahead: then it averages last year's estimates, so the figure carries forward year after year instead of going blank. Hover a row's name to see which.
Same month last year rounds to the nearest whole dollar — the spreadsheet
wrapped that lookback in ROUND(), and on a bill nobody is pretending to know
to the penny it reads as what it is: an estimate. It is the only rule that
rounds, so on a row where the cents are the figure it reads instead as a
number that is 28 cents wrong. Tick Keep the cents in the row's settings and
that row copies last year's figure exactly; every other same-month row goes on
rounding. Either way the row's tooltip says which one it is doing. A half-dollar
rounds away from zero on both sides — a −$100.50 bill to −$101, the same way
a $100.50 refund goes to $101 — rather than the browser's own tie-break, which
sent the two in opposite directions and always gave the expense the smaller
figure.
A rising row is "repeat the last month" with a clock on it: the figure steps up by the percentage you give it once each calendar year, never monthly — a renewal letter, not compound interest. Inside a year it simply repeats, so typing the real new rent when it lands takes over exactly as it does everywhere else, and the row grows from that from then on. No percentage typed means no rise; it repeats rather than inventing a figure.
Most expenses have a day attached, and plenty of them only fall in some months. A row can say so, and once it has, the Month page can remind you about it and get out of your way in the months it isn't due.
Open a row and tick It has a due date:
Ticking the months also tells the projection rule where it may go. A repeat the last month row only fills the months you ticked, so it's safe on a bill that isn't monthly; and a cycle keeps the beat you stated rather than counting from the last month you happened to type something into — which used to mean one bill paid late moved every future estimate along with it. A figure you type is never affected: it wins wherever you put it.
That's the one place a due date touches a computed figure. A row with no ticked months behaves exactly as it always has.
All of that is behind the ⓘ beside It has a due date in the row editor. What the app then does with a due date — the warnings, and what the Month page gathers up — is behind the one on the Due and Waiting card, where you are reading the result rather than setting it up. Each points at the other.
A warning starts a set number of days before the date — seven unless you change it in Preferences — and appears as a small pill on the row and in a Due and Waiting card at the top of the month. It stops the moment the month's figure is recorded as something that happened rather than an estimate, which is also what marking the whole month as entered does to every row at once. There is no separate "paid" tick, because an actual figure already means exactly that.
Nothing here is ever a colour on its own. The pill carries a mark and words — Due in 3 days, Due today, 12 days late — and the colour only agrees with them.
A bill due on the 3rd is one you settle a week earlier — which is in the previous month. Nothing in the figures can tell that apart from paying on the day: either way it's one payment a month. So the row has to say, and The money leaves is where you say it.
Pick the month before and the whole row shifts: the reminder lands on the page you're actually on, the estimate goes in the month the money really leaves, a figure recorded there counts as having paid it, and the row can drop off the months in between. Without it, somebody who always pays early reads as late every single month, for ever — which is the fastest way to teach yourself to ignore a warning.
The warning still counts down to the due date, so "a week before the 3rd" starts on the 27th of the month before.
A bill due on the 1st is one you pay in the month before it, so the card looks into the beginning of next month as well as the month you're reading. Standing on 25 August with a week's warning set, the August page tells you the 1 September insurance is due in seven days — which is the point at which you can still do something about it.
A line about next month says so, shows next month's figure, and opening it records the payment there, where the money will actually go. Only a deadline still to come reaches back like that: one that's already late belongs on its own month's page, which is a click away.
A row whose schedule excludes the month you're reading drops off the list, with a quiet line at the foot of the section saying how many went and offering them back. Your choice sticks until you change it.
It only ever drops a row that has genuinely nothing recorded. A figure, a note, or split amounts all keep it on the page, whatever the schedule says — so a bill that turned up in a month you weren't expecting it never disappears, and the rows you can see always add up to the total underneath them.
That also means a row set to repeat the last month mostly won't drop off: it fills every month with an estimate, and an estimate is something recorded. The rows this shortens a month by are the ones that are actually blank in between.
The awkward case, and the one this was built around: a quarterly bill the town hasn't managed to send for three quarters. The money is genuinely owed and genuinely not late, so shouting "overdue" at it is wrong — and quietly forgetting about it is wrong too.
Pause reminders until is the answer. The warnings stop, and the app keeps counting: the Due and Waiting card names the row, says how many periods have gone by unpaid, and gives a figure for what they come to. Where a period was never billed at all it uses what the bill last came to and says so, rather than presenting an estimate as a total. If nothing has ever been recorded for that row there's no figure to judge it by, and it says that instead of showing $0.00.
Marking a month as entered leaves a paused row blank rather than stamping its estimate as paid — it tells you how many rows it left alone — because that's the whole point of having said you're still waiting.
When the bill does finally arrive, the waiting line opens the oldest period still unpaid — it names that month first, since it is usually not the month you are reading — so the figure is recorded where the money should have left. Fill it in and the line moves on to the period behind it. The line's figure is the periods' own amounts where they have one; a period with nothing recorded borrows the last figure you actually recorded, never one of the app's own estimates, and a total that leans on any estimate says so in italics. A bill whose due date has already gone by opens as actual — it happened, even in a month you have not marked entered yet, so recording it is what pays it.
Some rows are only true for part of the plan. You change credit cards and stop redeeming cash back; a subscription ends; a bill you paid for eight months goes away. Left alone such a row keeps estimating itself forward, sits on every Month page with nothing to say, and gets copied into next year for you to delete by hand.
Open the row and set Stopped using it to the last month it had money in it — the same field, and the same words, an account uses when you close it. From the month after:
A figure you type into a later month still stands. A charge that turned up after you thought you were done is a fact, and it keeps the row on that month's page. Nothing in the year you were using it moves, either: the row and every dollar it recorded stay exactly where they happened, and the grid's row label says "stopped Apr 2026" so a row whose figures end halfway across doesn't read as an oversight. Clear the field and the row is simply in use again.
An overflow sweep row watches one account and moves whatever it would end the month holding above a threshold into another account. The threshold is either a goal's target, read live — edit the goal and every future month moves with it — or a plain dollar amount. A goal set to months of expenses resolves from each year's own spending, and the row's label, its editor and a share link all name the figure for the year the row is in (a year built ahead measures its own projected expenses).
Accounts are a list you own — add as many as you like, each with its own name, growth rate, and a choice of which account that interest is paid into. Open one by clicking its name at the left of the grid, or with ⚙ Account settings in any of its balance cells — the same button "⚙ Row settings" is on a budget row.
Interest & Dividends is a line of its own at the foot of Income: what every account earned that month, added together, with the two halves named on hover.
In the live year it is worked out from each account's rates rather than typed, so you correct it on the account rather than in the grid. The Income total counts it — it is money in, and money in vs money out and the savings rate had always counted it while the grid itself showed it nowhere.
A Dividends row that earns on last month's balances reads December's from the year before, so January is never blank in a year built ahead (until 2026-09-01 it was — the row only looked inside its own year, and a projected year lost a twelfth of its dividend income).
In a past year it is stated, the way the balances there are. Nothing is computed in a finished year — a rate an account carries today says nothing about what it paid in 2021 — so click the month on the account's row and type what it really earned. That figure counts as income for the year and leaves the balance beside it alone, which already holds the money. You can state what a month earned without stating what it held; hovering the blank cell reads it back.
Marking a live year as history writes its earnings down at the same time as its balances, so freezing a year never costs it the interest it made.
A year whose accounts earn nothing — or a past year you haven't typed any figures into — shows no such row at all.
A row that earns interest names the accounts it earns on, and any income row can be marked as pay so the giving percentages count it.
An account set to grow rather than pay interest reports its growth on the account itself, and nowhere else. Hover or tap a balance and it reads "Growth $88.40 · not income" under the figure; the account's year-total column gives the year's. There is no Growth row in either lens — growth is a flow, and a flow among the account balances reads as an account, which is what it is not.
It is in none of the figures that count income: not Interest & Dividends, not Money In, not the savings rate. So once an account is set to grow, those figures fall by however much of them was really appreciation — which is the honest number, and the reason to set it.
The consequence worth knowing is that Money In less Money Out no longer explains the change in your closing total, and it should not: the balances climbed by more than any money that arrived. The difference is on the account that grew, which is the account whose balance moved.
Stating a balance does not restate that month's growth. The month you pin keeps the figure the rate implied; every month after it chains from your number, so their growth follows.
But the app will offer you the figure. Type a balance that differs from the one the plan expected and a button appears saying what the month must have done for that balance to be right — "Say This Month Grew $1,279.42", or Fell where the market went the other way. It is exact rather than a guess: every other figure in the month is already accounted for, so the difference has one candidate left. Take it and the reconciliation line changes to "Matches the plan exactly", because you have just explained the whole gap.
It only ever offers. The app will not decide on its own that a difference was the market — it might have been a payment you never recorded, and there is no way to tell from here. So the button waits to be pressed, it never fills a Growth box you have already written in, and it appears only on an account set to grow, where the residual really does have one candidate. On a savings or current account the reconciliation line still just states the gap and stops.
A past year states its growth the same way it states its interest — click the month on the account's row — and freezing a live year writes it down with the balances. Converting a year to a summary is the one place it is not carried across: a summary holds flows and balances, and growth is neither. No money is lost (the balance rows already hold every dollar of it), only the line saying how much of the year's climb was appreciation, and the confirm says so before it happens.
The current year is live. Months you've entered are actuals; everything after them is an estimate that recomputes the way the spreadsheet's formulas did — the account balances included — so the current month reads as the estimate it is until you mark it entered, rather than looking like money already counted.
Every figure says what it's made of, subtotals and year totals too:
| Style | Meaning |
|---|---|
| Plain | What happened |
| Italic | A projection |
| Dashed | A total spanning both |
The year-total column adds a row's months up — except an account, where it shows the balance the year closed on, since twelve monthly balances added together would be the same money counted twelve times. Hovering one gives that year's interest and dividends, which is what the account actually made you; the CSV export carries the same figure.
Hovering a total that is a sum — a row, a section subtotal, the interest line — also gives what it averages a month, with the number of months it was divided by, since that is the half of an average you can't check by looking. A row that says it stopped is averaged over the months up to the stop rather than over the whole year; months with nothing in them still count, so a bill paid four times a year reads as what it costs monthly rather than as the size of one payment. Account rows get no average — a balance divided by twelve is a rate of nothing.
Click any cell to edit it. Beyond typing a figure:
Sometimes what you want to record about a month is that nothing happened — the water bill never arrived, the refund never landed. Write the note and leave the amount empty.
The month stays blank: nothing recorded, still unpaid, still counted as outstanding, still warned about. The note shows as a dot on the grid, in the month's notes, and in the cell's tooltip. It's the difference between "the bill never came" and a stated $0.00, which is the opposite claim — the bill came and it was nothing — and which does mark the month as settled.
A note on a future month rides alongside its estimate rather than replacing it, so annotating next March doesn't empty it. And a note on a past blank month is not a figure for later months to carry: a row that repeats its last figure, grows it, averages it or bills on a cycle looks past the note to the last real amount. (Until 2026-09-01 those four rules read a note-only month as a real $0.00 — April's estimate went to nothing, the average dropped, the quarterly bill re-phased onto the note month, and every balance after it moved.)
A note on a budget row — as opposed to one of its months — reads in both lenses: on the row's name in the year grid, and on that row's line on the month page, which has no row name of its own.
Every note you've written anywhere in a year is gathered into a box of its own under the budget — cells, split parts, balances and rows — each one clickable to jump back to what it was about. The box isn't there at all in a year you haven't annotated. Notes are grouped by month and laid out across the width of the card, so a year's worth reads at a glance instead of running down one long strip.
A note written anywhere is the row's hover text, and the row is marked with the same little dot wherever it is — a budget row, a person, a savings goal, a retirement income row, a holding, Property, What You Owe, a trip's lines and its parts, a donation, and the rows of a summary year. Everything that can hold a note says so the same way. Point anywhere along a dotted row and the note opens beside it, however many lines it runs to, and a pasted link wraps rather than running off the side. On a phone, where a hover bubble of any kind is a dead end, tapping the row opens the editor the note was written in. (Both of those were fixed on 2026-09-12. The note used to hang off a small 📝 beside the name, as the browser's own hover text: a phone never shows one at all, a table scrolled sideways under a parked cursor dropped it without a sound, and the marker was a 15px thing to aim at. The dot replaced it the same day: it says a note is there, and the whole row is what you point at — the way a budget cell works.)
Mark a month entered to freeze its estimates into numbers, like overtyping formulas in Numbers. A figure you typed as an estimate is frozen too — it becomes an actual like the rest, so a due-dated bill you estimated by hand stops reading as late once its month is entered (until 2026-09-01 it kept its estimate marking, read late for ever, and was counted among the outstanding dues). A row marked as still waiting on its bill is left blank either way.
Once every month of a year is entered you can mark the whole year as history: the balances it computed become the balances it states, and nothing you typed changes — the grid reads exactly the same the moment after as the moment before. It also notes the year's end-of-year total, which is the figure the year-by-year chart draws it at, and which you can correct with ✎ End-of-year figure.
Nothing is lost either way. ↩ Re-open as live puts the year back to a computed one, including any balance you'd stated by hand.
A finished year can also be folded into Previous Years — one total per row, each account showing its end-of-year balance, whatever the year stated it earned as one "Interest & Dividends" row, and whatever grew on paper as a Growth row of its own, stated but never counted as income. The Progress charts carry on unchanged. Nothing is lost: the months are kept. While the year is folded its rows read but do not edit — it is a view of the year behind it, so a total typed there would be thrown away — and ↩ Re-open ⟨year⟩ as a Year puts every figure, note, split and stated balance back exactly as it was. History folds oldest first and re-opens newest first, so the folded years stay one unbroken block at the front of the plan and Previous Years is one page rather than a chip each. A year can be deleted outright too. A folded or imported year holds its account balances as figures you stated at the time rather than a chain to recompute, and an account you've since closed stops at its last month instead of following you into later years.
The Month page carries a Close ⟨month⟩ card, under the month's Income, Expenses, Transfers and Accounts cards and split into two columns where there is room: the jobs that finish a month, as a list that ticks itself. Nothing on it is a box you tick by hand — each line asks the plan whether its job is done, and a line only says ✓ Done when it is. Click a line to go to where that job is done; none of them does the job for you.
"Build ⟨next year⟩" duplicates the live grid the way Charlie used to duplicate the sheet. Build it as early as you like to see where the plan is heading.
A year that hasn't arrived shows nothing but projections, and they start from the year before rather than a blank page:
Each hands over as soon as you type a real month in.
Paid every week or every other week? Let the calendar count. In Preferences, under Paychecks vary by month, pick a Pay schedule and type a payday you know — any real one, past or future. Every month's paycheck count then works itself out from the calendar, in every year: every other Friday gives two checks most months and three in the two months that hold a third payday, and next year's threes land in the right months without you touching them. A count you type still wins for that month — useful when a holiday moves a payday, or an extra half-check lands. When you first set the schedule (or change it), counts you had typed that the calendar agrees with are cleared, since the schedule now gives them, and any that differ are kept as your overrides; a message says how many of each. A frozen year is left alone: its counts are written down when you freeze it (hover one and it says "Frozen with the year"), so moving the schedule later never recounts a past year's row, and Re-open gives you back exactly the counts you had typed.
Every paycheck count says where it came from. Hover one and it tells you whether it's a figure you entered, one from your pay schedule, one taken from the same month a year ago, or — where there's nothing to go on anywhere — two a month assumed, which it names outright and invites you to correct. An assumption inherited from last year still reads as an assumption, however many years along, rather than being laundered into "from last year".
It keeps tracking the current year as that changes — rows included. Add a row to this year, retire one, rename it, change its rule or move it, and next year is changed to match, so a year you built in advance never quietly describes a budget you no longer have. Anything you set up in next year alone is left alone.
A row you have marked as stopped doesn't come with you — not into a year you build now, and not into one you built earlier, unless that year holds the month it stopped in or a figure you typed in it.
And it doesn't become the current year — Progress and the rest carry on reading this one — until 1 January, or until you mark December entered.
Where the whole plan stands and where it has been, on one tab.
Goals add up whichever accounts you tick, so splitting an account is a matter of ticking the new ones. Select all beside the list ticks the lot, and turns into Clear all once everything is ticked — and the press is a change in its own right, so the goal behind the window counts the new list at once (fixed 2026-09-07: it used to wait for Done). An account you have closed is not offered — it holds nothing for a goal to count — so an old goal that named one drops it the next time you save, with a line saying so; no figure moves, because a closed account had no balance to be counting. A target is a figure, or a number of months of expenses: type 6 into "Months of expenses" (up to 120 — ten years; nothing at or below zero) and the target becomes six months of this year's Expenses total, live, everywhere the target is read — the tile, the pace check, and any sweep row tied to the goal. The button beside Target copies that figure once instead, as a plain number that stays put (it clears the months box, and while months are set the Target box is read-only — the figure follows the year's expenses). The Savings Rate & Runway card shows six months of expenses as its third tile, worked out from the same monthly figure the runway divides by — the year's own Expenses total over twelve, always; a months goal's target is that figure times its months, and a sweep row tied to it is re-run until the spend it produces and the target it ran on agree. Each goal answers two questions: how much of it counts — up to the target, or all of it however far past — and who is paid first. Name another goal there and this one counts only what sits above that goal's claim: the earlier goal's target, plus whatever it in turn leaves to the goals ahead of it, so three goals can queue on one account and each stop at its own target. (A goal already queued behind this one is not offered — the two would claim each other — and if a stale form names one anyway the save says so and leaves the claim empty.)
The goals ahead take what they need account by account, in the order the accounts are listed on the Household tab — so a fund with plenty in the current account fills itself from that and leaves the savings account under it for the goal behind. Drag the accounts into the order you want them spent, and put the ones a goal should reach for last at the bottom. Until 2026-09-07 that order was inferred — a goal was assumed to empty its other accounts before touching a shared one, which is one particular order and the wrong one as often as the right one. The figure on the tile is what those accounts hold less what was taken, so no dollar is counted by two goals in a queue and none is missed; two goals with no "claimed first by" between them are not in a queue at all, and both count the money in full.
A goal can only claim out of accounts it counts: untick the shared account from the earlier goal and it stops claiming anything from the later one, whatever it is still short of. (The link itself is left alone — the goal is still queued, it is simply taking nothing.) The whole queue is walked (fixed 2026-09-07): where three are queued and the middle one shares no account with the last, the first goal's claim on an account they do share is taken — on the tile, at the sweep line and in a shared link alike — and the line under the bar names whichever goals actually took money, not merely the one named as "claimed first by". Until that fix the tile and the sweep read different figures for that queue, and the foot named the wrong goal. Deleting a goal frees the goals that were claimed first by it — they count from the first dollar from then on — and the toast names them.
The goal window opens at the same width as Preferences and is cut into the same ruled blocks — What It Is · The Target · What Counts Toward It — in three columns rather than Preferences' four, because the options here are whole sentences and a quarter of the window cuts them mid-word.
Plus progress, target dates, required monthly saving, and when each goal lands — which reads the years you've built before it guesses at anything. Month by month, from this one to the end of the last year in your plan — each month read from its own year, even where an imported year runs past December — it asks whether the accounts that goal counts have reached what it needs; if they do, that month is the answer and no rate comes into it, and the tile says "your plan reaches it about …" rather than "at this pace". Only past the end of what you've built does it need a rate, and it uses the last built year's growth in those accounts against whatever gap is still open at that point — the nearest year is the one where next year's pay, raises and sweeps have already been applied, and it says which year it took: "growing $X/mo in 2027, the last year you have built". The gap is the whole amount still to arrive, so a goal queued behind another counts what the goals ahead of it still need. A goal with no date gets the answer on its tile; the pace line follows the soonest-dated goal you haven't met and sets its landing month against its target — or, when the accounts are flat or hold no balance yet, says so instead of moving on to a later goal.
Before September 2026 it was one division — this year's rate, held flat forever — so a 2032 target was answered with six years of "this year again", and a single good year (a bonus, a windfall) was projected into every year after it. Build another year and every one of these answers sharpens.
Where the Total Is Heading goes dashed the moment it passes the month you've entered through, so a month the plan has merely worked out never reads as one that happened, whether it's next month or a year you've built ahead.
A net worth strip under the goals adds up everything the plan knows about: the liquid total, the Investments tab's holdings, the retirement accounts, the Household tab's property at your stated values, and everything you owe. The giving fund is deliberately left out — that money is already given — and a part with nothing recorded shows no tile rather than a $0.00.
Each part's tile opens the tab it comes from — Liquid the Budget, Invested the Investments tab, Retirement the Retirement tab, and Property and Owed the Household tab. Click it, or Tab to it and press Enter. With the calculator's Pick on, a click takes the figure onto the tape instead.
Which month it is read at is yours to choose, once the two answers differ. The month in progress is not settled — every figure in it is the plan's own estimate until you mark it entered — so Liquid and Total are drawn in italics there, the same mark the grid puts on a figure it worked out. An As of switch offers the last month you marked entered instead, whose balances are what the accounts really ended that month holding. Where the current month is itself entered there is one answer and no switch. Across the year end the switch offers last December — once December is marked entered, and until you enter January — so 31 December can be recorded in January, priced at the year's closing prices.
A settled month prices your holdings at its own close. A price is only true until the market next opens, so an end-of-August figure built from today's prices drifts from the first trading day of September on. With a Twelve Data key in Preferences, the settled view asks what each ticker closed at on the month's last trading day — one lookup per ticker, once, since a close never changes (a lookup that came back with no close is tried again the next day) — and values Invested and Retirement with it. Only the holdings Net Worth actually prices are looked up; the giving fund is not part of it. A month ending on a weekend or a market holiday takes the last day the market was open. Each tile says where its prices came from: all at the month's closes, some still at today's price, or "looking up…" while the answer is on its way. The month in progress keeps using live prices.
What a close can't turn back stays today's figure, and the tile says so: share counts (a purchase after the month ended still shows), a price you typed by hand or a holding with lookups off, a retirement account kept as typed balances, and what a property would fetch. The plan keeps no history of those, which is why there is no picker over every month in the plan.
Debt appears in one of two places and comes off the total either way. A debt secured on a property is inside that property's figure, which is the property's equity; everything else — cards, student loans, a personal loan — is the Owed tile. Nothing is ever counted twice, and the total is the same number whichever side of the line a debt sits.
If something is held in a currency you have given no rate for, the card says so and leaves it out, rather than adding it in at face value.
Money that honestly lives in two places is counted once: a brokerage tracked as a budget account and as an Investments pane is reconciled by the pane's ✎ Edit dialog, which lets it name the budget account(s) its holdings are. The total then subtracts that overlap, keeping the pot at what the holdings are worth.
Record turns the strip into history. Each press states the card's exact figures as a dated snapshot — a deliberate act, like pinning a balance; the app never records one on its own — and two or more draw the net-worth-over-time line, with the parts in the hover.
The year's totals in the units the questions are actually asked in:
Both move as real months land.
The oldest years come from bi-weekly sheets whose rows the importer couldn't tell apart. They're sorted out on load, and any row it reads wrong is one click to fix. Those years have no Income / Expenses / Transfers split to read, so their spending bar counts everything the old sheets recorded going out — and the years imported from the old spreadsheet never recorded which rows were transfers, so their bars are the same either way. A line under each chart says so rather than letting them look comparable.
(These charts lived on a tab called History until August 2026; a tab order saved before then just drops the dead entry.)
The charts above say what a year cost. This says what it was spent on — every expense row in the live year, biggest first, with its share of the year and what the same row came to last year beside it.
The change column is the point. A share of spending on its own only tells you which row is biggest, and you already know which row is biggest.
Counted exactly as the bars above are, so the parts always add to the total. One colour for every bar with the names on the axis: a key of forty colours is unreadable for anyone.
The year-by-year chart opens the tab and the trips follow it. The trips are still what the tab is for — but they read newest-first, and the newest year is usually trips that are still only titles, so the page used to open on a row of boxes with no figures in them. The chart is the only card here that says how this year compares with the last few, and it gives the tab something to open on.
The tab opens on the giving-over-time chart, then the giving fund, then the donations log, each one filed under the year of its date. Date one in any year, past or future, and that year gets its own table — which appears when it has something in it and goes when it doesn't.
Each donation carries the event it was given through (a walk, a ride, an appeal) beside the foundation receiving it, and is either done or planned. A planned one reads in italics, stays out of the year's totals, and is counted up separately as what's still to go.
Above the year tables sits a donor-advised fund's holdings, for those who have one — a Preferences switch, since plenty of people don't. It is a holdings table like the Investments tab's, with the same price lookup above it: the fund's prices are dated there, topped up when the tab opens, and its Refresh covers the fund alone. Donations are tracked either way.
Every year shows what its giving came to as a share of gross comp and of take-home pay, on meters drawn against one shared scale, so the years compare with each other at a glance. Once a second year has something in it, a chart appears above them: dollars given as bars, both percentages as lines over the top.
"Given" counts what left your own accounts — fund deposits and cash gifts. A grant out of the fund isn't counted twice.
The take-home tile names the rows it divided by, because take-home isn't every income row — it's the ones that are pay. A row counts if it's on the per-check rule, if you've ticked This is my pay in its settings, or, failing both, if it's named like pay ("Salary", "Wages", "Paycheck" — the fallback that lets an old summary year, whose rows carry no rule at all, still work). So the tile lists what it counted, and where a row got in on its name alone it says so and points at the tick that overrides it in either direction.
A year still running that has giving planned in it also shows a second row of tiles under the first, saying the same three things about the year's projected total: the planned fund deposits and cash gifts added to what's already gone, and the share that comes to of each kind of income.
The columns line up, so a projection reads straight down from the figure it projects, and it's marked as an estimate the way everything else in the app is — italic figures, a dashed meter rail, never a colour. On the chart, that year's bar is filled and outlined solid to what's been given, and a dashed box carries on above it to the projected total; the gap between them is the giving still to come.
It's the missing half of the fraction: take-home pay is already read from the whole year, projected months included, so measuring eight months of giving against twelve months of pay read low all year and jumped in December.
A planned grant is left out of the projection the same way a made one is — that money left your account when its deposit did — so the projection can be smaller than the "more is planned" figure above the table, which counts every planned row.
Each year can be cloned forward as next year's plan. The button names the year it writes into — the first one with nothing in it yet — and every copy lands Planned, dates moved to the new year and their day kept, so an appeal you give every February stays in February. Nothing you've actually given can be changed by it, and the year you cloned from is untouched.
A whole year's donations can also be deleted at once, card and all; undo in the header brings them back.
One pane per place you hold investments — add, rename and reorder them.
Automatic price lookups are optional (Twelve Data; free key kept on the device, cached six hours, and every price still editable by hand).
A holding can carry its cost basis — what you paid, off your statement — and the tables then show cost and gain, in dollars and percent, per holding and per pot. Holdings without a basis are honestly left out of the footer's sum. The same field works on the Retirement tab's holdings, since every holdings table is one renderer.
A holding can also carry a note, at the bottom of its window — which lot it is, where the shares came from, why the price is typed by hand. The row is dotted and says it on hover, like every other note in the app, and Find reaches it wherever the holding lives.
A card that rolls every pot still yours — the panes here plus the retirement accounts — into one by-ticker table, with each holding's share of the whole, so concentration across pots is finally one number. The giving fund stays out (that money is already given). A shared Investments link carries the retirement accounts and your allocation targets, and says so.
Both sit at the bottom of the tab, after the tables they read from, side by side on a desktop window and stacked on a narrow one, and each fills the window on its own with the ⤢ button.
Both appear only once they have something to say: two tickers and two accounts for the first, two targets for the second. A single bar is a table row wearing a bigger heading.
A pane that names budget accounts also reconciles against them: its subtitle shows the budget's figure for the same pot and how far behind or ahead of the holdings it is, or that the two are in step.
Retirement accounts each have a type (401(k), Roth IRA, HSA…), an owner, and their own contribution history — so nothing about Roth IRAs is special-cased. Contributions are rows you click to edit, one per year and account.
Where the balances are heading, counting both the IRA contribution rows you type and the 401(k) cards' own percentages, carried forward in today's money until each of you retires and pro-rated across the year you go. A year you have typed always wins, in both directions, so a year you deliberately skipped stays skipped.
Past the first of those retirements the line bends and the pot pays out.
You set what the household spends a year, and the pot covers whatever other retirement income — Social Security, a pension, a partner still working — doesn't.
Everything on the tab is in today's money, which is what makes the return setting a real one (growth after inflation).
Once you enter brackets on the Tax tab, the withdrawal is grossed up to cover the tax on it — a Tax column appears in the year-by-year table, a fourth tile names what the year owes, and the withdrawal rate says it is measured on the gross figure. Until then nothing on the tab moves by a cent.
It does not model required minimum distributions, and it says so rather than pretending.
The bracket tables you type, federal and state. They answer two questions: what this year owes on what you are paid, and what a retirement withdrawal has to be before tax in order to leave you what you planned to spend.
The answers lead the tab and the tables sit under them — the tables are a year's typing you do once and then read back, the answers are what you come back for. Until you have stored a table there are no answers to lead with, so the order flips and the tables come first.
That gap was the biggest remaining dishonesty in the drawdown: a pre-tax 401(k) has to pay out more than you spend, and the projection used to draw exactly what you spend.
Nothing ships with the app — no bracket, no rate, no deduction, not even as a placeholder or a realistic-looking example. The app may work tax out; it deliberately does not know what the tax is, the same rule the contribution limits have always followed. A year with no table means no tax.
Type a table by hand or paste one in. The paste box reads the layouts the published schedules actually use — rate-first, range-first, the over/but-not-over form, a bare two-column table, or a single flat rate — and tells you what it will store as you type. It copes with what a real clipboard does to a table: a wide table with a column per filing status (yours is picked out), markdown bullets, headings, citation links, and a copy that lost its line breaks and arrived as one run-on line.
It refuses whole rather than importing half — bands that don't climb, a rate over 100%, a schedule whose top row is missing, two schedules pasted together, or a wide table with no column for your status — and it names the lines it ignored.
A table carries forward. The newest one at or before a year is what that year uses, so one schedule covers a forty-year projection. The "Years You've Stored" card says which stretch of years is reading which table, rather than leaving you to infer it. Federal and state carry separately.
Each Other Retirement Income row carries its own taxable share, with a separate state share once you have a state table. A row nobody has answered for is counted as 0% and said so — "untaxed" and "nobody has said" are different claims.
"What ⟨year⟩ Owes" applies your tables to the whole package on the Compensation tab — salary after the raise, the bonus, equity that vested, an employer contribution — and shows the federal and state figures, the effective rate and what the next dollar meets. The tab collected brackets for a year and used them only for a withdrawal forty years out; this is the nearest question, and the app had every part of it already.
It is brackets and your deduction, and nothing else — no credits, no itemising, no payroll tax, and no notice of what has already been withheld. So it is what the schedule says on that income, never what you still owe in April, and the card says so in as many words. Nor is it a household figure where two people earn: the app records a second earner as take-home in the budget and never as gross, so there is no second package to add. It reads the newest year you have recorded comp for rather than the calendar year, because a raise letter arrives when it arrives.
It will not do required minimum distributions, a Roth-versus-traditional withdrawal order, the Social Security provisional-income formula, itemising, capital gains, NIIT or IRMAA. Each is a figure with a year attached that would be quietly wrong within twelve months while looking authoritative.
Who the plan is for: a partner, your children.
A new plan starts with one adult called "Me" — a placeholder to type your own name over, so the tab opens on a household of one rather than on an empty screen asking you to declare that you exist. It has no birth month: every age, retirement year and 18th birthday here is worked out from that field, so a guessed one would print a wrong date as though the app had worked it out. Fill it in and the rest follows. Restoring a backup never adds anyone — only a plan created from nothing gets the default.
A new plan also starts with two accounts, Cash and Savings, both in that person's name — the two the starter budget actually moves money through. Investments and Other bank used to be there too and appeared nowhere else on a fresh plan, so they read as two empty rows of furniture; + Add Account is how you get them, or anything else, back.
Each person has a role, an optional birth month and — for an adult — a retirement age (halves allowed, for anyone waiting until exactly 59½), which month of that year it lands in (their birthday, or the January before or after it, since "retires at 55" is several different dates), and a plan-to age: how far the retirement projection runs for them, a planning horizon rather than a guess at a lifespan.
Their age, retirement year and the month a child turns 18 are all worked out rather than stored, so none of it goes stale.
Nothing on this tab changes a single figure the budget computes. Owner is a way of reading the plan, not an input to it.
The household's property — a home, a car, a boat — at what you say it would sell for, minus whatever is still owed on it.
Equity, not a balance: nothing flows through a house, so the budget engine never sees these rows, and the Progress tab's net worth is where the figure lands.
A value you state goes stale the moment you look away, so each property's editor carries a link to somewhere that will tell you what it is worth today — Redfin or Zillow for a home, Kelley Blue Book or Edmunds for a vehicle, J.D. Power for a boat. "Other" offers none: a kind with no sensible tool says nothing rather than guessing at one.
It is a link, not a lookup. The app fetches nothing, holds no API key and talks to no new endpoint — you press the link, read the figure on the site, and type it into the box. That is not a compromise so much as the only honest option: there is no free valuation API a page like this one can use. Zillow retired its public API in 2021 and its replacement does not expose the Zestimate at all, Edmunds closed its in 2018, and every free tier that still answers with a figure wants a key — which a single-file page on a public site cannot keep secret. It also means the value on the row stays a figure you stated, which is what the Progress tab's net worth promises it is.
The app cannot find your particular house, and does not pretend to. Both
sites key their real pages on an id of their own — Zillow on a _zpid, Redfin
on a propertyId — and neither can be worked out from an address. So a property
can carry a value page link: you find it once, paste the URL, and from then
on that button goes straight there. A saved link leads, with the two tool pages
still behind it for when you want to go looking again.
The link is kept in this browser alone — never synced, never in a backup, never
in a share link — and the box is a password box, so your password manager can
offer to remember it and carry it to your other devices on a path the app never
touches. That is the same arrangement the price-lookup key uses, and
for the same reason. Only http and https links are accepted (paste the whole
address, https:// included), and a saved one is labelled by its own hostname
rather than by anything you type. A paste that is not a web link is refused
with a message, and the link you had stays.
Every debt the household carries, secured on something or not.
This used to be a single number typed onto a property row, retyped by hand or left to rot while every other figure in the app kept itself true — and a loan not attached to a house or a car had nowhere to live at all.
Give a debt a balance, the month it was true, a rate and a payment and the app does the rest:
It says what it is missing rather than filling it in. No payment typed means nothing is projected — the balance stands exactly as you typed it. And if the payment is smaller than the first month's interest the debt genuinely grows, so the card says so in words instead of drawing a line climbing away: that is a fact about the loan and it should not look like a fault in the app.
Nothing here reaches the budget grid. The payment leaving your account is already a row there, and counting the debt as well would take the money out twice.
(A plan written before this arrives keeps its figures exactly: whatever was owed on a property becomes a debt secured on it, and the net worth is identical to the cent. No rate, payment or date is invented for it.)
Currency in Preferences was only ever a label — it changed the symbol in
front of every figure and nothing else — so a household with a flat in one
country and a brokerage in another could not be written down at all.
A property, a debt and an investment pane can each say which currency they are in, converted by a rate you state here.
Where comp stands, and how it got there.
| Button | What it does |
|---|---|
| Σ Calc | A tape calculator that floats over the page, or docks beside it |
| ⌕ Find (⌘K / Ctrl+K) | One search box over all of it |
| ↩ Undo (⌘Z / Ctrl+Z) | Walk back through this sitting's changes |
| ⚙ Preferences | Settings that apply across the whole app |
| ⇩ Back Up | Export, restore, or start again |
| ↗ Share | Create a read-only link |
| ☁️ Sign In to Sync | Optional Google sign-in |
Each button wears a glyph in front of its word. They are plain text characters rather than an icon font or emoji, so nothing extra is fetched for a header that paints before anything else runs, and each takes the theme's own text colour — no meaning ever rides on hue alone. A screen reader is told to skip them; the word beside each one is the whole label.
The row is always one line that scrolls sideways, beside the app's name. When the controls don't all fit, the row keeps its place next to the name and scrolls there; only an upright phone, where the name leaves too little room, puts the row on a line of its own under it. Tabbing onto a control that is half past the edge scrolls it fully into view. With a mouse or trackpad, ‹ › arrows appear at the end of the row whenever something is off an edge. The controls used to wrap to three lines, and with the calculator's sheet along the bottom the plan was a slot between them. Flick the row to reach the controls past the edge; the name stays put.
Two characters minimum, and it searches everything: every year's rows and notes (split-part notes and balance notes included), donations, trips and their line items — the parts of a split line and their merchants included — goals, people, property, and holdings by ticker or note — the giving fund's included.
A budget hit opens straight into its cell's editor; everything else lands you on the right tab. Enter opens the first result — the same as clicking it (added 2026-09-04; until then Enter did nothing, and you had to reach for the mouse). The keyboard lands somewhere you can see: after a result, if the focus would otherwise have fallen to nowhere — the way it does after a ⌘K pressed from the middle of nothing — it goes to the tab you landed on, so the next Tab press carries on from there; a budget hit's editor keeps its own focus, and a Find button you clicked gets it back as before. The same rule in every app in the family (Flow Metrics set it on 2026-09-04). When results are capped it says how many more there were rather than trimming silently.
Every change banks the state it replaced, up to twenty deep, and ⌘Z / Ctrl+Z (or the ↩ Undo button, which appears once there is something to undo) walks straight back through them, one honest step at a time.
Σ Calc in the header opens a small calculator that stays open while you read — no other app to switch to. It is a tape: every figure you put on it is totalled, averaged and counted, and a figure you no longer want comes off with its ✕.
1200*3 + 45, or (total - 500) / 12.
Click a row on the tape and its figure goes into the box wherever the
cursor is, wearing its kind ($10239, 9.43%, 210) and nothing else —
type the operator, click the row, carry on.
Six names read the tape — and — so
is the tape plus five percent. is a percent, not a
remainder: is 10. Enter works it out and puts the answer straight
on the tape, labelled with what you typed, so a chain of workings reads
back as a list. On a phone the box brings up the full keyboard — its
key has the digits and — with autocorrect off, so a
name like is left as you typed it.The years run across the top, oldest on the left and the newest on the right, with the one you're reading drawn as a filled pill. They ran the other way round until September 2026 — the way the sheet tabs did in Numbers — and were turned round to match the rest of the app: the month strip below, the twelve columns of the grid and every year chart on the page all read left to right in time, and the years were the one place that didn't. The app still opens on the year you're living in, so what moved is where the old years sit, not where you land.
Click a year to open it, or step one year at a press with the ‹ › arrows at the strip's right end — the month strip's arrows, in this lens since September 2026, where they used to scroll the rail sideways and hide themselves whenever every year already fitted. ‹ is the year before and › the year after, which is what those arrows mean in the month strip too, so one habit carries between the two lenses; each greys out at its end rather than wrapping round. The strip still drags and swipes sideways. From the keyboard, one Tab lands on the strip and the left and right arrow keys walk along it (Home for the oldest year, End for the newest).
The tabs above the strip, and the strip itself, can be pinned to the top of the page: press the pin at the right-hand end of the tab row and both stay put while a long grid scrolls under them, the way the header already does. Press it again to let them go. The setting belongs to the device you set it on — a desk monitor has room to spend on chrome and a phone does not — so it is never synced, never in a backup and never carried in a share link. It is in Preferences too, for anyone who would rather find it in words.
On a phone the pin holds the tab row only. Three bands of chrome is most of a small screen, so the year strip is let go there and only the tabs stay — the one that buys you changing tab without scrolling back to the top. The tabs themselves sit in one row that scrolls sideways — at every width, so a narrow window or a docked calculator no longer stacks them several rows deep, with ‹ › arrows beside the row for a mouse — and dragging a tab to the end of the row scrolls it along, so a tab can be dropped anywhere; a tab reached from the keyboard shows its focus ring whole, on every side (fixed 2026-09-04 — the scrolling row used to slice the ring off along the top and at both ends). And whether or not anything is pinned, a control you reach with Tab or Shift+Tab is scrolled to just below the header and any pinned bars, never behind them.
Every folded and imported year stands as one Previous chip at the oldest end of the strip, set in italics, and opens one page with a card per year, newest at the top. A year built before it has started is underlined with dots, this year carries an underscore wherever it has got to in the strip, and hovering any of them says which in words. A This Year button sits at the right-hand end — greyed out while you're on this year, live once you have wandered off it, the Month lens's button on the same terms — and takes you back in one click, from however far down the strip you have read. It stays put rather than coming and going, and the strip remembers where you scrolled it: picking a year never slides the others sideways, and a year half off an edge is scrolled only far enough to show it. On a phone the strip tightens so the year or month you're reading always shows whole: that button reads Now, and on the narrowest screens (under 360px) it steps aside — the underscore still marks this year, and the arrows still walk you back. However tight the strip gets, the arrows and that button stay at least 24 pixels square — big enough to hit without catching the one beside it (fixed 2026-09-24).
The same strip carries the Budget's Year / Month switch at its left end, and in the Month lens the years are replaced by the plan's months — the arrows step from one to the next in both lenses now, and the current month is marked with an underscore wherever it has got to. Both lenses put their way back to now in the same place, and both grey it out when you are already there. The switch, the strip and the arrows stay on ONE line at every width, a phone included — the strip scrolls, and the period you are reading is always brought into view. See Year or Month.
Changing what you're looking at starts at the top; changing which period you're looking at holds still. Press a tab and the view you open begins at the top of its page, rather than at whatever offset the last one had you scrolled to — before September 2026 it kept that offset, so where you landed on Goals depended on how far down the Budget you had been, and changed again with the pin on. Pressing the tab you're already on does nothing, so it never throws the page about. A Find result that only changes the view lands the same way.
(Until September 2026 the page did move, and not because the app asked it to: with the strip pinned, every redraw of the Month page slid the reader about 15px further down — the browser's own scroll anchoring banking the momentary height of a page being rebuilt under a sticky bar. Six presses of ‹ left you 90px from where you started. The app now turns that adjustment off and keeps your place itself.)
The year rail, the month rail and the Year / Month switch are the exception, on purpose: press 2025, then 2024, then Mar 26 and the page stays exactly where it is, because that is a comparison — you are reading the same rows while the figures change under them. (Previous Years is a stack of short cards rather than a grid, so there is simply less page to hold; that is the window, not the app moving you. A Find result in one of those years scrolls to that year's own card rather than the top of the stack.) Every other control that changes what a view is showing rather than which view you are on — the This Year button, the ‹ › arrows, the switches inside a card — holds the page in the same way.
A Find result that opens a record holds too: the editor is over the cell you searched for, and moving the page would leave you at the top of a long grid the moment you closed it.
The keyboard stays where you pressed, too (fixed 2026-09-24). Most buttons redraw the page, and the redraw used to throw away the button you had just pressed — so from the keyboard, pressing ‹, "Mark Sep 26 entered", a split line's ✓ Paid or the calculator tape's ✕ sent you back to the very top of the page. Now the press leaves you on the same button drawn again; where that button has gone or greyed out, on the one that takes over its job (Re-open after Mark entered) or on the year or month chip you have landed on (This Year, Build 2027, Re-open 2025 as a Year). Moving a row up or down in its editor leaves you on that row in its new place.
Click any row — budget categories, accounts, goals, holdings, retirement accounts, trips and their line items, PTO entries, donations, the old yearly summaries — to change, annotate or delete it. Every table has a + Add button, rows reorder with ↑↓, and each editor shows only the settings the current choice actually uses.
(500) off a statement and the box reads
−$500.00, the way every ledger means it, and so does $ (1,234.56), the way
Excel's Accounting format and most statement exports write it, with the
currency symbol outside the brackets (fixed 2026-09-04: that form used to
arrive positive, in a typed box and in an imported CSV alike). And a minus sign is a minus sign
whichever one it is: a figure copied out of a statement PDF or a typeset
table carries a real MINUS SIGN rather than the hyphen on your keyboard, and
that used to be stripped out with the currency symbol — so −200 in a transfer
row arrived as +200 and the money moved the other way. Both spellings read the
same now, in a typed box and in an imported CSV alike. (A dash used as
punctuation — "Rent — $500" — is still just punctuation; only the characters
that really are hyphens and minus signs count.) And text that is not a
number is refused, not read as "clear this" (fixed 2026-09-07): type
abc into a money box and leave it, and the box says so and goes back to
the figure it held — it used to delete the figure, and on a sweep row that
blanked the row without a word. An emptied box still clears: that is a
statement, and it is kept.The way to a spreadsheet, and the way home again.
What the import reads, and what it deliberately doesn't:
Text fields are defused against spreadsheet formula injection, and a BOM keeps Excel honest about UTF-8.
⌘P prints the page properly. The furniture goes — the header controls, the tab bar, every + Add and ✎ Edit — every horizontal scroller opens out so a budget year prints all twelve months, cards try not to be torn across a page break, and a long table repeats its headings. What you folded away stays folded: a fold is a decision about what you want to read, and printing is not the moment to overrule it.
Printing borrows the Light theme and gives it back afterwards, so a dark theme doesn't arrive on paper. Nothing is saved, and the theme you were looking at is the one you are still looking at.
If a chart is filling the window when you print, it comes down first and prints in its own place on the page, with everything else. (Until 2026-09-04 it did the opposite: the full-screen window is hidden on paper, and the card was hidden inside it, so the one chart you were looking at was the one missing from the print.) The window stays down afterwards.
The header's ⚙ Preferences button opens one editor holding the settings that apply across the whole app rather than to a row. It is laid out in five ruled blocks — the same rule and small heading the Budget Row and Account windows use — and the last of them is the one worth reading twice: everything above it travels with the plan, and nothing in it does.
Your Plan
| Setting | What it changes |
|---|---|
| You file taxes as | Which bracket table the Tax tab reads and which Roth (MAGI) threshold it checks against — always asked, since the Tax tab needs no household. Filing jointly also counts both incomes once there are two of you |
| Compensation tab follows | Which person that tab's salary history is about, once there are two of you |
How Figures Read — neither changes a number:
| Setting | What it changes |
|---|---|
| Currency | A three-letter code (USD, EUR, GBP, CAD) — the currency the plan itself is in. It changes how figures read, not what they are. Something held in another currency is a rate you state on the Household tab |
| Budget row order | Your own arrangement, or alphabetical within a section (which turns dragging off and leaves your order stored underneath) |
What Each Tab Offers
| Setting | What it changes |
|---|---|
| Paychecks vary by month | Three some months, two in others — this is what lets a row be an amount per check |
| Pay schedule · A payday you know | Every week or every other week, plus one real payday — the Paychecks row then counts each month from the calendar. Shown once Paychecks vary by month is ticked |
| Donor-advised fund | Whether the Giving tab shows a fund's holdings. Donations are tracked either way |
| PTO days a year | What each new holiday-planner year starts with |
| Warn about a due bill this many days ahead | How early a hand-paid bill with a due date starts saying so on the Month page. 0 warns on the day itself |
Rates The Plan Assumes — four figures with no source but you:
| Setting | What it changes |
|---|---|
| Dividend row interest %/yr | The rate a dividend row uses when it has none of its own |
| Retirement real return %/yr | The growth rate the Retirement tab assumes, after inflation. An account can state its own and follow this only when it doesn't |
| If it goes worse / better %/yr | The two rates around it that draw the cautious and optimistic lines on the same chart |
This Device Only — three settings that live in this browser and nowhere else: never synced, never in a backup, never in a share link.
| Setting | What it changes |
|---|---|
| Keep the tabs on screen while scrolling | Pins the view tabs — and the Budget's year or month strip — to the top of the page. The pin button at the right-hand end of the tab row is the same switch |
| Zoom | The exact percentage (50–200%), the 5% steps in the header being the everyday version |
| Twelve Data key | The price-lookup key — typed here, handed to your password manager, and out of backups, share links and sync |
Four themes, shared with every other app in this family and listed alphabetically in the header dropdown below Auto: Dark, Light, Midnight (deep indigo/navy — the base palette, and what Auto shows on a dark system) and Sepia.
The palettes come from theme.css, a byte-copy of the generated file in the
private claude-theme-pack repo, which is the source of truth for every app
here — a colour is changed in the pack's tokens.json and rebuilt, never
retuned in this file. The pack's own gate checks every token for WCAG AA
contrast on each surface it can sit on, which is what lets the grid lean on
colour at all.
Your theme belongs to the device, like the zoom: it lives under its own localStorage key rather than with the plan, so it is never synced, never in a backup, and a share link never carries the sender's theme. Anything unrecognised falls back to Auto — the default since 2026-08-22, which follows your own system rather than opening dark on a machine set to light — and the picker's own options are the only list of themes the app has.
Colour is never the only thing saying what a figure is. A projection is italic, a total spanning actual and projected months is dashed, a snapshot's estimate carries a dashed rail — the same conventions read the same way in all four palettes.
⇩ Back Up in the header opens one dialog for everything to do with the file on disk.
money-map-YYYY-MM-DD.json. The app remembers the
day, which is what ticks Backup downloaded on the month's
Close card; a year's CSV is remembered the same way."What if we bought the house" means changing a dozen figures across five tabs, and there was no way to see the before and the after together — two browser windows and a good memory.
Compare with a backup puts the headline figures side by side: net worth, liquid savings, what you owe, this year's income and spending, the savings rate, the runway, the pot at retirement and the year the money runs out. It reads the file and writes nothing — there is deliberately no "use this one instead", because that is what Restore is and putting it here would turn a comparison into a way to lose your plan by mis-clicking.
A scenario in this app is what it already was: a plan you exported and then changed. A second plan living inside the first was the obvious design and is the wrong one — it doubles what syncs, doubles what a backup carries, and gives the app two answers to "what am I worth" when the whole thing is built on there being one.
Every figure is read from the same function the tab that owns it draws, so a comparison can never quote a number the app itself doesn't show. A difference is worked out only where both plans have an answer; a row only one of them can answer is kept and dashed, because that is itself the finding. More spending and more owed read as worse, not better, and a gap between two percentages is stated in points.
Folded away at the foot of that dialog, under Start again, is Delete all data. It's behind a fold on purpose: the one irreversible action in the app shouldn't sit a mis-click away from Export.
Pressing it opens a confirmation of its own that:
It clears the price-lookup key and the cached ticker prices along with the plan — "everything" includes the credential.
You stay signed in, and the deletion reaches your other devices. The
emptied plan goes out through the normal save path, so the phone sees it land
and asks "another device has cleared its data — clear this one too?";
cancelling keeps the phone's copy and restores it everywhere. The surviving
document holds { json: "<blank plan>", updatedAt } — no name, no month and
no figure in it.
(Until 2026-08-14 this button deleted the Firestore document and signed you out instead, which looked tidier and was worse: your other devices were never told, so the next edit on the phone re-created the document and signing back in poured the whole plan back.)
Show someone part of the plan without giving them an account, a file or edit rights.
You pick which tabs go in and how many years go with them. The whole payload
rides in the link's #fragment, so nothing is uploaded, nothing is stored on
the reader's device, and whatever they already had saved in their own browser
is untouched.
The most personal things stay behind by default:
The dialog tells you what you're sending: what the link costs in characters, any tab the year window left empty, and when a tab drags another tab's data along with it (Giving measures donations against the salary history, so a Giving link carries it).
Trimming the years shortens the link, never the figures: the oldest year kept is re-seeded with the balances it opened on, so the recipient sees the same numbers you do.
It's a snapshot — later edits don't appear, and a link can't be withdrawn once sent, so treat one like emailing a spreadsheet.
A link made by a newer version of the app than the copy you're running is refused with an explanation rather than opened with pieces silently missing — reloading the page picks up the current version, and the link then opens.
Firebase, free tier — entirely optional.
Signing in with Google does one thing: puts the same plan on your other devices. Without it the app is fully usable and fully local.
Sync is enabled in this deployment, backed by the financialplan-60c6e
Firebase project. FIREBASE_CONFIG in the sync module at the bottom of
index.html points at it, and setting that constant back to null returns
the app to local-only mode and hides all sync UI.
The whole state travels as one JSON string in a financialplan/{uid}
document, because Firestore refuses arrays nested inside arrays and this
plan's tables are exactly that. firestore.rules is the
checked-in copy of what the console enforces, confining every account to its
own document.
Sign-in goes through Google Identity Services — a popup straight to
accounts.google.com, exchanged for a Firebase session — rather than
signInWithPopup, which opens at <project>.firebaseapp.com and dies on the
corporate networks that block those hostnames one at a time.
Same account, same data, same rules; only the doorway differs. This is why
GOOGLE_CLIENT_ID is a separate constant: it is not part of firebaseConfig
and can't be derived from it.
localStorage stays in charge and the cloud only mirrors it.
The first time a given Google account signs in on a browser, if both sides already hold something, a dialog asks which copy to keep and names what is in each — years, goals, people, trips — rather than guessing by timestamp. It is the one dialog in the app that a click outside will not close: "which copy of your data?" has no safe default.
After that, an update pushed from another device arrives live — and an edit made offline never overwrites a newer copy from another device (2026-10-26). Every save carries a version number that goes up by one, and each device remembers the version it last saw. A save is sent only if the cloud is still that version; if another device saved in between, the two are combined (2026-10-29) rather than one replacing the other.
When both devices changed the plan, both sets of changes are kept. Each device remembers the copy it last synced, so it can tell what each side actually changed since then: a figure typed on the phone and a different one typed on the laptop both survive, a goal added on one and a row renamed on the other both arrive. You are only asked about something both devices changed to different values — the window names each one (for example "2026 · Parking · Oct 2026 — this device: $122.00; Google: $120.00") and your choice settles just those; everything else is kept either way. Which tab you are on and the price cache never count as a change. When a device has no remembered copy to compare against (the first sign-in on it, or the first sync after this update), the window falls back to choosing a whole copy, but it now shows when each copy was last saved, marks the newer one, and lists what is different. An offline edit waits on this device — it is not handed to Firebase to replay later — and is sent through the same check when the connection comes back (on reconnect, when the page comes back into view, or a minute later). Device clocks no longer decide which copy is newer, so a phone whose clock runs fast cannot win every time.
A change that arrives while a window is open closes that window — if the window was filled in from the plan (2026-09-18). Edit a goal on the laptop while the phone deletes another one, and the laptop's editor used to stay up showing the old row; because a row editor finds its row by its position in the list, the next box you finished wrote the whole form into whichever row had moved up into that place. Now the row editor, the cell editor, the Delete All confirmation and a Compare table close when another device's plan arrives, and the sync message says so and says what became of your typing: a row editor saves each box as you leave it, so it is usually "Nothing in it was waiting to be saved"; if you were mid-box, or adding a row you had not saved yet, it tells you that was not kept. Help stays open (it is only words), Find stays open and searches again, Share stays open and rebuilds its link, and Back Up is only buttons. The same applies when the newer plan comes from another tab.
Two windows in one browser are one device. They share one saved plan, so only the first of them takes an update off the cloud; the other picks it up from the browser's storage a moment later, the same way it picks up anything you change in the window next to it (see Where Your Data Lives). Before 2026-09-18 that second window simply stayed behind, and its next press sent its old plan back to the cloud under a newer date — over the phone's work as well as the other tab's.
Underneath that, an empty copy never beats a copy with data in it,
whichever looks newer — otherwise signing in on a fresh browser would push its
emptiness, stamped now, over the device that actually holds the plan. Naming
your household counts as data, not just budget years. Clearing everything
deliberately still reaches your other devices, but each one asks before it
follows.
The button reads ⚠️ Not syncing, and the note at the foot of the page gives the cause and the fix. Nothing is lost when it happens — this browser is still the source of truth.
There is no retry button on purpose: Google retries the transient causes itself, and the state clears the moment a save gets through. If Google had also dropped the listener that brings other devices' changes in, it is re-opened when the next save gets through, and on a timer in the meantime — until 2026-09-01 the button said Syncing again while incoming changes were quietly lost until a reload.
The page can live in the Dock or the Applications folder instead of a tab. Nothing is downloaded and there is no separate version to keep updated — it is the same page in a window of its own, so it updates when the site does.
The two are not equivalent, and the difference is your data. Chrome's installed app shares storage with the browser, so the plan you already have is simply there. A Safari web app gets its own storage container: it shares no localStorage with Safari, so it opens EMPTY, and the way to fill it is to sign in and let sync pull the plan down. Treat the Safari one as another device, not as a shortcut to the tab you already had.
Installing is a window, not a sandbox — an installed app can reach exactly what any tab on this origin could already reach, no more and no less. The one real difference is Safari's, and it runs in the safer direction: its separate container cannot see the sibling apps' data at all.
manifest.webmanifest is what makes the install a real app rather than a bare
shortcut, and two things in it are deliberate. scope is "./" — every
one of these apps is served from one origin, and a scope of / would swallow
the sibling apps into this app's window; relative keeps it right on the local
server too, where the app sits at the root rather than under
/financial-plan/. And it carries no file_handlers, protocol_handlers
or share_target: those hand outside data to a page on a shared origin, and
nothing here needs them. The CSP gained exactly one directive for all this,
manifest-src 'self'.
The app opens and works with no connection — on a plane, on the Tube, on hotel wifi that has stopped answering. Your plan was always stored in the browser rather than fetched from anywhere, so once the app itself loads, everything except the online-only extras behaves normally: every tab, every projection, every edit, saved as usual.
What needs the network still needs it — signing in to sync and holding price lookups — and each says so rather than failing quietly.
This is sw.js, a small service worker, and it was refused here for a long
time. Two rules make it safe enough to have changed that:
It only ever caches files that are already public in this repo — the page itself, the vendored chart library, the stylesheet, the icons. Never your plan, never a sync reply, never a price quote. That matters because these apps share one browser origin, and a cache is shared across the whole origin rather than belonging to one app: keeping it to public files means there is nothing in there that could not be read straight off GitHub anyway.
It always tries the network first. The cache is a fallback for a connection that actually failed, never a shortcut taken while you are online. So you can never be quietly running yesterday's version — if the network answers, you get the current app, every time. Offline costs you a few seconds' wait before it gives up and uses the saved copy.
Belt and braces on the one risk that remains: if this device is running an older version from its cache and finds a plan saved by a newer one — another device updated first, then synced — it stops and says so rather than reading your figures with code that predates them. Nothing is changed or deleted; connect and reload and it picks up the current version.
That stop can also happen while you are using the app — the newer plan arrives through sync, or from another tab running the newer version. If a window was open at that moment (a row editor, Help, anything), the message used to be drawn UNDERNEATH it: the app had stopped, and you were left looking at an editor whose every press did nothing, with the explanation and its Reload button hidden behind it. Since 2026-09-18 the stop closes whatever is open first, so the message and Reload are always what you see and what the keyboard is on. Since 2026-09-24 the message also takes over the page completely: a screen reader announces it, the keyboard lands on Reload however the stop came about, nothing behind it can be reached with Tab, and no later message can pop up over it.
A stopped window also stops syncing, completely. A change you had made a moment before is normally sent to the cloud about a second later, and that send used to go ahead even though the window had just stopped — uploading its OLDER plan with the newer plan's date on it, which another of your devices could then have taken over the newer one. Now the waiting send is cancelled, nothing can be sent from a stopped window (it is checked at the moment of sending), and it stops listening for changes. Reload, and it carries on in the current version.
If the worker ever misbehaves, sw-kill.js is the switch that removes it:
copy it over sw.js and push, and every installed copy uninstalls itself and
goes back to being an ordinary online-only page.
One file — index.html — no build step, alongside theme.css (byte-copy
from the private claude-theme-pack, the palette source of truth for all apps),
a vendored chart.min.js, and sw.js with its sw-kill.js escape hatch.
Served by GitHub Pages from main.
There is a sample plan. The welcome screen offers Load Sample Data beside Start Fresh:
an invented household of three — two earners, a child, five accounts, this year's budget with two
years of real history behind it — which fills every one of the nine tabs. It carries one of every
shape each feature reads differently: a secured mortgage and an unsecured student loan and a card
with no payment said; a flat and its mortgage in another currency; equity and an employer
contribution on the comp years; and a spending row that existed last year and does not now. It
carries a note on each kind of row that can hold one — a budget cell, a summary row, a property, a
debt, a trip line and a donation — so the hover text is met rather than described. It exists because this is the
most complex app of the family and an empty grid shows none of what it does. It goes in through
the same door a backup does, which is worth saying because coerceShape is built to carry an odd
shape rather than throw: a key in the wrong place there does not fail, it settles to a default and
the feature it was meant to show draws $0.00 or nothing at all. Its tests therefore read the
values the way their readers do rather than only counting entries. The figures are
invented, the app says so in its own header while the sample is loaded, and the tax bands are
round numbers labelled "not real tax brackets" in the field the reader is shown — the app's
promise that it ships no tax figure of its own is kept by making the demo's obviously fake. It
saves like any other plan and is removed with Back Up → Start Again.
State is versioned. The schema is 7 today, as the SCHEMA constant.
Schema 7 moved what was owed off the property rows and into debts of their own,
once — a pure re-filing that leaves the net worth identical to the cent, since
a debt secured on a property reduces that property's equity exactly as the old
number did.
Every entry point runs the payload through coerceShape(), whose upgrades are
presence-based and safe to run twice. Its first act is to pin every id — people,
accounts, goals, budget rows, portfolios, trips, and every field pointing at one — to
[A-Za-z0-9_-], at most 64 characters. That is the rule the sibling apps already
keep, and here it guards something specific: cells, overrides and balance
adjustments are stored under <id>|<month> keys, so an id containing a | from
a hand-edited backup or a crafted share link would be read back with the split in
the wrong place. Ids are re-slugged rather than replaced, so the references and
the stored figures follow their row instead of being orphaned, and a plan whose
ids are already well-formed is left exactly as it was. migrate() walks an older plan up to
the current schema — and because all of its gates are <, a plan from a
newer build would sail through untouched, so load() checks for that case
first and halts instead.
It also holds every free scalar in settings to a type — the PTO allowance, the
currency code and the two assumed rates. Those look like the least
important fields in the file and are in fact the most exposed ones: a read-only
share link carries the whole of settings on purpose, so that a recipient reads
the sender's figures under the sender's own assumptions. Until this landed a
crafted link could put markup where the PTO card prints its allowance without
escaping — running script on an origin shared with every other app in the account. A value that cannot be read is removed
rather than blanked, so the app's own default still wins the merge.
computeAll() is the only place numbers are calculated. Every cell,
balance and goal figure derives from stored inputs at render time; nothing
computed is ever persisted.
The icon is drawn by make_favicon.py (Pillow). The inline SVG in the
page is what browsers use, favicon.ico is the fallback a browser fetches on
its own, and the install icons (icon-192, icon-512, icon-512-maskable,
apple-touch-icon) are files because a manifest icon cannot be a data URI.
One script draws all of them from one set of coordinates rather than leaving
binaries nobody can review in a diff. The three shapes differ on purpose:
rounded where nothing will mask them, full-bleed with the mark inset where the
platform crops to a circle, and square for Apple, which applies its own
corners. Re-running it means bumping the ?v= on every favicon.ico
reference — two in index.html, one in privacy.html — or the old icon stays
cached for months.
Open tests.html on a local server — it runs the pure functions in the real
index.html inside a hidden iframe:
python3 -m http.server 8016
All fixtures are synthetic. On this Mac only, if the import files are present, it additionally diffs the JS engine against the spreadsheet's own cached values for the live year's own months. (Months beyond that are now a projection the app works out from the rules, where the spreadsheet had them typed in, so the two are not meant to agree.)
And a smoke walk, because everything else is a pure function. Pinning the engine and the
tax tables leaves the largest part of the file — the render layer — never executed at all, so
a throw inside a tab would ship green. A coverage run on 2026-08-27 measured exactly that:
renderMonthView (35KB), gridCard (27KB), openCellEditor (16KB) and 208 others sat at
zero, and none of the nine tabs had ever been drawn by anything. The walk loads the sample
plan in a second, full-size frame, visits every tab, reads the Budget through both lenses
— the year grid and the month page are two different renderers over one tab — opens a grid
cell's editor, presses every button that isn't destructive, and fails if the frame throws or
a view comes back empty. Verified by breaking renderMonthView on purpose. Nothing it does
can write: save() and confirm() are replaced in that frame before anything is pressed,
and the saved plan is read back at the end and compared.
One group does write, and cleans up after itself. "Two open copies of the app"
(2026-09-18) can only be tested with the REAL save(): it boots its own frames, plays the
other window by writing fin-state directly, and checks that the stale frame adopts instead
of overwriting. Each of those tests snapshots every key this origin holds first and puts all
of them back in a finally, so the plan in the browser you run the suite in is untouched.
The half that LISTENS for the other window cannot run in a frame at all (it is top-level
only, because the suite's frames share storage with planted fixtures), so it is pinned as
source and was proven in a two-page headless run.
CI (.github/workflows/tests.yml) runs the same page headless on every push.
The page only runs on localhost, and enforces that itself. GitHub Pages
publishes tests.html next to the app, and on that origin the hidden frame
would be a live session — a signed-in browser would start real cloud sync
inside a frame nobody can see. Anywhere but localhost it refuses, explains
itself, and changes nothing. The frame is also marked data-fin-tests, which
the app's sync module checks so it never initialises inside the harness — the
same guard as the sibling apps.
Charlie's own one-time migration from the Numbers spreadsheet:
python3 import_xlsx.py "~/Downloads/Financial Plan.xlsx"
Then open the app → ⇩ Back Up → Restore JSON… → pick
financial-plan-data.json.
migrate_local_data.py does the one thing the app deliberately won't do to
your data by itself: it takes the next year back out of the live grid.
python3 migrate_local_data.py financial-plan-data.json
The spreadsheet ran 24 months so it could see a year ahead; the app ends a year at December and starts the next with "Build ⟨year⟩". So the hand-typed next-year months go and the grid comes back to twelve — next year's paycheck counts included. They were kept once, on the reasoning that the rollover picks them up; but a twelve-month grid never shows them, so they could not be read or corrected and came back every time the next year was rebuilt. Starting the new year repeats the same month of the old one instead, three-check months and all. It writes a new file beside the input and never modifies the original.
(Folding a year into a summary is a per-year decision with a button on the year itself — the script never does it for you.)
merge_debit_card.py is the other one-off: it makes the ATM row and the
Debit Card row into one row, in every year.
python3 merge_debit_card.py financial-plan-data.json
The same spending had been recorded under four ids since 2013 — atm,
debit-card, atm-debit and 2020's cash — and two of them were both wearing
the name "ATM". Nothing paired them, because Where the Money Goes matches this year's
rows against last year's by category id: the live year's row read "new this
year" while the previous year's read "down 100%", two lines apart in the same
table. All four become debit-card, named Debit Card, which is what the
row actually holds — card purchases and cash taken out on the card. cash is
also the checking ACCOUNT's id, so it is listed in ONLY_IN with the single
year it may move in rather than trusted to a bare id match; only cells are
ever rekeyed, which is the rule normalizeIds follows in the app.
2019 is the only year that is a real merge: it kept both rows, and four months have a figure in each. Those become one split cell, the app's own way of saying a month was several payments, so every original amount is still on the screen and nothing is added up out of sight. The script asserts each year's row total is unchanged, writes a new file beside the input, and never modifies the original.
Why there is a package.json in a repo with no build step. It is not a package and it
installs nothing — it exists so Dependabot has a manifest to scan. Its only entry is the
Chart.js that is vendored as chart.min.js beside the app, pinned exactly, and CI passes
--omit=dev so npm never downloads it. Dependabot cannot re-vendor a file, so a version-bump
PR would otherwise raise the manifest while the app went on serving the old bytes; a test pins
the two to the same version, which makes a manifest-only bump fail and turns the PR into the
right instruction — update the file too, in all four repos that carry it.
Not on an ordinary visit any more. init() used to run unconditionally at the foot of the
sync module, so firebase-app, firebase-auth, firebase-firestore and the Google sign-in
client were fetched from www.gstatic.com and accounts.google.com before anyone had touched
anything — four requests to Google carrying the visitor's IP and user-agent, on a page that
might never sync. That is what made the old privacy wording false; this is the change that
lets the strong sentence be true.
It cannot be made fully lazy, and that is the whole difficulty: a returning signed-in reader has to be recognised without clicking anything, and the only thing that knows whether this browser holds a live Firebase session is Firebase. So the app records the answer itself:
fin-sync-live | meaning | on load |
|---|---|---|
'1' | a session was live at last report | load Firebase now |
'0' | there was none, or they signed out | load nothing |
| absent | never asked, or a browser from before this change | fall back to the legacy fin-sync-uid marker |
onAuthStateChanged writes '1' or '0' on every auth report, including the null one
that follows signing out — so signing out stops the requests, not just the syncing. The
absent case is the migration and costs at most one eager load per browser:
fin-sync-uid has been written on the first successful sync for an account since long
before this, and is never removed, so its presence means "this browser has signed in at some
point". A browser that has never signed in has neither key and never takes that path.
The warming is load-bearing, not an optimisation. requestAccessToken() has to be called
from inside the click handler or the browser judges the popup unsolicited and blocks it, and
awaiting a cold SDK import first would spend the gesture. So the load starts on
pointerenter, pointerdown and focus — all of which fire before click. onClick still
awaits ensureInit() as a fallback, for somebody who tabs straight to the button and presses
Enter; if the popup is refused there, the existing popup_failed_to_open message says what to
do and the second press always works. ensureInit() is idempotent, or a hover and a click
would start two Firebase apps.
The click listener is wired at the boot branch, not at the end of init() — init() may
not have run yet, and the button has to be pressable in order to be what causes it to run.
tests.html pins the shape of all of this, and the privacy page's wording with it.
All three sync apps are on the same Firebase version, moved together, exactly like the
vendored Chart.js: package.json pins it for Dependabot and tests.html pins the manifest to
the firebasejs/… URL in index.html, so a manifest-only bump fails. Bumping means changing
the URL and the pin in the same commit, in all three repos, and then proving a real Google
sign-in still works on the live origin.
<main> opens above the tab strip, not below it. It used to wrap the tab panel alone,
which had two consequences: the tabs sat in no landmark at all (axe-core's region rule),
and — the reason worth acting on — the skip link jumped past them, so a keyboard user who
took "Skip to content" had the entire tab row behind them, reachable only by shift-tabbing
back. The tabs and the panel they drive are one widget, so the landmark goes round both. The
share bar comes inside with them: it describes what is on screen, so it is content rather
than furniture.
role="tabpanel" still goes on the inner div and never on <main> — putting a role ON an
element IS its role, so it would silently replace the landmark. That older note stands
unchanged.
Every page in this repo passes axe-core at WCAG 2.1 A and AA, the 2.2 AA additions and its best-practice rules, in all four themes, with data loaded, on every tab and in every window (last run 2026-09-24). The things axe cannot see are checked by hand the same day: a Tab through every view reading the focus ring, every window opened from the keyboard and closed with Esc, hover colours, a 320px-wide window, widened text spacing and reduced motion.
The one genuinely third-party thing this app runs is Google's Firebase SDK, and it is loaded
by URL from www.gstatic.com — so nothing was watching it. Dependabot reads manifests, and
no manifest named it; the clean bill of health it reported covered nothing at all. (There are
no known advisories against the pinned version — the problem was that nobody would have been
told if there were.)
package.json is that manifest. It installs nothing — it is private, has no scripts, and
CI passes --omit=dev — and the bytes that run still come from Google's CDN at page load.
That creates the same way of ending up lying that a vendored library has: Dependabot cannot
rewrite a URL, so a version-bump PR would raise the manifest while the page went on fetching
the old one. tests.html pins the manifest's version to the firebasejs/… URL in
index.html, which makes a manifest-only bump fail and turns the PR into the right
instruction: a newer SDK exists, now change the URL too.
Never let that pin become a ^ or ~ range — a range cannot be checked against a URL.
The header reads just Money Map, with no subtitle beside it. Until 2026-10-24 it carried
a strapline (· Charlie's Epic Money Map) that was a Preference you could change or clear;
every app in the family dropped its strapline that day, and this one's Preference went with
it. A plan or share link saved before then still has a tagline field in it — the app strips
it on load, so nothing reads it and it is not saved back.
financial-plan-data.json and
expected-2026.json are gitignored from the very first commit; git status must never show them.A calculator to check an income against your own tables, and a "What the Pot Has to Pay" card that works the first whole year of retirement through line by line. Those figures are read off the same records the Retirement chart is drawn from, so the two can never disagree.
Your spending target now means what you get to spend — after tax. That is a change in what the figure means, and both tabs say so out loud.
total*1.05%200*5%+ - * / ( )total