Investment Hub
A browser-based suite for one household's money, in four tools sharing one login and one hub — Stock Portfolio, Wine Cellar, Spend and Bank Holdings — with AI analysis via the Claude API and cloud sync via Supabase.
Hub Dashboard (index.html)
The hub page shows a cross-asset net worth summary when logged in:
- Total Portfolio Wealth — stocks, wine and bank-held bonds and funds, in EUR. Stocks are the market value the portfolio page computed with per-trade FX, never a sum of
shares × price (that would add pounds to euros). A * marks a total that is missing something it could not value.
- Per-asset-class values — Stocks (cost basis) and Wine Cellar (AI-estimated × qty) shown separately on each hub card
- Wine gain/loss delta — estimated value vs. purchase price across all holdings; shows valuation age (e.g. "valued 14d ago") when no purchase price is recorded
- Stock delta label — when the snapshot was taken, and how many holdings it excludes
- Spend card — this month's spending and savings rate, transfers excluded
- Bank Holdings card — bonds and funds no market API can price, with the age of each valuation shown rather than hidden
- Values load automatically after login via two parallel Supabase queries; revert to
— — on logout
- Not logged in: hub cards show
— — placeholders — no value-prop landing, login is in the navbar
Trackers
📈 Stock Portfolio Tracker (portfolio.html)
- Trade / Moves Import — Import buys & sells directly from a DeGiro Transactions CSV or Revolut statement CSV (upload or paste). Builds a full transaction ledger; positions and cost basis are derived automatically. Revolut PDFs and BancoBest confirmations fall back to AI extraction. Re-importing the same export is safe — already-imported moves are skipped
- Portfolio Import (snapshot) — Paste current holdings from any spreadsheet or broker export (tab, comma, semicolon, pipe separated). Choose to add to or replace your existing portfolio
- ISIN Resolution — Automatic ISIN-to-ticker resolution via a 4-tier strategy (local DB → Finnhub → FMP → Claude AI)
- Asset Type Normalization — Imported asset types normalized to Stock, ETF, Crypto, REIT, Bond, Commodity, Cash, Other
- Live Market Prices — a keyless quote proxy prices every holding first, in batches, and reports each quote's currency; only what it misses falls through to Finnhub → FMP → Alpha Vantage, then an AI ticker resolver. London prices quoted in pence convert correctly, and no API key is needed to refresh
- International Stocks — Smart ticker resolution for European exchanges (Paris, London, Frankfurt, Amsterdam, Milan, Swiss)
- Portfolio History — Save snapshots over time with visual bar chart tracking
- AI Analysis — Personalized portfolio insights via Claude with 6 investment perspectives
- Trade Ideas — Concrete daily trade suggestions with execution plans
- Allocation Charts — Interactive type and sector allocation breakdowns with sector slicer; summary stats (invested, market value, gain) scope to the selected sector when a filter is active
🍷 Wine Cellar Tracker (wine.html)
- Label Scanning — Take a photo of any wine label; Gemini Vision AI (primary) or Claude Vision (fallback) identifies the wine and pre-fills all details
- Mobile: standard OS picker — choose "Take Photo" or "Photo Library" (iOS & Android)
- Desktop: file picker OR live camera via
getUserMedia
- AI Valuations — Gemini with Google Search grounding (primary, falls back to Claude on quota errors) searches Wine-Searcher, recent auction results (Sotheby's, Christie's, Acker, Zachys, Hart Davis Hart), and retailer listings to estimate current market value per bottle. Returns: EUR + USD estimate, low–high range, confidence level (High / Medium / Low), cited sources, and an explanation note. Vintage-specific pricing — never averaged across years. Critic scores from label notes (e.g. "96/100") are passed to the prompt as anchors. A staleness warning appears when a valuation is over 60 days old. A navigation guard warns before leaving the page while valuations are running
- Drink-Window Status — Each bottle shows a live status badge: 🔵 Not Ready / 🟢 Ready (first 5 years of window — drink priority) / 🟡 At Peak / 🔴 Past Peak; cellar summary line counts bottles in each state
- Cellar Management — Add, edit, and delete bottles with full metadata (vintage, region, varietal, appellation, etc.)
- Inline form validation — errors appear beside the relevant field, no blocking alerts
- Undo delete — 5-second grace period with an Undo button before the record is removed from the database
- Search, Sort & Filter — Live search bar; sort by name, vintage, value, gain %, or recently added; collapsible advanced filter chips by country and varietal; sort preference saved across sessions. Summary stats (bottles, invested, value, gain) update to reflect only the filtered subset when any filter or search is active
- CSV Export — Download the full cellar as a
.csv file
- Value Tracking — Total estimated collection value always shown; Gain / Loss shown per bottle only when a purchase price is provided (purchase price is optional)
- Allocation Charts — Breakdown by region, varietal, or country
- Cellar History — Save value snapshots over time
- AI Cellar Analysis — Drink-window recommendations, portfolio highlights, diversification assessment, and buying suggestions; auto-scrolls to results after render
- Keyboard shortcuts —
n = new bottle · Ctrl+Shift+S = save snapshot · Escape = close any dialog
💶 Spend (spend.html)
- Bank statements in, without per-bank code — OFX/QFX needs no setup at all, CSV/TSV asks once about its columns and then never again, and PDF goes to a cheap extraction service. A bank nobody has seen imports without a code change
- Nothing extracted is trusted — every row is checked against the statement's running balance, and the statement's own opening and closing balance must account for every row taken from it. A row that should not be there breaks that total by exactly its own amount
- A statement can describe several products — a current account, a card and a mortgage in one PDF. Card purchases route to their own linked account and the repayment is recorded as a transfer, so the same money is never counted twice
- Categories that learn — a merchant you have filed before is filed the same way again, without asking a model. Add your own categories, and mark one as saved or invested so a pension or a child's trust fund stops counting as spending
- Where the money goes — period rollups, year-on-year comparison, recurring-charge detection, savings rate
🏦 Bank Holdings (holdings.html)
- Bonds and funds held at retail banks, which no market API can price
- Honest valuation — the age of a valuation is shown, not hidden, and a stale one is excluded from totals rather than quietly counted
Getting Started
1. Start a local server
ES modules require an HTTP server (file:// won't work):
python -m http.server 8000
# Then open http://localhost:8000
Production is Vercel (vercel.json holds the security headers and cache rules). Any static host
works, but the cache rules are not cosmetic — see the Pitfalls in CLAUDE.md before changing them.
2. API keys
Stock Portfolio
Wine Cellar
| Provider | Used For |
|---|
| Claude API | Label scanning (Vision fallback), valuations (Gemini fallback), cellar analysis |
The wine tracker needs only the Anthropic API key. Gemini is called server-side via the Supabase Edge Function; no separate Gemini key is required. Supabase is optional for cloud sync.
Enter keys via the 🔑 API Keys button in each tracker.
3. Database (Supabase)
- Create a Supabase project
- In the SQL Editor, run the four baseline schema files:
supabase_schema.sql, wine_schema.sql, spend_schema.sql, holdings_schema.sql
- Then run every file in
supabase/migrations/ in filename order, except any listed as a conditional recovery script in tests/helpers/pg-harness.js. Never run anything in supabase/maintenance/ as a migration — those are rollbacks and operational scripts
- Set the project URL and publishable key in
services/state.js, in each module's state.js and in index.html, then deploy the edge functions in supabase/functions/
- Accounts are invite-only. Public signup is disabled in the project's Authentication settings, and an admin invites people from
admin.html (linked from the hub for admins). That page calls the admin-invite edge function — deploy it with npx supabase functions deploy admin-invite --no-verify-jwt. Make someone an admin with insert into admin_users (user_id) values ('<their auth user id>');
- Email and redirects. Supabase's built-in email service sends only two auth emails an hour and only to your organisation's team members, so configure a custom SMTP provider before inviting anyone else. Set the Site URL to your domain, and list only your own origins under Redirect URLs — never a wildcard such as
*.vercel.app, which would let a stranger receive someone else's password-reset link
All four tools share one Supabase project and one account per person. Before any new migration is run against production, npx vitest run applies it to a real Postgres first.
Architecture
Vanilla HTML, CSS and ES modules. No build step and no framework, so what is in the
repository is what runs in the browser. Requires an HTTP server — ES modules do not load over
file://.
services/ — shared logic: state, storage, auth, pricing, currency, telemetry, and the
import-* family that turns any bank or broker export into one row shape
data/ — pure data with no imports: sectors, perspectives, translations, category icons
wine/, spend/, holdings/ — self-contained page modules, each with its own state and
storage, importing from services/ only for genuinely shared logic
src/ — pure mirrors of logic still trapped inside DOM-coupled services, so it can be tested
supabase/ — hand-run migrations, edge functions, and operational scripts that must never run
as migrations
The full file map lives in CLAUDE.md, alongside the Common Pitfalls — a list of
the mistakes that reached real data, and why the obvious fix for each one was wrong. Read that
before changing ingestion, money handling or failure reporting. It is deliberately the only copy:
a second tree here would drift from it within a month.
Wine Cellar — Detailed Usage
Scanning a label
- Open Wine Cellar (
wine.html)
- Click 📷 Take Photo / Upload Image (on mobile, this opens the camera directly)
- Photograph the front label clearly
- Gemini Vision AI (primary) or Claude Vision (fallback) identifies the wine and shows the result
- Click ➕ Add to Cellar — the form is pre-filled; confirm and save
On desktop you can also use 🎥 Live Camera for a live-preview capture.
Updating valuations
Click 💎 Update Valuations to estimate current market value for all unvalued bottles (Gemini with Google Search grounding, Claude fallback). Individual bottles can also be valuated with the 💎 button on their card.
Each valuation card shows:
- EUR price with low–high range, plus a USD equivalent
- A confidence badge (🟢 High / 🟡 Medium / 🔴 Low) based on whether the AI found direct listings, comparable data, or had to estimate
- Sources — a brief citation of the specific retailer or auction result used
- A staleness warning if the valuation is over 60 days old
Note: Valuations use Anthropic's live web search feature when available; results are grounded in real listings but should still be cross-checked with auction houses or Wine-Searcher for precision.
AI cellar analysis
Click 🤖 AI Analysis for a full assessment of your cellar:
- Diversification across regions and vintages
- Which bottles to drink now vs. hold for appreciation
- Investment highlights and improvement recommendations
Wine Cellar buttons
| Button | Action |
|---|
| 📷 Take Photo / Upload | Scan a wine label with camera or file picker |
| 🎥 Live Camera | Open webcam for live capture (desktop) |
| ➕ Add Bottle | Add a bottle manually (or after a scan) |
| 💎 Update Valuations | AI-estimate current market value for all bottles |
| 💾 Save Snapshot | Save current cellar value to history |
| 🤖 AI Analysis | Full cellar analysis from a master-sommelier perspective |
| 🏷️ Classify Types | AI-classify bottles that have no type assigned |
| 🔄 Reclassify All | Re-classify all bottles including already-typed ones (useful after adding new categories) |
| 🔑 API Keys | Configure Anthropic and Supabase keys |
Stock Portfolio — Investment Perspectives
| Perspective | Inspiration |
|---|
| Value | Warren Buffett / Benjamin Graham |
| GARP | Peter Lynch |
| Quant | Data-driven / systematic |
| Macro | Top-down macroeconomic |
| Passive | Index-focused, cost-conscious |
| Technical | Chart patterns / indicators |
Data Persistence
| Layer | Stock Tracker | Wine Cellar |
|---|
| localStorage | API keys, sector cache, history | API keys |
| Supabase | positions, snapshots, assets, price history | user_wines, wine_snapshots |
| Claude cloud | Portfolio state (inside claude.ai) | — |
Security
- API keys are stored only in
localStorage in your browser. They are never sent to any server other than the relevant API provider directly.
- Supabase anon key is designed for public use — Row Level Security (RLS) is enabled on all tables so each user can only read and write their own data.
- HTML output — all user-supplied and AI-returned content rendered via
innerHTML is passed through escapeHTML, which escapes &, <, >, ", and '.
- Invitations — only the
admin-invite edge function holds the power to create accounts, and it checks admin_users on the server for every request. The admin page is hidden from everyone else as a courtesy, not as the protection. Revoking can only remove an invitation nobody has accepted.
- Invitation links — the hub refuses an invitation link when the browser is already signed in, because such a link's tokens are not bound to the browser that opens it. Test invitations in a private window.
- Direct browser API calls — the Anthropic SDK header
anthropic-dangerous-direct-browser-access: true is the intended mechanism for client-side API calls. Never commit your API key to source control.
Development
Running locally
python -m http.server 8000
# Open http://localhost:8000
Running tests
npm install
npx vitest run
Tests import each pure module directly — the services/*-core.js family, the import-* parsers, money-core.js, telemetry.js — and use the src/ mirrors only for logic still inside a DOM-coupled service. The whole suite runs in under two seconds.
| Area | Test files | What it guards |
|---|
| Rules, read from the source | db-constraints, failure-handling, html-escaping, ledger-save, price-refresh-order | No code path emits a value a CHECK constraint would reject; failure handling follows one standard and blocking dialogs cannot grow; every escaper escapes five characters and no value reaches an inline handler; the client and the atomic save function agree on every column; a price refresh asks the keyless proxy first |
| Migrations, on a real Postgres | migrations, ledger-save-db | Every migration applied twice through PGlite, every rollback applied and its migration re-applied, row-level security scoping what it claims, and the atomic ledger save rolling back a bad row |
| Money | money-core, fx-derivation, returns-xirr, price-cache-currency, quote-currency, calculations, allocation | Minor units (pence is not pounds), trade-date FX, XIRR, currency provenance, totals and weights |
| Statement import | spend-import, spend-pdf, import-pdf, import-standards, import-contract, categorize-core, spend-core | CSV, OFX and PDF parsing, balance chains and whole-statement totals, card routing and expansion, the row contract, categorisation precedent, savings rate |
| Broker import | import-trades, import-parsing, position-management | DeGiro and Revolut parsers, dedupe, pence kept at the parse boundary, ledger to positions |
| Pricing and tickers | pricing-core, pricing-untracked, exchange-detection, sector-lookup, ticker-resolution, price-fetching | Proxy batching, keep-at-cost, exchange and sector detection. price-fetching and part of ticker-resolution still exercise an old mirror and are due for removal |
| Wine, hub, holdings |
Making changes
| Concern | File(s) |
|---|
| Shared styles | css/styles.css |
| Wine styles | css/wine.css |
| Hub page | index.html |
| Stock tracker | portfolio.html, services/*.js |
| Wine tracker | wine.html, wine/*.js |
| Sector data | data/sectors.js |
| Investment perspectives | data/perspectives.js |
| DB schema — stocks | supabase_schema.sql |
| DB schema — wine | wine_schema.sql |
Requirements
- A modern web browser (Chrome, Firefox, Safari, Edge)
- Python 3 or any static file server for local development
- Anthropic API key — required for the AI features across the suite
- Finnhub / FMP / Alpha Vantage — at least one key for live stock prices
- Supabase project — optional, for cloud sync across devices
Changelog
Unreleased — price keys off the browser
No version bump: only services/ and portfolio.html changed.
- The shared Finnhub, FMP and Alpha Vantage keys no longer reach any browser. Every signed-in account used to be able to read them, and the portfolio page kept a copy in local storage. They now live only on the server, in the new
market-data function, which calls the providers for everyone; old browser copies are deleted on the next visit. Prices, fallbacks and currency handling are unchanged.
- Keyed price lookups are now counted per person on the admin page, alongside AI calls and the keyless proxy.
- Needs, in this order: new keys created at each provider, set as function secrets,
market-data deployed, the 20260920_price_keys_off_the_browser.sql migration run straight away, then the client shipped and the old keys revoked.
v3.56.3
Editing an account no longer rewrites its currency.
- The account form had no currency field and saved
EUR every time, so editing a GBP or USD account — including a card account an import created in another currency — silently re-denominated it. Currency drives every conversion downstream, and nothing else would have caught it. There is now a currency field, and an unrecognised code is refused rather than stored.
- Editing also recoloured the account and un-archived an archived one. A field the form does not show is now carried over, never defaulted — including the learned statement references.
v3.56.2
Fixes from the first run of the agent review gates over the batch import.
- A statement can no longer file itself against the wrong account. Only the IBAN and NIB are learned. Reading every long number off a real statement had also collected the bank's company number, three branch telephones and the account numbers of the card, the term deposit and the pension fund — so the current account would have claimed the card's number, and a card statement would have filed itself into the current account.
- A file that fails to save is no longer counted as imported, and it stops the batch instead of the next file erasing its report.
- A single statement that saves itself now says so: the ledger, the "Undo" banner and a summary all appear, where before the screen simply sat on "Reading…".
- A statement already imported in full no longer stops the queue — it is counted as "already in the ledger" and the batch continues.
- A new bank's column-mapping dialog stops the batch until it is answered, and its Cancel skips just that file. Before, the next file overwrote the draft, and confirming could save one bank's columns under another bank's layout.
- Picking files while an import is in progress is refused rather than starting a second run over the same state.
- The confirm dialog's styles moved to the shared stylesheet: the "which account is this statement for?" question, and the Undo and Forget confirmations, had no styling at all on the Spend page.
v3.56.1
When a statement does not add up, the import says which rows are the reason.
- The warning used to say only that the rows did not match the statement's own balance. It now checks whether leaving out the rows that carry no running balance makes it add up — and if so, names them with their total. Those are usually a card, loan or wallet section: money belonging to another account, or an itemisation of a movement already listed.
- Measured on a real statement: with a card section's lines added as movements, every per-row balance check still passed (a row with no balance is not in that chain) while the whole-statement total failed. That is the case this now explains.
v3.56.0
Import a folder of statements at once, and let each file find its own account.
- Several files in one go. The file picker takes many; each is read in turn. A file whose checks all pass — every row reconciles, the statement adds up, nothing flagged, no account change — is saved without asking. The first file with anything to look at stops the queue and waits, and the rest continue once you deal with it. A summary says how many imported, how many needed a look, and how many could not be read.
- A statement files itself. The IBAN, NIB or account number printed on it identifies the account. The first statement from a new account asks once — use an existing account, or create one there and then — and every later statement from it goes straight to the right place. Matching is exact, so a card statement never lands in the current account. Needs the
20260930_account_statement_refs.sql migration.
- The account dropdown is now only for files that name no account at all, which is common in CSV exports.
v3.55.8
Full descriptions from statements, and the bank on every transaction.
- A description that wraps onto a second printed line is no longer cut short. Banks print long lines ("… - associacao onda do norte - gaspar cama") over two lines; the extractor kept only the first and was never told the rest belonged to it. It is now told to join the continuation and to keep each description complete rather than summarise it. Needs
extract-statement redeployed.
- Each transaction names its bank, under the description, instead of only a coloured dot whose tooltip a phone cannot show.
- A long description now wraps over up to three lines on a phone, instead of one line cut with "…".
v3.55.7
A trial of Gemini 3.5 for statement import and categorisation, for the admin only.
- While the trial is on, the server runs Gemini 3.5 (thinking "minimal", Google's default temperature) beside the current model on the same statement section or batch. The page puts 3.5's rows through the same balance and statement-total checks, records only counts and yes/no results (an
ai-trial diagnostic), and discards them. Nothing from the trial is shown, saved or used.
- On only when both hold, checked on the server: the secret
AI_SHADOW lists the task, and the person importing is an admin. Remove the secret to end it.
- Needs
extract-statement and categorize-transactions redeployed.
v3.55.6
- After an import, card payments are offered as transfers. Once a card's purchases and the payment that settles them are both in the ledger, the payment is money moving between your own accounts — counted as spending, the same money counts twice. Pairing the two legs has always existed behind a Find transfers button, but nothing pointed to it, so card payments were being reclassified by hand one row at a time. The import now says how many movements look like transfers and offers to review them. It still asks before marking anything, because pairing rewrites rows.
v3.55.5
Statement import and categorisation run on the shared AI layer.
- A statement section that Gemini cannot read now reaches Claude's answer. The page waited 55 seconds while the server could take 90 (Gemini, then Claude), so every Claude fallback was paid for and thrown away. The page now waits 115 seconds; Gemini has 45, Claude 60.
- An unreadable or stopped answer goes to the fallback, including a safety or refusal stop, not only a cut-off one.
- Nothing from a statement reaches a log or an error message; the layout note, the one field with no limit, is capped. Same models as before (Gemini 2.5 Flash, Claude Haiku), temperature 0. Needs
extract-statement and categorize-transactions redeployed.
v3.55.4
The AI features stop running prompts written by the browser.
- Portfolio analysis, trade ideas and the movers note send data (which perspective, the holdings or the price moves); the server builds the prompt, checks every field, times the call and returns the answer's text only. Before, the server ran any prompt it was sent, with no time limit.
- Wine valuation, label reading, cellar analysis and reclassify send bottles or an image; the server builds each prompt from checked fields. Bottle text is tidied, never refused.
- One way to call AI on the server (
_shared/ai.ts and a task table): every call timed and recorded, cut-off answers treated as failures. Needs analyze-portfolio and wine-ai redeployed.
v3.55.3
Wine valuations say when a price was not looked up, and the fallback costs less.
- Every valuation says how it was priced. Gemini is asked to search but decides for itself, and often answers from memory. Such a price is kept, marked low confidence, and its note begins "No search: price from the model's own knowledge, as of YYYY-MM", using the date the model gives for it. Whether a search ran is read from the provider's own record, not the model's word.
- The Claude fallback uses Sonnet 4.6 instead of Opus: the same web search, faster, and about 40% cheaper per token. Label reading and cellar analysis use it too.
- Gemini thinks less for a valuation (thinking level "low"): calls dropped from 20-38s to 10-16s. Needs
wine-ai redeployed.
v3.55.2
Batch wine valuation: faster, fewer failures, and every price on the right bottle.
- A price can no longer land on the wrong bottle. The server gave result N the id of bottle N, so if the model skipped or reordered a wine, every price after it shifted. Each result must now name the wine it is for, and anything that names none is refused rather than guessed.
- One bottle per request, three at a time. Three wines in one search-backed call needed nine or more searches and often ran out of time, failing all three together. A slow bottle no longer holds up the others.
- No guessed prices. When Google refused a search, the server retried without one and saved a price recalled from memory. Valuations now go to Claude, which searches.
- Gemini 3.5 for wine, with time to answer. Gemini was taking about 20 seconds against a 20-second limit; it now has 45, and room to finish thinking. Every call logs how long it took. Needs
wine-ai redeployed.
v3.55.1
Wine valuations finish again, and stop costing money when they fail.
- A valuation no longer times out when Gemini is slow. Gemini gave up after 20 seconds, the Claude fallback then ran up to sixteen web searches, and the page stopped waiting at 55 seconds — while the server finished and paid for a result nobody saw. The fallback is now capped at five searches (eight for a batch of three), each stage has a time budget, and the page waits up to 115 seconds. Needs
wine-ai redeployed.
- A call that times out is now recorded as a failed call, so the admin page shows Gemini failing rather than Claude seeming to be chosen first.
- A personal pricing choice no longer changes everyone's prices. "Keep at cost" and the learned pricing ticker live per person; the shared catalogue refuses a repointed ISIN, an overwritten currency and malformed tickers. Migration
20260921_per_user_asset_prefs.sql.
v3.55.0
Invitations, the admin dashboard, and a record of what the AI costs.
- Admins invite people by email from a new admin page, linked from the hub and the portfolio page for admins only. It lists who was invited and who joined, and revokes an invitation nobody has accepted. Needs the
admin-invite edge function.
- Opening an invitation asks for a password, instead of signing the person in once and leaving them unable to sign in again. An expired or used link now says so, and an invitation link opened while already signed in is refused, so nobody can be switched into someone else's account.
- The Sign Up button is gone. Public signup is disabled, so it could only fail; the menu says accounts are by invitation. The hub's sign-in messages are toasts, not blocking dialogs.
- The admin page shows who uses the app: accounts, active in the last 7 and 30 days, recent signups, how many people use each tool, and per person when they were last active, what they use, and problems reported this month. Counts and dates only, never an amount. Needs the
20260918_admin_usage_report.sql migration.
- Every AI call and price lookup is now recorded — who, which feature, which model, tokens, failures, web searches — and the admin page shows calls, tokens and an estimated cost per feature and per person over the last 30 days. Counted, not limited. Needs the
20260919_usage_events.sql migration and the seven functions redeployed; history starts from that deploy.
- An invitation failure says why, for example that the email could not be sent.
v3.54.0
- Card purchases reach the right account. A statement's card section id was never copied onto its rows, so card routing never ran and no card account was ever created — while the test for it did the mapping by hand and stayed green. Turning it on surfaced two more bugs, both fixed: a purchase proven to replace a settlement now stays in the account that paid it, and a correct statement no longer reports that it does not add up.
- A London price is never read as pounds. A price quoted in
GBp was recorded as pounds with the number still in pence, a hundred times too high. Pence is now kept as pence and converted downstream, which is how existing London trades were already stored, correctly.
- A CSV no longer claims a currency the file never printed. A missing currency column is recorded as a guess from the account, so a better source can still correct it.
- An AI broker import with an unreadable part is refused, not half-imported. A partial ledger would have left a plausible cost basis that was simply missing trades, and nothing else could have caught it.
- Batch wine valuation checks every answer before saving. A missing price is rejected, a bottle the model skipped counts as unvalued rather than valued, and a value that jumps more than threefold is held back to confirm one at a time.
- Price refresh asks the keyless quote proxy first, in batches. It used to be the last resort, called one symbol at a time. Most holdings now price in a few requests, only the misses reach the keyed services, and a signed-in user with no API keys can refresh. Shipped just after this version's bump;
services/ revalidates, so it needed none.
v3.53.0
- A failed save can no longer wipe your trade ledger. Saving deleted every transaction and inserted them again as two separate requests, so one bad row left the ledger empty with nothing but a console message. Saves now run inside one database transaction that rolls back on failure (migration
20260914_atomic_transactions_save.sql), and a failure tells you not to reload.
- Price history is private to its owner. Its read policy let any signed-in account see every account's rows, which is an inventory of who holds what (migration
20260911_rls_with_check_and_scope.sql).
- Toasts work on Spend and Bank Holdings. Their styles lived in the wine stylesheet, which only the wine page loads, so both tools had been showing unstyled messages at the foot of the page.
- Every migration now runs through a real Postgres before it is run by hand. The suite builds a database with PGlite, applies every migration twice and every rollback once. On its first run it caught a migration that would have silently failed on any new project.
- Test toolchain upgraded to clear four Dependabot vulnerabilities. None of them reached the deployed site.
v3.52.0
- Security: a shared catalogue value can no longer run code in another person's session. A sector name from the shared asset table reached an inline click handler through an escaper that left both quote characters intact. The escaper now escapes all five characters, and every handler that carried a value uses a delegated listener instead. A test enforces both rules.
- The hub's chart is real. It was a hardcoded rising line with fixed month labels, drawn for every account including an empty one. It is now drawn from your own snapshots and hidden until there are at least two.
- Empty hub cards say what to do next instead of sitting blank beside a dash.
- The admin check fails closed, price history is read only for the signed-in user, and a previous session's ledger is no longer shown before sign-in.
v3.51.1
- Fixed: importing a statement could leave no way to categorise it. The Categorise button was hidden whenever any suggestion was still waiting for confirmation, and the suggestion queue lives only in the open page. So after categorising one statement and leaving a suggestion unanswered, the next import offered nothing — reloading the page was the only thing that brought the button back. The button now counts only spending that has neither a category nor a suggestion, so a new import always has somewhere to start, and a queue of unanswered suggestions still shows separately.
- A categorisation run no longer throws away unanswered suggestions. The queue was replaced wholesale by each run, so categorising a second statement silently discarded the first one's pending questions. Runs now merge, and a row already carrying a suggestion is not sent to the model a second time.
v3.51.0
- Categorisation now learns from the ledger, which is where the learning already was. A merchant filed as Dining across past imports still came back suggested as Leisure, because learning ran entirely off a separate rules table — written only when someone corrects a row one at a time. Every past decision sitting in the ledger was ignored. The categoriser now reads it first: a merchant you have filed before is filed the same way again, without asking a model at all.
- The same merchant written three ways is one merchant. Banks print "COMPRA OPORTO CRICKET", "OPORTO CRICKET CLUB" and "OPORTO CRICKET CLUB 0003791851" for one place. Matching is on shared significant words, needing two, so a common prefix like compra or pagamento cannot make everything match everything. Where you have filed a merchant both ways, nothing is assumed — that is a question, not a precedent.
- "Accept all confident" now teaches. It explicitly suppressed learning, so the more suggestions you accepted, the less the app knew — the exact opposite of what accepting them means.
v3.50.0
- The category icon is now searched, not typed. Typing "do" for dog saved the literal text "do" as the icon — the field looked like a search box and behaved like a text field. Typing is now only ever a query: it filters a grid of 68 icons, and clicking one is the only thing that stores a value, so there is no path from raw text into the data at all.
- Search works in Portuguese as well as English —
cão, poupança, ginásio, farmácia — because "cão finds nothing while dog works" is the same dead end as having no search.
- Curated, not exhaustive. Sixty-eight icons covering what a household actually spends on, maintained in one readable file. A full emoji picker would need a vendored library and would make the search worse, not better: more noise to page past before reaching the dog.
v3.49.3
- Fixed: a successful import could show nothing at all. Ledger filters survived the import, and several of them hide exactly the rows it added — leaving the type filter on needs review to deal with one unconfirmed transaction hid every clean row of the next import. Eighty movements would arrive, reconcile, and be invisible. An account filter or a month chosen by hand did the same. An import now clears what would hide it, because the one thing you must see afterwards is what you just imported.
v3.49.2
- Fixed: the category icon field mangled most emoji. It carried
maxlength="4", which counts UTF-16 code units — and an emoji is rarely one. A gym emoji is six units, so it was cut in half, leaving a dangling joiner that renders as a broken glyph. The field looked like it was refusing emoji when it was quietly truncating them. Length is now measured in what a person would call a character.
- And there is a picker. Twenty common category icons, one click each; typing any other emoji still works.
v3.49.1
- The import now shows which lines it left out, and why. It reported a count, which asks to be taken on trust — and the whole point of leaving something out is that it might have been the wrong call. Each line is listed with its amount and the reason.
- Fixed: a bank whose rows do not start with a date had no sections at all. Heading detection tested for a following row using the strict "starts with a date" rule rather than the widened one the importer actually uses, so on a statement that needed widening no heading could ever qualify. The headings were on the page; the test for them could not fire.
- Fixed: a document with no headings produced a layout id shared by every other such document — the hashing seed, unchanged. Confirmed as a layout, one bank's section roles would have been replayed onto another bank's statement. There is now no id at all. Account numbers, IBANs and direct-debit references are also no longer mistaken for headings.
v3.49.0
- One standard for failure handling across the app, with the parts that are invariants enforced by a test. A failure the user must know about gets a notice and a report; one they need not know about is still reported;
console.error is never the handler and alert() is not one either. Separately, an operation whose correctness cannot be checked as it runs records how it went, because every import bug found here was a silent wrong result that threw nothing.
- The check that matters most: context keys must exist. Unknown keys are dropped silently, so a diagnostic written with
rowsParsed instead of parsed sends, looks healthy, and carries nothing — a blind spot inside the mechanism built to remove blind spots. The test reads the permitted list out of the telemetry module rather than restating it.
- Broker trade imports now record how they went, like statement imports. A split read as a sale or an ISIN resolved to the wrong ticker leaves a plausible ledger and a wrong cost basis, and raises nothing.
v3.48.0
- Imports now record how they went, not only when they broke. Error reporting only ever sees exceptions, and every import defect found so far was a silent wrong result — a mortgage section filed as income, a card bill counted twice, a debit read as a credit. None of them threw, so none would ever have appeared in an error log. Each import now writes a diagnostic: whether the statement's own balances accounted for the rows taken from it, how much of the document could be verified, how many lines were read and deliberately left out, what the card handling did.
- Counts and verdicts only. Diagnostics go through the same allow-list as error reports, which names permitted keys one at a time — a description, a merchant or an amount cannot leave through a key that does not exist.
signature identifies a statement layout, never a bank or an account holder.
supabase/maintenance/import-diagnostics.sql prints the last fifty imports in a readable form, for pasting into a bug report.
v3.47.0
- An import is now checked as a whole, not only row by row. The per-row test proves each amount against the balance printed beside it, and cannot prove the set of rows is right — a row carrying no balance is not in that test at all, which is how a mortgage section's capital and interest lines were imported as income while every per-row check passed. The statement's own opening and closing balance must now account for everything taken from it: a row that should not be there breaks the total by exactly its own amount, and a missing row breaks it by exactly the amount that is missing.
- The two balances are found structurally, with no keyword. Looking for "saldo" or "balance" would be another per-language list to maintain and would fail on the first bank that words it differently. Every figure printed outside the movement rows is a candidate, and the pair that makes the document add up is the answer — finding it is the proof, because a coincidence would have to reproduce the exact total of the rows extracted. Verified against two real statements from different banks: both reconcile, and the mortgage bug fails as it should.
v3.46.0
- A statement covering several products no longer files the other products as spending. A CGD "extrato global" carries the current account, a card and a mortgage. The mortgage section prints the instalment's capital/interest split — 1.003,16 + 688,89, which is the 1.692,05 already debited from the account — and those two lines were imported as separate income. Rows under a product section are now understood to be an itemisation or a balance, never a movement of their own.
- A row the statement never dated is never imported. Loan breakdown lines are printed without a date because they share the instalment's, and the model was filling one in. A movement whose date had to be guessed is not a movement that was observed; the import says how many such lines it read and left out.
v3.45.2
- Fixed: filing a category by hand looked like it had not saved. The ledger draws the category badge, the confidence and the accept/reject buttons from the pending-suggestion queue rather than from the row, and editing a transaction never removed its entry from that queue — so the model's guess kept being drawn on top of the category just saved. The write had always worked; only the display was stale, which is worse than a visible failure because the natural response is to save it again.
v3.45.1
- Extraction requests are bounded by rows, not just by characters. A statement of many short movements makes a small prompt and a very large answer — a real CGD export puts 101 rows into 5,845 characters — and it is the answer that exhausts the model's output budget and the request timeout. Chunks are now capped by line count too, and the provider timeouts raised from 25s to 45s.
- A failed extraction says which provider failed and why. "Unavailable right now" is returned only when both providers fail, which is almost never a passing outage — it is usually a missing key or an exhausted quota, neither of which resolves by waiting. The response now names each provider and its failure class, using status codes alone.
v3.45.0
- Currency comes from the account, and goes through the app's own currency layer. Three places in the statement path hardcoded EUR, so a GBP or USD statement imported as euros silently — and the balance check cannot notice, because currency is not part of the arithmetic it verifies. Spend now uses
money-core, the same module the portfolio uses, which also means GBp (pence) is no longer folded into GBP and read as a hundred times too much. A currency the source states but we cannot recognise is rejected rather than quietly replaced.
- A statement printed newest-first now imports. The balance chain only ever tested the ascending form, so a descending document failed every pair: refused outright on the deterministic path, and flagged almost row-by-row on the AI path. Both directions are scored and the rows are turned round — not re-sorted by date, because document order is the only within-day ordering a statement gives you.
- An import can be undone. Rows are tagged with the import that created them and the last one can be taken back in a single action. Every other guardrail here assumes a mistake can eventually be corrected; until now correcting one meant deleting rows one at a time.
- A statement with no running balance says so plainly. It imported with zero rows flagged, which reads as verified when nothing had been checked at all.
v3.44.4
- Fixed: a debit written with a trailing minus was recorded as income.
100,00- is how German, Austrian and Swiss exports mark money going out; it parsed as +100. The PDF path had the same bug by a different route — it stripped parentheses before deciding the sign, so (100,00) was also positive. Both parsers are now one parser, which is why the bug existed twice: there were two places to find it in.
- Amounts written those ways are no longer dropped. The PDF line patterns required an amount to end in a digit, so
100,00- and (100,00) matched nothing and the row was discarded — a partial import that reports itself as complete, which is the most dangerous shape this feature has.
- A wrapped description can no longer open a phantom card section. A continuation line like
LISBOA PT VISA 1234 passed every test for a section heading. Injected as one it would route the following account movements to a card account. Headings must now start at the left margin, where a wrapped description never does.
v3.44.3
- A remembered statement layout can now be forgotten. Confirming one was a single tap with no way back, and a confirmed layout is replayed silently on every later statement matching it — so a wrong confirmation did not spoil one import, it spoiled every future import from that bank until somebody noticed. The import screen now lists what it remembers and lets any of it go; the next statement then asks again.
- Fixed: a statement spanning a year boundary dated part of itself wrongly. The period detector matched both ends of a printed range and returned only the last, so a period running 15/12 to 15/01 was described as "in 2026" and a row printed 31/12 became 31/12/2026 — a future date, in the wrong month. Nothing downstream could catch it: the balance chain reconciles either way, because only the dates were wrong.
v3.44.2
- A statement from an unrecognised bank no longer fails before the extractor sees it. Whether a PDF was even offered to the AI was decided by a pattern requiring
dd/mm at the start of every row, so any bank using ISO dates, month names, or a layout with the date mid-row was refused with "no dated transaction lines" — the extractor exists for exactly those layouts, and our own pattern was preventing it from looking. ISO and month-name dates are recognised, and when no row starts with a date the net widens to anything carrying both a date and an amount. The report says when that happened.
- The refusal that remains is a real one — nothing in the document carries both a date and an amount — and now says what that usually means: a scan rather than a text PDF, or a summary page rather than the movements.
v3.44.1
- Fixed: imports failed to save with a check-constraint error. The card-repayment rule stamped
categorySource: 'auto', but that column allows only rule, ai or manual. Everything up to the final write succeeded, so the import parsed, verified and displayed as ready before dying on save. It is recorded as rule, which is what it is — a deterministic match on amount, sign and date, not a model's guess. A test now reads the CHECK constraints out of the migrations and fails if the code can emit a value the database would reject.
v3.44.0
- A first statement from a new bank says what it found, and assumes nothing. The import report lists the sections detected — how many rows each produced and which account they went to — and one tap remembers that layout. The next statement shaped like it imports without asking. What it asks about is deliberately structure ("this is my card section"), never extraction ("does row 14 reconcile"): the second is the app's job and the user has no basis to answer it.
- A remembered layout is recognised by its heading text, not by position. A bank inserting one line shifts every index, so matching on line or page numbers would silently misread a redesigned statement. A layout that no longer matches is shown again rather than replayed.
- A card section is imported as the card's own account. One statement PDF describes two accounts — the current account and the card — and importing both into one ledger is what makes a card's spending and its repayment collide. Card purchases now route to a linked card account, created on first import and matched by the card number printed in the section heading thereafter. Two candidate cards and nothing to tell them apart is refused and flagged, never guessed.
- The repayment is recorded as a transfer, not as spending. Once the purchases are counted, the account row that pays the card is repayment of debt; counting both overstated every month by the settlement, invisibly. Both legs are printed in the same document, so the link is proven on amount, sign and date rather than matched against bank-specific wording.
- Routing is decided before duplicate detection, not after. Duplicate detection is per account, so a card purchase checked against the current account's history would have matched nothing and been re-added on every single import.
- A card bill now reads as what was bought. Where a statement itemises its credit-card settlement, the purchases replace the lump payment in the ledger instead of being discarded — so a month shows "Continente 425,02" and "Galp 150,00" rather than one opaque "Cartoes bkcf - deb. mensal-cob 725,02". The swap only happens when the purchases can be proven to account for the settlement to the cent; the month's total is identical either way, which is what makes it a presentation change rather than a reinterpretation of your money.
- It refuses rather than guesses. If nothing matches the total (the settlement may fall in the next statement period) or if two rows match it, the lump row stays and the reason is reported. Each card is reconciled against its own settlement, so a statement carrying two cards cannot cross-match.
- Expansion runs after balance verification, never before. The running balance moved by the settlement, so checking the chain against the expanded rows would report breaks that are not errors.
v3.43.1
- Card sections are read as itemisation, not as movements. A purchase listed under a credit-card section is detail of the card payment already on the statement, so ingesting it added the same money twice — and because those sections print charges without a minus, the duplicate landed as income. One line therefore overstated income and understated spend at the same time. Detail lines are still read, because they say what a lump "PAG.CTA.CARTAO" was actually spent on, but they improve the description of the movement they belong to instead of becoming one.
- The balance guardrail stopped skipping the rows that needed it most. Rows with no running balance were passed over silently, and "nothing was checkable" counted as a pass. Detail rows have no balance by nature, so interleaving them also broke the check for the ordinary rows around them — a few card lines could leave most of a statement unverified while the import reported that all checks reconciled. Imports now report how many of the possible checks actually ran.
- A category cannot contradict the arithmetic. Money that left the account is never filed as income, whatever confidence the model returns; it goes to review. A refund — a positive amount on a spending category — is still allowed, because that one is real.
v3.43.0
- Two new tools: Spend and Bank Holdings. Spend is the flow layer the suite lacked — a transaction ledger, category breakdown, year-on-year comparison, recurring-charge detection and a projection baseline. Bank Holdings covers bonds and funds held at retail banks that no market API can price; roughly €157k that had been invisible to net worth.
- Statement import that does not know about any particular bank. A format is learned once and replayed for free thereafter: OFX/QFX needs no configuration at all (the format is specified, so
TRNAMT is the amount by definition), CSV/TSV asks you to confirm its columns once, and PDF goes to a cheap extraction service. Verified against a real 971-row export — parsed in full, no AI call, and its running-balance chain reconciles across all 970 adjacent pairs. A statement in a language the alias table has never seen imports without a code change.
- AI categorisation that does as little AI as possible. Learned rules run first (one correction categorised 140 of 971 real rows), transfers and known income are settled structurally, and only the long tail reaches Gemini Flash. Results are matched by id rather than position, categories outside your own list are refused, and anything at least 3× the batch median goes to review whatever its confidence — a large one-off filed as routine spend is worse than one left uncategorised, because a gap is visible and a wrong category is not.
- Nothing an AI returns is trusted. Extracted rows are re-checked against the statement's own running balance; a mis-signed or hallucinated amount breaks the chain and is flagged for review rather than written to the ledger.
- Custom categories, and a savings rate that stops lying. Categories can be added, renamed and merged. A category can be marked saved or invested, so money moved to a pension or a child's trust fund is recorded as an outflow but not counted as consumption — on a real month that reads 60% rather than 43.3%.
- Client error reporting on all five pages. Replaces a single handler that
alert()ed raw messages and swallowed them. Messages are redacted in the browser before they are sent (amounts, IBANs, long reference digits) and context is an allow-list, because an error here can quote a bank description.
v3.42.1
- The app version is now always visible, in the navbar. v3.42.0 hid the portfolio page's header (which carried
v3.x.x) and the hub's eyebrow (which carried the other copy) to get content above the fold on phones — leaving no way to tell on the device whether a deploy had actually landed. The navbar now shows a small build tag on every page, at every width, and scripts/bump-version.js keeps it in sync automatically.
v3.42.0
- London tickers now go to the one price tier that states its units. Tiers 1–3 return a bare number with no currency, so a pence quote for a
.L symbol is indistinguishable from pounds and lands in the portfolio 100× high. Rather than guess what FMP does for London, .L/.IL symbols are tried against the keyless quote proxy first — it reports a real currency and already folds GBp → GBP at the boundary. Verified with a stubbed proxy: 450 GBp → £4.50 at tier 4, where an FMP-first path would have recorded 450. Non-pence symbols keep the cheap-first ordering, and this also saves FMP quota on exactly the symbols it handles worst.
- Mobile: the first position card is above the fold again — 434px, down from ~630px. The portfolio page opened with a decorative header, the totals block, and five full-width buttons stacked before any actual holdings. Now only Update Prices stays on screen; Add Position / Import / Save Snapshot / API Keys collapse behind a ⋯ More toggle. The decorative page header is hidden on phones (the navbar already names the page — the hub's hero is untouched), card padding drops from desktop's 25px to 16px, and the duplicated top nav links give way to the bottom tab bar.
- Implemented with a CSS-only checkbox, not
<details>: a closed <details> hides its non-summary children outright, so display: contents cannot bring them back and all four buttons vanished on desktop. Caught by rendering it. Desktop is verified back to one flat row of five.
- Note: desktop button order now leads with Update Prices and ends with API Keys (primary action first, admin last).
- Destructive icon buttons get a real touch target. The snapshot/ledger delete measured 25×20px; it now carries a 44×44 hit area via a pseudo-element on coarse pointers, with no visual or layout change.
- Allocation rows no longer clip their value inside a fixed 60px box on narrow screens.
- Removed 62 lines of dead CSS (the unreferenced
.pos-card-mobile family) and simplified the design-system alias selectors that paired live classes with those deleted twins. The duplicate .position grid block was deliberately left alone — the two copies are not identical, and merging them risks regressing the v3.38 mobile overflow fix for no user benefit.
- Added the missing
portfolio.btn.more string in English and Portuguese — t() falls back to the raw key, so the button would otherwise have rendered the literal text portfolio.btn.more.
v3.41.0
- The app was running two copies of every service module — and one of them was silently broken. HTML entry points imported services with a cache-busting query (
./services/state.js?v=3.40.2) while the services import each other bare (./state.js). The browser keys its module registry on the full URL, so those are two separate module records: two state objects, two copies of every module-level variable. Confirmed damage, not theory:
- The "resolve missing ticker" dialog was dead in 3 of its 4 entry points.
setMissingTickerResolver() was injected on the versioned instance; all three interactive: true callers live in services/portfolio.js (re-enable pricing, import positions, import trades) and read null on the other instance. The failure mode was silence — a skipped branch, not an error — so unpriceable tickers after a broker import just never prompted.
state.baseCurrency diverged from the toggle: the entry script wrote it to one instance, every consumer read 'EUR' from the other. After a reload with USD selected, the button said USD while all the maths and formatting used EUR.
- Service and data modules could never receive updates.
vercel.json served /services/ and /data/ as max-age=31536000, immutable, but the bare sibling imports carry no version at all — so a returning browser could hold year-old copies with no invalidation path. data/i18n.js is imported bare by both wine/ and services/, meaning translation changes never shipped.
- Fix:
services/, data/ and src/ are now imported without a version query everywhere and are cache-busted by header (max-age=0, must-revalidate) instead. css/, lib/ and wine/ keep immutable — they version every URL consistently. Verified in a browser: bare imports resolve to one shared instance, a versioned URL is provably a separate record, and all 11 modules boot with the resolver injection landing correctly. The rule is now documented in CLAUDE.md.
After deploying this, hard-reload once (Cmd/Ctrl+Shift+R). Browsers that already cached the bare service URLs under the old year-long immutable header will keep serving them until that entry is evicted; the new header only governs fresh responses.
v3.40.2
- Regression fix (introduced in v3.39): resolver-priced holdings were being dropped from every total. v3.39 marked a holding's quote currency "unknown" whenever it was priced under a different ticker than the one stored, to avoid assuming a US ADR's USD quote was the EU listing's EUR. But tiers 1–3 never report a currency, so every holding with a learned
pricingTicker was excluded — and for an ISIN-keyed broker import (DeGiro), the resolver is the reason prices work at all, so that was most of the portfolio. The rule is now simply: the quote's currency follows the ticker actually queried, which resolves both cases correctly (AIR.PA → EUR, ADR EADSY → USD) instead of discarding information we have.
- A bare ISIN now takes its currency from its learned pricing ticker. An ISIN quoted as
AIR.PA is a euro instrument. Without this the cost basis had no currency, so even with the fix above the holding was still excluded and the portfolio total read zero.
- Snapshots could write an incomplete EUR total labelled complete. A holding excluded from the base totals returned before the EUR accumulator, leaving
eurComplete true — so an EUR figure missing that holding was saved as a full valuation, and the hub reads exactly that number as your net worth.
buildCashFlows turned a missing FX rate into a zero cash flow. x * null is -0 and Number.isFinite(-0) is true, so an unconvertible buy was recorded as capital that cost nothing and XIRR ran away without bound. Such rows are now skipped and counted.
- Income and fees silently under-reported for the same reason (
amount * null === 0), and resolved unknown currencies to the base currency — the exact guess-when-you-don't-know the totals loop refuses. Both fixed.
- Allocation charts still summed across currencies and stamped the result
€ — byte-for-byte the defect the hub's deleted computeStockValue() carried, in a module the money layer never touched. Values are now converted to the base currency before aggregating, so the bar widths, percentages and sector ranking are correct too.
state.priceCurrency was never cleared on logout, so one session's "currency unknown" could exclude the next user's holding of the same ticker; and single-symbol refresh discarded the quote currency, making the exclusion a one-way ratchet no amount of refreshing could clear.
- +5 tests (872 total).
v3.40.1
- The price cache bypassed every currency guard added in v3.39. Three holes, all on the
price_history round-trip rather than the live pricing path: loadLatestPricesFromDB() SELECTed currency and then discarded it, so prices restored on page load silently re-inherited whatever currency the asset claimed; savePriceHistoryToDB() wrote r.currency || 'USD', turning a momentary gap in knowledge into a permanent false record that later reads trusted; and the record builder stored the asset's currency rather than the quote's, so a holding priced under a learned ADR ticker persisted a USD price labelled EUR. Prices now round-trip with their real currency, NULL means unknown (the holding is flagged and excluded until refreshed), and a legacy row still holding raw pence is folded to pounds on read instead of being trusted.
- +6 tests (867 total) covering the round-trip in both directions.
v3.40.0
- The hub's headline net worth was adding pounds to euros. Fixed.
loadHubValues() summed shares × avg_price straight from the positions table — which has no currency column — and stamped the result €. With GBP and USD holdings that number was simply wrong, and it disagreed with the portfolio page, which computes the same figure correctly using per-trade FX. The hub now reads the canonical EUR total off the latest snapshot instead of computing its own. computeStockValue() is deleted; its removal is the fix, because nothing should ever total money across currencies again.
- Snapshots now record which currency they were taken in (migration
20260812_snapshots_base_currency.sql: base_currency, total_invested_eur, total_market_value_eur, excluded_positions). savePortfolioSnapshot() computes totals twice — once in the display base, once pinned to EUR — so the history chart finally plots one currency on one axis. Previously a snapshot taken with the USD toggle on was stored with no record of the base and rendered as €, reading ~8% high with nothing to indicate it.
- Legacy snapshots are skipped, not assumed to be EUR. Rows written before these columns existed keep
NULL and are excluded from the hub, drawn faded in the chart with an explanatory tooltip. We cannot reconstruct the rates, prices and currency assignments of a past capture, and guessing would bake today's assumption into the permanent record.
- The hub says what it doesn't know. No snapshot with a EUR total →
— — and "open Portfolio to calculate", never a confident number nobody computed. A partial valuation is marked with * and "excludes N", and the card carries an "as of 3 Aug" line so a stale figure can't pass for a live one.
toBaseCurrency, txRateToBase and computeInvestedBase take an optional target base, so a EUR figure can be computed while the toggle is on USD (trade-date FX already stores both bases per transaction, so historical rates stay historical).
- +7 tests (861 total). Verified end-to-end with the toggle on USD: the app's EUR total matches an independent hand computation exactly, and hub net worth equals portfolio market value + cellar value to the cent.
v3.39.0
- Currency correctness — the app no longer guesses when it doesn't know. New pure module
services/money-core.js (normalizeCurrencyCode, normalizeQuote, convert, shouldOverwriteCurrency) is now the only place currency codes and conversion are decided. The invariant it enforces: every monetary value crossing into state or the DB is in major units, and every currency field is a case-normalized ISO-4217 code.
- Pence (GBp) is finally handled. Yahoo returns the literal
"GBp" for London listings. Every previous check uppercased first — and "GBp".toUpperCase() is "GBP", so pence silently became pounds at factor 1, a 100× overstatement that flowed into totals, snapshots and the database. Minor-unit codes whose only difference from their ISO code is letter case (GBp, ZAc, ILa) are now matched before any case folding, at ingest. deriveFxToBases was fixed the same way.
toBaseCurrency and getExchangeRate return null instead of lying. They used to return the unconverted amount (and a rate of 1) when a currency or rate was unknown, which turned "we don't know" into a confident wrong number. Holdings that cannot be converted are now excluded from every total and flagged: an amber chip on the card (⚠ quote currency unknown · excluded from totals) and a header line (⚠ Excludes N holdings — currency unknown). Snapshots record excludedPositions so a partial valuation is never mistaken for a complete one.
- Quote currency is carried end-to-end, and is no longer confused with the asset's currency. The ticker resolver is explicitly instructed to prefer a US-listed ADR, so a Paris listing could be priced from a USD quote and converted as if it were EUR.
state.priceCurrency now records what the quote tier actually reported; when a holding is priced under a different ticker the quote currency is marked unknown rather than assumed. AI web-search prices, which carry no currency at all, are marked the same way.
assets.currency is no longer a race. buildAssetRecord() infers currency from the ticker suffix and runs for every position on every price refresh, unconditionally overwriting the provider-reported value. Currency now carries provenance (user > quote > > , migration ) and a weaker source can never overwrite a stronger one. Stored / values self-heal to ISO on load.
v3.38.0
- Mobile (iPhone) layout fixes — the portfolio page no longer scrolls sideways. The history chart drew one 40px-wide column per snapshot in a flex row with no scroll container, so from 7 snapshots onward the whole document became wider than the screen. Because
.app-navbar is position: sticky (which pins vertically only), the whole page slid left and the brand text was cut off mid-word. The bar row now lives in a .table-scroll region and is capped to the last 30 snapshots ("showing last 30 of N"), so the page stays exactly viewport-width no matter how long the history gets.
- Position cards: the value column is no longer clipped, and names are readable again. The card grid's middle track kept
min-width: auto, so a long instrument name pushed market value / gain-loss / yield off the right edge. The track list now says minmax(0, 1fr). Below 480px the card drops to two columns — name gets the full width (~272px instead of ~75px, so "Vanguard FTSE All-World…" is identifiable rather than "Vanguar…"), with value and a single-row action bar stacked underneath.
- Chart bars are proportional again. Heights were measured from a
min × 0.95 baseline with no axis labels, so any series sitting near the low end of the range collapsed to a ~12px stub while representing most of the value. Baseline is now zero, with a divide-by-zero guard for a flat/empty history.
- Chart legend no longer breaks "Market Value (Profit)" into a three-line stack (
flex-wrap on the row, nowrap on the labels, shorter text).
- Snapshot log Gain/Loss no longer clips: the row is
repeat(auto-fit, minmax(96px, 1fr)) and the percentage moves to its own line.
- iOS safe area — every page now sets
viewport-fit=cover, so env(safe-area-inset-bottom) actually resolves (it silently reported 0 before, meaning the bottom tab labels sat in the home-indicator strip). Nav padding and page clearance now add the inset instead of replacing it.
- Removed a dead
.pos-card-body ruleset and two no-op declarations (flex-shrink on a grid item; a min-width: 0 whose comment claimed the opposite of its effect).
v3.37.0
- Portfolio return metrics — money-weighted (XIRR) + income by year — three new cards on the portfolio page, all derived from the transaction ledger and FX-normalized to your base currency. Portfolio Return shows the combined lifetime XIRR (dated cash flows — buys −, sells +, dividends net of tax +, fees − — plus today's live market value as the terminal inflow), with an expandable audit table of every flow. Income by Year breaks net dividends / tax / fees and yield-on-cost down per calendar year (exact from the ledger — no valuation needed). Return by Year (XIRR) treats each year as its own money-weighted period, sourcing opening/closing values from saved snapshots near each Jan 1 (±21 days, price-bearing snapshots only); years without a nearby snapshot honestly read "insufficient data" rather than showing a fabricated number, so coverage improves as snapshots accumulate.
- New pure module
services/returns-core.js (buildCashFlows, xirr/xnpv, computeYearlyIncome, computeYearlyXirr) — no DOM/state/network, tests import it directly (same pattern as pricing-core.js / import-brokers.js, no src/ mirror). +23 tests (830 total).
v3.36.0
- Asset-type taxonomy unified ("Stock" vs "Shares" were the same thing) — the AI ISIN-resolver returns free-text types ("Shares", "Shares (REIT)") that were stored raw, while manual/default paths used the canonical "Stock" — so the allocation chart split one asset class into synonym buckets.
normalizeAssetType() (which already knew shares → Stock) is now applied at every ingest point (AI resolution, ISIN mapping persistence, ledger rebuild, asset upserts), at load (positions + asset DB — existing rows self-heal in the DB on the next natural save), and at chart aggregation as defense-in-depth. New aliases: Shares (REIT) → REIT. Your chart will show one "Stock" bucket and a proper "REIT" bucket after the next load.
v3.35.0
- EU quote proxy — Tier 4 of price fetching — the root cause of "ETFs never price" was structural: EU-listed UCITS ETFs (Xetra/LSE/Euronext) have no US listing, Finnhub free only quotes US symbols, and FMP free premium-gates most EU listings. New
quote-proxy edge function fetches quotes server-side from Yahoo's chart API (keyless, broad EU coverage, returns the listing currency; Yahoo is CORS-blocked from the browser, hence the proxy — and Stooq's old CSV endpoint turned out to be dead, caught in pre-ship testing). Runs as Tier 4 in fetchStockPrice, so it covers refreshes and ticker validation — closing the trap where 🔎 search finds a correct EU ticker (search endpoints have broad coverage) that the free quote endpoints then reject.
- "No price" is never a mystery anymore —
fetchStockPrice now records a per-tier failure story (e.g. Finnhub: no quote (US-listed only on free plan) · FMP: symbol premium-gated · Alpha Vantage: no key · EU proxy: no data), shown under each instrument in the resolve dialog and in the price-update summary. The single-symbol FMP tier also gained the text-first premium-notice detection the batch tier already had.
- Deploy note:
supabase functions deploy quote-proxy (config: verify_jwt = false, same manual-auth pattern as the other functions).
v3.34.0
- Biggest Movers: active holdings only — the widget iterated every cached price, so sold/closed positions (whose prices persist in the cache) appeared as "movers." Now requires an open position (shares > 0); closed-position performance belongs to a realized-P&L view, not movers (PO-confirmed).
- Per-card ticker resolver — active cards with no live price now show a clickable "⚠ no live price · resolve ticker" line (same pattern as "kept at cost · re-enable pricing") that opens the resolve dialog scoped to that one symbol: 🔎 search by name, enter a ticker (validated before persist), or keep at cost. No more triggering a full price refresh just to fix one stuck holding. The decision-applying logic is now a single shared
applyResolveDecisions() used by both the bulk post-refresh dialog and the per-card path; a rejected ticker explains itself instead of failing silently.
v3.33.0
- Historical (trade-date) currency conversion — Total Invested / Income / Fees were numerically identical under the € and $ toggles: each transaction stored a single
exchangeRate captured at import time against whichever base was active then (EUR), with no record of which — so toggling to USD just relabeled EUR numbers. Now every transaction carries fxRates: { EUR, USD } — the ECB reference rate at the trade date (Frankfurter, free/keyless; one range request per gap, cached immutably in localStorage). Display uses fxRates[base], falling back to live rates until backfill covers a row — so the toggle converts correctly even mid-repair. Legacy exchange_rate is kept in the DB one release for rollback but never read.
- Automatic repair of all existing data — on the next load after deploying, every transaction missing trade-date rates is backfilled and persisted (persist only-on-change), including resolving and writing back missing
currency fields so rows are self-consistent. New imports and manual buys/sells backfill immediately. GBX (pence) converts via GBP/100.
- Snapshot totals de-drifted — the snapshot's invested-total math was a near-copy of the header's and had already missed two fixes (the split-inflation ratio and this FX fix). Both paths now share one
computeInvestedBase() helper (cost-based split-invariant ratio + trade-date FX). Historical snapshots are left untouched — each records its own base currency at capture and can't be faithfully recomputed.
- Migration:
20260707_transactions_fx_rates.sql (transactions.fx_rates JSONB). +16 tests (807 total).
v3.32.0
- Four real bugs found and fixed by reviewing (and independently verifying) unmerged branches from an automated background agent — code-reviewer confirmed each was still present in current code, and I ran the proposed fixes against the actual failure scenarios before shipping:
- Split-inflated "Total Invested" — the header scaled invested capital by
shares / totalBuyShares, but a stock split multiplies shares while leaving cost basis unchanged, so a 4:1 split reported invested capital ~4× too high (also skewing gain/loss % and snapshots). Now uses a cost-based ratio (shares × avgPrice / totalBuyCost), which is split-invariant.
- Orphan position after deleting the last transaction — deleting a holding's only remaining transaction removed it from the ledger but left a stale
state.portfolio entry behind forever (nothing ever pruned it). Fixed at the one safe point: deleteTransactionRow now removes the position when its ledger goes empty — scoped there specifically, so manually-added positions with no transaction history are never touched.
- AI resolver double-suffix ticker — the Claude ISIN-resolver fallback concatenated
ticker + exchange unconditionally; when the AI already returned a fully-suffixed ticker (e.g. SAN.PA), appending .PA again produced the unpriceable SAN.PA.PA, silently stored in the asset DB. Now only appends when the ticker doesn't already contain a dot.
- Redundant transaction-delete DB write —
deleteTransactionRow called saveTransactionsToDB() directly and via saveTransactionsToStorage() (which already calls it internally), firing two concurrent delete-then-insert operations per delete. Removed the redundant direct call.
- +83 tests (708 → 791) cherry-picked from verified, still-relevant branches: ledger sort/split-detection regression guards,
setUntracked/buildAssetRecord.untracked coverage, DegiroAccount/broker-detection/number-parsing edge cases, and AI-response-parsing helpers (extractLlmJsonArray, classifyFmpBatchText) newly extracted into services/pricing-core.js and wired into the resolver/FMP-batch paths they mirror. Two duplicate branches (same tests, less complete) were discarded in favor of their more thorough twin.
v3.31.0
- Desktop UX repair (ux-auditor + optimization-quality joint review) of the v3.30 issues:
- Fees figure fixed — a descendant
div:last-child CSS catch-all was blowing the header's "Fees" value up to 24px display type; rules are now direct-child scoped and the income/fees line has its own .header-income-row class (verified 12px mono in preview).
- Toolbar out of the grid — the search/sort toolbar was rendering as a card-sized grid cell (broken first row). It now lives in its own
#positionsToolbar container above the grid, is never rewritten while you're typing (caret/focus preserved, hack removed), and search/sort/expand only re-render the grid — not the header, allocation charts, or sales history.
- Styled controls — search input & sort select use the design-system
.form-input/.form-select (no more white browser-default box); review banner and empty-search note span the full grid row.
- Quieter cards — action buttons are now ghost-style (32px, transparent, colored on hover) with the destructive delete de-emphasized until hover; the per-card transactions panel styling moved into a real
.pos-tx-panel rule.
- Dead code removed — the retired global Transactions/Income sections (~175 lines:
renderIncomeHistory, renderTransactionsLedger, filters and their wirings) are deleted; state.js now declares the live toolbar/panel state fields.
- Two ledger-delete defects fixed — per-row delete no longer silently no-ops on symbol-case mismatch, and the no-timestamp fallback now matches shares/price/amount so it can't delete the wrong same-day transaction.
v3.30.0
- Fix: mapped tickers now actually stick — resolving a ticker in the dialog fired the DB save without awaiting it, and the post-fetch
loadAssetsFromDB() then rebuilt the asset cache from the DB, racing (and usually wiping) the fresh mapping. Next refresh had no mapping → failed again → asked again. persistPricingTicker/setUntracked are now awaited everywhere, so a mapped ticker survives the same run, the session, and reloads.
- See the ticker actually used — cards show
↳ priced as XYZ whenever a learned/user mapping differs from the stored symbol, and the resolve-dialog ticker inputs are wider (monospace) so long tickers/suffixes are fully readable.
- Transactions & income live on the asset — each card gets a 📒 button that expands the asset's dividend/tax/fee summary + full transaction history (with per-row delete). The global Transactions and Income & Fees sections are retired.
- Positions toolbar — search positions by name/ticker and sort by Value (default), Top gainers, Top losers, Name, or Ticker.
v3.29.0
- Sprint: close the import→price→review loop + desktop layout (team-planned from quality + UX audits):
- S1 — after any import (trades or positions), the price fetch runs interactively: holdings that can't be priced open the resolve dialog on the spot instead of failing silently.
- S2 — user-typed ISIN→ticker mappings are validated (must return a live price) before being applied or persisted; failures re-open the dialog for just the failed rows. A typo can no longer become a permanent dead mapping.
- S3/S4 — the import-time resolve dialog now matches the pricing one: 🔎 search-by-name per row (candidate picker), typing a ticker auto-selects "Map to ticker", and a Cancel import escape hatch aborts before anything is saved.
- S5 — the transactions ledger is grouped per asset: collapsible cards per symbol (count in the header), search auto-expands matches, per-row delete unchanged.
- S6–S8 (desktop) — currency toggle moved into the portfolio header (action bar fits one row), positions become a multi-column card grid ≥1024px, allocation charts side-by-side ≥768px, resolve dialogs widen ≥1200px, overlay blur parity. All additive breakpoints — mobile verified pixel-identical.
v3.28.0
- Fix the ticker-resolve flow (typed tickers were being dropped) — in the "resolve missing price" dialog, when there was no AI suggestion the action dropdown silently defaulted to Keep at cost, so a ticker you typed was discarded and the holding was marked untracked. Now: typing a ticker auto-selects "Use ticker", the no-suggestion default is an inert Skip (never a silent keep-at-cost), and Apply honours a typed ticker even if the dropdown wasn't touched. A user-entered ticker that fails validation now reports why instead of a generic "no price".
- "Kept at cost" is now recoverable and durable — the untracked flag is persisted (
assets.untracked, migration 20260703_assets_untracked.sql) so a deliberate keep-at-cost survives reload, and each kept-at-cost holding shows a click-to-re-enable-pricing link on its card (clears the flag + refetches). No more dead-end where a holding could never be priced again without re-importing.
- FMP daily-limit is no longer a silent failure — when the 250/day FMP quota is reached and the tier pauses (falling back to Finnhub/Alpha Vantage/AI), the price-update summary now says so, instead of prices mysteriously not updating with only a console note.
v3.27.0
- Optimization & Quality audit fixes (13 findings) — a full read-only audit of the recently-shipped pricing/import code, actioned end to end:
- Data safety: wine type-classification now matches AI results by stable bottle id (was a weak chunk index that could silently mis-type and persist bottles).
- Import speed: ISIN resolution runs through a bounded concurrency pool instead of one-at-a-time with 1.5s/0.5s sleeps (~3× faster imports, still within Finnhub's 60/min).
- FMP quota: a real date-keyed, persisted daily counter that resets at midnight and actually pauses the FMP tier near the 250/day cap (falls back to Finnhub/AV/AI) — the old counter reset every reload and never gated.
- No snapshot spam: a refresh where the 15-min freshness cache skipped everything no longer writes a portfolio snapshot / re-saves price history.
- Consistency: shared pure helpers (
normalizeForPricing, batch mapping, freshness, pooled) extracted to services/pricing-core.js so the shipped code is what the tests exercise; Phase-C AI validation and wine valuation batches are now pooled; detectSplitPairs no longer runs quadratic; the AI resolver distinguishes a transient outage from a genuine no-match.
- Tooling/tech-debt:
npm run bump X.Y.Z rewrites every ?v= string in one shot; removed the dead tryAlternativeFormats; +13 unit tests for the new pure helpers (708 total).
v3.26.0
- Interactive ticker resolution ("no more silent missing prices") — a manual Update Prices run now ends with a resolve dialog for any holding still unpriced after the batch/concurrency/AI tiers. Per holding you can: accept the AI web-search suggestion (pre-filled), search by name/description (FMP
search-symbol → Finnhub search, pick from candidates), enter a ticker directly, or keep it at cost. Every chosen ticker is validated (a price is fetched) before it's trusted, then persisted as the learned pricing_ticker so it auto-applies next refresh. Automatic/background refreshes stay silent (non-interactive) — only the button opens the dialog.
- Wiring —
pricing.js exposes setMissingTickerResolver() / searchTickerByName(); fetchMarketPrices({interactive:true}) triggers the dialog (callback injection avoids a circular import between pricing and portfolio).
v3.25.0
- Price-fetch performance (minutes → ~seconds) — "Update Prices" was strictly sequential (~1s per symbol) with a ~14-candidate sequential fallback per failure. Now: (1) a single FMP batch quote (
symbol=A,B,C, chunked 50) prices all holdings in ~2 calls; (2) residual misses go through a bounded-concurrency pool (not fixed sleeps); (3) the alternative-format search tries all candidates in one batch call; (4) a 15-min freshness cache skips symbols already priced live (2nd refresh ≈ instant); (5) learned tickers persist so they're fetched directly next time; (6) counts recomputed from final state to avoid races, with an FMP daily-quota warning.
- Provider-independent AI fallback — the
resolve-tickers edge function now uses Gemini 2.5 Flash + Google Search grounding → Claude + web_search (the wine-tool pattern) to find a priceable ticker — or, for instruments no data API carries, a live-web grounded price flagged "≈ Web search (AI)". Every AI ticker is validated against a price API before it's trusted.
- New agent — added an Optimization & Quality subagent (
.claude/agents/optimization-quality.md) that owns performance budgets, batching/concurrency, and cross-cutting quality.
v3.24.0
- Fix EU price-fetch failures (resolve → price → fallback refactor) — European holdings stored with Finnhub-style exchange suffixes (
.FRK, .AMS) failed every price API. Now: (1) a normalizeForPricing suffix map remaps them to the FMP/Yahoo format (.FRK→.DE, .AMS→.AS, …) before the FMP/Alpha-Vantage tiers, and the alternative-format search fans a dotted ticker across all EU exchanges + the bare base instead of only stripping the suffix; (2) a validated Claude fallback — a new resolve-tickers edge function suggests a priceable ticker (US ADR or correct exchange suffix) for whatever still fails, and the client only trusts a suggestion after confirming it returns a price; (3) a working ticker is persisted (assets.pricing_ticker) so later refreshes fetch it directly; (4) holdings that genuinely can't be priced are shown at cost basis with a calm message instead of a scary failure dump. Migration 20260630_assets_pricing_ticker.sql adds the column; deploy the resolve-tickers edge function.
v3.23.0
- Mobile responsiveness pass (consistency-first) — four shared standards applied app-wide: (1) tap targets —
.btn ≥44px, .btn-sm ≥36px, position/ledger action buttons 28→36px, modal close 28→40px; (2) one responsive-table pattern — a shared .table-scroll wrapper replaces inline overflow-x:auto, and the lowest-value columns hide on phones (Ccy in Income & Fees, Fee/Tax in the Transactions ledger) via .col-hide-mobile; (3) bottom-sheet modals at ≤480px — the position, trade-review, unresolved-symbol, ticker-picker, generic, and wine-confirm dialogs anchor to the bottom, full-width with reachable actions; (4) reduced the ledger search min-width. Desktop layout unchanged. Reviewed by the UX auditor (PASS).
v3.22.0
- DeGiro Account statement import (dividends) — a new parser for DeGiro's
Account.csv (the cash/dividends statement, separate from Transactions.csv). Imports dividends + withholding tax into the income stream, so DeGiro dividends now appear in the summary-bar Income, per-card badges, and Income & Fees table (clearing the "DeGiro dividends not imported" notice). Trades and per-trade commissions are skipped (they already come from the Transactions export → no double-counting); dividend reversals net out per (ISIN, value-date). Verified against a real 2,099-row export: 212 dividends across 5 currencies, 0 errors. No DB migration needed.
v3.21.1
- Schema audit + date hardening — a read-only audit of the live DB vs the repo schema found one remaining drift:
transactions.date is DATE NOT NULL (the repo said TEXT). A missing/malformed date (possible from the AI/PDF fallback) would have aborted the whole transaction save. saveTransactionsToDB now coerces every row to a valid YYYY-MM-DD (falling back to the row timestamp, then today); supabase_schema.sql updated to match production. No migration needed.
v3.21.0
- Unresolvable symbols — manual ticker mapping (no silent drops) — when an ISIN can't be auto-resolved on import (e.g. some European UCITS ETFs), the trade is no longer silently dropped. A dialog lets you map it to the correct ticker (remembered for next time, flagged
source:'user'), keep it as an untracked cost-only holding, or explicitly skip it. Mapped ISINs auto-resolve on future imports via the existing asset-DB lookup. Untracked holdings (symbol = ISIN) are excluded from price fetching and badged in the UI. Migration 20260628_assets_source.sql adds the assets.source column.
v3.20.2
- Fix transaction save (CHECK constraint) — the deployed
transactions table limited type to buy/sell, so dividend/fee/split/isin_change rows were rejected (error 23514), aborting cloud sync during imports. Migration 20260627_transactions_type_constraint.sql widens the type allow-list to the full taxonomy.
v3.20.1
- Fix transaction save failure — split/corporate-action rows were writing
total_amount = null, violating the deployed DB's NOT NULL constraint and aborting the whole transaction save during DeGiro imports. These rows move no cash, so total_amount now defaults to 0.
v3.20.0
- Revolut real-world hardening — fixes three accuracy bugs found in a full 7-year Revolut export:
- Stock splits are now captured. Revolut reports a split as a signed share delta (e.g. AAPL +3 for a 4:1, HYZN −78.4 for a reverse split); these were silently dropped, leaving wrong share counts.
computePositionsFromLedger now applies additive (delta) splits alongside the existing multiplicative (ratio) splits.
- "DIVIDEND TAX (CORRECTION)" rows are no longer mis-counted as dividends (they net ~0 and now skip).
- "CUSTODY FEE REVERSAL" is stored as a negative fee so refunds reduce total fees instead of increasing them.
- Version bump to 3.20.0
v3.19.0
- Full transaction taxonomy — the ledger now models
buy / sell / dividend / fee / split / isin_change. computePositionsFromLedger folds fees into cost basis, applies splits (shares ×ratio, cost unchanged), and aggregates per-asset dividends/fees/withholding tax. Migration 20260626_transactions_income_fields.sql adds fee/tax/ratio/note columns
- Split / corporate-action review — importing surfaces zero-price corporate actions and auto-detected split pairs (e.g. DeGiro's 10:1 NVIDIA buy/sell pair) in a review dialog so share counts and cost basis stay correct instead of being silently corrupted. A safety banner flags any holding that goes share-negative
- Dividends & fees — Revolut
DIVIDEND and CUSTODY FEE rows are captured (with withholding tax) into the ledger; account-level fees use a reserved CASH bucket
- Income UI — summary-bar Income/Fees, per-card dividend badge + yield-on-cost, an Income & Fees table (gross/tax/net), and a searchable, filterable Transactions ledger with per-row delete that re-derives positions
- Version bump to 3.19.0
v3.18.0
- Fix DeGiro CSV import — Portuguese exports failed to parse: the accented
Preços header was stripped to preos and never matched (now diacritic-insensitive), and European numbers ≥ 1000 without thousands separators (1208,8800) parsed wrong (number parser rewritten with a "last separator wins" heuristic). Zero-price corporate actions (transfers, ISIN changes) are now skipped silently
- Fix dedupe of identical fills — Genuine same-day/same-price partial fills of one order were collapsed, undercounting shares. Dedupe now matches by occurrence count (multiset), so identical fills are kept on first import while re-imports are still fully skipped
- PDF import under strict CSP — pdf.js is now vendored locally in
lib/ (loaded on demand) instead of from a CDN, so PDF trade extraction works with the app's script-src 'self' Content-Security-Policy
- Version bump to 3.18.0 — All cache-busting query strings and visible version labels updated
v3.17.0
- Fix batch valuation misalignment — Results from the AI were applied to bottles by positional index; if the AI returned fewer items than the chunk size, valuations were silently written to the wrong bottles. Fix matches each result to its bottle by ID (
Map lookup), skipping any result whose ID is not found in the batch
- Test coverage: hub dashboard —
src/hub.js pure-function mirror + tests/hub.test.js (238 tests) covering hubFmt, computeStockValue, computeWineValue, computeWineCost, computeWineDelta
- Test coverage: sector filter scoping —
tests/calculations.test.js extended with filterBySector and formatSectorPositionCount tests; src/portfolio.js gains those two testable pure helpers
- Test coverage: cellar filtered totals —
tests/wine.test.js extended with computeTotals(filtered) coverage verifying scoped stats match the filtered subset
- Version bump to 3.17.0
v3.16.0
- Trade / broker import — Import buys & sells directly from a DeGiro Transactions CSV or Revolut statement CSV. Builds a full transaction ledger; positions and cost basis are derived automatically. Unstructured text (Revolut PDF, BancoBest confirmations) falls back to AI extraction via the new
extract-trades edge function. Re-importing the same export is safe — already-imported moves are skipped via fingerprint deduplication
services/import-brokers.js — Pure parsers for DeGiro and Revolut CSV formats; normalizeTrades, dedupeTrades, computePositionsFromLedger helpers. No DOM or network dependencies — fully unit-testable
supabase/functions/extract-trades/ — New edge function: extracts structured trades from unstructured statement text (Revolut PDF text, BancoBest option confirmations) via Claude
- Wine module version alignment — All
wine/ module imports in wine.html and cross-imports within wine/ now use a unified ?v= query string, eliminating dual module-instance issues that caused window._wineState to be a different object than the state inside cellar.js
- Version bump to 3.16.0 — All cache-busting query strings and visible version labels updated
v3.15.0
- Filter-scoped stats — Wine Cellar — Summary bar (bottles, invested, estimated value, gain %) now reflects only the filtered subset when search or filter chips are active. Snapshots always save full-cellar totals regardless of active filters.
computeTotals() accepts an optional bottles array (defaults to full cellar)
- Filter-scoped stats — Stock Portfolio — Summary header (invested, market value, gain/loss) scopes to the selected sector when the sector slicer is active. Position count shows "X of Y" when filtered. Snapshots remain full-portfolio
- Hub dashboard — cross-asset net worth — The hub page now populates real values after login: stock cost-basis (positions × avg_price from Supabase) and wine cellar value (SUM of estimated_value × qty from user_wines) are fetched in parallel and shown in the existing hub cards alongside
#hubTotalValue. No new API calls — data comes from already-stored Supabase records only
- Wine gain/loss delta on hub —
#hubWineDelta shows % gain vs. purchase price where available; falls back to "valued Xd ago" staleness label when no purchase price is recorded
- Stock delta label —
#hubStockDelta shows "cost basis" in neutral grey to signal live prices are not fetched on the hub page
- Hub values clear on logout — All three value elements revert to
— — when the user signs out
.hub-card-delta.neutral — New CSS variant for muted grey delta labels (uses var(--neutral))
v3.14.5
- Improved valuation accuracy — New pricing rules require cross-referencing at least 3 sources and using the median price, not the cheapest outlier. A single listing 30%+ below all others is flagged as likely ex-tax or erroneous. Specialist merchants and auction houses are weighted more heavily for rare/collectible wines (Port, Burgundy, First Growths)
- Robust classification JSON parsing — AI responses with preamble text ("Here is the classification:") and truncated arrays (no closing
]) are now handled gracefully via partial array extraction + JSON repair
- Classification batch size reduced to 15 — Prevents response truncation on large cellars
v3.14.4
- Security: manual auth verification on edge functions — Replaced Supabase gateway
verify_jwt (incompatible with newer sb_publishable_ keys / ES256 tokens) with in-function auth via supabase.auth.getUser(). Both wine-ai and analyze-portfolio edge functions now validate the user token against the live auth service before processing requests
- Security: CORS origin allowlisting — Edge functions restrict
Access-Control-Allow-Origin to known domains (cacoventures.com, Vercel deploy URL) instead of *
- Security: input validation on edge functions — Server-side limits: max 15K char prompts, 50 bottles per batch, 8192 maxTokens cap, 2MB image size. Prevents abuse via oversized requests
- Security: sanitized error responses — Edge functions no longer leak internal API error details (Gemini/Claude model names, rate limit info) to the client; errors are logged server-side only
- Security: Content Security Policy —
vercel.json adds CSP, HSTS, X-Frame-Options, and X-Content-Type-Options headers
- Security: restricted wines UPDATE policy — SQL migration limits
wines table updates to only drink_window and type fields; identity columns (name, winery, vintage) are immutable
- Schema: transactions table — Added
transactions table definition to supabase_schema.sql with full RLS policies (was missing from schema docs despite existing in live DB)
- Batched classification — "Classify Types" and "Reclassify All" now process bottles in chunks with a shared
classifyBatch helper, preventing prompt-too-long errors on large cellars
- Compact analysis prompt — Cellar analysis uses pipe-delimited bottle format with 10K char truncation, staying within the 15K server limit
- Fix valuation refresh UX — Single-bottle 💎 valuation now updates only the affected card in-place (
updateBottleCard) instead of re-rendering the entire cellar. Filters, scroll position, and sort order are preserved
- Batch valuation size reduced — Client and server batch size reduced from 5 to 3 bottles per request, preventing edge function timeouts during Gemini grounded web searches
- Vercel deployment — Added
vercel.json with security headers, cache rules, and no-build static config
- Robust classification parsing — JSON array extraction handles AI preamble text and truncated responses
v3.14.3
- Wine Cellar — fix "Recently Added" sort — Sort by "Recently Added" now correctly shows newest bottles first (was returning unsorted array because
created_at was not mapped from the database)
- Drink-window "Ready" uses 5-year urgency window — "Ready" status now means the wine is within the first 5 years of its drinking window (drink priority). After 5 years it transitions to "At Peak". Previously used the midpoint of the full window, making the "Ready" filter too broad for long-lived wines
- New beverage types: Aguardente & Gin — Dedicated type categories with icons (🔥 / 🍸) and colors, added to the type dropdown, filter chips, label recognition prompt, and AI classification. Spirits no longer lumped under "Other"
- "Reclassify All" button — Re-runs AI type classification on ALL bottles (including already-typed ones), useful for re-categorizing bottles after the new types were added
- Pricing golden rules — Valuation prompts (single and batch) now enforce: (1) Portuguese retail sites searched first, (2) 23% IVA applied on international ex-tax prices, (3) exact bottle format pricing (no extrapolation from 750ml), (4) current in-stock prices only (stale/launch prices skipped)
v3.14.2
- Wine Cellar — robust label JSON parsing — Label recognition now uses a 3-step JSON extraction pipeline (sanitise → regex extract
{…} → truncation repair) mirroring the battle-tested pattern from the batch valuation edge function. Fixes frequent "Could not parse wine data from label" errors, especially on the Claude Vision fallback path
- Increased label maxTokens — Label recognition token limit raised from 1024 to 2048, preventing Claude's more verbose responses from being truncated mid-JSON
- AI source indicator — The scanning progress message now shows "Gemini → Claude fallback" and the recognition result card displays which model actually answered (Gemini Vision or Claude Vision)
- Diagnostic logging — On parse failure, the raw AI response is logged to the console and a snippet is shown in the error message for easier debugging
v3.14.1
- Wine Cellar — fix WORKER_LIMIT error on large photo uploads — Uploaded photos are now resized and compressed client-side before being sent to the AI edge function. Images are scaled to a maximum of 1600 px on the longest side and re-encoded as JPEG at 85% quality, reducing a typical 5–10 MB smartphone photo to under 300 KB. Camera captures were already compressed via canvas; this brings file-picker uploads to parity and prevents Supabase Deno worker memory exhaustion (
WORKER_LIMIT / HTTP 546) with no change to recognition accuracy
v3.13.0
- Full design-token audit — systematic pass across all JS service files (
services/portfolio.js, services/navbar.js, services/analysis.js) and wine.html replacing every hardcoded hex color (#60a5fa, #4ade80, #f87171, #94a3b8, #64748b, #334155, etc.) with the correct CSS custom property token (var(--gold), var(--up), var(--down), var(--text-secondary), var(--surface-2), etc.)
- Style guide comprehensive update —
style-guide.html rewritten to accurately document the current premium design system: correct wine token palette, current header pattern (dark ink + coloured left border, no gradient), perspective tab gold active state, and trade-idea legend using semantic tokens
- Design System v2 section added (§17 in style guide) — live demos of all new mobile-first components:
.hero-metric-section, .hub-card-premium (stock + wine), .pos-card-mobile, .chip-scroll-row / .chip-filter, .ai-insight-card, .seg-tab-row, .bottom-tab-nav, .btn-stock / .btn-wine-primary / .btn-ghost-*, .dw-badge variants
styles.css blue references fixed — seven #60a5fa (Tailwind blue) occurrences in .position-value, .market-news-card, .search-result-symbol, .position-calc-display, .nav-brand.active, .lang-toggle, .slicer-clear all replaced with gold / text tokens
v3.12.0
- Premium design system v2 — full redesign of all three pages (hub, portfolio, cellar) targeting HNWI audience; no more blue/purple gradient headers
- Hub page (
index.html) — new hero total-wealth metric, gold sparkline SVG chart, dark premium hub cards with ambient glow; side-by-side layout on desktop, stacked on mobile
- Headers — replaced gradient backgrounds with
var(--ink-3) dark card + 3px coloured left border: gold for stocks, wine-rose for cellar
- Cormorant Garamond promoted to all page titles and hub card names; DM Mono enforced on all monetary amounts
- Mobile bottom tab nav — fixed
Hub / Stocks / Cellar bar appears at ≤640px; safe-area-inset padding for iPhone notch; active tab tinted gold (stocks) or wine-rose (cellar)
- New CSS component classes —
hub-card-premium, pos-card-mobile, pos-icon-badge, chip-scroll-row, chip-filter, ai-insight-card, seg-tab-row, bottom-tab-nav, btn-stock, btn-ghost-stock/wine, dw-badge variants
- Drink-window badges —
.dw-ready, .dw-peak, .dw-hold, .dw-past pill badges unified in styles.css design system
- UX test suite (
tests/ux-scenarios.html) — 8 interactive scenarios across first-impression, navigation, stock portfolio, wine cellar, and visual design audit sections; automated WCAG contrast checker and CSS token validator; session-only state (no localStorage); export report as .txt
v3.11.0
- Design system tokens —
css/styles.css now defines a full :root token set: background layers (--ink, --ink-2, --ink-3, --surface, --surface-2), borders (--border, --border-hover), text (--text-primary/secondary/tertiary), semantic colours (--up, --down, --gold, --wine*), radii (--r-sm/md/lg/xl)
- New font stack — Cormorant Garamond (display), Instrument Sans (body), DM Mono (mono) loaded via Google Fonts in all three HTML entry points
- Monetary values use DM Mono —
font-family: var(--font-mono) applied to all price/value/percentage elements: .total-value, .position-value, .mover-pct, .allocation-bar-value, .bottle-gain, .cellar-stat .stat-value, sales table cells
- All hardcoded colours replaced —
styles.css and wine.css now use CSS variables throughout; wine palette maps to --wine, --wine-light, --wine-dim
- Style guide updated —
style-guide.html reflects new token palette, font stacks, and uses CSS vars in its own inline styles
v3.10.0
- Gemini Vision for label scanning — Gemini Vision is now the primary model for wine label recognition; Claude Vision acts as automatic fallback if Gemini is unavailable
- Gemini grounding for valuations — Single-bottle and batch valuations now use Gemini with Google Search grounding as the primary engine (real-time web data); Claude is the per-chunk fallback on Gemini quota errors (429s retried up to 3× before switching)
- Scalable batch valuation — Client sends sequential batches of 5 bottles; the edge function runs each batch as parallel Gemini grounding calls (CHUNK_SIZE=5), preventing timeout on large cellars (tested to 800+ bottles)
- Navigation guard — Browser beforeunload warning and same-site link interception prevent accidental page navigation while valuations are running
wine/utils.js — Shared utility helpers (escapeHTML, showToast, showConfirm) extracted into their own module
- Additional test coverage — New tests for exchange detection, asset type normalization, sector lookup, and investment perspectives
v3.9.0
- Wine Cellar — live web-search valuations — Valuation API calls now use Anthropic's
web_search_20250305 tool so Claude fetches real Wine-Searcher listings and recent auction data (Sotheby's, Acker, Zachys, Hart Davis Hart) instead of relying solely on training knowledge
- Dual currency — each valuation now returns both a EUR estimate and a USD equivalent
- Confidence level —
"high" / "medium" / "low" badge on every bottle card, coloured green / amber / red
- Source citation — brief reference to the specific retailer or auction result used (shown on bottle card)
- Staleness warning — amber banner on any valuation older than 60 days, prompting a refresh
- Critic score pass-through — critic scores detected in label notes (e.g. "96/100", "94 points") are forwarded to the valuation prompt as quality anchors
- Vintage-specific guard — prompt now explicitly instructs Claude to price the exact vintage, never an averaged producer price
- Robust JSON extraction — response parser now finds the last text block (handling web-search
tool_use/tool_result interleaving) and extracts the JSON object via regex rather than whole-string parse
v3.8.1
- Wine Cellar — optional acquisition price — Purchase price is no longer required when adding a bottle. Bottle cards show estimated value only when no cost basis is provided; Gain / Loss row is hidden. Focus is on total collection value rather than P&L.
v3.8.0
- Wine Cellar Tracker — new tracker at
wine.html with AI label recognition (Claude Vision), per-bottle valuations, cellar analysis, allocation charts, and Supabase sync
- Hub page — new
index.html landing page linking to both trackers
portfolio.html — existing stock tracker moved here; index.html is now the hub
wine_schema.sql — Supabase tables wine_bottles and wine_snapshots with full RLS
src/wine.js — pure function mirror of wine modules for testability
tests/wine.test.js — 48 new tests: cellar totals, bottle gain/loss, allocation grouping, validation, scan-result parsing, snapshot building
- Security hardening —
escapeHTML in all wine modules updated to also escape ' → '
v3.6.1
- ISIN multi-ticker picker — When an ISIN resolves to multiple exchange listings (e.g. same ETF on London, Frankfurt, Amsterdam), a styled modal lets the user choose which listing to track
v3.6.0
- Import mode: Add or Replace — Import dialog now lets users choose between merging new positions or replacing the portfolio entirely
- Canonical asset type normalization — All imported asset types normalized to a standard set via a centralized mapping