Skip to content
isna
Features · Pricing · Privacy · About · Help

Product

What's new

Every release is recorded here — the date, the version and what actually changed. No "fixes and improvements".

1.26.0

Added

  • A household whose trial has ended becomes read-only, which is what the pricing page has been promising since this morning. Thirty days of everything, then: all of it stays visible, all of it stays exportable, and nothing new can be written until somebody subscribes. Until now the site said that in three languages and the application did not do it — a household past its trial lost the paid modules and every quota, and could still write. The refusal is one guard over the whole API rather than a check in each module, because the failure mode of per-module checks is the module somebody forgets, and it refuses by method rather than by feature: closing the finance module would take away the household's own ledger, which is the thing being promised it keeps. Four things deliberately keep working. Signing in, changing a password and turning on two-factor, because somebody whose trial ran out has to be able to get in — that is how they pay. Billing, for the same reason. Asking for an export, which is itself a write and the one this household needs most, and asking for the household to be deleted. And turning notifications off, which cannot depend on what is owed. The jobs that write rows on their own — subscription self-charging, standing bills — pause rather than skip: the due dates stay exactly where they are, so a household that subscribes next week is charged from where it left off instead of finding a month of its own records missing. Reminders keep coming, because a reminder is a notice about something the household already told us, and hiding an expiring passport to make a point about payment would be a poor way to treat somebody.
  • The app says where you stand before you find out the hard way. A bar at the top carries the days left in the trial — a different sentence in the last week, because "five days left" is news and "twenty-three days left" is background — and, once read-only, what that means, with a link to the plans in the language the app is being used in. Neither bar can be dismissed: both say something that stays true until it is acted on. The refusal, if one is reached anyway, now explains itself rather than saying "you do not have permission", which was wrong — the household has every permission and no subscription.

Fixed

  • The app stopped offering things a read-only household cannot do. "Quick expense", "Quick task", "Quick event" and the add-an-event box were all still there and fully enabled while the bar above them said the household was read-only, so the way to discover the state was to fill in a form and be refused on saving — exactly what the bar was added to prevent. Whether somebody may write is now one answer, their role and the household's standing taken together, computed once and asked everywhere, instead of twenty-three screens each asking only about the role. Found by taking a screenshot of the previous change rather than by any test: the tests call the API directly, and the API was behaving correctly throughout.

1.25.0

Fixed

  • Every legal page opened by telling the reader it might not be true. All six — privacy, terms, cookies, the imprint, the DPA and the subprocessor list — carried a banner saying "This is a draft… it should be reviewed by a lawyer before anyone relies on it", in all three languages. For a product that is live and about to take money that is worse than useless: it tells the person agreeing to the terms that the terms may not hold, which is both unhelpful to them and untrue of a shipped product. The banner is gone. In its place each page is dated, which is what a reader actually needs — you can tell which version you agreed to — and keeps the one useful half of the old notice: if a page and the application disagree, that is a mistake, here is where to report it. A lawyer's review is still wanted and may still change wording; a pending review is a note for us, not a disclaimer for the reader.
  • The site fetched its fonts from Google, so every visitor's address reached Google before a word of the page was read. Two families, Archivo and JetBrains Mono, pulled from `fonts.googleapis.com` with a preconnect to `fonts.gstatic.com` ahead of them. A German court held in 2022 that embedding Google Fonts this way infringes the GDPR (LG München I, 3 O 17493/20), and it started a wave of claims against sites that did it; this product sells into Germany, and its own privacy policy says in as many words that there is no analytics, no ad network and no third party receiving anything. Both faces are now served from our own origin, the two the first paint needs are preloaded, and the site's content-security policy no longer permits either Google host — so the promise is enforced by the policy and not only by intention. The fonts are variable, so one file per character subset carries every weight: four files, 110 KB in total, and only the latin and latin-ext subsets that Serbian, German and English need. Both are under the SIL Open Font License, and the licence text now ships beside them. The application had the same two `<link>` tags, dead ones — its own policy already said `font-src 'self'`, so the request was blocked in production and every screen quietly fell back to a system font. It now loads the same self-hosted files and is set in the type it was designed in for the first time.
  • Member avatars were drawn by a third party that no page declared. Every avatar was an image from `api.dicebear.com`, so each member's browser announced itself to that service on every render, carrying a stable identifier for the member in the URL. No name left the household — the seed is random — but the IP address and the identifier did, and the service appeared in neither the privacy policy nor the subprocessor list, both of which say nobody receives anything. Avatars are now drawn from the member's initials on their own colour, which is exactly what the component already showed whenever the remote image failed. The avatar-style picker went with it: it chose between styles of a picture that is no longer fetched, so it changed nothing on screen, and a control that does nothing is its own small dishonesty. The stored style and seed are untouched, for whatever draws avatars next.
  • A reply to any email from the application went nowhere. Mail is sent from a no-reply address, which is reasonable, but nothing set a reply-to header — so somebody answering a bill reminder or a trial-ending notice wrote into a void and was never told. Replies now go to an address a person reads, set per environment.
  • Three invented customer quotes were still in the repository. They never shipped — the flag that would publish them was off, with a comment explaining that publishing fabricated reviews is a banned unfair commercial practice in the EU — but a flag somebody could flip is not the same as not having them. They are deleted, and the slot holds the true thing it was already showing: why the product exists.

1.24.0

Added

  • The application can now hear from the payment provider, which is the half of billing that does not need an approved store. Nothing could take money and nothing could be told that money had been taken: the subscription model and the per-feature gating were built, the provider was decided and published, and there was not one webhook handler — every `webhook` in the source was a comment anticipating this one. There is still no checkout, so nobody can subscribe yet; what exists is the receiving end, built now because the store is waiting on an identity check and this part never needed one. Every event is handled as "here is the subscription as it stands" rather than as a step in a sequence, so a delivery that arrives twice, or out of order, cannot grant a second subscription or undo a newer fact — ordering comes from the provider's own timestamp, not from arrival. The endpoint authenticates with the request signature alone, because its caller is somebody else's server with no account here: the body is read as bytes and size-capped before anything parses it, the signature is compared in fixed time, and a deployment with no signing secret accepts nothing at all. Configuration is checked when the service starts rather than when a customer reaches a checkout, and it refuses the mistakes that are easy to make and silent to live with — the same identifier under two prices (which is what pasting a product ID looks like, since both are plain integers), a payment link where an identifier belongs, or a provider declared live without its keys. A plan is granted here and never taken away here: a household that has just paid sees it within one request cycle, while the end of access stays where it belongs, with the trial and read-only work.

Changed

  • Prices are in US dollars with tax included, and the free plan is gone — thirty days of everything, then read-only. Two things were wrong at once. The store processes every transaction in dollars whatever currency it displays, so a customer shown €8 was charged about $9.30 and had it converted back by their own bank, with a fee — a surprise for an EU customer as much as anyone else; and the pricing FAQ described a currency conversion at checkout that the provider does not do and never did. Prices are now $9 a month or $90 a year for a household, $17 or $170 for the higher tier, final at that figure: tax comes out of our margin rather than being added at the end. The bigger change is what the first column says. A permanent free plan converts worse than a trial and holds sensitive household data indefinitely at our cost, so a new household now gets thirty days of the whole product without a card and then becomes read-only — everything stays visible and exportable, writes stop, and subscribing at any point brings them back. That claim was published in five places and each has been corrected, the terms included, where "the account returns to the free plan" is now "the account becomes read-only". The application has not caught up: a household registering today still gets a free plan with no expiry, and the pricing page says so in as many words rather than promising a countdown nothing runs yet.

Fixed

  • The site linked to an EU dispute-resolution platform that no longer exists. The European Commission's ODR platform closed on 20 July 2025 and the obligation to link it closed with it, so the paragraph is gone from the terms in all three languages. Not being obliged to use a consumer arbitration board is a separate statement and still stands.
  • The registration form offered a currency Croatia stopped using in 2023. There were two currency lists — one shared by every finance form, which had already dropped the kuna, and a second hard-coded in the registration form, which had not. They are now one list, so the next time this changes it cannot be half done.

1.23.1

