A native macOS app for tracking your net worth over time.
Ascend records balance snapshots, not transactions. Whenever you feel like it, you write down what each of your accounts is worth on that date — and it works out everything else: what your net worth is, how much of it you can actually spend, how fast it is growing, how much of your income you are putting away, when you will hit your target, and where you will be in five years.
It began as a replacement for a spreadsheet that did the same job with four hardcoded columns. The point of rewriting it was the thing a spreadsheet cannot do: create, rename, retype, reorder, archive and delete accounts without touching a single formula, and without ever silently changing the history you have already logged.
It is a single-user, local, offline app. No accounts, no sync, no bank connections, no telemetry. Your data sits in a file on your Mac.
Not a budgeting app and not an expense tracker. There are no transactions or spending categories, because it never asks where your money went — only what it adds up to today.
| Screen | What it does |
|---|---|
| Dashboard | Nine headline figures, net worth against usable cash, change per record |
| Balances | The input table — one row per date, one column per account, everything else calculated |
| Trends | Stacked balances, account comparison, growth rate, savings rate |
| Allocation | Where your money sits right now, as a donut and a table |
| Goals | Set a target; see progress, what's left, and how many more records it will take |
| Projections | Your assumptions, the 1/3/5-year outlook, months to goal, and a month-by-month forecast |
| Accounts | Create, describe, retype, recolour, reorder, archive, restore and delete accounts |
Dashboard, Balances and Trends share a period filter — all time, 3, 6 or 12 months, or year to date — and Trends can hide individual accounts. Filtering never rewrites history: a visible record's change is still measured against the record that really preceded it, even when that one falls outside the window.
Appearance follows macOS, with a manual override under View ▸ Appearance (⌃⌘1/2/3).
Every account carries a description and a type, plus four properties that are pre-filled from its type and then entirely its own:
- Counts toward Usable Cash — off for restricted accounts like a food card
- Counts toward Savings Rate — on for savings and investment accounts
- Monthly contribution — used by projections
- Expected annual return — used by projections
Exactly one account is the leftover destination: in projections it receives
income − expenses − contributions each month.
Account types are yours to edit. Main, Savings, Investment and Restricted are just the starting rows — rename them, change their defaults, delete the ones you don't use, or add your own (Crypto, Pension, Property) from Account Types… on the Accounts screen. A type supplies defaults when an account is created and never rewrites an account afterwards, because doing so would retroactively change historical Usable and Savings Rate figures. A type still in use cannot be deleted.
Adding an account reads as 0 € in earlier records, so historical totals never change. Archiving keeps history intact. Hard-deleting strips the account from past records, and says how many it will affect before you confirm.
| Value | Formula |
|---|---|
| Total | Σ all balances |
| Usable | Σ balances where counts toward Usable Cash |
| Change €, % | vs. the previous record |
| Savings Rate | Σ Δbalance of savings accounts ÷ previous total |
| Total Growth | latest total − first total |
| Est. records to goal | ⌈remaining ÷ average change⌉ |
| Total invested / month | Σ contributions of accounts that are usable or savings |
| Leftover / month | income − expenses − total invested |
| Projection, monthly | balance × (1+r)^(1/12) + contribution, leftover account also gets the surplus |
A contribution to an account that is neither usable cash nor savings — an employer-loaded food card, say — is not funded from your salary, so it counts toward neither the invested total nor the leftover deduction. The balance still grows by it.
Values that are genuinely undefined — a change with no prior record, a percentage over a
zero total — render as —, never as 0.
Records are ordered by date, then by creation time. Two records may share a date, and their order decides the savings-rate column, so it is preserved explicitly rather than left to chance.
Views (SwiftUI) → Engine (pure value types) → Models (SwiftData)
Engine/ must never import SwiftData or SwiftUI. It holds every derived number as
plain structs, which is what makes the whole calculation suite testable without launching
the app. Only user input is persisted — Total, Usable, Change, Savings Rate and the
projections are recomputed on every read, so an account-flag edit re-derives all history
instantly and the dashboard can never disagree with the table.
Services/PortfolioStore.swift is the only bridge between the two worlds.
Needs Xcode and XcodeGen (brew install xcodegen).
./scripts/install.shBuilds, ad-hoc signs, and copies the app to /Applications/Ascend.app. Ad-hoc signatures
do not expire and a locally built app carries no quarantine flag, so it just opens — no
Apple Developer account needed, ever.
Build without installing, run the tests, or regenerate the icon:
./scripts/build.sh
./scripts/test.sh
swift scripts/make-icon.swiftAscend.xcodeproj is generated from project.yml and deliberately not committed.
./scripts/dev.shBuilds a Debug copy and runs it beside the installed release, so you can try a change with real windows without disturbing the app you actually use. The preview:
- keeps its own profiles in
~/Library/Application Support/Ascend Dev/(seeded with the sample data the first time — File ▸ Import Backup… an export from the release if you want your own numbers); - never checks for updates, so it cannot be replaced by a release under you;
- wears a red DEV badge beside the net worth in the sidebar.
Running the script again rebuilds and relaunches the preview only. The badge and the
separate data root are switched on by ASCEND_DEV=1, which only a Debug build reads —
a release binary compiles that check away, so no environment variable can put the badge
on the real app.
On launch — and once a day while it stays open — Ascend quietly checks the repo for a newer release. If there is one, a toast drops in at the top of the window; Update… opens Sparkle's window, where Install Update is the only thing that downloads or replaces anything. Ascend ▸ Check for Updates… does the same on demand. Updates are verified against an Ed25519 signature, so a zip that isn't the one produced by the release script is refused.
Installing from a release zip rather than from source works the same way afterwards, with one first-launch wrinkle: a downloaded app carries a quarantine flag and Ascend is not notarised, so macOS will refuse it once. Go to System Settings ▸ Privacy & Security and click Open Anyway. Updates installed by Sparkle clear the flag themselves, so that happens only the first time.
./scripts/release.sh 1.2 -m "What changed"That bumps the version in project.yml, builds, zips, signs the zip with the key in
your login Keychain, and adds the entry to appcast.xml. It then prints the three
things it does not do — create the GitHub Release with the zip attached, commit
project.yml and appcast.xml, push — in that order, because installed copies read
appcast.xml from main and must not learn about a zip before it exists. The first
run generates the signing key and stops so its public half can go into project.yml.
The signing key lives only in your login Keychain, as "Private key for signing Sparkle updates". Back it up: without it, installed copies can never accept another update.
Data lives in local SwiftData stores on your Mac, one per profile, under
~/Library/Application Support/Ascend/Profiles/ (the development preview uses
Ascend Dev/ beside it). File → Export Backup… (⇧⌘E) writes a JSON file carrying the
open profile's accounts, types, records and settings; File → Import Backup… replaces
that profile's store from one.
On first launch the app seeds itself with the four accounts and five records carried over from the original spreadsheet, plus its goal and projection assumptions, so no screen starts empty. Those figures are the author's own starting point — rename the accounts, delete the records, or import a backup to make it yours.
./scripts/test.sh104 tests, none of which need the app to launch. They pin the engine's arithmetic value by
value, the account and type lifecycles (adding an account leaves historical totals
untouched; archiving preserves them; editing a type never rewrites existing accounts),
number parsing in both 1.234,56 and 1,234.56 conventions, period filtering, backup
round-trips, and the exact strings each screen renders.