Fixed

  • The background jobs worked out what day it was in UTC, so a household east of it was a day late on everything that matters. Request handlers have asked `clock.TodayIn(...)` for the household's own date since the helper existed; the sweeps predate it and asked `clock.Today`, which is UTC. For the last hours of every UTC day a household ahead of it is already on tomorrow, so a renewal or a standing bill falling on that household's today looked like a future date and was skipped — the subscription went uncharged and the bill unwritten until the next run, a day late, every day. The sweeps cannot ask the request context because they walk every household in one pass, so they now take each household's zone once per run and resolve per row, with every date derived from a single instant so that a slow run cannot straddle midnight and disagree with itself. The SQL date filter is widened by a day to match, because a filter on the UTC date drops the very rows this is meant to catch, and both widened sweeps now order oldest-first ahead of their row cap — without that the cap is an arbitrary slice, and the tomorrow-dated rows the new check discards could hold the same slots run after run, starving bills that are genuinely due. Birthday reminders no longer say "Today" on the wrong day, budget alerts at a month boundary are no longer filed under the previous period (the same key they deduplicate on, so the wrong one was being overwritten), and the member overview's "this month" is the right month. Four places stay on UTC deliberately, each now carrying a note saying why.
  • The web image builds its own Caddy now, so a Go advisory no longer blocks releases until somebody else rebuilds. The image was built on `caddy:2-alpine`, which carries whatever Go toolchain and libraries its last upstream build happened to use. When Go publishes an advisory, the vulnerability gate fails on code nobody here can patch, and the only move is to wait — which has now happened twice, for about three weeks from 20 September and again from 14 October, with releases blocked throughout. Caddy is compiled here instead, from a pinned release against a Go toolchain and dependency versions this repository chooses: the four findings that prompted this are one library bump and one toolchain, both stated in `web/Dockerfile`. The Go minor floats on purpose, so standard-library advisories — three of those four — arrive fixed with the next build and need no change at all. Nothing about what the server does has changed: the Caddyfile is untouched and uses only standard directives, so the build plugs in nothing beyond the modules the official binary already has. The container also stops running as root, which building the runtime stage ourselves made free, and CI now starts the image and asks it for a page — until this change nothing ever ran the thing it scanned.
  • Two denial-of-service advisories landed against a cryptography package the framework pulls in by itself, and every build stopped. `System.Security.Cryptography.Pkcs` 10.0.0 is not referenced by any project here — it arrives transitively — but CVE-2026-84008 (high) and CVE-2026-70162 (moderate) were published against it, and because NuGet audit findings are errors in this repository, `dotnet restore` failed on all eighteen projects before a line was compiled. Nothing in the codebase had changed; the advisories had. The package is now pinned to 10.0.13, where both are fixed. Central transitive pinning was already switched on, so one line in `Directory.Packages.props` does it and no project needs to take a dependency it does not otherwise want.
  • The Android bundle stopped building, and the floor for which phones it installs on has moved to Android 7.0. `androidbrowserhelper`, the library that fronts every Trusted Web Activity session, now declares `minSdkVersion 24`; the generated manifest declared 23, and the merge of the two failed outright. Bubblewrap resolves that library itself, so this arrived without anything here changing. The floor is raised rather than the library held back — the devices between Android 6.0 and 7.0 are a rounding error by now, and pinning the component that handles every session to an older release to keep them is the worse trade. Nobody is affected today: the app is not published yet.
  • CI was installing vulnerable packages while `npm audit fix` insisted there was nothing to do. `web/package-lock.json` had fallen behind its own `node_modules`, and `npm ci` takes the lockfile exactly as written — so the build resolved the versions the lockfile pinned while a local tree had already moved on. That is also why the fix looked like a no-op: the report is read from the lockfile and the proposed fix is compared against `node_modules`, so the two disagreed and the tool reported "up to date" while still printing findings. Five pinned entries moved, three of them high: `brace-expansion`, `fast-uri`, `serialize-javascript` and `source-map-js`. All are dev-time transitives of the build tooling, none reaches the browser bundle, and every patched version is a semver-compatible patch bump, so no declared range moves. A clean `npm ci` followed by `npm audit --audit-level=high` now exits 0.

1.23.0

Added

  • A subscription can now charge itself. Tick it, say which wallet it leaves, and each renewal is recorded as spending on the day it falls — no recurring bill to set up beside it. (The wallet is optional, the same as it is on an expense typed in by hand; without one the spending still counts, it just moves no balance.) Until now the only way to see a subscription in the ledger was to describe the same Spotify twice, once as a thing you pay for and again as a bill, with two amounts free to drift apart. The two ways are mutually exclusive, and saving a subscription that does both is refused rather than quietly charging the household twice for one thing. The job that posts the charges catches up on renewals it slept through — a fortnight it did not run still bills every week in it — but refuses to invent more than two months of history, because a subscription abandoned a year ago is not a year of spending that belongs in this month's figures. A charge is idempotent on the subscription and the day it fell on, so running the job twice, or crashing between the charge and the date moving on, cannot bill the same renewal again. Subscriptions on trial are left alone: the point of the trial-end reminder is that somebody decides before it starts costing anything. And pausing means what it says — the months a subscription sits paused are not billed when it is resumed, which is the difference between the job having been down and the household having deliberately stopped paying.
  • A service, repair or fill-up can now leave a wallet. The logbook recorded what a car cost and the household ledger never heard about it, so the running-cost picture and the family's money were two separate stories about the same euro. Tick "also record this in the household finances" on a logbook entry and the spending is written alongside it — one wallet, one category, one entry that can be corrected or deleted from either end without the two drifting apart. It goes through the same exchange rates, budget alerts and wallet rules as an expense typed in by hand, because it is one: Finance offers a narrow way in and the vehicle module uses it rather than reaching across the boundary. The old `expenseId` field, which expected the client to create an expense itself and which nothing ever filled in, is gone. Someone who may not see the household's money — a teenager on a limited role — still sees the logbook, and sees that the car was serviced, but not which wallet paid for it. Writing to the ledger asks for an adult on its own account as well as at the route, so that opening a logbook route wider later cannot quietly open the money with it.

Fixed

  • Releases and CI had both stopped, because MinIO took its images private. The object storage the service runs on is a third-party image, and it stopped being downloadable — quay.io and Docker Hub now refuse an anonymous pull for the exact digests we pin as readily as for a tag. `docker compose pull` is all-or-nothing, so the next release would have built everything and then failed at the last step, and continuous integration was dying before it ran a single test. The image is now mirrored into the project's own registry from the copy the server was already running: the same bytes, the same version, somewhere nobody else can withdraw it from. Nothing about how files are stored or served has changed.
  • The job that empties the trash had never once emptied it. `Entity` is mapped table-per-type, EF refuses `ExecuteDelete` against a TPT hierarchy, and the refusal only arrives at runtime — so every six-hourly run threw on its very first statement. Nothing deleted was ever actually deleted, and because the throw came first, the three unrelated sweeps queued behind it — members, spent idempotency keys, processed outbox messages — never ran either. The purge is now written as SQL against the base table, which is enough: of the twenty-seven foreign keys into `entities`, the twenty-three the table-per-type mapping builds all cascade, and the remaining four unlink a surviving row rather than block the delete. It also runs last, so that a sweep which cannot finish no longer takes the other three with it.
  • A bill that created itself took the money out of no wallet at all. `RecurringExpense.WalletId` was added so a standing bill would name the account it leaves, and only the confirm-by-hand path ever read it. The expense the materialisation job wrote left it null, so the balance the household is meant to trust sat still while the money went out — the exact hole the field exists to close. Auto-created bills now carry the wallet through.
  • A wallet could be deleted out from under the bill that pays from it. The guard asked only about history — expenses, transfers, envelope allocations — so a wallet with a standing instruction pointing at it but nothing yet recorded looked unused. Deleting it did not stop the bill; it left it charging the household to nothing, unattended, with nobody looking when it started. An active auto-creating bill now counts as pointing at the wallet.

1.22.2

Fixed

  • The consent bar's script was inline, which its own CSP forbids. Astro inlines a component `<script>` small enough to fit, so the bar shipped as `<script type="module">` in the page — and `script-src 'self'`, the policy added for it in 1.22.0, blocks exactly that. The bar would have stayed dead even once the edge picked the policy up. The behaviour now lives in `public/consent.js` and is pulled in with `src`, which is what `'self'` means. Verified by serving the real build behind the real policy: bar appears, refusal and acceptance both persist across pages, the cookies page reopens it, and the browser reports no violations.
  • The edge deploy now recreates Caddy instead of reloading it. The reload added in 1.22.1 reported success and the edge went on serving the previous policy, so whatever it was talking to is not what terminates TLS. Recreating the container cannot miss it: the process starts from the file as it stands. The file is still validated first, so a broken config fails the deploy rather than being discovered when the edge does not come back.

1.22.1

Fixed

  • Edge configuration has never actually deployed. The Caddyfile reaches the server as a bind mount rather than inside an image, so `docker compose up -d` sees no change to the container and leaves Caddy running on the config it booted with. The file on the host was current and the running config was not — every header, routing and CSP change since the pipeline was written shipped and then sat there. Caught when v1.22.0 put the consent bar live and production carried on serving `script-src 'none'`, which blocked its only script. The deploy now validates the Caddyfile inside the container and reloads Caddy, so an edge change takes effect on the release that carries it, and a bad file stops the deploy instead of taking the edge down on the next restart.

1.22.0

Added

  • The six legal pages, in all three languages. Imprint, privacy policy, terms, cookies, data processing agreement and subprocessors — eighteen pages, drafted from what the software actually does rather than from a template. The retention periods in them are the constants the jobs run on (trash 30 days, household deletion a 7-day grace, dormant accounts 30 days, backups 30 days), the cookie list is every cookie the two applications set, and the subprocessor list is every third party that receives anything. Every one of them opens with a banner saying it is a draft awaiting legal review; the health-data, children's-data and DPA sections are the ones most worth a lawyer's time. The footer links that pointed at them have been dead since the footer was written, and now resolve.
  • A cookie consent bar, in all three languages, with categories, a stored answer and a control on the cookies page to change it. It is honest about its own situation: there is no statistics cookie on isna.app to accept or refuse, and the bar says so rather than implying a choice that does nothing. The mechanism is real — a refusal is stored, and anything added later has to wait for the `isna:consent` event before it loads.
  • The about page has its photographs: the family in the opening frame, and the person who builds it beside the section that names him. Both carry alt text that describes the picture, which the frames' production notes never did — those name the shot that is owed and made poor alt text for a real one.

Changed

  • The home page no longer opens by saying families fall apart. The point of the section is that it is the small forgotten date that costs you, not the big mistake, and the paragraphs under it are about a lapsed registration and a bill paid twice. The heading now says that instead, in all three languages.
  • The company's own details are filled in. `{LEGAL_ENTITY_NAME}`, `{MOR_PROVIDER}`, `{SUPPORT_EMAIL}` and the rest were being rendered as literal braces — the footer was shipping "© 2026 {LEGAL_ENTITY_NAME}" to every visitor on every page. They now come from one typed module, so an address change is one edit rather than a search across three languages. The contact page no longer lists four separate addresses that were all going to be the same inbox.

Internal

  • The marketing site's CSP said `script-src 'none'`, and it was accurate: the site shipped no JavaScript at all. The consent bar is now the one script on it, served from the same origin, so the policy is `script-src 'self'` — the narrowest setting that lets the bar run. No inline script, so no `'unsafe-inline'`; a page that ever needs one should get a hash instead of widening this further. The Caddyfile was validated before the change went near the server.

1.21.0

Fixed

  • Three labels in the app were the untranslated key. The transfer dialog's amount field read `fields.amount`, the envelope allocation dialog's read the same, and saving an edited loan raised a toast saying `toast.saved`. All three are keys that exist — just not in the namespace the call looked them up in, and i18next has no fallback namespace to save it. A check now runs in CI over every `t('…')` in the app and fails the build on a key that cannot resolve; it understands plural suffixes, an explicit `{ ns }` option, and files where several components each hold their own translator.
  • Money ran out of its tile on the statistics page. The figures are set at 30px, which fits a short number and not `41.561,36 RSD` — that needs 197px against the 130px a phone gives it. It is not a phone problem either: the grid is two columns on a phone and four from `lg`, so the same tile is narrower at 1024px than at 640px, and the figure overflowed there too. A stat is now measured against its own tile rather than the viewport, and takes the largest size that fits — so a household with short numbers keeps the headline 30px, which is what it should look like.
  • Every currency axis on a Serbian household's charts read `ilj. RSD`. A compact amount with its unit is wider than the axis it is drawn in, so each tick rendered as its own tail end — three labels on the finance chart, all identical, all meaningless. Axis ticks now carry the number without the currency; the tooltip and the tiles above still give the figure with its unit, which is where a precise number belongs.
  • The day-by-day chart could not be read on a phone. Its two axes took 100px of 315px, leaving the bars less room than the numbers beside them. Below `sm` the chart is now two panels over one set of day labels — the running total above, what each day cost below — each with a single axis and about 260px of plot. One legend covers both.
  • The everyday scale on that chart was never actually applied. 1.20.0 set the daily axis to the ordinary days so one rent day could not flatten the other thirty, but neither `domain` nor `allowDataOverflow` clips a bar — the rent-day bar was still drawn at its full height, 385px in a 78px panel, straight up out of its own plot. The bar is now drawn at the ceiling and reported at its real value, so the twenty-nine days the change was made for are finally distinguishable. The caption under the chart no longer says to hover, which a phone cannot do.

Changed

  • The about page has a place for a photograph of the person who builds it, in the left rail beside that section — square, so it does not read as a pair with the family photo above it.
  • The full-width band across the about page is gone, along with the frame that was waiting for a picture for it.
  • The history strip is the actual history: a notebook in 2023, a spreadsheet in 2024, copies of that spreadsheet for friends and family in 2025, and isna in 2026. The years before 2026 are about how the household kept its records, not about the product, which is what makes them true.
  • The household is four people, not five. "3 years in daily use" went with it — the app did not exist for three years — and is now three years of keeping records, which is what the strip beside it describes.

1.20.0

Changed

  • The day-by-day chart now shows what each day actually cost. The running total answers "are we ahead of last month", which is worth knowing once a month; it cannot answer "what happened on the 9th", which is the question someone opening this chart usually has. Each day's spending is now drawn as a bar beneath the two cumulative lines, on its own axis on the right — the two quantities differ by a factor of about thirty by month-end, and sharing one scale would have flattened the bars into a strip along the floor.
  • The four figures at the top lost their charts. Five months of history in a tile an inch tall was a shape too small to read a trend off, and it competed with the chart below that has room to show one properly. The tiles say the figure and what it means in words; the chart does the drawing.

Fixed

  • The tallest bar in every chart on the site was drawn with its head cut off. Axis ticks stopped at the last round number at or below `max + step / 2`, so whenever a series' maximum sat in the upper half of its step the top tick came out below the data — a series topping out at 157 was given an axis ending at 150, and recharts clipped the overshoot at the plot edge. Ticks now always run past the largest value. Insights, health and vehicles all read their domains from this.
  • One rent day was flattening the other thirty. A month where the largest day is €1,464 and the typical one is €78 gives every ordinary day about a twentieth of the height available, which is the month rendered as one spike and a row of stubs. The daily axis is now scaled to the everyday days — anything past the top is drawn solid rather than faded, with a line under the chart saying how many days ran over and that their figures are on hover. Nothing is hidden; the twenty-nine days you can now tell apart are the point.

Internal

  • The over-scale count is derived in the page from the same data the chart gets, rather than handed back from inside the chart's render. A child component calling its parent's setter during render is a render loop with a delay on it, and this is a pure function of data both already hold.

1.19.0

Changed

  • The finance overview shows shapes, not just totals. The four figures at the top keep their places and gain the context that makes them mean something: this month's spending against the last six as a row of bars, income as a line, and what was kept as a share of what came in. A figure on its own says how much; next to the months behind it, it says whether that is a lot.
  • A day-by-day curve of this month's spending, against the same stretch of last month. Both lines stop at today rather than the comparison running on to its own month-end: the question is whether this month is ahead so far, and a line that carries on past the point being compared invites reading a gap that is only the calendar.
  • Where the money went, as lengths. The top five categories this month with a sixth for the rest, measured against the largest rather than the total — six shares of a month all come out short and samey, and the question the eye is asking is which one is the big one.
  • The most recent entries, so the page shows the ledger is alive. Bills, wallets, budgets and envelopes keep the places they had.

Fixed

  • Every headline figure in the app was rendering at body size. The type scale adds five sizes of its own, which tailwind-merge does not know about, so it read `text-stat` as a colour and dropped it whenever a real colour followed in the same class list — `cn('text-stat …', 'text-ink')` came out without the size. Seven places were affected, including the shared primitives. The custom sizes are now declared to the merger, and a stat renders at 30px as it was meant to.
  • The chart's own axis was printing `.000.00` — a full amount is wider than the 60 pixels the axis gets. It uses the short form the insights charts already use.
  • A household whose income does not change was drawn as a flat line pinned to the floor of its tile, which reads as a collapse rather than as steadiness. With nothing to scale against, the line sits through the middle.
  • The five months of history in the spending tile were drawn on a surface about 1.03:1 against the card behind them — present, and invisible.

Internal

  • Two series added to the finance insights response: income per month, and spending per day. The by-month query already read both directions and threw income away in the `SELECT`, so income costs one more aggregate over rows that were being scanned anyway. The daily series exists because a month of a household's spending is more rows than the ledger returns in one page — a caller adding up a listing would have silently drawn the first fifty and stopped.
  • The overview makes two insights calls rather than one, because they answer different questions: the trend window is six months, and "top categories this month" is one. Neither window can be both.

1.18.0

Fixed

  • The wall tablet showed nothing but its three headings on a phone. It is built as one screen that never scrolls — fixed to the viewport, three columns, nothing clipped — and holding that on a phone gave each panel a third of whatever was left under the header. On a phone in landscape that is about sixty pixels: the heading, and none of the content beneath it. Below the tablet breakpoint the panels now stack and the page scrolls, with the clock staying put at the top. Measured before and after: the three panels went from 0px of visible content to 249, 605 and 340. The wall tablet itself is untouched — three columns, no page scroll, at 1024 and above.
  • Quick expense had nowhere to be reached from on a phone. The top bar's copy is hidden below the small breakpoint, so the third of the three things a household adds most often was missing exactly where it is most likely to be added. It now sits beside quick task and quick event, appearing precisely where the top bar's disappears, so there is never two of them.
  • A Viewer was offered the quick expense button although the role cannot create anything — seeing the household's money and being allowed to add to it are different permissions, and only the first was being checked. The dialog it opened would have been refused by the server.

1.17.0

Added

  • The site decides which language to open in. Serbian has no URL prefix — it is the default locale, so `/` is the Serbian home page, which meant every visitor in the world landed on Serbian. Arriving at `/` from Germany, Austria or Switzerland now goes to the German site, from Serbia stays where it already is, and from anywhere else goes to the English one.
  • Only the root is negotiated. A link to `/en/pricing` was meant as English by whoever sent it, and second-guessing that would break every shared link.
  • Anyone already inside the site is left alone. Without that, a visitor in Germany who picked Srpski would land on `/`, be sent straight back to German, and have no way to reach the Serbian site at all. A referer from the site's own origin is what marks somebody as already here, which covers the logo, the footer and the language menu at once; `?hl=` is the escape hatch for browsers that send no referer, and the language menu's Serbian link uses it.
  • A regression test for the routing, and the first check that the production Caddyfile even parses. Caddy terminates TLS for both the marketing site and the application, so a Caddyfile that fails to adapt takes the whole host down, and nothing had ever checked it. CI now adapts it and then drives twenty-six real requests through a copy of the live block with the client address spoofed per country.

Internal

  • The country lists are address ranges generated from the RIPE NCC's own daily allocation file rather than a GeoIP database. A database would mean building Caddy from source with a third-party matcher whose last release targets Caddy 2.8 while production runs 2.11 — and 2.10 changed the matcher interface — plus a MaxMind account, a licence key in CI and a licensed binary shipped in the image. Ranges in a text file need none of it and run on the stock image. Refreshing is `node deploy/geo/build-ranges.mjs`.
  • The root's answer depends on who is asking and no cache key includes that, so all three answers are `private`. The header has to be deferred or the upstream's `public, max-age=300` overwrites it — the same trap that cost `/_astro` its immutable year, and one a config check cannot see.
  • Worst case at the root is 0.29ms to scan 17,725 prefixes, and deep paths are untouched at 0.011ms because the path test short-circuits first. Measured rather than assumed.

1.16.0

Added

  • The three pieces of the about page design that 1.15.0 left out. Both photo frames now render — the family beside the opening at 4:3, and a band under the argument that runs the full width of the window at 3.4:1, which is the only thing on the site that breaks out of the 1240 measure. Each draws the hatched panel naming what belongs there until a photograph is named in `shots.ts`, the way every other frame on the site has always handled a picture it does not have yet.
  • The third figure under the opening, and the four-part history strip at the foot, both in all three languages.

Fixed

  • The years in the history strip were moss-600 on white, 3.46:1 at 13px and under AA. They are the darker moss throughout; the last cell is still marked out by its ground rather than by its colour.
  • The caption on an empty frame was prose-muted, 3.03:1 against the hatch. `Shot` sets it at prose now. It was invisible while no frame anywhere was empty; the two on this page are the first in a while.

1.15.0

Changed

  • The about page is an editorial spread rather than one column of prose. The opening sits beside a small strip of figures, the argument the product is built on moves onto a green ground of its own, and who builds it gets a section rather than a bold run-in halfway down a paragraph list. The prose itself is unchanged in all three languages — the page reads the same sentences in a different order of importance.
  • It ends on the two links the document asks for, Contact and Roadmap, and a sign-off.

Fixed

  • The Serbian about page opened with a paragraph reading "Tekst:". It was a label in `website.md` telling the author where the body starts, and it came through the parser as the first sentence a Serbian reader saw.
  • All three about pages ended on a paragraph reading just "Contact". The document ends that page with two links; the parser kept the first word and dropped the rest, so every language finished on a heading with nothing under it.
  • The about page named its author as `{NAME}, {ROLE — e.g. a software engineer in {CITY}}` — three unfilled tokens, visible on the live page, on the one page whose job is to say who is behind the product. It now names him.

Added

  • Frames for two photographs — the family beside the opening, and a band the full width of the window under the argument. Both are empty and neither renders, so the page is not shipping hatched rectangles captioned "family photo"; naming the files in `shots.ts` lights them up. Both paths were exercised before being left dormant.
  • A strip of figures under the opening, and a history strip at the foot which ships empty on purpose. The design fills it with a 2023–2025 timeline, and two of those rows are checkable against this repository and the rest of the site: the first commit here is 2026-09-01, and "opened to other families in 2025" reads oddly beside a pricing page that still marks both paid plans "coming soon". Neither disproves an earlier prototype elsewhere, which is why the rows are left for the author rather than reworded. The section is the contact band until they are filled.

1.14.0

Changed

  • Every product screenshot on the site was re-taken against a new demo household. The family is now the Harpers — Chilli, Bandit, Bluey, Bingo and the dog — replacing the Bennetts. All 39 existing pictures were replaced and 15 new ones added, so the site ships 54.
  • Fifteen more rows on the module pages now show a screen instead of only prose. Of the 43 rows across the eight module pages, 27 have a picture where 12 did. Finances is complete at six of six; Health is five of six. The sixteen still without one are mostly meant to stay that way — the subject is not a screen, the thing is not built yet, or the only screen that shows it is already on the page one row above.
  • The ninth card on the features overview — reminders and the daily digest — has a picture at last. It describes something that lives inside the other eight modules and has no page of its own, so it was the one card on that page still rendering as a hatched placeholder.

Fixed

  • The demo household's money made no sense, and it was the first thing a reader saw on two finance screenshots. Two years of card spending was never paid off, so the cards closed at −5,928 and −10,435, while every salary piled up untouched in the current account, which closed at 42,914. The cards and the cash tin are now settled from the current account at the end of each month and the surplus swept to savings, all of it derived from the ledger the run actually wrote rather than from fixed amounts. The month still running is left alone, so the cards carry what has been spent so far and the picture reads as a real mid-month balance: 4,905 in the current account, 32,155 in savings, 520 and 335 on the two cards.
  • "Where the original is" was empty in every screenshot of it — the field the documents module is built around, and the one its feature page gives a row of its own. One repeated string sat on every third record and null on the rest. Fifteen of the seventeen documents now say where the paper is; the payslip and the tax return still say nothing, because they arrive as files and there is no paper to find.
  • "Expiring soon" showed one document. Of seventeen, only the car insurance fell inside the window and everything else was between five months and two and a half years out. Four now fall in the next three months, which is what a household with a passport, a policy and two insurance cards actually looks like.
  • Both cars ran out of insurance on the same day. The two vehicles shared one set of renewal dates, so each card read "73 days left" — two cars bought two years apart. They have their own dates and their own insurers now.
  • Measurements were seeded for children only, so three health records in four showed "No readings yet" under a heading the health page devotes a row to. Adults now have a year of weight readings and a year of blood pressure.
  • A ten-year tetanus booster reminder rendered as "In 3349 days" in the Coming up panel, which reads as a fault rather than as medicine. There is no next-due date on it now, and a dental check-up sits in the panel at a horizon a person would recognise.
  • Four tags were being created and put on nothing, and every document sat at "Versions (1)" — both named on the documents page as things it does. Nine documents are tagged, and the lease has the version history the page uses as its own example.

Added

  • `SCREENSHOTS.md`: how to re-take the whole set, the four review gates that have each caught a wrong picture, what every image is for, the known gaps and why they are gaps — and what would have to be built for iOS and Android store pictures, which is the same seeded household and the same gates plus a device runner.

1.13.0

Changed

  • All eight module pages are laid out as a ledger. A masthead the page opens on, a dark rail saying where the module sits in the set, the module's contents as a numbered list with dotted leaders, and then one ruled row per thing the module does, each marked with a large outlined numeral. It replaces a hero with a boxed contents panel followed by eight alternating full-width bands, which read as a stack of unrelated sections rather than one document. No copy changed in any of the three languages.
  • A row with no screenshot no longer reserves an empty frame for one. Most rows on most of these pages have no picture yet — the wall tablet page had four hatched rectangles and nothing else, the calendar six — and a column of labelled empty frames is a worse advertisement than none. A row with a picture sets its text beside it; a row without sets its heading and body in two columns and uses the whole width.
  • The module pages close on the dark band, as the features overview already does. The whole `/features` subtree now ends that way; every other page keeps the inset card.

Added

  • Links between neighbouring modules. The dark rail carries the module before this one and the one after it, wrapping at both ends, so the eight pages can be read straight through. Until now the only way from one module to the next was the link at the very bottom of the page, and there was no way back to the previous one at all.
  • The contents list links to the rows it names, and each row is an anchor target that clears the sticky header.

Fixed

  • The "all features" link, the "in this module" label and the "next" label were 3.03:1. They used the shared `eyebrow` style, which is prose-muted — under the 4.5:1 that 12px text needs. This template sets its own at prose, 5.33:1. All nine text pairings were measured in all three languages; the lowest is now 5.33:1.

Internal

  • The mock puts a scrolling ticker of specimen records under the masthead — "passport · 28 days", "car insurance · 90 days". Written out for eight modules in three languages that is twenty-four sets of invented sample data on a commercial page, and `website.md` governs none of it. The band carries what is already true instead: the module counter and the two modules either side.
  • Each row's figure is bespoke in the mock — reminder bars, a version history, an upload in progress — and none of those generalise past the documents module, so the rows take the screenshots that exist and nothing where none does.
  • The masthead heading is the existing `--text-hero` step, which tops out at 78px rather than the mock's 92px. Nothing was added to the type scale for one page.

1.12.0

Added

  • A monthly/yearly switch above the plan cards. The yearly price was only ever readable as a sentence inside the monthly one — "€8 / month or €80 / year" — which is not a form anyone can compare across three columns. The switch puts the year on the same footing as the month: pick yearly and the cards show €6.67 and €12.50, with a line under each saying what is actually charged and when, so the smaller figure cannot be mistaken for a monthly debit. It is two radio buttons and a little CSS, not a script; the site still ships no client JavaScript. Only the pricing page carries it — the home page's cards are a teaser pointing at that page, not the place anyone picks a billing period.
  • A line under the cards for what every plan includes, so the things nobody has to choose between are not buried in a sixteen-row comparison table further down. It is derived from that table rather than written again, and filtered to rows whose three cells actually agree — if one of them ever stops being universal it drops out of the line instead of contradicting the table.

Changed

  • The plan cards are raised and separated instead of sharing hairlines, with the middle one lifted off the page. Three cards in a flat grid gave equal weight to three unequal things; the recommended plan now carries a moss outline, a larger figure and a badge across its top edge. Each card also says in a line who it is for, and the two paid ones say what they add to the plan before them rather than repeating what it already had. No new colours: the mock's `#f7f7f6`, `#eaf6ec` and `#4d9a55` are `stone-100`, `moss-100` and `moss-600` exactly.
  • The badge on the middle card reads "Recommended", not "Most popular". Billing is not live and no one has chosen any plan yet, so the second would have been invented — the first is ours to say and true.

Fixed

  • The badge and the recommended plan's button would have failed contrast in the mock's own green. White on moss-600 measures 3.46:1, under the 4.5:1 that text size needs; both are moss-800 instead, at 7.01:1. All fourteen text pairings on the new block were measured in all three languages, the lowest now 5.19:1.
  • Hovering the selected period left dark text on the dark green pill, at 2.69:1 — the hover colour meant for the unselected pill was landing on the selected one too.
  • The two period pills both rendered as selected. Tailwind compiles `peer-checked:` to the general sibling combinator, so the checked monthly radio also matched the annual label that follows it; naming each peer pins the rule to one label.
  • The three price figures sat at three different heights in German, where two of the card blurbs wrap to a second line and the third does not. Two lines are reserved whether or not they are used, so the one row on the page that exists to be compared at a glance is on one baseline.

Internal

  • The card figures come from structured per-plan copy rather than being parsed back out of "€8 / month or €80 / year", which is written three different ways across the three languages. The comparison table still quotes both periods in one cell, which is what `website.md` prescribes.
  • `PlanCards` takes one `full` flag for the pricing page's complete block — switcher above, universal line below. Without it the home page renders the cards alone and does not ship a hidden copy of the yearly figures it could never show.

1.11.0

Changed

  • The features page is a ledger rather than a wall of cards. Nine identical tiles in a two-up grid was the right shape when the frames were empty; once every one of them had a screenshot in it the page became a long scroll of the same card repeating, and each shot was too small to show anything. Each module now gets a full-width band of its own — number, name, what it does, and a screenshot large enough to read — with the ground alternating white and pale green and the picture changing sides down the page, so scrolling it feels like turning pages. The head names all nine up front as a row of numbered chips, because the bands are long and that is the only place the whole set is visible at once. The copy is unchanged in all three languages.
  • The page closes on a full-bleed dark band instead of the inset pale card every other page ends with. The rest of the page is a run of bands; ending it on a card would break the rhythm at the last step. Every other page keeps the card.

Fixed

  • The "read more" links and the module numbers were too faint on the green bands. Moss-600 on the pale green ground measures 3.1:1, under the 4.5:1 the text size needs; every other pale-green block on the site pairs with the darker moss, at 6.3:1, and these now do too.

Internal

  • The alternating rows swap the column widths rather than reordering the items inside fixed ones. `order` moves an element to the other column but leaves that column the width it had, which rendered every second screenshot a third narrower than its neighbours — 672px against 448px, with the band heights jumping to match. Caught by measuring the rendered page rather than reading the markup; there is now one screenshot width at every breakpoint.
  • The bands are `Section`s with its existing tones and spacing steps rather than hand-rolled grounds, and the shot's lift is the `--shadow-raised` token, which turned out to be the exact shadow the design asked for. The eight "read more" links each carry the module name for anyone hearing them out of context, since on their own they all read the same.
  • The closing block takes a tone the way `Section` and `Shot` do — one markup tree, two skins — so the register link and the pricing gate still exist once.

1.10.2

Internal

  • The API and worker images were each carrying about half a gigabyte they could never run. Neither publish named a runtime, so the output included `runtimes/` for every platform SkiaSharp ships a native for — twenty of them, Windows and macOS and four flavours of musl included — inside images that only ever run linux-x64. Three files accounted for half of it on their own: the Windows native SkiaSharp debug symbols, at 85MB each. Measured on the API: 605MB of publish output, 549MB of it `runtimes/`, against 74MB once the runtime is named. The worker goes 601MB to 71MB. That is roughly a gigabyte of layer across the two images, pulled and extracted on every deploy, and it is what filled the production disk mid-deploy on 2026-09-13 — the layer that ran out of room was writing `libSkiaSharp.pdb`.
  • The runtime identifier had to go on the restore as well as the publish. `--no-restore` against an assets file restored without one fails with `NETSDK1047`, which the naive version of this change did — confirmed before committing it rather than in CI.
  • Our own debug symbols stay. They are under a megabyte for all fourteen assemblies together, and they are the difference between a stack trace with line numbers and one without. The weight was never ours.

1.10.1

Changed

  • The home page said "data in the EU" three times before you had finished the first screen — once in the pill above the headline, once in the strip of four claims under it, and once more inside the small print between them. The claim is worth making; it is not worth making three times in one glance. The small print no longer carries it, which is what the mockup did too, and leaves it in the two places where it is doing work: the pill that opens the page and the strip that qualifies the promise. The other claims in that line are untouched.

Internal

  • `website.md` picks up the eight-card layout it had stopped describing. The bento singles out two of the eight — the calendar's lead-in and the four view names under it, the emergency sheet's printed format — and those strings went into the code without going into the document that is meant to be the reviewed source of the site's wording. They are written there now, in each of the three languages, along with the fact that the last half-row of the grid is deliberately empty. The strings themselves have not changed.

1.10.0

Changed

  • The eight modules on the home page are no longer eight identical boxes. A three-column hairline grid gave every module the same weight, which is the one thing they do not have: the calendar is what the rest hangs off, and the emergency sheet is the single page somebody prints and tapes inside a cupboard door. The section is a bento now — the calendar takes a quarter of the grid on the moss ground the hero already uses, with its four views named underneath it; the emergency sheet is the one dark tile, with the format it prints at set beside it; the other six stay quiet. The section heading moved to the left with the "everything" link opposite it, rather than centred with the link below the grid. The last half-row is deliberately left empty: a bento that fills its rectangle exactly is a table with rounded corners.
  • The card titles and bodies are unchanged in all three languages. What is new is three optional fields — the calendar's lead-in and its four view names, and the emergency sheet's paper format — and a `variant` naming which of the eight is the large tile and which is the dark one, so the layout reads that from the content rather than counting positions.

Internal

  • Placement is explicit from `lg` up rather than auto-flowed. The two wide tiles are not adjacent in content order, so auto-placement would have dropped them into whatever gap came next instead of the slot the design gives them. The cards stay in content order in the DOM, so a screen reader hears them as written; the visual order differs in two places, which is safe only while the tiles hold nothing focusable — noted in the component, because the moment one becomes a link that stops being true.
  • The quiet tiles have no hover state. They are not links, and a border that answers the pointer promises a click that never comes — the features overview already draws this distinction, applying the same hover only to the cards that really are links.
  • The bento falls back to an even grid if the eight ever becomes nine. The features overview already lists a ninth module the home page leaves out, so the count is not as fixed as the layout would otherwise assume.

1.9.0

Changed

  • The home page opens on a moss field with the product in it. The hero was a centred column on near-white with nothing below the fold, and a browser-window frame further down holding the dashboard at arm's length. It is now one block: the header sits on the same pale green ground with no line under it, the copy holds a narrow measure on the left with the closing words of the headline set in moss, and the dashboard runs off the right edge instead of sitting inside a frame — so the page starts inside the app rather than in front of a picture of one. The trust bar moved up from further down the page to become the hairline foot of that block, which is where it says something: four claims on the same ground as the promise they qualify, rather than a row of ticks a screen later. The copy is unchanged in all three languages; only where it sits and what it sits on.
  • The headline's closing phrase is content rather than markup — `hero.h1Accent` names the words to set in moss for each language, and a page that wants the sentence plain can print `h1` and ignore it. It is a substring rather than a second half, so if the two ever drift apart the whole headline still reads and only the emphasis is lost.

Internal

  • The hero's left gutter is measured from the centred container the rest of the page uses, so the headline, the trust strip and every section below line up at any width — checked against the real content edge from 375px to 1920px. The first version of that arithmetic added the gutter on top of the centring offset and put the hero 20px left of everything else between 1240 and 1304 pixels, which is most laptops.
  • The product shot's frame is shorter on smaller desktops. A 2:1 capture cropped into a fixed 430px-tall column showed 46% of its width at 1024px — a sidebar and a sliver. The height now steps with the breakpoint and the visible width stays between 72% and 100% everywhere.
  • The header takes a `tone`, and the home page is the only caller asking for the moss one. Every piece of chrome on that ground steps darker, not just the background: the call to action, sign-in, the language switcher, the mobile disclosure and the unpublished nav items. The first pass recoloured only the background and the nav links, which left the header's own button at 3.1:1 against its new ground while the identical button directly below it sat at 6.3:1.
  • `trustBar` is typed as four strings rather than an array of them, because the strip that renders it is four cells wide.

1.8.0

Added

  • Finance opens on an overview. The page had eight tabs and every one of them answered a single question well, which left the question people actually arrive with — how are we doing — spread across four of them. The new first tab answers it in one screen: what is in each wallet and the total across them, what this month has cost and brought in and what is left of it, how the budgets and the envelopes are filling up, and what is due in the next seven days. That last part is the piece that could not be had at all before: bills and subscriptions live in separate tabs with their own dates, so knowing that rent, the internet and two subscriptions all fall inside the week meant opening both and reading the dates off them. They are one list now, oldest first, with a total over it. The ledger is untouched — expenses are still listed by day, with the running total, where they were. Each panel links to the tab it summarises, and a household on the free plan sees the parts it has rather than a screen of locked panels.

Fixed

  • A wallet kept showing its old balance after you spent from it. Enter an expense, pick the wallet it came out of, open the wallets tab, and the balance was the one from before — until the page was reloaded. Balances are deliberately not stored: they are worked out from the ledger on every read, precisely so that an edited expense or a deleted transfer cannot leave a stored number behind. The screen just never asked again. Saving an expense, deleting one, and confirming a recurring bill all move money and all now refresh the wallets and envelopes alongside the ledger, which the helper that does it had been warning about in its own comment for three other callers. There is a test that spends from a wallet and watches the balance move without a reload; it fails if the refresh is taken out again.

1.7.6

Fixed

  • The changelog page on the website had been empty for its whole life. It is built from this file rather than keeping a second copy of it, which is the right arrangement — but the site's image never copied the file into its build context. The page globs `../../../CHANGELOG.md`, which inside the container is `/src/CHANGELOG.md`, and the `COPY` steps brought in `site/` and the design tokens out of `web/` and nothing else. `import.meta.glob` answers with an empty object when a pattern matches nothing rather than failing, so the build succeeded, the page rendered its heading and its layout, and every release that has ever shipped was absent from it. It was invisible locally, where the file is exactly where the page expects it, and it took publishing a release and then going to look at the page to find. The file is copied in now, and a build that cannot see it stops with a sentence saying so, because the silence is what let this sit — not the missing line.

1.7.5

Added

  • The marketing site shows the product. Every screenshot frame on the site had been drawing a hatched panel naming the picture that was owed — the mock's placeholder, reproduced honestly rather than papered over, on the grounds that an empty frame which names its contents beats a wrong one. There was never anything behind them: not one frame had ever been passed an image. A household is now seeded and photographed, and thirty-three pictures are in place — the five frames on the home page, eight of the nine cards on the features overview, all eight feature heroes, and twelve of the blocks down the feature pages. The rest keep their captions, because the shots that exist for them would show either the wrong screen or an empty one, and the placeholder is still the more honest of the two. One file now says which picture belongs in which frame, so the next batch is a list to extend rather than a hunt through the components.

Fixed

  • Insights showed a household that was ahead as being deeply behind. The finance tab's "Net flow" tile was spending minus income, so a family that took in 51,600 and spent 41,367 was told its net flow was minus 10,232 — the right magnitude with the sign of the opposite convention. The ledger's own summary calls that same figure "Net" and colours it red when it is positive, which is coherent on its own terms; the insights tile borrowed the number and gave it a name that means the reverse. It is now income minus spending, so money kept is positive, and it is green when the household is ahead and red when it is not.
  • Everything the site serves was being re-downloaded every five minutes, including the fingerprinted CSS and JavaScript that is safe to keep for a year. The catch-all header block deletes the `Server` header, and a delete makes Caddy hold that whole block back until the response is written — which is after every plain header handler has run, so the five-minute default quietly overwrote the immutable rule sitting above it. That rule had never once taken effect. `caddy validate` accepts the broken arrangement and the working one alike, so nothing had ever flagged it; it was found by serving the built site and reading the responses. The screenshots, which are now the heaviest thing on the site, are cached for a day rather than the immutable year, because their names are stable rather than content-hashed and a recapture has to be able to reach people.

Internal

  • The seeded household's money did not add up, and every figure photographed for the site inherited it. Income was two salaries a month and the ledger was groceries, so the year read 71,040 in against 8,952 out — a family of four apparently living on 750 a month, while the recurring-bills screen beside it promised 1,420 of rent that never reached the ledger at all. Three things were missing and all three are now there: the bills the recurring module already describes, written into the ledger and pointing back at the rule that produces them; the fixed costs that are not bills, being travel passes and after-school care; and everyday spending at something like the rate a household of four actually spends, rather than one entry every other day. Salaries came down to what two people in Berlin bring home. The year now reads 51,600 in against 41,367 out, with rent at a third of what arrives.
  • Every expense was filed under whatever category a cycling index happened to reach. Registration seeds sixty-eight of them and each caller took `spend[i % length]`, so the electricity bill sat under bank fees, the weekly shop under school, subscriptions under whatever fell out, the food budget was attached to a category called "Adults", and both salaries were income of the freelance kind. Templates name their category now, and there is one lookup that returns null rather than a wrong guess. This was legible in the screenshots, which is how it was found.
  • The seed covers two years rather than one. Insights compares a period against the one before it, and on twelve months of history there is nothing behind the comparison: the year reported "+1,492% vs the comparison period" and drew a chart with one series where the feature exists to draw two. That was a fact about the seed rather than about the household.
  • The demo seeder doubled half the household whenever it was run twice, which the README has promised is safe the whole time it has not been. The guard asked each module's list endpoint whether anything was there and read the reply with `Array.isArray` — but wallets, expenses, loans and envelopes answer with a paged object rather than a bare array, so the guard saw something that was not a list, concluded the module was empty, and filled it again. One accidental second run left 414 expenses, ten wallets, four loans and eight envelopes. Health had no guard at all, being per-member with no family-wide list to ask, and quietly grew a second copy of all four histories every time.
  • Neither car had ever been seeded. The vehicle payload sent a member role of `Driver`, which is not one of the six the API defines, so both vehicles failed on a JSON binding error before anything was written — and with them went the service histories, the renewals and the logbook. The app's own vehicle dialog sends `Owner` for a driver, and now so does the seeder. A dry run cannot catch this: it checks the shape of a payload, not whether a string is a member of an enum.
  • Three frames were about to advertise the wall tablet with a photograph of the ordinary dashboard. The kiosk redirects to the dashboard unless kiosk mode is switched on, and that setting lives in the browser's own storage rather than on the account, so the capture never saw the screen it was aiming at and silently photographed the redirect instead. The output proved it: the wall tablet hero was byte-identical to the dashboard hero. Kiosk mode is now switched on for exactly those three captures and off again immediately, since the app shell reads the same flag to drop its navigation and would otherwise strip the chrome from every other shot; a kiosk frame that lands anywhere else now fails the run rather than returning a picture of the wrong thing. The health card and hero had a quieter version of the same fault, photographing the member roster while the copy beside them sells blood types, allergies and vaccinations.
  • Confirming the demo account's address is worth a line of output and not a whole run: it is written straight to the column the way the plan is, and a database that cannot be reached is now warned about rather than thrown, since a free-plan seed reaches the database for nothing else and would otherwise lose everything over a banner.
  • Screenshot frames took their caption as alt text. That was harmless while no frame had an image and wrong the moment they did — the captions are production notes, and two of them named pixel sizes the images have never had. Alt text is its own thing now, written per locale, and the frame above the fold on the home page loads eagerly rather than lazily, being the largest thing painted there.

1.7.4

Fixed

  • No document could be opened, downloaded or replaced. Every file the household had was sitting rejected, and the three releases that had tried to fix this had each been treating a symptom. The cause was one line, present since object storage was first written and never once exercised: uploading a thumbnail asked the S3 client to skip hashing the body, which it refuses to do without TLS, and the API reaches MinIO over plain HTTP inside the container network. So the thumbnail upload threw for every image and every PDF — that being exactly the set of files that get a thumbnail at all. The write then sat outside the guard that makes a thumbnail optional, so the failure was read as a verdict on the file, and five attempts later the file itself was rejected. A rejected file cannot be downloaded, previewed, or swapped for a new one. `1.7.2` had released the stranded files and given them retries, which sent them round the same wall five more times. Payload signing is now skipped only where TLS is doing that job instead, and storing a thumbnail is inside the guard alongside drawing it, because a thumbnail is decoration and the file is not. The same two calls are what an export and a zip extraction use, so both were broken and neither had been tried yet.
  • The screen said "check the entered fields" when nothing had been typed. A 422 carrying field errors names the offending field, which is right for a rejected form; a 422 carrying none of them is a refused operation, and the server's own sentence is the only thing that says what happened. Falling straight through to the generic message told a household with no readable documents to check a form they had not filled in, and hid the reason from the person reporting it — including through three attempts at fixing the fault above.
  • An upload could be refused by storage over a content type nobody chose. The allowlist rewrites a type the browser guessed wrong, and signs the upload URL for the corrected one — but never told the browser, which sent its own. The signature covers that header, so storage refused the upload outright. A `.csv` picked out of a file dialog was enough. The ticket now states the type it was signed for.

Internal

  • The file processing job had never been run by a test. Every suite around it stopped at the commit, which is the last step before the fault, so the one line that broke every file in production was invisible to a green build. It now runs, against a real PDF, through to a readable file — and again with storage refusing the thumbnail, to hold the line that a thumbnail failing does not cost the household the file.
  • The orphan cleanup logged how many files it had considered, not how many it deleted. It reported clearing away two files every six hours while both were attached and neither was touched, which reads as the cleanup being where they had gone.
  • The integration suite's own MinIO still carried the Docker Hub tag that was withdrawn on 2026-09-11, so it could not start locally at all. It now pins the digest CI and production already use.

1.7.3

Infrastructure

  • MinIO is fetched from quay.io rather than Docker Hub, pinned by digest. MinIO withdrew `minio/minio` and `minio/mc` from Docker Hub, and because `docker compose pull` is all-or-nothing, the `1.7.2` deploy failed outright on an image that had simply stopped existing — with nothing in the release itself having changed. The digests pinned here are the images production was already running, so the storage engine did not move; only where it is fetched from. A floating tag is what left the deploy exposed, so neither reference carries one any more. The same withdrawal had been killing CI before a single test ran.

Internal

  • The operator console's plan tests proved a granted plan had taken effect by watching a free household be refused at the expense ledger. `1.7.2` opened that ledger to every plan, so the proof quietly stopped proving anything and three tests failed. They now read the budget module, which is still what a free household does not get. The failure had been sitting on `main` since `1.7.2` merged, hidden behind the MinIO withdrawal stopping CI before the suite ran.

1.7.2

Added

  • The free plan gets a ledger and a vehicle of its own. Finances and the vehicle logbook were both all-or-nothing, and a free household got neither — so the only part of finance it ever saw was a screen asking it to pay for something it had never used. Both modules now open on every plan, narrow rather than shut: a working expense ledger with one wallet, and a vehicle it can record — make, plate, odometer, who drives it. What stays paid is what turns either into a system: envelopes, budgets, loans, recurring bills, subscriptions and a category tree of its own; a vehicle's service history, its cost figures, its renewal dates and its paperwork. Those stay visible as locked tabs rather than disappearing, so what is behind them makes its own case. Reading the categories stays free even though shaping them does not, because every expense names one. A downgrade deletes nothing — the limits are counted on the way in, so a household that drops to a smaller plan keeps what it has and is only stopped from adding more.
  • The wall tablet can be narrowed to one person. The board showed the whole household and nothing else, which is right on the way out of the door and wrong when somebody wants to know what is theirs. Tapping a face narrows today's events and the chores to that person; the shopping list is left alone, having no owner to narrow to. Faces rather than names, at a size meant to be read across a kitchen. Tapping the face already showing goes back to everyone, so the way out is the same gesture as the way in — and because a wall tablet is shared, the pick expires on its own rather than leaving the board narrowed to whoever walked past last.

Fixed

  • A storage hiccup could destroy an uploaded file. The processing job rejected a file on any exception at all, but it runs after the content checks have already passed, so what fails there is the machinery around the file — storage briefly unreachable, a codec crashing on one odd image, a scanner that is not answering. None of that is a verdict about the file, and rejection is a dead end: downloads refuse it, the document editor refuses it, and the orphan cleanup will not even collect it, because it is attached to something. A household lost a file over a fault that had cleared a second later. Attempts are now counted and the file waits to be retried, rejecting only after five tries; a failed thumbnail does not reject at all, a thumbnail being decoration and the file not. Malware, and a content type that does not match what was declared, are still verdicts and still reject at once.
  • An upload whose commit never landed stayed pending forever — a closed tab or a dropped connection, with the bytes already sitting in storage. A download now finishes that commit first, through the same checks the upload would have used.
  • Editing a record was refused over the file attached to it. A document's title, its expiry date and a vehicle's odometer are the household's own writing, and were being turned away over a photo that never finished processing. Only a file being newly attached has to be ready.

1.7.1

Added

  • An operator console, for the person who runs the service rather than anyone using it: who has signed up, who may sign in, which household is on which plan, and whether the door is open to new ones. Nobody using the app can tell it exists. Three gates stand in front of it and none is trusted alone — the request must arrive on the operator listener, which is loopback-only; the account must hold the platform-operator role, granted by the CLI and by no endpoint anywhere; and that account must have two-factor on, read from the database on every request rather than from a claim a thirty-day cookie could outlive. Off that port the routes answer 404 rather than 401, so they do not announce themselves, and they are kept out of the exported `openapi.json` entirely.

Fixed

  • Sessions could not be cut short. `AddIdentityCore` never brought the security-stamp validator that `AddIdentity` would, so a cookie stayed good for its full thirty days no matter what happened to the account behind it. Registering it surfaced a second thing: the stamp bump on its own signs out the very browser that just changed its password, so that session is refreshed on the spot — every other device out, this one carrying on.
  • The operator's household detail failed on every call. It ordered by a role turned into a string, which EF evaluates on the client: allowed in a final projection, impossible in `ORDER BY`, so the whole query failed to translate. The list screen worked because it orders by a real column, and does it before projecting. In the console the fault looked like a dead button — the panel returned nothing while loading and nothing after failing, so a broken query and a broken click were indistinguishable. It now opens over the page and renders something on every branch, including the server's own message, which is what identified this bug in the first place.

Internal

  • The operator read endpoints had never been run by a test: the suites around them covered the writes and the three gates, and left the reads to the console. All six now execute against a household with members, files and a subscription, because a query can translate perfectly against empty tables and still fall over on real rows.

1.7.0

Added

  • Reset the finances. Clears every movement of money — expenses, transfers between wallets, money put into or taken out of an envelope, and payments against a loan — and leaves the household's arrangement standing: the wallets, categories, budgets, subscriptions, recurring bills, envelopes and the loans themselves. What people want when they say "start the finances again" is an empty ledger, not an empty app to set up from scratch. Balances return to their opening figures on their own, because they were never stored. Owner only, confirmed by typing the household name, and permanent rather than by way of the trash — the screen points at the export first, for anyone who wants a copy.

Fixed

  • The wall tablet's light/dark button did nothing for any household whose own theme is dark, which is most of the households that put a tablet on a wall. Light had no class of its own, so asking for it removed one that was doing nothing and left the tokens inherited from the page above.
  • Figures ran off the side of a phone. A stat tile would not shrink below its own content, so a long amount widened its column and took the row past the edge of the screen. The currency now drops to a second line rather than leaving the card.
  • A ticked-off task looked unfinished in the calendar's agenda. The marking was written for the month and week views, which are built from different elements, so it was never applied in the view a phone opens on.

1.6.1

Fixed

  • The API gave a database that was still starting about five seconds before exiting. `1.6.0` was the first release with migrations to apply on a container that restarted alongside Postgres, and its deploy failed on exactly that: `57P03 — the database system is not yet accepting connections`, four attempts in five seconds, and a stack trace. Startup now waits up to ninety seconds for a server that reports itself as starting, and still fails immediately on a wrong password or a missing database, where waiting would only bury the reason.

Infrastructure

  • The deploy's readiness check allows five minutes rather than two and a half, so it cannot give up before the thing it is waiting for.

1.6.0

Added

  • Wallets. The household's money now has a standing balance — an opening figure plus everything that moves it — instead of a number scoped to a calendar month. The balance is derived rather than stored, so no path that touches money can leave it quietly wrong.
  • Transfers between wallets, recorded as one row with two ends. What arrived is kept as well as what left, so a bureau's cut is two facts rather than one figure and a guess.
  • Envelopes. Money set aside without leaving the wallet: what the household can freely spend is wallets minus envelopes. An envelope can be aimed at an open loan, or at nothing in particular.
  • Expenses and recurring bills name a wallet. Blank stays a real answer — every record made before this existed has no wallet, and inventing one would put invented movements into the balances.
  • The wall tablet chooses its own light or dark, on the tablet rather than on the phone that set it up, and both panels now look a week ahead instead of only at today.
  • The category picker is searchable, and shows the tree with children under their parents. Applied everywhere a category is chosen — expenses, subscriptions, budgets and recurring bills.

Changed

  • Activity entries say what kind of record changed and what happened to it, rather than a bare title and a time.
  • A wallet that money has passed through is archived rather than deleted, and the refusal says so instead of failing generically.

Fixed

  • The dashboard listed the titles of records the viewer is not allowed to see. Its activity section was the one query in that handler with no visibility rule on it, so an adults-only or private record announced itself by name on everyone's home screen.
  • Emptying the trash did nothing. It failed outright with a server error, and then — through two further layers — reported success while deleting nothing. Purging a single item had the same fault and no test at all.
  • Linking an account to a member, or unlinking one, took up to five minutes to take effect. Deleting or restoring a linked member had the same lag.
  • The glance rail and the calendar refresh when a task is ticked off.

Internal

  • The integration suite went from 25 failures to none. Most were fixtures that had never been moved to match the plans work or the visibility model, and were masking the two bugs above.

1.5.4

Added

  • The marketing site is built and checked in CI, on its own rather than behind the backend suite.

Changed

  • The marketing site rebuilt to the design mock.

Fixed

  • The registration rate limit no longer starves the integration suite.

1.5.3

Added

  • SEO groundwork for the marketing site.

1.5.2

Added

  • The remaining written pages: privacy, about, FAQ, contact and the rest.

1.5.1

Added

  • The features overview and its eight pages.

1.5.0

Added

  • Plans and entitlements. What a household has paid for is stated once, as data, so the pricing page and the enforcement cannot drift apart — and the screens a plan does not include show a locked state rather than an error.

1.4.5

Added

  • The plans and their prices, on the marketing site.
  • A testimonials section, built and left switched off until there are testimonials.

1.4.4

Added

  • The marketing site's 404 page.

1.4.3

Changed

  • The marketing site copies the application's design tokens in rather than importing them across packages, so it builds without the application's dependencies.

1.4.2

Fixed

  • npm pinned in the site image, so the lock file is accepted by the version that builds it.

1.4.1

Fixed

  • The site's lock file works on Linux.

1.4.0

Changed

  • The marketing site is a static Astro build, replacing WordPress.

1.3.6

Added

  • Registration defends itself against automated signups.

Removed

  • `website.md`, which had been swept into the repository by accident.

1.3.5

Fixed

  • Sites unrelated to this application are kept off its network.

1.3.4

Fixed

  • Caddy serves sites that are not part of this application, rather than taking them down.

1.3.3

Fixed

  • The deploy waits for the API to come up rather than guessing at five seconds.

1.3.2

Fixed

  • The backup image is published, so the whole stack can be pulled. A compose file cannot pull an image nobody built.

1.3.1

Fixed

  • The deploy step has the access it needs on the host.
  • The host checks out the tagged commit before deploying, rather than whatever it had.
  • The stack starts without `STORAGE_DOMAIN` set.

1.3.0

Fixed

  • The release pipeline had never deployed. A `secrets` reference inside a workflow matrix made the file unparseable, which GitHub answered by running it on every branch push, failing in no time, and never running it on a tag at all.

Added

  • The TrxReport extension, so the test steps accept `--report-trx`.

1.2.0

Nothing. The tag carries no commits that `1.1.0` did not already have, and is

1.1.0

Four days, and most of what `1.0.0` had left out.

Added

  • Health records and the emergency panel — what someone is allergic to and what they take, reachable when it matters.
  • Per-item visibility and the Limited role. Every record can be family-wide, adults-only, private, or scoped to the member it is about; a teenager sees family-visible records and their own, never the adults-only ones and never the money.
  • Subscriptions in the finance module.
  • Vehicles — registration, insurance, inspection and the vignette, each with what it costs and when it lapses.
  • Contacts, and generic linking between records of any kind.
  • Loans, alongside the rest of the finance module.
  • Wall tablet mode, for a screen left on a kitchen wall.
  • The isna logo, across the application.

Changed

  • The "2c" design, applied throughout — Archivo, 4px geometry, flat surfaces, and a header, icon rail and dashboard rebuilt to match. Light is the default, and the theme toggle agrees with the page it is on.
  • The dashboard's event figure counts the coming week rather than everything ahead.
  • Two review passes over the whole application, each working through a written list.

Fixed

  • Registration sessions, mail timeouts, locale detection and a mislabelled control.
  • Search snippets.
  • Pages that hide the glance rail are as wide as the ones that show it.

Infrastructure

  • The release pipeline: a tag builds the images and deploys them over SSH.
  • The worker image runs on the ASP.NET runtime, which it needs, rather than the bare one.
  • The Postgres 18 volume mount path in the production compose file.
  • `.editorconfig` reaches the Docker build context.

1.0.0

The first release. Everything below arrived across milestones M0–M6.

Family, members and access

  • Register a family, sign in with an HttpOnly cookie session, and optionally turn on TOTP two-factor.
  • Invite people by email with a signed, expiring link; accepting joins them to the family.
  • Three roles per family — owner, adult and viewer — enforced on every write endpoint.
  • Members can be adults, children, pets or anything else, each with a colour, an avatar and a birthday.
  • A member can be linked to a login, or exist purely as someone things are tracked for.
  • Per-member detail page pulling together their events, tasks, documents and finances.
  • Soft delete with a 30-day trash, and an activity log of who changed what.

Calendar

  • Events with times or as all-day, the two kept apart at the schema level so all-day never drifts across a timezone.
  • Full recurrence: daily, weekly, monthly and yearly rules with intervals, weekday and month-day selectors, counts and end dates.
  • Editing or deleting a recurring event asks for the scope — this occurrence, this and future, or all — and each behaves the way calendars have taught people to expect.
  • Recurrence is expanded server-side against a bounded window, capped, and DST-safe.
  • Month, week, day and agenda views, drag to move, resize to reschedule.
  • A read-only ICS feed per calendar behind a private token, and ICS import with duplicate detection.
  • Quick-add that parses "dentist tuesday 3pm" into a real event.

Tasks and lists

  • Tasks with due dates, priorities, subtasks and assignees.
  • Recurring tasks anchored either to the due date or to when they were completed.
  • Board and list layouts, drag to reorder.
  • Shopping and packing lists, reusable as templates, with check-off and clear-checked.

Documents

  • Uploads go straight to object storage through a presigned URL, then get committed.
  • MIME type is sniffed from the file's own bytes, not trusted from the client.
  • Thumbnails for images and PDFs, PDF preview in the browser, download for everything else.
  • Folders, tags, versions, per-family storage quotas, and optional virus scanning.
  • Expiry dates with an "expiring soon" view and reminders ahead of the date.

Finances

  • Expenses in any currency, with the exchange rate for that date snapshotted onto the expense so later rate movements never rewrite history.
  • Splits equally, by percentage, or by exact amounts, stored as basis points and allocated with the largest-remainder rule so shares always add back to the total.
  • Categories in a hierarchy, budgets per category or per member with progress and projection, and recurring bills that either materialise on their own or wait for the actual amount.
  • CSV export of any period.

Insights and search

  • Finance, household, calendar and document insights, each over a chosen period with an optional previous-period or year-over-year comparison.
  • Charts themed to the design tokens, following light and dark.
  • The task distribution chart is off until it is explicitly switched on: counting chores per person turns into scoring people rather easily.
  • Search across every module, ranked, with a trigram fallback for near-miss names, and a Ctrl/Cmd-K command palette.

Notifications

  • Reminders for events, tasks, bills, document expiries and birthdays, planned ahead and re-planned when the thing they belong to moves.
  • Email and web push, chosen per topic.
  • Daily and weekly digest emails at each user's own local hour, skipped when there would be nothing in them.
  • One-click unsubscribe through a signed link.

Your data

  • Export everything as a ZIP: every record as JSON plus every file you uploaded.
  • Deleting a family asks you to type its name, waits seven days, and can be cancelled throughout.
  • Nightly database backups to object storage with 30-day retention and a restore script that loads into a scratch database first.

Throughout

  • Serbian (Latin), English and German, with dates, numbers and currencies formatted per locale.
  • Light and dark themes.
  • Installable as a PWA with an offline shell.
  • Every family's data is isolated by a global query filter, verified by tests that try to cross the boundary.

Known limitations

  • The ICS feed has not yet been validated against Google Calendar and Apple Calendar as live subscribers.
  • Digest templates are covered by tests for rendering, interpolation, the unsubscribe link and email-safe markup in all three locales, but nobody has opened them in a real email client.
  • The three locales have been checked automatically for parity, placeholders and untranslated strings, but not yet read end to end by a fluent speaker of each.

Start with the one deadline you cannot afford to miss.

Set your family up in two minutes. Thirty days of everything, and it does not ask for a card.