diff --git a/.github/scripts/rename_app.py b/.github/scripts/rename_app.py new file mode 100644 index 0000000..29d3ad8 --- /dev/null +++ b/.github/scripts/rename_app.py @@ -0,0 +1,85 @@ +"""Rename the app everywhere a person sees its name — and nowhere else. + + python3 .github/scripts/rename_app.py "NewName" # dry run: list every change + python3 .github/scripts/rename_app.py "NewName" --apply # make the changes + python3 .github/scripts/rename_app.py "NewName" --apply --initials NN # pick favicon letters + +Run it on the rename branch right after merging the latest master into it, so it +catches every place the name appears at that point (the redesign stack adds many). + +Changes: + - "FinLens" -> NewName (titles, header, footer, Settings, README, docs, messages) + - "FINLENS" -> NEWNAME (file header comments) + - the "FL" initials in the favicon -> the new name's initials + +Never changes (on purpose): + - lowercase "finlens": what's left is the GitHub repo name in URLs (gitluis/finlens) + and the old eval kind the app still accepts (published AI Eval results, + "finlens-ai-eval"). Storage itself has generic names since + v2.0.2 (app_db, app_fs, app_ keys, app-state.json), so a rename never + touches people's data. + - CHANGELOG history (only a new entry is added, by hand), published eval results + (docs/EVAL.md, docs/eval-results.json, docs/eval-runs/) and docs/archive/ — + past history stays as is. + - The GitHub repo name and URLs (rename the repo in Settings; GitHub redirects + old links). +""" +import pathlib, re, subprocess, sys + +if len(sys.argv) < 2 or sys.argv[1].startswith("-"): + sys.exit(__doc__) +new = sys.argv[1].strip() +apply = "--apply" in sys.argv[2:] +if not re.fullmatch(r"[A-Za-z][A-Za-z0-9 ]{1,30}", new): + sys.exit("The new name must be 2-31 letters, digits or spaces, starting with a letter.") +# Favicon initials: the capitals of the name ("FinLens" -> "FL", "Tally House" -> "TH"), +# else its first letter ("Cuadre" -> "C"). Override with --initials XY. +caps = re.findall(r"[A-Z]", new) +initials = ("".join(caps[:2]) if len(caps) >= 2 else new[0].upper()) +if "--initials" in sys.argv: + initials = sys.argv[sys.argv.index("--initials") + 1][:2] + +root = pathlib.Path(__file__).resolve().parents[2] +SKIP_FILES = {"CHANGELOG.md", "docs/EVAL.md", "docs/eval-results.json", + ".github/scripts/rename_app.py", ".github/bank-names.txt"} +SKIP_PREFIXES = ("docs/eval-runs/", "docs/archive/") +RULES = [ + (re.compile(r"FinLens"), new), + (re.compile(r"FINLENS"), new.upper()), +] + +files = subprocess.run(["git", "ls-files"], cwd=root, capture_output=True, text=True, check=True).stdout.split() +changed_files, changes, kept = 0, 0, 0 +for rel in files: + if rel in SKIP_FILES or rel.startswith(SKIP_PREFIXES): + continue + path = root / rel + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + kept += len(re.findall(r"finlens", text)) + out_lines, n_file = [], 0 + for i, line in enumerate(text.split("\n"), 1): + new_line = line + for rx, rep in RULES: + new_line = rx.sub(rep, new_line) + if rel == "index.html" and 'rel="icon"' in new_line: + new_line = new_line.replace(">FL", f">{initials}") + if new_line != line: + n = sum(len(rx.findall(line)) for rx, _ in RULES) or 1 + n_file += n + if not apply: + print(f"{rel}:{i}: {line.strip()[:110]}") + out_lines.append(new_line) + if n_file: + changed_files += 1 + changes += n_file + if apply: + path.write_text("\n".join(out_lines), encoding="utf-8") + +mode = "Changed" if apply else "Would change" +print(f"\n{mode} {changes} occurrences in {changed_files} files (favicon initials: {initials}).") +print(f"Left as is: {kept} lowercase 'finlens' (repo URLs, the old eval kind still accepted).") +if not apply: + print("Dry run only. Add --apply to make the changes.") diff --git a/.gitignore b/.gitignore index b6a3318..042aa8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,14 @@ # Financial data — never commit real transaction exports or backups data/ -finlens-data.json # State backup (all transactions + settings), plus copies archived by "Start over" — # ignored wherever the CSV folder lives -finlens-state.json -finlens-state-backup-*.json -# Settings exports and saved AI analyses -finlens-export-*.json -finlens-analysis-*.md +app-state.json +app-state-*.json +app-state-backup-*.json +# Settings exports and saved AI analyses / history +app-export-*.json +ai-analysis-*.md +ai-history-*.md # macOS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b04670..2665397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [2.0.2] — 2026-11-04 + +### Changed — Storage names (start fresh after updating) +- **Everything FinLens stores now has a generic name, not the app's,** so renaming the app never touches anyone's data: + + | What | Up to v1.18.x | Now | + |---|---|---| + | IndexedDB (all data) | `finlens_db` | `app_db` | + | IndexedDB (connected folder) | `finlens_fs` | `app_fs` | + | localStorage keys | `finlens_*` | `app_*` | + | Backup file in your folder | `finlens-state.json` | `app-state.json` | + | Start over's backup copy | `finlens-state-backup-*.json` | `app-state-backup-*.json` | + | JSON export | `finlens-export-*.json` | `app-export-*.json` | + | Saved AI answer / history | `finlens-analysis-*.md`, `finlens-ai-history-*.md` | `ai-analysis-*.md`, `ai-history-*.md` | + | AI Eval results (`kind`) | `finlens-ai-eval` | `ai-eval-results` (old files still load) | + +- **Names only: nothing is copied from the old names** (decided 2026-11-04). After updating, FinLens opens empty. **Before updating, export JSON** (Settings → Data & reset). After: connect your folder or import your CSVs, then import that JSON. The old copies stay in the browser and folder until you remove them (browser site data; delete the old `finlens-state.json`). +- **`.gitignore`** lists the new file names instead of the old ones. `data/` is still ignored as a whole; an old `finlens-state.json` or export kept anywhere else in the repo folder no longer is, so delete those (they hold your data, so don't leave them in the repo). +- **CLAUDE.md §20:** the storage names and the old ones. + +### Technical — Rename +- **Rename script** (`.github/scripts/rename_app.py`) for the name people see, once the new name is chosen: `python3 .github/scripts/rename_app.py "NewName"` lists every change (dry run), `--apply` makes them. It renames "FinLens" / "FINLENS" in titles, the app bar, the footer, Settings, messages, the README and current docs, and favicon letters if there are any. History stays as written: older CHANGELOG entries, published eval results, `docs/archive/`. The AI request doesn't contain the name, so a rename needs no eval re-run. +- **Stacked on #51** (merged in), so the script and the storage names work on the redesigned code. +- Part of the **v2.0 — Public launch** milestone. Left open on purpose until the name and domain are chosen; merge it before the GitHub Pages deploy. + +--- + ## [1.18.1] — 2026-11-02 ### Docs diff --git a/CLAUDE.md b/CLAUDE.md index efd7c77..838a007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ Until then, this section is the rule. FinLens is a **single-origin, zero-build, vanilla JavaScript personal finance dashboard** that runs entirely in the browser. There is no server, no npm, no bundler, no framework, and no TypeScript. The entry point is `index.html`; everything else is a plain `.js` file in the `js/` folder. -**Current version:** v1.18.1 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release). +**Current version:** v2.0.2 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release). **What it does:** Imports CSV exports from a bank (checking + savings) and credit cards, parses and normalizes them into a unified transaction model, and renders a multi-tab financial dashboard with budgeting, debt tracking, savings forecasting, investment planning, and Claude-powered AI analysis. @@ -79,8 +79,8 @@ FinLens is a **single-origin, zero-build, vanilla JavaScript personal finance da | Markdown render | marked.js | jsDelivr 15.0.12 (pinned + SRI) | | HTML sanitizer | DOMPurify | jsDelivr 3.4.17 (pinned + SRI) | | AI | Anthropic Messages API (`claude-sonnet-5-5`, `AI_MODEL` in `views-ai.js`) | Direct browser fetch | -| Persistence | `finlens-state.json` via File System Access API → IndexedDB → `localStorage` (see §4.5) | Browser native | -| IndexedDB | Native — `finlens_db` (all app data, §19) and `finlens_fs` (CSV folder handle, §14) | Browser native | +| Persistence | `app-state.json` via File System Access API → IndexedDB → `localStorage` (see §4.5) | Browser native | +| IndexedDB | Native — `app_db` (all app data, §19) and `app_fs` (CSV folder handle, §14) | Browser native | Scripts that render model output (marked, DOMPurify) are version-pinned with `integrity`/`crossorigin` attributes. When bumping either, recompute the SRI hash from the exact file and update both `index.html` and this table. @@ -113,7 +113,7 @@ FinLens/ └── js/ ├── core.js ← State, helpers, CSV parsing, date range, account filter ├── aggregation.js ← Pure data aggregation: sums, groupings, recurring detection - ├── db.js ← IndexedDB layer (finlens_db) + ├── db.js ← IndexedDB layer (app_db) ├── migrate.js ← Rewrites data saved by older versions: account codes, plan links (§22) ├── ui.js ← View registry, drawer, theme, chart helpers, CRUD helpers ├── views-main.js ← Overview, Transactions, Income, Bills, Spending, Transfers @@ -237,7 +237,7 @@ The optional fields live inside `bills`, so they're saved, loaded, exported and - **Deleting an item removes the links to it** (`unlinkPlanItems(type, id)`, saved as `txOverrides`). So does clearing its list in Data & reset (§20). An edit left with nothing else in it is removed. - **The drawers store only what the user changes (v1.16.16).** `showTxDrawer()` reads its fields back from the form when it opens. On save, a description, category list, type or note that differs from that is stored; an untouched one stores nothing. That matters because a stored `type` wins over transfer detection (`detectTransfers()` leaves the row out, `reapplyTransferKeywords()` doesn't reset it and `undoTransferDetection()` keeps it, §11), and stored `categories` win over the category rules (Re-categorize all, `recategorizeAll()`, skips them; an import's `mkTx()` uses them) and over detection's `Transfer out` / `Transfer in` (v1.16.23). The flags follow the same rule (§4.2); the link is stored as the drawer shows it. - **Changing the type to Transfer** also stores its direction (`Transfer out` / `Transfer in`) as the category, when the categories don't have one. - - **Changing the type away from Transfer (v1.16.26)** takes `Transfer out` / `Transfer in` off the categories you chose, or off the edit's when you changed only the type; an edit left with no categories stores none. The row shows its own category (`ownCategories()`, §11). Bulk Set type does the same. Both then run `redetectTransfers()`, so the leg the row was paired with is checked again at once. Before v1.16.26 the direction stayed: in memory, in `finlens-state.json`, and in the edit when you changed the categories too. + - **Changing the type away from Transfer (v1.16.26)** takes `Transfer out` / `Transfer in` off the categories you chose, or off the edit's when you changed only the type; an edit left with no categories stores none. The row shows its own category (`ownCategories()`, §11). Bulk Set type does the same. Both then run `redetectTransfers()`, so the leg the row was paired with is checked again at once. Before v1.16.26 the direction stayed: in memory, in `app-state.json`, and in the edit when you changed the categories too. - **A description changed back to the original, or a note emptied,** is removed from the edit: it would show the same as no edit. An edit left empty is removed, so a save that changes nothing stores nothing. - **`showTransferDrawer()`** stores each leg's description and note the same way (`storeChangedText()`, shared with `showTxDrawer()`). Bulk edits store only the field they set. - **Up to v1.16.15 every drawer save stored all four fields**, so a transfer saved for a note stopped following the keywords and a category stopped following the rules. Those edits are kept: they can't be told apart from deliberate ones. Clear transaction edits removes them. @@ -264,7 +264,7 @@ Transactions are created by `mkTx()` in `core.js` and never mutated directly — // computed from manually entered starting balance + transactions _originalDesc: string, // Immutable original CSV description _originalCategories?: string[], // The CSV's category and type, before edits and transfer detection (v1.16.1). - _originalType?: string, // Set by mkTx(); DB.loadAll() takes them from the row; finlens-state.json keeps them. + _originalType?: string, // Set by mkTx(); DB.loadAll() takes them from the row; app-state.json keeps them. // Missing on transactions from a file written by ≤v1.16.0 (see §20, Clear transaction edits) } ``` @@ -280,7 +280,7 @@ Transactions are created by `mkTx()` in `core.js` and never mutated directly — **Categories (v1.16.25).** A transaction shows, in this order: its edit (`txOverrides[id].categories`, from the drawer, bulk Set category or deleting a category); for a detected transfer, Transfer in / Transfer out (`detectTransfers()`); what **Re-categorize all** gave it (`State.ruleCategories[id]`); else its import's category (`_originalCategories`). - **Category rules don't re-apply on load,** unlike flag rules. They apply when a CSV is imported (`normalizeRow()`: a checking or savings row gets `autoCategory()`; a card row with a CSV Category keeps it) and to what's already loaded only when the user clicks Re-categorize all. So its result is data to save, not something worked out again. - **Re-categorize all** (`recategorizeAll()`, ui.js) gives each loaded transaction `categoryFromRules()`: a category rule matching `_originalDesc`, else the CSV's own Category for a format that has the column (the card export), else the built-in rules (`DEFAULT_RULES`). It saves the result in `State.ruleCategories` only where it differs from the import's category or from the IndexedDB row's, and removes it where it's the same as both again (a rule removed). The two can differ: a re-import re-reads a checking row with the current rules, but the store keeps the row from its first import. It saves it under edits and detected transfers too, without changing what they show. -- **Applied by `mkTx()` and `reconstituteTx()`,** so it holds after a reload from IndexedDB or `finlens-state.json`, a re-import, Rebuild and Clear transaction edits. `reconstituteTx()` leaves a record that is a transfer alone: a state file's transfers keep the category detection gave them, and that boot doesn't detect transfers again. `loadStateFromFile()` passes the file's own `ruleCategories`. +- **Applied by `mkTx()` and `reconstituteTx()`,** so it holds after a reload from IndexedDB or `app-state.json`, a re-import, Rebuild and Clear transaction edits. `reconstituteTx()` leaves a record that is a transfer alone: a state file's transfers keep the category detection gave them, and that boot doesn't detect transfers again. `loadStateFromFile()` passes the file's own `ruleCategories`. - **Not in the IndexedDB rows.** They can be missing (§4.5), Rebuild and Clear transactions empty the store, a re-import skips rows already stored, and a row holds the CSV's values (§10). - **Up to v1.16.24** Re-categorize all changed only memory, so a reload brought the import's categories back. It also gave card rows with no matching rule the built-in category and overwrote transfer categories. @@ -300,7 +300,7 @@ Transactions are created by `mkTx()` in `core.js` and never mutated directly — } ``` -The account record lives in `State.accounts`, the IndexedDB `accounts` store and `finlens-state.json`, so `files` needs no storage of its own. Settings → Import lists every file of every account (`importedFiles()`). +The account record lives in `State.accounts`, the IndexedDB `accounts` store and `app-state.json`, so `files` needs no storage of its own. Settings → Import lists every file of every account (`importedFiles()`). ### 4.4 View Registration Pattern @@ -334,7 +334,7 @@ registerView('viewId', 'Page Title', (root) => { ``` User action → State.xxx = newValue → lsSet('key', value) ↓ - localStorage.setItem('finlens_key', JSON.stringify(value)) + localStorage.setItem('app_key', JSON.stringify(value)) ↓ (if DB._db ready) DB.setState(key, value) [fire-and-forget] ↓ @@ -342,20 +342,20 @@ User action → State.xxx = newValue → lsSet('key', value) ↓ (debounced, via _saveStateDebounced) File System Access API ↓ - finlens-state.json in user's backup folder + app-state.json in user's backup folder ``` **Read path (on page load — init.js):** ``` loadStateFromFile() → found? - ↓ YES: Load from finlens-state.json, Object.assign(State, loaded) + ↓ YES: Load from app-state.json, Object.assign(State, loaded) ↓ NO: DB.open() + DB.loadAll() → IndexedDB → found? YES: Populate State from IndexedDB → NO: Fall back to localStorage defaults ``` **Key principles:** -- **Filesystem is the source of truth** — `finlens-state.json` contains the authoritative copy of all state. +- **Filesystem is the source of truth** — `app-state.json` contains the authoritative copy of all state. - **Delayed writes (v1.18.0)** — `lsSet()` calls `_saveStateDebounced()`, which writes the file `SAVE_DELAY_MS` (1.5 s) after the last change. Each new change restarts the wait, so a burst of edits makes one write. Changes reach localStorage and IndexedDB at once; only the file waits. Up to v1.17.0 the wait was 5 s. - **Save state (v1.18.0)** — `State._savePending` is true while a write waits, `State._saving` while one runs (a count underneath, since a manual save can overlap the scheduled one). Neither is saved anywhere. Every change to them, and every folder change through `updateFolderBanner()`, goes through `_notifySaveStatus()`: it updates the leave warning, then the sync badge (`updateSyncBadge()`, §9). - **A manual save covers a waiting one:** `saveStateToFile()` cancels the timer, since it serializes State as it is when it gets there. @@ -365,11 +365,11 @@ loadStateFromFile() → found? - **Survives browser cache clearing** — If the browser clears IndexedDB/localStorage, the filesystem file remains on disk and will be restored on the next page load. - **Fallback chain** — If filesystem load fails, IndexedDB provides a session-local cache. If IndexedDB isn't available, localStorage defaults apply. - Always use `lsSet(key, value)` and `lsGet(key, default)` — **never** call `localStorage` directly. -- The `finlens_` prefix is applied automatically by `lsSet`/`lsGet`. -- **Two keys have older storage names (v1.16.4).** Browser storage (localStorage and IndexedDB `app_state`) keeps `State.recurringConfirmed` as `recurring` and `State.categoryRules` as `catRules`; every other key is stored under its State name. `STORAGE_KEYS` / `storageKey(stateKey)` in `core.js` is the one mapping. Code that saves or loads keys by State name in a loop must go through it: JSON import, Data & reset's plan clears, `DB.loadAll()` and the new-database copy (`DB.APP_STATE_KEYS`, v1.16.5), `migrate.js`. Up to v1.16.3 the JSON import didn't, and its values were lost on reload. Don't rename the storage keys: existing data lives under them. `finlens-state.json` and JSON exports use State names. +- The `app_` prefix is applied automatically by `lsSet`/`lsGet`. +- **Two keys have older storage names (v1.16.4).** Browser storage (localStorage and IndexedDB `app_state`) keeps `State.recurringConfirmed` as `recurring` and `State.categoryRules` as `catRules`; every other key is stored under its State name. `STORAGE_KEYS` / `storageKey(stateKey)` in `core.js` is the one mapping. Code that saves or loads keys by State name in a loop must go through it: JSON import, Data & reset's plan clears, `DB.loadAll()` and the new-database copy (`DB.APP_STATE_KEYS`, v1.16.5), `migrate.js`. Up to v1.16.3 the JSON import didn't, and its values were lost on reload. Don't rename the storage keys: existing data lives under them. `app-state.json` and JSON exports use State names. - Don't call `saveStateToFile()` directly in hot paths — it's meant to be debounced via `_saveStateDebounced()` in `lsSet()`. - **Never use transactions straight from JSON.** `JSON.parse` turns `date` into a string and freezes the `category` getter. `loadStateFromFile()` rebuilds them with `reconstituteTx()`, as `DB.loadAll()` does; any new code that reads transactions from JSON must do the same. -- **IndexedDB may not hold the loaded transactions.** After a boot or restore from `finlens-state.json` (a new browser, cleared site data, `_restoreStateIfBrowserEmpty()`), the `transactions` store can be empty or hold only some of them. Code that changes the loaded transactions works from `State.transactions`, never by replacing them with the store's rows: a store row may only fill in a value a transaction lacks. Up to v1.16.0, Clear transaction edits replaced them and wiped every transaction (§20). +- **IndexedDB may not hold the loaded transactions.** After a boot or restore from `app-state.json` (a new browser, cleared site data, `_restoreStateIfBrowserEmpty()`), the `transactions` store can be empty or hold only some of them. Code that changes the loaded transactions works from `State.transactions`, never by replacing them with the store's rows: a store row may only fill in a value a transaction lacks. Up to v1.16.0, Clear transaction edits replaced them and wiped every transaction (§20). - **An import never drops loaded transactions** (v1.16.3). `loadFiles()` adds the files' transactions to the loaded ones (`mergeImported()`): the same id is replaced by the file's, every other loaded transaction stays, as `DB.importTransactions()` keeps every row (§10). Up to v1.16.2 it replaced all of them with the files' ones, so importing only the card file left only the card's in memory, and the autosave wrote that to the file that boot loads first. Removing rows is Rebuild from your folder's job (§20), with one exception, next. - **A file read into another account takes its rows with it** (v1.16.17). Changing a file's **Read as** changes the account, so its rows come back with new ids (§10). `loadFiles()` then removes the file's earlier rows from the old account: memory, IndexedDB and, through the autosave, the state file. Their edits and plan links move to the new ids. Up to v1.16.16 both copies stayed, until Rebuild. Rules in §10. - **One transaction per id** (v1.16.19). Two exports of one account that share dates give the shared rows the same ids (§10). `mergeImported()` keeps one, the first file's, as IndexedDB keeps the first row it stores. Up to v1.16.18 memory kept both, and the autosave wrote both to the state file. `loadStateFromFile()` keeps one copy of an id the file holds twice. Anything that adds to `State.transactions` must keep ids unique: totals, transfer detection (it tracks legs by id) and links all assume it. @@ -571,8 +571,8 @@ pct(n, decimals?) // '12.3%' lsGet(key, default) // Read from localStorage (auto-prefixed) lsSet(key, value) // Write to localStorage + mirror to IndexedDB app_state + auto-save to filesystem (debounced) storageKey(stateKey) // The lsSet/lsGet key for a State key: 'recurring', 'catRules', else the State name (STORAGE_KEYS, §4.5) -loadStateFromFile() // Read finlens-state.json from backup folder; returns loaded state or null if not found -saveStateToFile(state) // Write state to finlens-state.json in backup folder (fire-and-forget async) +loadStateFromFile() // Read app-state.json from backup folder; returns loaded state or null if not found +saveStateToFile(state) // Write state to app-state.json in backup folder (fire-and-forget async) _saveStateDebounced() // Schedules a save SAVE_DELAY_MS (1.5 s) after the last change — called by lsSet() (v1.18.0) flushPendingSave() // Runs a waiting save now (tab hidden, page closing); null if none was waiting backupWouldBeLost() // A save is waiting or running and the folder is connected and writable: the leave warning's test @@ -629,13 +629,13 @@ newPlanId(type, list?) // a new budget/bucket/debt id: prefix + '_' + 10 ran escapeHtml(str) // Escape HTML entities for safe innerHTML insertion escapeAttr(str) // Escape for use in HTML attribute strings renderMarkdown(md) // marked → DOMPurify (no images/styles/forms) → safe HTML string; escaped-text fallback; only ~~double~~ tildes strike through -lsClearAll() // Remove every finlens_* localStorage key — only "Start over" should call it +lsClearAll() // Remove every app_* localStorage key — only "Start over" should call it deleteIndexedDB(name) // Delete a whole IndexedDB database → 'deleted' | 'blocked' | 'error' // Folder + state-file maintenance (call from a click handler — permission prompts need a user gesture) folderWithWriteAccess() // Connected folder handle with read-write access, or null -saveStateNow() // Write finlens-state.json immediately → 'saved' | 'no-folder' | 'denied' -retireStateFile(mode) // 'archive' (→ finlens-state-backup-DDMMYYYY[-n].json, key stripped) | 'delete' +saveStateNow() // Write app-state.json immediately → 'saved' | 'no-folder' | 'denied' +retireStateFile(mode) // 'archive' (→ app-state-backup-DDMMYYYY[-n].json, key stripped) | 'delete' forgetDataFolder() // Forget the folder handle + name; nothing on disk changes downloadFile(name, content, mime) // Trigger browser file download debounce(fn, ms) // Returns debounced function @@ -819,12 +819,12 @@ The account id comes last on purpose: `migrate.js` relies on it to rename accoun **Re-upload safety:** IDs are final when they reach `DB.importTransactions()`, which reads the stored keys once and skips any ID it already holds. Re-uploading a file — or a newer export with rows appended — inserts only genuinely new rows. On each import it also deletes leftover copies from the old bug: a suffixed row whose base transaction is in the file but whose exact ID is not (and that no other file of the import gives, v1.16.19, below). Same-day identical rows always appear together in a bank export, so the file is authoritative for how many copies exist. The import results card reports these as "leftover duplicate copies removed". -**Memory follows the same rules** (v1.16.3). `loadFiles()` puts the files' transactions into `State.transactions` the way `DB.importTransactions()` puts them into the store (`mergeImported()`): an id it already has is replaced by the file's, a new one is added, a leftover copy as above is removed, and every other loaded transaction stays. So importing one file, or a newer export that starts later, keeps the other accounts and the older rows, and `finlens-state.json` gets them all. +**Memory follows the same rules** (v1.16.3). `loadFiles()` puts the files' transactions into `State.transactions` the way `DB.importTransactions()` puts them into the store (`mergeImported()`): an id it already has is replaced by the file's, a new one is added, a leftover copy as above is removed, and every other loaded transaction stays. So importing one file, or a newer export that starts later, keeps the other accounts and the older rows, and `app-state.json` gets them all. **Overlapping exports of one account** (v1.16.19). Two exports that share dates (May – mid-September and August – October, imported together) give the shared rows the same ids, `_2`, `_3` included, since copies are counted within each file. Checking and savings always share an account; a card only when both files have the same name (§18), as two `card.csv` files from subfolders through the fallback folder picker. - **Memory:** `mergeImported()` keeps one transaction per id, the first file's. IndexedDB matches: the later files skip the ids the first one stored. - **Leftover copies:** `DB.importTransactions(txs, accountId, schemaId, importIds)` never removes an id another file of the import gives (`importIds`, every id of the import, from `loadFiles()`). An export made after one more identical charge posted that day has one more copy; it stays, whichever file is read first. -- **Up to v1.16.18** memory kept both copies, the autosave wrote them to `finlens-state.json`, and every total counted the shared rows twice. `loadStateFromFile()` keeps one copy of an id the file holds twice, and saves the file again. +- **Up to v1.16.18** memory kept both copies, the autosave wrote them to `app-state.json`, and every total counted the shared rows twice. `loadStateFromFile()` keeps one copy of an id the file holds twice, and saves the file again. - **Card exports with different names** are different accounts, so the rows they share have different ids and both stay. **"Read as" changes the ids** (v1.16.17). A file read as another kind than before (Settings → Import → Files in the folder, or the Choose CSV files card) goes into another account, so every row gets a new id. Before importing, `earlierCopies()` finds the file's earlier rows; after, `moveEarlierCopies()` takes them out. @@ -849,7 +849,7 @@ The account id comes last on purpose: `migrate.js` relies on it to rename accoun **The user's edit wins over both passes (v1.16.23):** - **A stored `type`** (`txOverrides[id].type`, whatever its value) keeps the row out of detection. It isn't marked and doesn't pair. The row it would have paired with is checked on its own, as if the typed row weren't there: it can pair with another row, match a keyword (a one-sided transfer), or stay as imported. A type set to Transfer already made the row a transfer that detection skips; that's unchanged. - **Stored `categories`** (or an older single `category`) are kept when a row is marked. Only rows without them get `Transfer out` / `Transfer in`. -- **Where it applies:** every run of `detectTransfers()`: `loadFiles()`, `redetectTransfers()` (a keyword change, and a type changed in the drawer or with bulk Set type since v1.16.26), `DB.loadAll()` (a boot from browser storage) and Clear transaction edits (which empties the edits first). A boot from `finlens-state.json` doesn't run detection: the file holds its results. Before v1.16.26 a type change didn't run it, so the partner of a leg you retyped changed only at the next import, keyword change or reload. +- **Where it applies:** every run of `detectTransfers()`: `loadFiles()`, `redetectTransfers()` (a keyword change, and a type changed in the drawer or with bulk Set type since v1.16.26), `DB.loadAll()` (a boot from browser storage) and Clear transaction edits (which empties the edits first). A boot from `app-state.json` doesn't run detection: the file holds its results. Before v1.16.26 a type change didn't run it, so the partner of a leg you retyped changed only at the next import, keyword change or reload. - **Before v1.16.23** detection ignored edits. A row typed as Expense became a transfer again at the next keyword change, import or reload, and categories added to a detected transfer were replaced at the next reload. Up to v1.16.15 the drawer stored the type with every save, which kept a saved Transfer out of detection's way and hid the category half. **Both legs of a transfer are kept.** There is no deduplication. The `Transfers` view pairs them visually. @@ -859,12 +859,12 @@ The account id comes last on purpose: `migrate.js` relies on it to rename accoun **A transfer that stops being one (v1.16.24)** gets back what it was before detection, from `undoTransferDetection()`. `loadFiles()` uses it for the transfers it keeps (§4.5), `redetectTransfers()` for every transfer before it runs detection again. Detection then marks again the ones that still match. - **Type:** the stored `type`; else the CSV's (`_originalType`: a card's Sale, Return or Payment, a bank's Withdrawal or Deposit); else its IndexedDB row's; else debit or credit by sign. A stored Transfer type stays a transfer. - **Categories:** `ownCategories()` (v1.16.26), the first of: the stored ones, without `Transfer out` / `Transfer in`; the one Re-categorize all saved (`State.ruleCategories`, v1.16.25; skipped where it doesn't exist); the ones it has besides `Transfer out` / `Transfer in` (without `ruleCategories`, Re-categorize all puts its category on a detected transfer too); the CSV's (`_originalCategories`: a card file's own Category, otherwise what the import's `autoCategory()` gave, category rules first); the row's; `autoCategory()` now; `Other`. Before v1.16.26 the stored ones were kept with `Transfer out` / `Transfer in`. -- **Before v1.16.24** `reapplyTransferKeywords()` didn't use it: a transfer went back as debit or credit by sign, with `Other` or `Income` when it had no other category. A card Payment then counted as income, and the wrong category was saved to `finlens-state.json`. Rows saved that way keep it until their CSV is imported again (Reload CSVs, Rebuild from your folder). +- **Before v1.16.24** `reapplyTransferKeywords()` didn't use it: a transfer went back as debit or credit by sign, with `Other` or `Income` when it had no other category. A card Payment then counted as income, and the wrong category was saved to `app-state.json`. Rows saved that way keep it until their CSV is imported again (Reload CSVs, Rebuild from your folder). - **The built-in rules give ZELLE, TRANSFER, ACH, VENMO, PAYPAL and COINBASE the category `Transfer`** (`DEFAULT_RULES`). So a ZELLE row back from a transfer is an expense with the category `Transfer`, and counts in Spending (below). **The type decides (v1.16.26).** A transaction is a transfer when `t.type === 'transfer'`, never because of its category. Every other type counts as money in or out by its sign (a card payment isn't income, §11.1). `sumIncome()`, `sumExpense()`, `sumTransfers()`, `byCategory()`, `byMonth()`, `detectRecurring()`, `transferPairs()`, `starterBudgetList()` and the Income, Spending and Bills screens all test the type only, and so do the Overview, Trends, Budget and the AI summary through them. - **The category `Transfer` is a label.** On a row that isn't a transfer it counts as spending or income like any other category. Only the type makes a transfer: a keyword, a pair, or Transfer chosen as the type. Without edits this changes nothing: the built-in rules give `Transfer` only to descriptions that contain a built-in keyword too, so every such row is already a transfer by type. The category `Transfer` from a user's category rule, or from a card file's Category column, no longer keeps a row out of the totals; a transfer keyword or the Transfer type does. -- **`Transfer out` / `Transfer in` only on transfers.** A row that isn't a transfer shows `ownCategories()` instead, wherever its categories come from: the drawer and bulk Set type (§4.1), `undoTransferDetection()`, `mkTx()` (an edit holding a direction) and `reconstituteTx()` (an edit, or a `finlens-state.json` entry from before v1.16.26). The edit itself keeps them, so `detectTransfers()`' `mark()` gives them back if the row becomes a transfer again. +- **`Transfer out` / `Transfer in` only on transfers.** A row that isn't a transfer shows `ownCategories()` instead, wherever its categories come from: the drawer and bulk Set type (§4.1), `undoTransferDetection()`, `mkTx()` (an edit holding a direction) and `reconstituteTx()` (an edit, or a `app-state.json` entry from before v1.16.26). The edit itself keeps them, so `detectTransfers()`' `mark()` gives them back if the row becomes a transfer again. - **Before v1.16.26** the totals went by category too: `Transfer`, `Transfer in` and `Transfer out` (some helpers all three, some only `Transfer`) counted as a transfer whatever the type. A ZELLE row set to Expense stayed on the Transfers screen and out of Spending's categories, and after a reload out of its total. **Pairs for display:** `transferPairs(txs)` (aggregation.js) pairs the two legs for the Transfers screen and the Overview's card footnotes. A leg with no partner has `to` (or `from`) = null: money that left (or came from) outside FinLens. @@ -993,7 +993,7 @@ Views build a fresh DOM on every `showView()` call. This is intentional. Do not - Uses the model in `AI_MODEL` (currently `claude-sonnet-5-5`) via direct browser fetch to `https://api.anthropic.com/v1/messages`. `claude-sonnet-4-20250514` was retired 2026-06-15 — check Anthropic's model deprecations page before changing models. - Requires `anthropic-dangerous-direct-browser-access: true` header (CORS bypass). -- API key is stored in `State.apiKey` (localStorage + IndexedDB `app_state` only — **never** in `finlens-state.json`, see §4.5). It is entered in **Settings → General → Anthropic API Key**; the settings card never renders the stored value back into the DOM. **Never log or expose it.** +- API key is stored in `State.apiKey` (localStorage + IndexedDB `app_state` only — **never** in `app-state.json`, see §4.5). It is entered in **Settings → General → Anthropic API Key**; the settings card never renders the stored value back into the DOM. **Never log or expose it.** - `buildFinancialSummary()` assembles a plain-text snapshot of current State. Account balances, total cash and net worth are the balances at the end of the selected period, the numbers the Overview shows (v1.16.11). The starting balance is included (v1.16.10). Debts report `debtBalance(d)`: a debt linked to a card account gives the card's current balance (v1.15.0); others give `d.balance` as before. Accounts are listed by `acctName()` (the AI Eval sandbox sets `accountLabels` to `{}`). **It always states its dates (v1.10.2):** - **The period line** gives the span of the transactions it covers (`summarySpan()`), e.g. "Period: All time (transactions from May 1, 2026 to Oct 20, 2026: 173 days, about 5.7 months)". - **A partial month** in the monthly trend is marked (`partialMonthNote()`), e.g. "2026-10 (through Oct 20)". @@ -1028,15 +1028,15 @@ The old `FileStorage` object and `finlens-data.json` were removed in v1.4.1. `views-data.js` stores the user's selected CSV folder handle in `idbGet`/`idbSet('csvDirHandle')`. That one handle is used for two things: 1. **Auto-reloading CSVs** after a page refresh when IndexedDB has no transactions, unless `State.csvAutoImportPaused` is set (§20). -2. **The state file** — `loadStateFromFile()` / `saveStateToFile()` read and write `finlens-state.json` in the same folder (the primary persistence source, §4.5). +2. **The state file** — `loadStateFromFile()` / `saveStateToFile()` read and write `app-state.json` in the same folder (the primary persistence source, §4.5). -The handle lives in a separate `finlens_fs` database (distinct from `finlens_db`), accessed through the lightweight `_idbOpen`/`idbGet`/`idbSet`/`idbDel` helpers in `core.js`. `finlens-state.json` is git-ignored by name, wherever the folder is. +The handle lives in a separate `app_fs` database (distinct from `app_db`), accessed through the lightweight `_idbOpen`/`idbGet`/`idbSet`/`idbDel` helpers in `core.js`. `app-state.json` is git-ignored by name, wherever the folder is. ### Folder permission (v1.9.3) - **It lasts until the last FinLens tab closes, not one page load** (Chromium: Edge, Chrome). A reload keeps access; closing the browser drops it, and a stored handle's permission goes back to `prompt` (confirmed in Edge, v1.9.6). Chromium can also revoke it after a tab sits in the background for a while, and since Chrome 122 the prompt can offer **Allow on every visit**, which keeps access until the user revokes it. Only a click can ask again — `requestPermission()` throws without user activation. Don't rely on any of these: check permission every time (next bullet). - **Check before touching files.** `loadStateFromFile()` and `saveStateToFile()` call `folderPermission(dir, mode)` (wraps `queryPermission`, never prompts) and skip quietly instead of throwing `NotAllowedError`. When access is missing they set `State._folderNeedsAccess`, which shows the **Reconnect folder** banner (`#folderBanner`, `updateFolderBanner()` in `views-data.js`) and makes the sync badge "Not synced" with Reconnect as its click. A successful save clears it. -- **Always ask for `readwrite`**, not `read`: the folder picker, Reload and Reconnect all do, because the same folder holds `finlens-state.json`. +- **Always ask for `readwrite`**, not `read`: the folder picker, Reload and Reconnect all do, because the same folder holds `app-state.json`. - **Prompting code runs only in click handlers:** `saveNowClick()`, `reconnectFolderClick()`, `reloadDataFiles()`, the sync badge's `syncBadgeClick()` (Reconnect, or Retry through `syncRetryClick()` → `saveStateNow()`), and the Data & reset actions via `folderWithWriteAccess()` / `saveStateNow()`. The tab-hidden and page-closing flushes (§4.5) only call `saveStateToFile()`, which never prompts. - **After reconnecting, the browser copy wins** (Luis's decision, 2026-10-24): `reconnectDataFolder()` writes the browser's state to the file and does not load the file over it. **Exception:** a browser with no transactions (new browser, cleared site data) restores from the file instead (`_restoreStateIfBrowserEmpty()`), so an empty state never overwrites the only backup. Picking a folder follows the same rule. @@ -1053,11 +1053,11 @@ The handle lives in a separate `finlens_fs` database (distinct from `finlens_db` - **Do not hard-code colors** — use CSS custom properties. - **Do not mutate `tx.amount` or `tx.date`** — immutable source values. - **Do not create new global variables** — use `const`/`let` at module scope where possible, or attach to an existing namespace object. -- **Do not write secrets to disk** — `apiKey` must never appear in `finlens-state.json`, exports, or logs. +- **Do not write secrets to disk** — `apiKey` must never appear in `app-state.json`, exports, or logs. - **Do not call `marked.parse()` directly** — use `renderMarkdown()`. -- **Do not commit real financial data** — use `samples/` (synthetic) for demos; `data/`, `finlens-state.json` (and its `finlens-state-backup-*` copies) and exports are git-ignored. +- **Do not commit real financial data** — use `samples/` (synthetic) for demos; `data/`, `app-state.json` (and its `app-state-backup-*` copies) and exports are git-ignored. - **Do not add a new place that stores data without updating §20** — "Start over" must erase it, or a reset silently leaves data behind. -- **Do not modify the user's CSV files** — cleanup and reset only ever touch FinLens's own storage and `finlens-state.json`. +- **Do not modify the user's CSV files** — cleanup and reset only ever touch FinLens's own storage and `app-state.json`. --- @@ -1155,7 +1155,7 @@ Multiple credit card files can be loaded simultaneously. Each gets a unique `acc --- -*Last updated: v1.18.1 — 2026-11-02 (v1.18.1: MIT license added, no section changes; v1.18.0: backup status in the sync badge, save before leaving: §1 version; §4.5 1.5 s save delay, save state, flush when the tab is hidden, leave warning, Start over; §6 sync badge classes; §8 `flushPendingSave()`, `backupWouldBeLost()`; §9 sync badge states, tooltip, name, announcements and clicks; §12 the badge in the app bar; §14 the badge's Reconnect and Retry are click handlers; v1.17.0: Settings → Database tab, DB Inspector and DB Schema rebuilt on Clarity: §1 version; §3 views-db.js role; §6 Database classes; §12 seventh Settings tab, its sub-tabs, the hidden-tab guard, the tab bar scrolling to the selected tab, view registrations; §19 database views; v1.16.27: §9 and §12 the developer views are in `NO_RANGE_VIEWS`; §14 the tab's name is Data & reset; §20 a render partway through an action keeps its button's done label (`DataCleanup.running`); v1.16.26: the type decides whether a transaction is a transfer: §4.1 the drawers note, a type changed away from Transfer; §8 the sums go by type, `ownCategories()`, `withoutDirection()`, `redetectTransfers()`; §11 the type decides, Transfer out / in only on transfers, a type change re-runs detection; §11.1 a retyped autopay's card payment; §13 the summary's totals go by type; v1.16.25: Re-categorize all is saved: §1 version; §4.1 `ruleCategories`; §4.2 categories; §8 `categoryFromRules()`; §9 `recategorizeAll()`; §20 where `ruleCategories` is kept, what clears it, left out of the JSON export; v1.16.24: a transfer that stops being one gets its own type and category back: §8 `undoTransferDetection()`, §11 un-transferring; v1.16.23: transfer detection respects the user's type and categories: §1 version, §4.1 the drawers note, §11 the user's edit wins; v1.16.22: §13 the AI summary's trend stops at a past period's end, subscriptions labelled current; §21 the parser finds sections by their heading's start; v1.16.21: §8 `overrideBalanceAt()`, `balanceAt()`; §18 a card's amount owed is taken back plus the later transactions, a bank balance minus them; the sign of a card's balance; v1.16.20: §18 a balance override at the end of every range is the override less the later transactions, no 7-day exception; v1.16.19: overlapping exports of one account count each shared row once: §4.5 one transaction per id; §8 `mergeImported()`, `importParsedCsv()`; §10 overlapping exports of one account; §19 the leftover-copy check keeps the import's ids; v1.16.18: a JSON import's transaction edits show at once: §9 `applyImportedEdits()`; §20 what takes effect without a reload; v1.16.17: a file read into another account takes its rows with it: §4.3 the account's `files`, `accountFiles()`; §4.5 the exception to an import never dropping rows; §8 `accountFiles()`, `earlierCopies()`, `moveEarlierCopies()`; §10 Read as changes the ids; §19 `DB.removeTransactions()`, `DB.patchAccount()`; §22 `_rekey()` is also used by imports; v1.16.16: the transaction and transfer drawers store only the fields you change: §4.1 TxOverride, §11 transfer keywords, §12 state mutations; v1.16.15: §1 desktop only; §3 `views-database.js` removed, the Changelog view reads CHANGELOG.md; §6, §18, §19 point to the current screens; v1.16.14: §9 the drawer is a modal dialog: inert page, Tab wraps, first field focused, focus returns after a re-render; v1.16.13: account order: §4.1 `accountOrder` keeps ids not loaded; §8 `orderedAccounts()`; §18 display order; v1.16.12: account names: §4.1 `accountLabels`; §4.3 an account's `name` is its default; §8 `defaultAccountName()`; §9 `acctName()`; §13 the summary lists accounts by `acctName()`; §18 names you type, and clearing them; v1.16.11: AI summary balances at the end of the selected period: §13 `summaryBalanceEnd()` and the date notes; §21 the parser reads the balance lines by how they start; v1.16.10: starting balance counted in every range: §8 `startingBalance()`, `currentBalances()`; §13 summary balances; §18 one balance rule; v1.16.9: the transaction drawer stores only the flags you change: §4.2 flags, §12 state mutations; v1.16.8: flags from transaction rules are worked out on load, never read from storage: §4.2 flags, §8 `txRuleFlag()` / `txFlags()` / `applyTxRulesToAll()`, §19 flags in the transactions store; v1.16.7: transaction links to budgets, buckets and debts by id: §1 version; §3 migrate.js role; §4.1 the `id` fields, TxOverride and its `link` shape; §8 `PLAN_LINK_TYPES`, `newPlanId()`; §9 `planItem()`, `linkedTransactions()`, `unlinkPlanItems()`, `ensurePlanIds()`; §20 clearing budgets, buckets or debts removes their links; §22 the plan-link migration; v1.16.6: JSON export carries account names and order, Read as, flag rules, transfer keywords and theme: §9 `JSON_EXPORT_KEYS`; §12 adding a persisted field; §20 what the export holds; v1.16.5: a new database copies every saved setting: §4.5 the keys that go through `storageKey()`; §12 adding a persisted field; §19 `DB.APP_STATE_KEYS`; v1.16.4: JSON import saves under the storage keys: §4.5 `STORAGE_KEYS` / `storageKey()`; §8 `storageKey()`; §12 adding a persisted field uses the State name; v1.16.3: An import keeps the loaded transactions it doesn't replace: §4.5 an import never drops loaded transactions; §8 `mergeImported()`, `undoTransferDetection()`; §10 memory follows the same re-upload rules; §11 kept transfers are detected again; v1.16.2: Imports read every file before changing anything: §8 `loadFiles()` options and result, `parseCsvFile()` reads only, `importParsedCsv()`, `importProblemText()`, `reloadFromDirHandle()`; §20 Rebuild from your folder reads before it clears, read before clearing; v1.16.1: Clear transaction edits works from memory: §4.2 `_originalCategories`, `_originalType` and what a transaction keeps of its row; §4.5 IndexedDB may not hold the loaded transactions; §10 a stored row holds the CSV's values; §20 Clear transaction edits; v1.16.0: Trends, AI Analysis, Settings and the Welcome rebuilt: §3 file roles; §4.1 `cardDetails`, `sampleData`, AI history `model`; §6 tile tokens and Insights/Settings/Welcome components; §7 rule 7 covers Trends; §8 `loadFiles()` sample option; §9 `shellHero()`, `NO_RANGE_VIEWS`, ☰ → ×, JSON export/import buttons; §12 Settings tabs renamed, the Welcome, range label hidden on Trends and Settings; §13 quick analyses, tab state, history; §20 new fields, Use my own files, Export/Import JSON; §21 quick analyses send `AI_PROMPTS`; v1.15.0: Plan screens rebuilt: §3 file roles; §4.1 `debtPlan` and the Budget / Bucket / Debt / Scenario shapes; §6 `--teal-text` and Plan components; §7 rule 7 covers the Plan charts; §8 plan helpers (`payoffPlan()`, `growthByYear()` …); §9 plan form helpers, `simulatePayoff()` kept unused; §12 Plan titles, Forecast tabs, range label hidden on Plan screens; §13 debt balances in the summary; §20 Clear forecast scenarios / investment mix, Clear debts resets `debtPlan`; v1.14.0: Finances screens rebuilt: §4.1 Bill shape and `recurringConfirmed`; §6 new tokens and Finances components; §7 rule 7 inline SVG charts; §8 new aggregation helpers; §9 drawer, `TX_VIEWS`, SVG/heatmap/format helpers, bill helpers, removed renderers; §11 keywords re-run detection, `transferPairs()`, card payments; §12 page actions and Transactions pills; §20 Clear bills also clears spotted-charge choices; v1.13.0: Clarity redesign — §6 tokens and components; §9 theme, chart colors and app bar helpers; §12 app bar navigation replaces the sidebar and the emoji rule; §17 footer; §20 Data & Reset; v1.12.0: bank-neutral account codes, names, samples and docs; §22 `migrate.js` added; §3, §4, §5, §10, §18, §21 updated; v1.10.5: no section changes: Import Logs race fix; v1.10.4: §8, §13 single `~` is literal; §21 excerpts escape `~`; v1.10.3: §13 refusals; §21 declined runs; v1.10.2: §13 summary states its dates; §21 per-month derivations; v1.10.1: §13 `AI_MAX_TOKENS`, thinking and cut-off answers; v1.10.0: §21 AI Eval added; §13 `callClaude()` raw mode; v1.9.6: §14 folder access lasts until the last tab closes, not one page load; v1.9.5: §7 chart guard; v1.9.4: views-db.js changelog reads CRLF checkouts; v1.9.3: §14 folder permission: re-request on click, Reconnect banner, browser copy wins; v1.9.2: §0 branch, PR and approval workflow added; v1.9.1: §4.5 rehydrate transactions loaded from JSON; v1.9.0: §20 Data & Reset added; v1.8.1: §10 re-upload dedup rewritten for the occurrence-suffix fix; v1.8.0: AI Analysis restored with API-key and output hardening; `renderMarkdown()` added; `samples/` added; §1–3, §13, §14, §17 corrected to match the code)* +*Last updated: v2.0.2 — 2026-11-04 (v2.0.2: generic storage names and the rename script: §2, §3, §4.5, §8, §14, §19, §20 storage names; v1.18.1: MIT license added, no section changes; v1.18.0: backup status in the sync badge, save before leaving: §1 version; §4.5 1.5 s save delay, save state, flush when the tab is hidden, leave warning, Start over; §6 sync badge classes; §8 `flushPendingSave()`, `backupWouldBeLost()`; §9 sync badge states, tooltip, name, announcements and clicks; §12 the badge in the app bar; §14 the badge's Reconnect and Retry are click handlers; v1.17.0: Settings → Database tab, DB Inspector and DB Schema rebuilt on Clarity: §1 version; §3 views-db.js role; §6 Database classes; §12 seventh Settings tab, its sub-tabs, the hidden-tab guard, the tab bar scrolling to the selected tab, view registrations; §19 database views; v1.16.27: §9 and §12 the developer views are in `NO_RANGE_VIEWS`; §14 the tab's name is Data & reset; §20 a render partway through an action keeps its button's done label (`DataCleanup.running`); v1.16.26: the type decides whether a transaction is a transfer: §4.1 the drawers note, a type changed away from Transfer; §8 the sums go by type, `ownCategories()`, `withoutDirection()`, `redetectTransfers()`; §11 the type decides, Transfer out / in only on transfers, a type change re-runs detection; §11.1 a retyped autopay's card payment; §13 the summary's totals go by type; v1.16.25: Re-categorize all is saved: §1 version; §4.1 `ruleCategories`; §4.2 categories; §8 `categoryFromRules()`; §9 `recategorizeAll()`; §20 where `ruleCategories` is kept, what clears it, left out of the JSON export; v1.16.24: a transfer that stops being one gets its own type and category back: §8 `undoTransferDetection()`, §11 un-transferring; v1.16.23: transfer detection respects the user's type and categories: §1 version, §4.1 the drawers note, §11 the user's edit wins; v1.16.22: §13 the AI summary's trend stops at a past period's end, subscriptions labelled current; §21 the parser finds sections by their heading's start; v1.16.21: §8 `overrideBalanceAt()`, `balanceAt()`; §18 a card's amount owed is taken back plus the later transactions, a bank balance minus them; the sign of a card's balance; v1.16.20: §18 a balance override at the end of every range is the override less the later transactions, no 7-day exception; v1.16.19: overlapping exports of one account count each shared row once: §4.5 one transaction per id; §8 `mergeImported()`, `importParsedCsv()`; §10 overlapping exports of one account; §19 the leftover-copy check keeps the import's ids; v1.16.18: a JSON import's transaction edits show at once: §9 `applyImportedEdits()`; §20 what takes effect without a reload; v1.16.17: a file read into another account takes its rows with it: §4.3 the account's `files`, `accountFiles()`; §4.5 the exception to an import never dropping rows; §8 `accountFiles()`, `earlierCopies()`, `moveEarlierCopies()`; §10 Read as changes the ids; §19 `DB.removeTransactions()`, `DB.patchAccount()`; §22 `_rekey()` is also used by imports; v1.16.16: the transaction and transfer drawers store only the fields you change: §4.1 TxOverride, §11 transfer keywords, §12 state mutations; v1.16.15: §1 desktop only; §3 `views-database.js` removed, the Changelog view reads CHANGELOG.md; §6, §18, §19 point to the current screens; v1.16.14: §9 the drawer is a modal dialog: inert page, Tab wraps, first field focused, focus returns after a re-render; v1.16.13: account order: §4.1 `accountOrder` keeps ids not loaded; §8 `orderedAccounts()`; §18 display order; v1.16.12: account names: §4.1 `accountLabels`; §4.3 an account's `name` is its default; §8 `defaultAccountName()`; §9 `acctName()`; §13 the summary lists accounts by `acctName()`; §18 names you type, and clearing them; v1.16.11: AI summary balances at the end of the selected period: §13 `summaryBalanceEnd()` and the date notes; §21 the parser reads the balance lines by how they start; v1.16.10: starting balance counted in every range: §8 `startingBalance()`, `currentBalances()`; §13 summary balances; §18 one balance rule; v1.16.9: the transaction drawer stores only the flags you change: §4.2 flags, §12 state mutations; v1.16.8: flags from transaction rules are worked out on load, never read from storage: §4.2 flags, §8 `txRuleFlag()` / `txFlags()` / `applyTxRulesToAll()`, §19 flags in the transactions store; v1.16.7: transaction links to budgets, buckets and debts by id: §1 version; §3 migrate.js role; §4.1 the `id` fields, TxOverride and its `link` shape; §8 `PLAN_LINK_TYPES`, `newPlanId()`; §9 `planItem()`, `linkedTransactions()`, `unlinkPlanItems()`, `ensurePlanIds()`; §20 clearing budgets, buckets or debts removes their links; §22 the plan-link migration; v1.16.6: JSON export carries account names and order, Read as, flag rules, transfer keywords and theme: §9 `JSON_EXPORT_KEYS`; §12 adding a persisted field; §20 what the export holds; v1.16.5: a new database copies every saved setting: §4.5 the keys that go through `storageKey()`; §12 adding a persisted field; §19 `DB.APP_STATE_KEYS`; v1.16.4: JSON import saves under the storage keys: §4.5 `STORAGE_KEYS` / `storageKey()`; §8 `storageKey()`; §12 adding a persisted field uses the State name; v1.16.3: An import keeps the loaded transactions it doesn't replace: §4.5 an import never drops loaded transactions; §8 `mergeImported()`, `undoTransferDetection()`; §10 memory follows the same re-upload rules; §11 kept transfers are detected again; v1.16.2: Imports read every file before changing anything: §8 `loadFiles()` options and result, `parseCsvFile()` reads only, `importParsedCsv()`, `importProblemText()`, `reloadFromDirHandle()`; §20 Rebuild from your folder reads before it clears, read before clearing; v1.16.1: Clear transaction edits works from memory: §4.2 `_originalCategories`, `_originalType` and what a transaction keeps of its row; §4.5 IndexedDB may not hold the loaded transactions; §10 a stored row holds the CSV's values; §20 Clear transaction edits; v1.16.0: Trends, AI Analysis, Settings and the Welcome rebuilt: §3 file roles; §4.1 `cardDetails`, `sampleData`, AI history `model`; §6 tile tokens and Insights/Settings/Welcome components; §7 rule 7 covers Trends; §8 `loadFiles()` sample option; §9 `shellHero()`, `NO_RANGE_VIEWS`, ☰ → ×, JSON export/import buttons; §12 Settings tabs renamed, the Welcome, range label hidden on Trends and Settings; §13 quick analyses, tab state, history; §20 new fields, Use my own files, Export/Import JSON; §21 quick analyses send `AI_PROMPTS`; v1.15.0: Plan screens rebuilt: §3 file roles; §4.1 `debtPlan` and the Budget / Bucket / Debt / Scenario shapes; §6 `--teal-text` and Plan components; §7 rule 7 covers the Plan charts; §8 plan helpers (`payoffPlan()`, `growthByYear()` …); §9 plan form helpers, `simulatePayoff()` kept unused; §12 Plan titles, Forecast tabs, range label hidden on Plan screens; §13 debt balances in the summary; §20 Clear forecast scenarios / investment mix, Clear debts resets `debtPlan`; v1.14.0: Finances screens rebuilt: §4.1 Bill shape and `recurringConfirmed`; §6 new tokens and Finances components; §7 rule 7 inline SVG charts; §8 new aggregation helpers; §9 drawer, `TX_VIEWS`, SVG/heatmap/format helpers, bill helpers, removed renderers; §11 keywords re-run detection, `transferPairs()`, card payments; §12 page actions and Transactions pills; §20 Clear bills also clears spotted-charge choices; v1.13.0: Clarity redesign — §6 tokens and components; §9 theme, chart colors and app bar helpers; §12 app bar navigation replaces the sidebar and the emoji rule; §17 footer; §20 Data & Reset; v1.12.0: bank-neutral account codes, names, samples and docs; §22 `migrate.js` added; §3, §4, §5, §10, §18, §21 updated; v1.10.5: no section changes: Import Logs race fix; v1.10.4: §8, §13 single `~` is literal; §21 excerpts escape `~`; v1.10.3: §13 refusals; §21 declined runs; v1.10.2: §13 summary states its dates; §21 per-month derivations; v1.10.1: §13 `AI_MAX_TOKENS`, thinking and cut-off answers; v1.10.0: §21 AI Eval added; §13 `callClaude()` raw mode; v1.9.6: §14 folder access lasts until the last tab closes, not one page load; v1.9.5: §7 chart guard; v1.9.4: views-db.js changelog reads CRLF checkouts; v1.9.3: §14 folder permission: re-request on click, Reconnect banner, browser copy wins; v1.9.2: §0 branch, PR and approval workflow added; v1.9.1: §4.5 rehydrate transactions loaded from JSON; v1.9.0: §20 Data & Reset added; v1.8.1: §10 re-upload dedup rewritten for the occurrence-suffix fix; v1.8.0: AI Analysis restored with API-key and output hardening; `renderMarkdown()` added; `samples/` added; §1–3, §13, §14, §17 corrected to match the code)* --- @@ -1165,7 +1165,7 @@ The full IndexedDB implementation is live. See **`docs/archive/INDEXEDDB_PLAN.md ### What changed - **`js/db.js`** — new file, loaded after `core.js`. Exposes the `DB` object and `reconstituteTx()` helper. -- **`DB.open()`** — opens `finlens_db` v1, creates all 5 object stores, migrates `localStorage → app_state` on first run. +- **`DB.open()`** — opens `app_db` v1, creates all 5 object stores, migrates `localStorage → app_state` on first run. - **`DB.loadAll()`** — called at boot in `init.js`. Restores all State from IndexedDB (including transactions). Users do not need to re-upload CSVs after a page refresh. The State keys it reads, and their defaults, are `DB.APP_STATE_KEYS`, each under `storageKey()` (v1.16.5). A new database copies the same keys from localStorage (`_migrateSync()`). - **`lsSet(k, v)`** — now also calls `DB.setState(k, v)` fire-and-forget when DB is ready. All existing `lsSet` call sites work unchanged. - **`State.transactions`** remains the in-memory working array. All views use `filteredTx()` unchanged. @@ -1201,13 +1201,18 @@ Add a new object to `BANK_SCHEMAS` in `core.js`. No other code changes needed if | Place | What | Cleared by | |---|---|---| | Memory | `State` | every action (then `renderActive()`) | -| `localStorage` | `finlens_*` keys via `lsSet`/`lsGet` (plus `finlens_navCollapsed`, left by the sidebar before v1.13.0 and no longer written) | per-item `lsSet(key, [])`; Start over → `lsClearAll()` | -| IndexedDB `finlens_db` | `DB.STORES`: transactions, tx_overrides, accounts, import_log, app_state | `DB.clearStores([...])`; Start over → `DB.destroy()` | -| IndexedDB `finlens_fs` | `csvDirHandle` (the connected folder) | Disconnect → `forgetDataFolder()`; Start over → `deleteIndexedDB('finlens_fs')` | -| Disk | `finlens-state.json` in the connected folder | rewritten by `saveStateNow()` after clears; Start over → `retireStateFile()` | +| `localStorage` | `app_*` keys via `lsSet`/`lsGet` (plus `app_navCollapsed`, left by the sidebar before v1.13.0 and no longer written) | per-item `lsSet(key, [])`; Start over → `lsClearAll()` | +| IndexedDB `app_db` | `DB.STORES`: transactions, tx_overrides, accounts, import_log, app_state | `DB.clearStores([...])`; Start over → `DB.destroy()` | +| IndexedDB `app_fs` | `csvDirHandle` (the connected folder) | Disconnect → `forgetDataFolder()`; Start over → `deleteIndexedDB('app_fs')` | +| Disk | `app-state.json` in the connected folder | rewritten by `saveStateNow()` after clears; Start over → `retireStateFile()` | CSV files on disk are **never** modified. +**Storage names are generic (v2.0.2)** — not the app's name, so a rename never touches them: IndexedDB `app_db` and `app_fs`, the `app_` localStorage prefix, `app-state.json` (backups `app-state-backup-*.json`), exports `app-export-*.json`, `ai-analysis-*.md`, `ai-history-*.md`. +- **Up to v1.18.x they were `finlens_db`, `finlens_fs`, `finlens_*`, `finlens-state.json`, `finlens-export-*`** and so on. FinLens doesn't read or move data under those names (Luis's decision, 2026-11-04: names only, start fresh). After updating, the app opens empty: import the CSVs again and import a JSON export made before updating. The old copies stay in the browser and the folder until removed by hand (browser site data; delete the old `finlens-state.json`). Start over doesn't touch them. +- **`.gitignore` lists only the new file names** (Luis, 2026-11-04). An old `finlens-state.json` or export left in a folder inside the repo is no longer ignored; `data/` still is, whatever it holds. +- **AI Eval results** are written with `kind: 'ai-eval-results'`; the view still loads `finlens-ai-eval` files (the published runs in `docs/`). + ### Actions | Action | Does | Keeps | @@ -1220,17 +1225,17 @@ CSV files on disk are **never** modified. | Disconnect folder | `forgetDataFolder()` — stops CSV auto-reload and state-file writes | Files on disk | | Use my own files (sample banner) | `clearSampleData()`: transactions, accounts and import log (memory + stores), `sampleData` off, state file rewritten, back to the Welcome | Plans made while trying it | | Export / Import JSON | `exportAllData()` / `importJsonFile()` (`JSON_EXPORT_KEYS`, rules below) — not a clear; listed because it lives here | — | -| Start over | `retireStateFile()` → forget handle → `DB.destroy()` → delete `finlens_fs` → `lsClearAll()` → alert → reload | CSV files (and a backup copy of the state file, if chosen) | +| Start over | `retireStateFile()` → forget handle → `DB.destroy()` → delete `app_fs` → `lsClearAll()` → alert → reload | CSV files (and a backup copy of the state file, if chosen) | ### Rules -- **Rewrite the state file after clearing anything it holds.** `finlens-state.json` is the first thing loaded at boot, so a stale file would restore cleared data. `_finish()` calls `saveStateNow()` and warns if write access was refused. +- **Rewrite the state file after clearing anything it holds.** `app-state.json` is the first thing loaded at boot, so a stale file would restore cleared data. `_finish()` calls `saveStateNow()` and warns if write access was refused. - **Clearing transactions must survive a reload.** A connected folder re-imports its CSVs at boot whenever no transactions are loaded (§14), so `clearImported()` sets `State.csvAutoImportPaused` and `init.js` skips the boot re-import while it's set. - **Ending the pause:** `loadFiles()` clears it, so any import the user starts ends it: Reload or Reconnect, Rebuild, picking a folder or files. `forgetDataFolder()` (Disconnect) clears it too, and Start over removes it with the other keys. - - **Browser storage only:** it lives in localStorage and IndexedDB `app_state`, never in `finlens-state.json`. Saves are debounced, so a backup file written before the pause ended could otherwise switch it back on. If browser storage is wiped, the folder re-imports as a recovery. + - **Browser storage only:** it lives in localStorage and IndexedDB `app_state`, never in `app-state.json`. Saves are debounced, so a backup file written before the pause ended could otherwise switch it back on. If browser storage is wiped, the folder re-imports as a recovery. - **Permission prompts need a fresh user gesture.** Destructive buttons use click-again-to-confirm (no `confirm()` dialog, which can outlast the ~5 s activation window), and actions ask for folder access before doing slow work. - **A button's done label survives a render partway through** (v1.16.27). `DataCleanup.running` names the action under way, and `_done()` ends it. A render meanwhile (Rebuild's import renders the tab) shows that button as Working… and leaves `lastDone` for `_done()`'s render. Every action ends in `_done()`, directly or through `_finish()`. -- **Clear transaction edits works from the loaded transactions, never from IndexedDB** (v1.16.1). The `transactions` store can be empty after a restore from `finlens-state.json` (§4.5); up to v1.16.0 the action replaced the loaded transactions with its rows, so every transaction vanished and `_finish()` saved the empty list to the file. +- **Clear transaction edits works from the loaded transactions, never from IndexedDB** (v1.16.1). The `transactions` store can be empty after a restore from `app-state.json` (§4.5); up to v1.16.0 the action replaced the loaded transactions with its rows, so every transaction vanished and `_finish()` saved the empty list to the file. - **The originals:** `_originalDesc`, `_originalCategories` and `_originalType` on the transaction (§4.2), flags from the rules. A store row only fills in the category and type when the transaction doesn't record them. - **A file from ≤v1.16.0 with no row:** if a transaction's category or type was edited, or it was marked as a transfer, they are worked out again from the description and amount (`autoCategory()`, debit/credit by sign), as an import of a file without Category and Type columns does. That is exact for checking and savings; a card's own Category and Type (Sale/Payment/Return) can't be recovered. Rebuild from your folder gets them back. Worked-out values aren't recorded as `_original*`. - **The `transactions` store isn't written.** When it's empty, the transactions aren't copied into it: filling it is an import's job (accounts, coverage, import log), and the state file, rewritten by `_finish()`, holds all of them. @@ -1238,16 +1243,16 @@ CSV files on disk are **never** modified. - **An import that reads nothing changes nothing.** `loadFiles()` reads every file (`parseCsvFile()` writes nothing) before it touches memory, storage, IndexedDB, the sample data or the import pause. When no transaction was read it stops there, and says why (an alert, or the caller's message with `quiet`). That covers every import: Rebuild, Reload, Reconnect, picking a folder, choosing files, boot. - **Rebuild clears only when nothing would be lost.** It passes `beforeImport` to `loadFiles()`, which calls it after the read. `_rebuildBlockers()` lists the files that would cost transactions: one the browser couldn't open or parse, one in a known format (by columns or name) with no readable row, one imported before whose columns are no longer recognised. If there are any, nothing is cleared and the tab names them. A CSV in another format that was never imported is skipped, as every import skips it, and the success message names it. - **After the clear, the import finishes.** `importParsedCsv()` logs a failing IndexedDB write and carries on, so `_finish()` still saves every transaction to the file. -- **Start over must leave nothing behind.** It sets `State._resetInProgress` so a pending debounced save can't re-create the file, and `DB.open()` registers `onversionchange` so other tabs release the database. `idbGet`/`idbSet`/`idbDel` close their connections so `finlens_fs` can be deleted. -- **Fields added in v1.16.0:** `cardDetails` and `sampleData` live in localStorage, IndexedDB `app_state` (`DB.loadAll()`) and `finlens-state.json`; Start over erases both with everything else. `cardDetails` is also in the JSON export. Importing your own files turns `sampleData` off after clearing the samples (`loadFiles()`). -- **`ruleCategories` (v1.16.25)** lives in localStorage, IndexedDB `app_state` and `finlens-state.json`, like `txOverrides`, and is kept by the same actions (it's keyed by transaction id, so an import brings it back with the transactions). Only Start over erases it. Re-categorize all removes a transaction's entry once its category is the import's again (its rule removed). Not in the JSON export (below). +- **Start over must leave nothing behind.** It sets `State._resetInProgress` so a pending debounced save can't re-create the file, and `DB.open()` registers `onversionchange` so other tabs release the database. `idbGet`/`idbSet`/`idbDel` close their connections so `app_fs` can be deleted. +- **Fields added in v1.16.0:** `cardDetails` and `sampleData` live in localStorage, IndexedDB `app_state` (`DB.loadAll()`) and `app-state.json`; Start over erases both with everything else. `cardDetails` is also in the JSON export. Importing your own files turns `sampleData` off after clearing the samples (`loadFiles()`). +- **`ruleCategories` (v1.16.25)** lives in localStorage, IndexedDB `app_state` and `app-state.json`, like `txOverrides`, and is kept by the same actions (it's keyed by transaction id, so an import brings it back with the transactions). Only Start over erases it. Re-categorize all removes a transaction's entry once its category is the import's again (its rule removed). Not in the JSON export (below). - **No link outlives its item (v1.16.7). Clearing budgets, buckets or debts removes the `txOverrides[…].link`s of that type, as deleting one item removes the links to it. Clear transaction edits and Start over remove every link with the rest of the edits. The ids live inside `budgets`, `buckets` and `debts`, so no new place stores data. - `clearAllData()` is kept as a wrapper for `DataCleanup.startOver('archive')`. Up to v1.8.1 it only cleared `localStorage`, which IndexedDB and the state file then restored on reload. - **The JSON export holds everything you entered or chose (v1.16.6).** `JSON_EXPORT_KEYS` (`ui.js`) lists the State keys and the kind of value each holds (`list`, `map`, `theme`); `exportAllData()` writes them with `version: '1.2'` and `importJsonFile()` reads the same list. - **Left out:** transactions and accounts (the CSVs bring them back), the API key (§15), this browser's own state (`sampleData`, `csvAutoImportPaused`, `lastFolderName`), the session (date range, accounts filter, current view), `customSchemas` (nothing creates one, §19) and `ruleCategories` (worked out from the category rules, which are in it: Re-categorize all after an import gives it again). - **Import:** accepts any version (1.0, 1.1, 1.2). A key that's missing, null or of the wrong kind keeps its current value. Each key is saved through `storageKey()` (§4.5). A file that isn't a JSON object is refused. - **Takes effect without a reload** (`_applyImportedSettings()`, for keys whose value changed): account names and order, flag rules (`applyTxRulesToAll()`), transfer keywords (`reapplyTransferKeywords()`) and transaction edits (v1.16.18). `theme` is carried but nothing reads it (light only for now). Category rules apply to the next import, "Read as" choices to the next CSV read. - - **Transaction edits** (`applyImportedEdits()`, v1.16.18): every loaded transaction is rebuilt from its CSV values (`DataCleanup._unedited()`, as Clear transaction edits does) with its imported edit on top (`reconstituteTx()`), then `detectTransfers()` runs. That's what importing the CSVs with these edits makes. A transaction the file has no edit for goes back to its CSV values, and flags an edit doesn't set follow the rules now in State. This rebuild covers new flag rules and transfer keywords too, so with new edits those two steps don't run on their own. Plan links need nothing: they're read from `State.txOverrides`. Up to v1.16.17 the edits were only saved: every screen kept the old values until a reload, and a reload from `finlens-state.json` kept an old edit's category and type where the file had no edit. + - **Transaction edits** (`applyImportedEdits()`, v1.16.18): every loaded transaction is rebuilt from its CSV values (`DataCleanup._unedited()`, as Clear transaction edits does) with its imported edit on top (`reconstituteTx()`), then `detectTransfers()` runs. That's what importing the CSVs with these edits makes. A transaction the file has no edit for goes back to its CSV values, and flags an edit doesn't set follow the rules now in State. This rebuild covers new flag rules and transfer keywords too, so with new edits those two steps don't run on their own. Plan links need nothing: they're read from `State.txOverrides`. Up to v1.16.17 the edits were only saved: every screen kept the old values until a reload, and a reload from `app-state.json` kept an old edit's category and type where the file had no edit. - **A new persisted field the user enters or chooses** goes in `JSON_EXPORT_KEYS` too. --- @@ -1288,7 +1293,7 @@ Up to v1.10.x the account kinds, `BANK_SCHEMAS` ids, account ids and default acc | Account ids | `bank_checking`, `bank_savings`, `card_` | | Default names | `Bank Checking`, `Bank Savings`, `Bank Credit Card ()` | -The old codes and names are listed in `migrate.js` and nowhere else in the code or current docs. They stay there so browser storage, `finlens-state.json`, its backups and JSON exports from older versions still load. +The old codes and names are listed in `migrate.js` and nowhere else in the code or current docs. They stay there so browser storage, `app-state.json`, its backups and JSON exports from older versions still load. **Where it runs** - **Boot** (`init.js`): `IdMigration.atBoot()`, after State is loaded. It rewrites State, then IndexedDB (`transactions`, `tx_overrides`, `accounts`, `import_log`, `app_state`, all in one transaction) and localStorage. It only reads the `transactions` store when the `accounts` store still has an old id. diff --git a/README.md b/README.md index c1b2e8b..eb4b484 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > A zero-install, browser-native finance dashboard. Drop in your bank CSVs, get instant clarity on your money. -**Version:** v1.18.1  ·  **Stack:** Vanilla JS, HTML, CSS  ·  **Install:** None  ·  **License:** [MIT](LICENSE) +**Version:** v2.0.2  ·  **Stack:** Vanilla JS, HTML, CSS  ·  **Install:** None  ·  **License:** [MIT](LICENSE) --- @@ -87,15 +87,15 @@ Use the top bar to move between views: Overview, Transactions, **Plan** (a menu FinLens stores all your data — budgets, bills, transaction edits, settings, AI history, forecasts — **directly on your computer**, never on a server. **How it works:** -1. When you pick a CSV folder (on the Welcome screen or in **Settings → Import**), FinLens begins auto-saving your complete state to `finlens-state.json` in that same folder. +1. When you pick a CSV folder (on the Welcome screen or in **Settings → Import**), FinLens begins auto-saving your complete state to `app-state.json` in that same folder. 2. A moment after each change (1.5 seconds), your dashboard state is written to this file. The badge in the top bar says **Saving…** until it's written, then **Synced**. Switching to another tab saves right away, and if you close the tab while a save is still waiting, the browser asks first. You can also click **Save now** in **Settings → Import** to force an immediate sync. -3. On every page load, FinLens checks for `finlens-state.json` first — if it exists, your entire state (budgets, categories, overrides, settings, AI history) is restored instantly. If the file doesn't exist, it falls back to the browser's IndexedDB or localStorage. -4. Because the file lives on your computer (not in the browser), **it survives browser cache clearing**. Even if you clear all browser data, your `finlens-state.json` remains on your disk and will be restored on the next load. +3. On every page load, FinLens checks for `app-state.json` first — if it exists, your entire state (budgets, categories, overrides, settings, AI history) is restored instantly. If the file doesn't exist, it falls back to the browser's IndexedDB or localStorage. +4. Because the file lives on your computer (not in the browser), **it survives browser cache clearing**. Even if you clear all browser data, your `app-state.json` remains on your disk and will be restored on the next load. **Disaster recovery:** -- If the browser IndexedDB is wiped or you switch browsers, your `finlens-state.json` file is still there on your disk. +- If the browser IndexedDB is wiped or you switch browsers, your `app-state.json` file is still there on your disk. - You can manually back up the file using your OS file manager, Dropbox, iCloud, or any cloud sync service. -- To restore from a backup: place the backup `finlens-state.json` back in your folder and refresh the browser. +- To restore from a backup: place the backup `app-state.json` back in your folder and refresh the browser. **No cloud sync:** FinLens never communicates with any server for data storage. Your data is yours, on your machine. If you want cloud sync, use your OS's native file sync (iCloud Drive, OneDrive, Google Drive, Dropbox). @@ -152,9 +152,9 @@ Seven tabs: **General** (theme and your API key), **Import** (your CSV folder, h - **No accounts linked.** You export CSVs manually — FinLens never connects to any bank. - **No data leaves your machine.** Everything runs in your browser. The only external request is the Claude AI call (if you use it), which goes directly to Anthropic's API using your own key. - **No server.** `index.html` opens directly from your filesystem. -- **Persistence:** all state is saved to `finlens-state.json` in your CSV folder (filesystem-first), with IndexedDB and localStorage as fallbacks. Survives browser cache clearing. Transactions survive page reloads without re-uploading CSVs. -- **Backup & Restore** — your `finlens-state.json` is a plain JSON file you can copy anywhere. It never contains your Anthropic API key. -- **Git-safe** — `data/`, `finlens-state.json`, settings exports and saved AI analyses are all git-ignored. +- **Persistence:** all state is saved to `app-state.json` in your CSV folder (filesystem-first), with IndexedDB and localStorage as fallbacks. Survives browser cache clearing. Transactions survive page reloads without re-uploading CSVs. +- **Backup & Restore** — your `app-state.json` is a plain JSON file you can copy anywhere. It never contains your Anthropic API key. +- **Git-safe** — `data/`, `app-state.json`, settings exports and saved AI analyses are all git-ignored. --- @@ -167,7 +167,7 @@ Seven tabs: **General** (theme and your API key), **Import** (your CSV folder, h FinLens uses `claude-sonnet-5-5`. **How the AI feature is secured:** -- **Key storage** — the key is stored only in your browser (localStorage + IndexedDB). It is never written to `finlens-state.json`. Versions up to 1.7.0 did save it there; v1.8.0 moves it into browser storage on load and rewrites the file without it on the next save (click **Save now** in **Settings → Import** to force it). For a personal, local-only app this is a deliberate trade-off; a multi-user deployment would proxy calls through a server holding the key in a secrets vault. +- **Key storage** — the key is stored only in your browser (localStorage + IndexedDB). It is never written to `app-state.json`. Versions up to 1.7.0 did save it there; v1.8.0 moves it into browser storage on load and rewrites the file without it on the next save (click **Save now** in **Settings → Import** to force it). For a personal, local-only app this is a deliberate trade-off; a multi-user deployment would proxy calls through a server holding the key in a secrets vault. - **Prompt injection** — your financial summary is sent inside `` tags, and the system prompt tells Claude to treat that content as data, never as instructions (merchant descriptions come straight from bank CSVs). - **Output rendering** — Claude's Markdown is converted with marked and sanitized with DOMPurify before it touches the page (`renderMarkdown()` in `core.js`). Scripts, event handlers and images are stripped, so injected text can't run code or exfiltrate data through image URLs. Both libraries are version-pinned with SRI hashes. @@ -215,7 +215,7 @@ FinLens/ └── js/ ├── core.js ← State, helpers, CSV parsing, bank schema registry ├── aggregation.js ← Pure data aggregation: sums, groupings, recurring detection - ├── db.js ← IndexedDB layer (finlens_db) + ├── db.js ← IndexedDB layer (app_db) ├── migrate.js ← Rewrites data saved by older versions (account codes, v1.12.0) ├── ui.js ← View engine, drawer, chart helpers, CRUD ├── views-main.js ← Overview, Transactions, Income, Bills, Spending, Transfers @@ -249,7 +249,7 @@ FinLens/ - **Clear individual plans:** budgets, bills, buckets, debts, forecast scenarios, the investment mix or AI history. - **Disconnect the folder.** - **Export or import JSON:** your plans, transaction edits and rules, account names, order and balances, card details, categories, transfer keywords and AI history in one file (no transactions, no API key). Older exports still import; anything they don't have stays as it is. -- **Start over from scratch:** type `RESET` to erase everything and reload. Your backup file is either kept as `finlens-state-backup-DDMMYYYY.json` or deleted, your choice. Your CSV files are never touched. +- **Start over from scratch:** type `RESET` to erase everything and reload. Your backup file is either kept as `app-state-backup-DDMMYYYY.json` or deleted, your choice. Your CSV files are never touched. **Looking at the raw data** — **Settings → Database** has two sub-tabs. **DB Inspector** browses each IndexedDB store, read-only and searchable; your API key is never shown. **DB Schema** diagrams the five stores and how they link. @@ -269,4 +269,4 @@ FinLens is released under the [MIT License](LICENSE). Copyright (c) 2026 Luis Ri --- -*FinLens v1.18.1 — Built for one person's finances, designed to be useful for anyone's.* +*FinLens v2.0.2 — Built for one person's finances, designed to be useful for anyone's.* diff --git a/index.html b/index.html index 281d9b5..2e66394 100644 --- a/index.html +++ b/index.html @@ -1220,7 +1220,7 @@
-
v1.18.1 · FinLens
+
v2.0.2 · FinLens
diff --git a/js/core.js b/js/core.js index 4753248..82584bb 100644 --- a/js/core.js +++ b/js/core.js @@ -45,25 +45,25 @@ function newPlanId(type, list) { } } const lsGet = (k, def) => { - try { const v=localStorage.getItem('finlens_'+k); return v?JSON.parse(v):def; } + try { const v=localStorage.getItem('app_'+k); return v?JSON.parse(v):def; } catch(e){ return def; } }; const lsSet = (k, v) => { - localStorage.setItem('finlens_'+k, JSON.stringify(v)); + localStorage.setItem('app_'+k, JSON.stringify(v)); // Mirror to IndexedDB app_state if DB is ready if (typeof DB !== 'undefined' && DB._db) DB.setState(k, v); // Auto-save full State to filesystem (debounced) if (typeof _saveStateDebounced !== 'undefined') _saveStateDebounced(State); }; -// Removes every finlens_* key from localStorage. Only "Start over" should call this. +// Removes every app_* key from localStorage. Only "Start over" should call this. const lsClearAll = () => { - Object.keys(localStorage).filter(k => k.startsWith('finlens_')).forEach(k => localStorage.removeItem(k)); + Object.keys(localStorage).filter(k => k.startsWith('app_')).forEach(k => localStorage.removeItem(k)); }; // Browser storage (lsSet/lsGet and IndexedDB app_state) keeps two State keys under older // names; every other key is stored under its State name. Existing data lives under these // names, so they stay. Code that saves or loads a key by its State name (JSON import, // Data & reset, DB.loadAll() and the new-database copy, migrate.js) goes through -// storageKey(). finlens-state.json and JSON exports use State names. +// storageKey(). app-state.json and JSON exports use State names. const STORAGE_KEYS = { recurringConfirmed: 'recurring', categoryRules: 'catRules' }; const storageKey = (stateKey) => STORAGE_KEYS[stateKey] || stateKey; const debounce = (fn, ms=300) => { let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a),ms); }; }; @@ -138,7 +138,7 @@ async function folderPermission(dir, mode) { try { return await dir.queryPermission({ mode }); } catch(e) { return 'denied'; } } -// Load state from finlens-state.json in the backup folder +// Load state from app-state.json in the backup folder async function loadStateFromFile() { try { const dirHandle = await idbGet('csvDirHandle'); @@ -150,10 +150,10 @@ async function loadStateFromFile() { // Saving needs read-write; without it backups are paused even if reading works. if ((await folderPermission(dirHandle, 'readwrite')) !== 'granted') State._folderNeedsAccess = true; if ((await folderPermission(dirHandle, 'read')) !== 'granted') { - console.log('[FinLens] Folder access needs to be renewed this session; loading from browser storage instead of finlens-state.json'); + console.log('[FinLens] Folder access needs to be renewed this session; loading from browser storage instead of app-state.json'); return null; } - const fileHandle = await dirHandle.getFileHandle('finlens-state.json'); + const fileHandle = await dirHandle.getFileHandle('app-state.json'); const file = await fileHandle.getFile(); const text = await file.text(); const loaded = JSON.parse(text); @@ -177,7 +177,7 @@ async function loadStateFromFile() { const seen = new Set(), all = loaded.transactions.length; loaded.transactions = loaded.transactions.filter(raw => { if (seen.has(raw.id)) return false; seen.add(raw.id); return true; }); if (loaded.transactions.length < all) { - console.log(`[FinLens] finlens-state.json held ${all - loaded.transactions.length} transaction${all - loaded.transactions.length === 1 ? '' : 's'} twice (the same id); one copy of each is kept`); + console.log(`[FinLens] app-state.json held ${all - loaded.transactions.length} transaction${all - loaded.transactions.length === 1 ? '' : 's'} twice (the same id); one copy of each is kept`); _saveStateDebounced(State); } const ovs = loaded.txOverrides || State.txOverrides || {}; @@ -195,8 +195,8 @@ async function loadStateFromFile() { // (migrate.js) and save the file again once it's loaded. const migrated = IdMigration.migrateState(loaded); if (migrated.changed.length) _saveStateDebounced(State); - IdMigration.logPlanLinks(migrated.plan, 'finlens-state.json'); - console.log('[FinLens] State loaded from finlens-state.json'); + IdMigration.logPlanLinks(migrated.plan, 'app-state.json'); + console.log('[FinLens] State loaded from app-state.json'); return loaded; } catch(e) { if (e.name !== 'NotFoundError') { @@ -206,7 +206,7 @@ async function loadStateFromFile() { } } -// Save state to finlens-state.json in the backup folder +// Save state to app-state.json in the backup folder async function saveStateToFile(state) { // "Start over" sets this so a pending debounced save can't re-create the file // between the reset and the page reload. @@ -235,7 +235,7 @@ async function saveStateToFile(state) { } return false; } - const fileHandle = await dirHandle.getFileHandle('finlens-state.json', { create: true }); + const fileHandle = await dirHandle.getFileHandle('app-state.json', { create: true }); const writable = await fileHandle.createWritable(); // Serialize State, being careful with non-serializable properties. // apiKey is deliberately excluded: secrets stay in browser storage only @@ -270,7 +270,7 @@ async function saveStateToFile(state) { await writable.write(JSON.stringify(serializable, null, 2)); await writable.close(); State.lastFsyncTime = new Date(); - console.log('[FinLens] Auto-saved state to finlens-state.json'); + console.log('[FinLens] Auto-saved state to app-state.json'); if (State._folderNeedsAccess) { State._folderNeedsAccess = false; if (typeof updateFolderBanner === 'function') updateFolderBanner(); @@ -288,7 +288,7 @@ async function saveStateToFile(state) { } /* ---------- Scheduled saves and leaving the page (v1.18.0) ---------- */ -// lsSet() schedules a write of finlens-state.json SAVE_DELAY_MS after the last change. +// lsSet() schedules a write of app-state.json SAVE_DELAY_MS after the last change. // Each new change restarts the wait, so a burst of edits makes one write. Changes reach // localStorage and IndexedDB at once; only the file waits. // State._savePending: a write is waiting. State._saving: a write is running (a count, @@ -321,7 +321,7 @@ function flushPendingSave() { return saveStateToFile(State); } -// True when leaving now could leave finlens-state.json behind this browser's copy: a save +// True when leaving now could leave app-state.json behind this browser's copy: a save // is waiting or running, a folder is connected (a stored handle, State._folderLinked) and // its access hasn't lapsed. With no folder, or access lapsed (the banner and badge already // say backups are paused), there's nothing to wait for. Start over never asks. @@ -386,7 +386,7 @@ async function folderWithWriteAccess() { } catch(e) { return null; } } -// Writes finlens-state.json right away, e.g. after clearing data, so the next page +// Writes app-state.json right away, e.g. after clearing data, so the next page // load can't restore what was just cleared from an older file. // Also used by Settings → Import → Save Now. Must be called from a click handler, // since it may need to ask for read-write access. @@ -398,9 +398,9 @@ async function saveStateNow() { return (await saveStateToFile(State)) ? 'saved' : 'error'; } -// Used by "Start over": retires finlens-state.json so reconnecting the same folder +// Used by "Start over": retires app-state.json so reconnecting the same folder // later doesn't bring the old state back. 'archive' renames it to -// finlens-state-backup-DDMMYYYY.json (API key stripped; a second reset on the same +// app-state-backup-DDMMYYYY.json (API key stripped; a second reset on the same // day gets -2, -3, … so an earlier copy is never overwritten); 'delete' removes it. // CSV files are never touched. // Returns { status: 'archived'|'deleted'|'none'|'no-folder'|'denied'|'error', name? }. @@ -409,7 +409,7 @@ async function retireStateFile(mode) { const dir = await folderWithWriteAccess(); if (!dir) return { status: 'denied' }; let fh; - try { fh = await dir.getFileHandle('finlens-state.json'); } + try { fh = await dir.getFileHandle('app-state.json'); } catch(e) { return { status: e.name === 'NotFoundError' ? 'none' : 'error' }; } try { let name; @@ -419,13 +419,13 @@ async function retireStateFile(mode) { try { const obj = JSON.parse(text); delete obj.apiKey; out = JSON.stringify(obj, null, 2); } catch(e) { /* keep raw text */ } const exists = async (nm) => { try { await dir.getFileHandle(nm); return true; } catch(e) { return false; } }; const stamp = dayjs().format('DDMMYYYY'); - name = `finlens-state-backup-${stamp}.json`; - for (let n = 2; await exists(name); n++) name = `finlens-state-backup-${stamp}-${n}.json`; + name = `app-state-backup-${stamp}.json`; + for (let n = 2; await exists(name); n++) name = `app-state-backup-${stamp}-${n}.json`; const w = await (await dir.getFileHandle(name, { create: true })).createWritable(); await w.write(out); await w.close(); } - await dir.removeEntry('finlens-state.json'); + await dir.removeEntry('app-state.json'); return { status: mode === 'archive' ? 'archived' : 'deleted', name }; } catch(e) { console.warn('[FinLens] retireStateFile:', e.message); @@ -434,7 +434,7 @@ async function retireStateFile(mode) { } // Disconnects the CSV folder: FinLens forgets the stored handle, so it stops -// auto-importing that folder's CSVs and writing finlens-state.json there. +// auto-importing that folder's CSVs and writing app-state.json there. // Nothing on disk is changed. async function forgetDataFolder() { await idbDel('csvDirHandle'); @@ -451,14 +451,14 @@ async function forgetDataFolder() { /* ---------- IndexedDB helpers (for persisting FileSystemDirectoryHandle) ---------- */ function _idbOpen() { return new Promise((res, rej) => { - const req = indexedDB.open('finlens_fs', 1); + const req = indexedDB.open('app_fs', 1); req.onupgradeneeded = e => e.target.result.createObjectStore('kv'); req.onsuccess = e => res(e.target.result); req.onerror = () => rej(req.error); }); } // Each helper opens its own short-lived connection and closes it when done -// (close() waits for the transaction), so finlens_fs can be deleted on reset. +// (close() waits for the transaction), so app_fs can be deleted on reset. async function idbGet(key) { try { const db=await _idbOpen(); return await new Promise(r=>{ const q=db.transaction('kv','readonly').objectStore('kv').get(key); q.onsuccess=()=>r(q.result??null); q.onerror=()=>r(null); db.close(); }); } catch(e){ return null; } @@ -523,7 +523,7 @@ const State = { lastFolderName: lsGet('lastFolderName', ''), // Set when imported transactions are cleared while a folder is connected, so the next // page load doesn't re-import the folder's CSVs. Any import the user starts clears it. - // Browser storage only, never finlens-state.json: a backup file written before the + // Browser storage only, never app-state.json: a backup file written before the // pause ended (saves are debounced) must not be able to switch it back on. csvAutoImportPaused: lsGet('csvAutoImportPaused', false), fileAssignments: lsGet('fileAssignments', {}), // filename -> kind @@ -782,7 +782,7 @@ function mkTx(o) { _originalDesc: o.description, // The CSV's category and type, before edits and transfer detection (v1.16.1). // "Clear transaction edits" restores them from the transaction itself, and the - // IndexedDB row stores them (DB.importTransactions()). finlens-state.json keeps them. + // IndexedDB row stores them (DB.importTransactions()). app-state.json keeps them. _originalCategories: [o.category], _originalType: o.type, }; @@ -913,7 +913,7 @@ function parseCsvFile(file, forcedKind) { // adds its new rows to IndexedDB (DB.importTransactions() skips ids it already holds), // logs the import and adds it to State.fileMeta. Returns the file's transactions; // loadFiles() puts them in State.transactions. A failing database write is logged and the -// import carries on, so the transactions still reach memory and finlens-state.json. +// import carries on, so the transactions still reach memory and app-state.json. // importIds: every id the files of this import give (loadFiles()); IndexedDB keeps them // when it removes leftover copies (DB.importTransactions(), v1.16.19). async function importParsedCsv(p, importIds) { @@ -1005,10 +1005,10 @@ function importProblemText(res, sep = '; ') { // - opts.unopened: [{ name, error }] CSV files the caller found but couldn't open (reloadFromDirHandle()) // - opts.beforeImport(result): called after the files are read, before anything changes; // returning false stops the import (Rebuild from your folder checks and clears here) -// Nothing changes (State, browser storage, IndexedDB, finlens-state.json) unless at least +// Nothing changes (State, browser storage, IndexedDB, app-state.json) unless at least // one transaction was read. Up to v1.16.1 it emptied the loaded transactions first, so a // folder with no CSV it could read left 0 transactions, and the next save wrote that to -// finlens-state.json. +// app-state.json. // Returns { imported, reason: null|'no-csv'|'nothing-read'|'stopped', transactions, // files: [{ name, kind, accountId, rows, transactions, problem }] }. async function loadFiles(fileList, assignments, opts = {}) { @@ -1100,7 +1100,7 @@ async function loadFiles(fileList, assignments, opts = {}) { // other (CLAUDE.md §10). Up to v1.16.2 an import replaced all loaded transactions with // the files' ones. Importing only the card file left only the card's transactions in // memory, and a newer export that starts where the last one ended left only the new rows. -// The autosave wrote that to finlens-state.json, which boot loads first. +// The autosave wrote that to app-state.json, which boot loads first. // - A loaded transaction with the id of one in the files is replaced by the file's. // - A leftover copy goes, as importTransactions() removes it from the store: a suffixed // id whose base is in the files but whose exact id isn't. @@ -1112,7 +1112,7 @@ async function loadFiles(fileList, assignments, opts = {}) { // are counted in that file alone, so its same-day copies get the same ids in both files. // The first file's copy stays, as in IndexedDB, where the later files skip that id. Up to // v1.16.18 both copies were kept, so the shared rows counted twice in every total, and the -// autosave wrote them twice to finlens-state.json. A loaded id held twice (a state file +// autosave wrote them twice to app-state.json. A loaded id held twice (a state file // saved then) keeps one copy too. async function mergeImported(loaded, fresh) { const baseOf = (id) => id.replace(/_\d+$/, ''); @@ -1121,7 +1121,7 @@ async function mergeImported(loaded, fresh) { const ids = new Set(fresh.map(t => t.id)); const bases = new Set(fresh.map(t => baseOf(t.id))); const kept = once(loaded.filter(t => !ids.has(t.id) && !(t.id !== baseOf(t.id) && bases.has(baseOf(t.id))))); - // A transfer from a ≤v1.16.0 finlens-state.json doesn't record its CSV type and + // A transfer from a ≤v1.16.0 app-state.json doesn't record its CSV type and // category; its IndexedDB row does, when this browser has one. const known = (t) => t._originalType && Array.isArray(t._originalCategories) && t._originalCategories.length; let rows = new Map(); @@ -1179,7 +1179,7 @@ function ownCategories(t, ov, row, ruleCats = State.ruleCategories) { // - Categories: ownCategories(), above. Since v1.16.26 the user's lose Transfer out / Transfer in // (a ZELLE row given Transfer out + Housing is Housing once it isn't a transfer; detection // gives it both back if it becomes one again); before, they were kept as they were. -// Only a transaction from a ≤v1.16.0 finlens-state.json lacks the CSV values; without a row, +// Only a transaction from a ≤v1.16.0 app-state.json lacks the CSV values; without a row, // its type and category are worked out as Clear transaction edits does. Changes `t` in place. function undoTransferDetection(t, row) { const ov = State.txOverrides[t.id] || {}; @@ -1194,7 +1194,7 @@ function undoTransferDetection(t, row) { // read as another kind than before ("Read as" savings → checking, say) gives the same rows // with new ids. Up to v1.16.16 the merge kept the old ones too, as it keeps every loaded // transaction a file doesn't replace (mergeImported()): the file's rows showed under both -// accounts, in memory, IndexedDB and finlens-state.json, until Rebuild from your folder. +// accounts, in memory, IndexedDB and app-state.json, until Rebuild from your folder. // - The accounts that held the file: those listing it in `files` (accountFiles()); an account // saved before v1.16.17 has no list, so its sourceFile or an import-log entry counts. // - The rows: each of the file's transactions gives the id it had in such an account, worked @@ -1241,7 +1241,7 @@ async function earlierCopies(read) { // that no file of this import went into, is removed (memory and IndexedDB). Its name, // balances, card details and place in the order stay, by id, for when a file goes back. // - IndexedDB: the old rows are deleted from the transactions store. -// loadFiles()'s autosave writes it all to finlens-state.json. State.fileMeta notes what moved +// loadFiles()'s autosave writes it all to app-state.json. State.fileMeta notes what moved // (the Last import card shows it). async function moveEarlierCopies(moves, read, fresh) { const ovs = State.txOverrides; diff --git a/js/db.js b/js/db.js index a5b0366..2ef19c9 100644 --- a/js/db.js +++ b/js/db.js @@ -2,17 +2,17 @@ FINLENS — db.js IndexedDB API layer — all database access. Loaded immediately after core.js. - Database: finlens_db Version: 1 + Database: app_db Version: 1 ================================================================ */ /* ---------- reconstituteTx: rebuild in-memory tx from a DB record ---------- */ // Mirrors mkTx() but operates on already-stored plain objects (no schema needed). // The flags come from txFlags() (the edit, else the current rules), never from the -// record: a stored row or a finlens-state.json entry holds them as they were when it was +// record: a stored row or a app-state.json entry holds them as they were when it was // written. `rules` defaults to State.txRules. // The category: an edit wins, then what Re-categorize all gave it (`ruleCats`, default // State.ruleCategories, v1.16.25), then the record's. A record that is a transfer keeps its -// own: a finlens-state.json entry holds the 'Transfer in' / 'Transfer out' detection gave it, +// own: a app-state.json entry holds the 'Transfer in' / 'Transfer out' detection gave it, // and that boot doesn't detect transfers again. (A stored row holds the CSV's type, and // DB.loadAll() detects transfers after.) function reconstituteTx(raw, ov, rules, ruleCats = State.ruleCategories) { @@ -36,12 +36,12 @@ function reconstituteTx(raw, ov, rules, ruleCats = State.ruleCategories) { balance: null, _originalDesc: src, // Carried over when the record has them: a transaction from mkTx() or from a - // finlens-state.json written since v1.16.1. DB.loadAll() adds them from the row. + // app-state.json written since v1.16.1. DB.loadAll() adds them from the row. _originalCategories: Array.isArray(raw._originalCategories) && raw._originalCategories.length ? [...raw._originalCategories] : undefined, _originalType: raw._originalType || undefined, }; // Only a transfer shows Transfer out / Transfer in (v1.16.26, ownCategories()). An edit can - // still hold them, and so can a finlens-state.json entry written before v1.16.26 for a row + // still hold them, and so can a app-state.json entry written before v1.16.26 for a row // changed away from Transfer (the drawer and bulk Set type left them on it). A stored row // holds the CSV's type: DB.loadAll() detects transfers after, which gives them back. if (tx.type !== 'transfer' && withoutDirection(cats).length !== cats.length) tx.categories = ownCategories(tx, ov, undefined, ruleCats); @@ -51,7 +51,7 @@ function reconstituteTx(raw, ov, rules, ruleCats = State.ruleCategories) { /* ---------- DB object ---------- */ const DB = { _db: null, - DB_NAME: 'finlens_db', + DB_NAME: 'app_db', DB_VERSION: 1, // Every State key kept in app_state, with the value loadAll() uses when the store has @@ -166,7 +166,7 @@ const DB = { _migrateSync(appStateStore) { for (const k of this.APP_STATE_KEYS.map(([stateKey]) => storageKey(stateKey))) { try { - const raw = localStorage.getItem('finlens_' + k); + const raw = localStorage.getItem('app_' + k); if (raw !== null) { appStateStore.put({ key: k, value: JSON.parse(raw) }); } @@ -210,7 +210,7 @@ const DB = { * account read together, the one with fewer copies of a same-day row (exported before the * last one posted) doesn't delete the copy the other file just added. Up to v1.16.18 it * did when that file came second, and the browser database then held one row fewer than - * memory and finlens-state.json. + * memory and app-state.json. * * Returns { inserted, skipped, removed, dateRangeMin, dateRangeMax, gapWarning }. */ @@ -323,7 +323,7 @@ const DB = { /** * Deletes the rows a file gave under its old account once it's read as another kind * (v1.16.17, moveEarlierCopies() in core.js). `rows` is [{ id, account }]; a row the store - * doesn't hold is skipped (after a restore from finlens-state.json it may hold none). + * doesn't hold is skipped (after a restore from app-state.json it may hold none). * Each account's txCount goes down by the rows deleted from it; its date range is left as * it is. Resolves the number of rows deleted. */ @@ -582,7 +582,7 @@ const DB = { /* ── Cleanup & reset (used by Settings → Data & reset, views-cleanup.js) ── */ - /** Every object store in finlens_db. Keep in sync with open() → onupgradeneeded. */ + /** Every object store in app_db. Keep in sync with open() → onupgradeneeded. */ STORES: ['transactions', 'tx_overrides', 'accounts', 'import_log', 'app_state'], /** Returns { [storeName]: recordCount } (null for a store that couldn't be read). */ @@ -608,7 +608,7 @@ const DB = { }, /** - * Closes this tab's connection and deletes finlens_db entirely. + * Closes this tab's connection and deletes app_db entirely. * Resolves 'deleted', 'blocked' (another FinLens tab still has it open — the * delete finishes when that tab closes or reloads) or 'error'. */ diff --git a/js/init.js b/js/init.js index fe223fc..63d3e0d 100644 --- a/js/init.js +++ b/js/init.js @@ -105,12 +105,12 @@ } /* ---- Load persisted state: filesystem → IndexedDB → localStorage ---- */ - // Try filesystem (finlens-state.json) first — this is the source of truth + // Try filesystem (app-state.json) first — this is the source of truth const fsState = await loadStateFromFile(); if (fsState) { // Merge filesystem state into State object Object.assign(State, fsState); - console.log('[FinLens] Boot: Loaded state from finlens-state.json —', State.transactions.length, 'transactions'); + console.log('[FinLens] Boot: Loaded state from app-state.json —', State.transactions.length, 'transactions'); } else { // Fall back to IndexedDB // DB.loadAll() populates State from IndexedDB — budgets, bills, settings, AND diff --git a/js/migrate.js b/js/migrate.js index 13a3444..b17db35 100644 --- a/js/migrate.js +++ b/js/migrate.js @@ -6,7 +6,7 @@ /* ---------- v1.12.0: neutral account codes ---------- */ // Up to v1.10.x the account kinds, schema ids and account ids were named after two // banks. They are now bank_checking, bank_savings and credit_card (card_ for card -// accounts). Data saved by those versions (browser storage, finlens-state.json, its +// accounts). Data saved by those versions (browser storage, app-state.json, its // backups and JSON exports) still carries the old codes, so this file is the one place // that lists them (CLAUDE.md §22). // @@ -158,7 +158,7 @@ const IdMigration = { return old ? this._rekey(tx, tx.account, old) : null; }, - /* ---------- state objects (State, finlens-state.json, exports) ---------- */ + /* ---------- state objects (State, app-state.json, exports) ---------- */ _account(a) { if (!a || typeof a !== 'object') return a; const id = this.accountId(a.id), kind = this.kind(a.kind), name = this.accountName(a.name); @@ -204,7 +204,7 @@ const IdMigration = { return null; }, - // Rewrites a state object in place: State, a parsed finlens-state.json (transactions + // Rewrites a state object in place: State, a parsed app-state.json (transactions // already rebuilt with reconstituteTx()), or a JSON export. Safe to run on data that's // already new: nothing matches, nothing changes. // Returns { changed: [keys], codes: [keys the account codes changed], idMap: Map(old tx @@ -264,7 +264,7 @@ const IdMigration = { // // An item without an id gets `_m`, n being its position when this runs. The id // depends only on the position, so the same old data gets the same ids wherever it's - // migrated (browser storage, finlens-state.json, an export), and running it again + // migrated (browser storage, app-state.json, an export), and running it again // changes nothing. A link { type, idx } becomes { type, id: list[idx].id }: the item it // pointed at, so no link changes target. A link past the end of its list pointed at // nothing and is dropped. @@ -325,7 +325,7 @@ const IdMigration = { // Boot: browser storage (localStorage + IndexedDB app_state, through lsSet, which also // schedules the state-file save) gets State's budgets, buckets, debts and txOverrides // when the migration changed them, or when a copy there still needs it (State came from - // finlens-state.json, which loadStateFromFile() had already migrated). The four are + // app-state.json, which loadStateFromFile() had already migrated). The four are // written together, so a copy's links always come with the lists they point into. async storePlanLinks(plan) { const needs = (copy) => this.migratePlanLinks(JSON.parse(JSON.stringify(copy))).changed.length > 0; @@ -345,7 +345,7 @@ const IdMigration = { return true; }, - /* ---------- browser storage (IndexedDB finlens_db + localStorage) ---------- */ + /* ---------- browser storage (IndexedDB app_db + localStorage) ---------- */ async _all(store) { return new Promise((res) => { try { @@ -440,7 +440,7 @@ const IdMigration = { return result; }, - // Boot step (init.js), after State is loaded from finlens-state.json or IndexedDB. + // Boot step (init.js), after State is loaded from app-state.json or IndexedDB. async atBoot() { const mem = this.migrateState(State); const store = await this.migrateStores(new Map(mem.idMap)); diff --git a/js/ui.js b/js/ui.js index cc7dd6b..9d92682 100644 --- a/js/ui.js +++ b/js/ui.js @@ -273,7 +273,7 @@ function updateAcctUI() { // chart already on the canvas first. It also destroys charts whose canvas has left the // page: views rebuild their DOM on every render (§12) and never destroyed the old charts, // so each render leaked them. Statics (defaults, getChart, register) are inherited. -if (typeof Chart !== 'undefined' && !Chart._finlensGuard) { +if (typeof Chart !== 'undefined' && !Chart._appChartGuard) { const ChartJS = Chart; Chart = class extends ChartJS { constructor(item, config) { @@ -283,7 +283,7 @@ if (typeof Chart !== 'undefined' && !Chart._finlensGuard) { super(item, config); } }; - Chart._finlensGuard = true; + Chart._appChartGuard = true; } /* ---------- CHART PALETTE ---------- */ @@ -537,7 +537,7 @@ function delCatRule(i){ State.categoryRules.splice(i,1); lsSet('catRules',State. // Re-categorize all (Spending → Category rules): every loaded transaction gets the category the // rules give it now (categoryFromRules(), core.js), and the result is saved (v1.16.25). Up to // v1.16.24 it changed the categories in memory only: nothing saved them, so a reload from -// browser storage brought the imported ones back, and finlens-state.json kept them only if +// browser storage brought the imported ones back, and app-state.json kept them only if // another change happened to save it afterwards. // - Saved in State.ruleCategories, by transaction id, where it differs from the import's // category or from its IndexedDB row's (lsSet: localStorage, IndexedDB app_state, then the @@ -548,7 +548,7 @@ function delCatRule(i){ State.categoryRules.splice(i,1); lsSet('catRules',State. // - Shown on every transaction except one with a category edit (the edit wins) and a detected // transfer (it keeps Transfer in / Transfer out). Under those it's still saved, and shows // once the edit is cleared or the transaction stops being a transfer. -// - The import's category is _originalCategories; a transaction from a finlens-state.json +// - The import's category is _originalCategories; a transaction from a app-state.json // written before v1.16.1 doesn't record it: then its IndexedDB row's, else the category it // shows, if nothing but the import can have set it (no edit, not a transfer, not re-categorized). async function recategorizeAll() { @@ -945,7 +945,7 @@ const _JSON_KIND={ function exportAllData() { const data={ version:JSON_EXPORT_VERSION, exportedAt:new Date().toISOString() }; for (const k of Object.keys(JSON_EXPORT_KEYS)) data[k]=State[k]; - downloadFile(`finlens-export-${dayjs().format('YYYYMMDD')}.json`, JSON.stringify(data,null,2),'application/json'); + downloadFile(`app-export-${dayjs().format('YYYYMMDD')}.json`, JSON.stringify(data,null,2),'application/json'); } // Reads any export version (1.0, 1.1, 1.2): a key the file doesn't have, has as null, or // holds the wrong kind of value keeps its current value. Each key is saved under its @@ -1001,7 +1001,7 @@ async function _applyImportedSettings(changed, before) { // state file, which doesn't record its CSV category and type (its IndexedDB row gives them, // when this browser has one). // Up to v1.16.17 the import only saved the edits: every screen kept the old descriptions, -// categories, types, flags and notes until a reload, and a reload from finlens-state.json +// categories, types, flags and notes until a reload, and a reload from app-state.json // kept an old edit's category and type where the file had no edit. async function applyImportedEdits(before) { const known=(t)=>t._originalType&&Array.isArray(t._originalCategories)&&t._originalCategories.length; @@ -1017,7 +1017,7 @@ async function applyImportedEdits(before) { detectTransfers(); } // Erase everything and reload. Clearing only localStorage (as ≤v1.8.1 did) was undone on -// reload by IndexedDB and finlens-state.json, so this delegates to the full reset in +// reload by IndexedDB and app-state.json, so this delegates to the full reset in // views-cleanup.js. Call it from a click handler (it may need folder write access). function clearAllData() { return DataCleanup.startOver('archive'); diff --git a/js/views-ai.js b/js/views-ai.js index f3224b3..e7dc430 100644 --- a/js/views-ai.js +++ b/js/views-ai.js @@ -142,7 +142,7 @@ registerView('ai','AI Analysis',(root)=>{ // Without a key, the way to add one comes first (above the columns, so phones see it first). if (!has) appendHtml(root,`

Add your API key to ask Claude

-

AI Analysis uses your own Anthropic API key. Add it in Settings → General. It's kept in this browser only and never written to finlens-state.json, exports or logs. Get one at console.anthropic.com.

+

AI Analysis uses your own Anthropic API key. Add it in Settings → General. It's kept in this browser only and never written to app-state.json, exports or logs. Get one at console.anthropic.com.

Add API key
`); appendHtml(root,`
@@ -388,7 +388,7 @@ function copyAIResponse(){ } function saveAINote(){ if (!aiCurrentResponse) return; - downloadFile(`finlens-analysis-${dayjs().format('YYYYMMDD-HHmm')}.md`, aiCurrentResponse, 'text/markdown'); + downloadFile(`ai-analysis-${dayjs().format('YYYYMMDD-HHmm')}.md`, aiCurrentResponse, 'text/markdown'); aiSay('Saved as a Markdown file.'); } // Every saved answer, newest first, as one Markdown file. @@ -400,5 +400,5 @@ function exportAIHistory(){ md.push('---','',`## ${e.question}`,'',`*${dayjs(e.timestamp).format('MMM D, YYYY h:mm A')}${e.model?' · '+e.model:''}*`,'',e.response,''); if (e.truncated) md.push(AI_CUT_OFF_NOTE,''); }); - downloadFile(`finlens-ai-history-${dayjs().format('YYYYMMDD')}.md`, md.join('\n'), 'text/markdown'); + downloadFile(`ai-history-${dayjs().format('YYYYMMDD')}.md`, md.join('\n'), 'text/markdown'); } diff --git a/js/views-cleanup.js b/js/views-cleanup.js index 9b6a039..bfdc4f7 100644 --- a/js/views-cleanup.js +++ b/js/views-cleanup.js @@ -6,9 +6,9 @@ Where FinLens keeps data (keep in sync with CLAUDE.md §20): - memory State - - localStorage finlens_* keys (lsSet / lsGet) - - IndexedDB finlens_db (DB.STORES) and finlens_fs (CSV folder handle) - - disk finlens-state.json in the connected CSV folder + - localStorage app_* keys (lsSet / lsGet) + - IndexedDB app_db (DB.STORES) and app_fs (CSV folder handle) + - disk app-state.json in the connected CSV folder (CSV files themselves are never modified) ================================================================ */ @@ -76,7 +76,7 @@ const DataCleanup = { // Puts every loaded transaction back to its CSV values, rebuilt from the loaded // transactions themselves, so none is dropped or added (v1.16.1). Up to v1.16.0 it // replaced them with IndexedDB's rows. That store is empty after a restore from - // finlens-state.json (a new browser, or cleared site data), so every transaction + // app-state.json (a new browser, or cleared site data), so every transaction // vanished, and _finish() then saved the empty list to the file. // IndexedDB's transactions store is left as it is: it holds what imports put there. async clearEdits() { @@ -84,7 +84,7 @@ const DataCleanup = { State.txOverrides = {}; lsSet('txOverrides', {}); await DB.clearStores(['tx_overrides']); - // A stored row only fills in what a transaction from an older finlens-state.json + // A stored row only fills in what a transaction from an older app-state.json // doesn't record. It never decides which transactions exist. const rows = new Map((await DB._getAllTransactions()).map(r => [r.id, r])); State.transactions = State.transactions.map(t => this._unedited(t, edits[t.id], rows.get(t.id))); @@ -120,7 +120,7 @@ const DataCleanup = { if (!canRead) return this._done('bad', `FinLens doesn't have access to “${State.lastFolderName}”. Nothing was cleared.`); // Read and check every CSV before clearing anything (v1.16.2). Up to v1.16.1 this - // cleared first, so a folder with no CSV FinLens could read (only finlens-state.json, + // cleared first, so a folder with no CSV FinLens could read (only app-state.json, // or files it can't parse) left 0 transactions, and _finish() wrote that to the file. // loadFiles() reads the files, then calls beforeImport: only then is the data cleared, // and only when nothing would be lost (_rebuildBlockers()). @@ -158,7 +158,7 @@ const DataCleanup = { // "Use my own files" on the sample-data banner: removes the samples (transactions, // accounts, their import log) and goes back to the Welcome. Plans made meanwhile stay. - // Runs from a click: with a folder connected it rewrites finlens-state.json. + // Runs from a click: with a folder connected it rewrites app-state.json. async clearSampleData() { this._dropImported(); _importLogCache = []; @@ -174,27 +174,27 @@ const DataCleanup = { const name = State.lastFolderName || 'the folder'; await forgetDataFolder(); buildAccountFilter(); - this._done('good', `Disconnected “${name}”. FinLens no longer reads CSVs from it or saves finlens-state.json there. Nothing on disk was changed.`); + this._done('good', `Disconnected “${name}”. FinLens no longer reads CSVs from it or saves app-state.json there. Nothing on disk was changed.`); }, /** * Erase everything FinLens stores, then reload. Must run from a click: it may - * need write access to the folder to retire finlens-state.json. - * stateFile: 'archive' (rename to finlens-state-backup-DDMMYYYY.json) | 'delete' + * need write access to the folder to retire app-state.json. + * stateFile: 'archive' (rename to app-state-backup-DDMMYYYY.json) | 'delete' */ async startOver(stateFile = 'archive') { State._resetInProgress = true; // blocks pending/future auto-saves until the reload const file = await retireStateFile(stateFile); await idbDel('csvDirHandle'); const dbStatus = await DB.destroy(); - const fsStatus = await deleteIndexedDB('finlens_fs'); + const fsStatus = await deleteIndexedDB('app_fs'); lsClearAll(); const lines = ['FinLens has been reset and will now reload with no data.']; if (file.status === 'archived') lines.push(`Your old backup file was renamed to ${file.name} (API key removed).`); - if (file.status === 'deleted') lines.push('finlens-state.json was deleted.'); + if (file.status === 'deleted') lines.push('app-state.json was deleted.'); if (file.status === 'denied' || file.status === 'error') { - lines.push(`Couldn't ${stateFile === 'archive' ? 'rename' : 'delete'} finlens-state.json, so it's still in the folder. If you reconnect that folder, rename or delete the file first, or your old data will come back.`); + lines.push(`Couldn't ${stateFile === 'archive' ? 'rename' : 'delete'} app-state.json, so it's still in the folder. If you reconnect that folder, rename or delete the file first, or your old data will come back.`); } if (dbStatus === 'blocked' || fsStatus === 'blocked') { lines.push('Another FinLens tab is still open. Close it so the database can finish erasing.'); @@ -209,7 +209,7 @@ const DataCleanup = { // - Description: the kept original. Notes and the plan link live only in the edit. // - Flags: from the transaction rules, as mkTx() sets them without an edit. // - Category and type: recorded on the transaction since v1.16.1, else from its - // IndexedDB `row`. A transaction from an older finlens-state.json with no row has + // IndexedDB `row`. A transaction from an older app-state.json with no row has // neither. If its category or type was edited, or it was marked as a transfer, they // are worked out again from the description and amount, as an import of a file // without Category and Type columns does. That is exact for checking and savings; @@ -272,14 +272,14 @@ const DataCleanup = { _coverageCache = {}; }, - // After clearing data that finlens-state.json also holds: rewrite the file now, + // After clearing data that app-state.json also holds: rewrite the file now, // so the next page load can't restore what was just cleared. async _finish(text) { const file = await saveStateNow(); if (file === 'denied') { - this._done('warn', `${text} But FinLens couldn't update finlens-state.json in “${State.lastFolderName}” (no write access), so reloading may bring the cleared data back. Allow access when asked, or disconnect the folder.`); + this._done('warn', `${text} But FinLens couldn't update app-state.json in “${State.lastFolderName}” (no write access), so reloading may bring the cleared data back. Allow access when asked, or disconnect the folder.`); } else if (file === 'error') { - this._done('warn', `${text} But writing finlens-state.json in “${State.lastFolderName}” failed (${State._lastSaveError || 'unknown error'}), so reloading may bring the cleared data back.`); + this._done('warn', `${text} But writing app-state.json in “${State.lastFolderName}” failed (${State._lastSaveError || 'unknown error'}), so reloading may bring the cleared data back.`); } else { this._done('good', text + (file === 'saved' ? ' Backup file updated.' : '')); } @@ -325,7 +325,7 @@ registerView('data-cleanup', 'Data & reset', (root) => {
This browser's database… · … · … · ${plural(edits, 'transaction edit')}
This browser's settings${settingsLine}
-
Your folder${folder ? `“${f}”: finlens-state.json (everything above except the API key). Your CSV files are never changed.` : 'No folder connected, so nothing is saved to disk. Connect one in Import.'}
+
Your folder${folder ? `“${f}”: app-state.json (everything above except the API key). Your CSV files are never changed.` : 'No folder connected, so nothing is saved to disk. Connect one in Import.'}
`); @@ -372,8 +372,8 @@ registerView('data-cleanup', 'Data & reset', (root) => {

Start over

Erases everything FinLens has stored in this browser, forgets the folder and reloads the app. Your CSV files are not touched.

${folder ? `
Your backup file - - + +
` : ''}
diff --git a/js/views-data.js b/js/views-data.js index adcc4ce..30d15a4 100644 --- a/js/views-data.js +++ b/js/views-data.js @@ -88,7 +88,7 @@ registerView('import','Import',(root)=>{ const pill=!linked?'Not connected' :needs?'Needs your permission' :'Read and write access'; - const sub=linked?`${plural(files.length,'CSV file')} · backup file finlens-state.json` + const sub=linked?`${plural(files.length,'CSV file')} · backup file app-state.json` :`${plural(files.length,'CSV file')} · chosen with the file picker, so FinLens can't save to it`; const acts=!linked ?`` @@ -116,7 +116,7 @@ registerView('import','Import',(root)=>{ appendHtml(root,`
${ICON_FOLDER} -

No folder connected

Pick the folder that holds your CSV exports. FinLens reads them and saves your plans and edits there as finlens-state.json, so clearing the browser loses nothing.

+

No folder connected

Pick the folder that holds your CSV exports. FinLens reads them and saves your plans and edits there as app-state.json, so clearing the browser loses nothing.

@@ -429,7 +429,7 @@ async function captureImportResults(res) { async function pickDataFolder() { if ('showDirectoryPicker' in window) { try { - // readwrite: the same folder holds finlens-state.json, which FinLens writes to. + // readwrite: the same folder holds app-state.json, which FinLens writes to. const handle = await window.showDirectoryPicker({ mode:'readwrite', startIn:'documents' }); await idbSet('csvDirHandle', handle); State._folderLinked = true; @@ -502,7 +502,7 @@ async function reloadDataFiles() { /* ---- Folder access: Reconnect banner, Reconnect + Save Now click handlers ---- */ // When this browser has no data of its own (no transactions: a new browser, or site -// data cleared), the folder's finlens-state.json is the only copy. Load it, exactly as +// data cleared), the folder's app-state.json is the only copy. Load it, exactly as // boot does, rather than letting the next save overwrite it with an empty state. // Needs read access already granted. Returns true if state was restored. async function _restoreStateIfBrowserEmpty() { @@ -513,13 +513,13 @@ async function _restoreStateIfBrowserEmpty() { Object.assign(State, fsState); buildAccountFilter(); renderActive(); - console.log('[FinLens] Browser had no data; restored', State.transactions.length, 'transactions from finlens-state.json'); + console.log('[FinLens] Browser had no data; restored', State.transactions.length, 'transactions from app-state.json'); return true; } // Click handler (banner + Import tab). Asks for read-write access to the stored folder. // Decision (Luis, 2026-10-24): after reconnecting, the browser copy wins. It overwrites -// finlens-state.json; the file is not loaded over the browser data. The one exception +// app-state.json; the file is not loaded over the browser data. The one exception // is a browser with no data at all (see _restoreStateIfBrowserEmpty). // Returns 'saved' | 'restored' | 'no-folder' | 'denied' | 'error'. async function reconnectDataFolder() { @@ -544,11 +544,11 @@ async function reconnectFolderClick() { const r = await reconnectDataFolder(); const name = State.lastFolderName || 'your folder'; _folderBannerMsg = { - saved: { kind:'good', text:`Reconnected to “${name}”. This browser's data was saved to finlens-state.json, and backups are back on.` }, - restored: { kind:'good', text:`Reconnected to “${name}”. This browser had no data, so it was restored from finlens-state.json.` }, + saved: { kind:'good', text:`Reconnected to “${name}”. This browser's data was saved to app-state.json, and backups are back on.` }, + restored: { kind:'good', text:`Reconnected to “${name}”. This browser had no data, so it was restored from app-state.json.` }, 'no-folder':{ kind:'warn', text:'No folder is connected. Pick one in Settings → Import.' }, denied: { kind:'bad', text:`Access to “${name}” wasn't granted, so backups are still paused. Click Reconnect folder and choose Allow. If no prompt appears, file access for this site is blocked in your browser's site settings.` }, - error: { kind:'bad', text:`Reconnected, but saving finlens-state.json failed: ${State._lastSaveError || 'unknown error'}.` }, + error: { kind:'bad', text:`Reconnected, but saving app-state.json failed: ${State._lastSaveError || 'unknown error'}.` }, }[r]; updateFolderBanner(); renderActive(); @@ -566,7 +566,7 @@ async function saveNowClick() { const r = await saveStateNow(); const name = State.lastFolderName || 'your folder'; _saveNowMsg = { - saved: { kind:'good', text:`Saved to finlens-state.json in “${name}” at ${dayjs().format('h:mm A')}.` }, + saved: { kind:'good', text:`Saved to app-state.json in “${name}” at ${dayjs().format('h:mm A')}.` }, 'no-folder':{ kind:'warn', text:'Not saved: no folder is connected. Pick a folder first.' }, denied: { kind:'bad', text:`Not saved: FinLens doesn't have permission to write to “${name}”. Click Save now again and choose Allow. If no prompt appears, file access for this site is blocked in your browser's site settings.` }, error: { kind:'bad', text:`Not saved: ${State._lastSaveError || 'unknown error'}.` }, @@ -587,7 +587,7 @@ function updateFolderBanner() { if (!needs && !msg && !sample) { el.hidden = true; el.innerHTML = ''; return; } const name = escapeHtml(State.lastFolderName || 'your folder'); const body = needs - ? `Backups paused. FinLens needs your permission again to use “${name}”, so changes aren't being saved to finlens-state.json. Your data is safe in this browser.` + ? `Backups paused. FinLens needs your permission again to use “${name}”, so changes aren't being saved to app-state.json. Your data is safe in this browser.` : ''; const kind = msg ? msg.kind : 'warn'; el.hidden = false; @@ -608,7 +608,7 @@ function updateFolderBanner() { if (sx) sx.onclick = () => DataCleanup.clearSampleData(); } -// The sync badge in the app bar (#syncBadge): is finlens-state.json up to date? (v1.18.0) +// The sync badge in the app bar (#syncBadge): is app-state.json up to date? (v1.18.0) // Luis's wording at rest: "Synced" or "Not synced". "Saving…" shows while a save is // waiting (the 1.5 s after a change, State._savePending) or being written (State._saving). // Each state has the click that fits it, and the button's name says what that does: @@ -631,14 +631,14 @@ function syncBadgeState() { tip: `Not synced: FinLens needs your permission again to save to “${name}”. Your data is safe in this browser. Click to reconnect.`, aria: 'Not synced, folder access lapsed. Reconnect folder' }; if (State._saving || State._savePending) return { key: 'saving', label: 'Saving…', cls: 'busy', action: 'settings', - tip: `Saving your changes to finlens-state.json in “${name}”.`, + tip: `Saving your changes to app-state.json in “${name}”.`, aria: 'Saving changes. Open Import settings' }; if (State._lastSaveError) return { key: 'failed', label: 'Not synced', cls: 'off', action: 'retry', tip: `Not synced: the last save to “${name}” failed (${State._lastSaveError}). Your data is safe in this browser. Click to try again.`, aria: 'Not synced, the last save failed. Retry save' }; return { key: 'synced', label: 'Synced', cls: 'ok', action: 'settings', - tip: at ? `Saved at ${at} to finlens-state.json in “${name}”.` - : `Synced: changes are saved to finlens-state.json in “${name}” a moment after you make them.`, + tip: at ? `Saved at ${at} to app-state.json in “${name}”.` + : `Synced: changes are saved to app-state.json in “${name}” a moment after you make them.`, aria: at ? `Synced, saved at ${at}. Open Import settings` : 'Synced. Open Import settings' }; } diff --git a/js/views-db.js b/js/views-db.js index b578573..f82d12e 100644 --- a/js/views-db.js +++ b/js/views-db.js @@ -9,7 +9,7 @@ /* ══════════════════════════════════════════════════════════════ 1. DB SCHEMA DIAGRAM - The 5 IndexedDB object stores in finlens_db, their fields and how + The 5 IndexedDB object stores in app_db, their fields and how they link, as an inline SVG. ══════════════════════════════════════════════════════════════ */ @@ -193,7 +193,7 @@ registerView('db-schema', 'DB Schema', (root) => { Link between stores type?Optional field (may be missing)
-

Database finlens_db · version 1 · IndexedDB in this browser. Your data stays on this device.

+

Database app_db · version 1 · IndexedDB in this browser. Your data stays on this device.

`); }); diff --git a/js/views-eval.js b/js/views-eval.js index 97e0508..4732851 100644 --- a/js/views-eval.js +++ b/js/views-eval.js @@ -1002,7 +1002,7 @@ const AIEval = { toJSON() { const rep = this.report; return JSON.stringify({ - kind: 'finlens-ai-eval', version: 1, + kind: 'ai-eval-results', version: 1, meta: this.meta, dataset: { files: (this.samples || []).map(s => ({ name: s.name, sha256: s.sha256 })), @@ -1029,7 +1029,8 @@ const AIEval = { async loadResults(file) { try { const data = JSON.parse(await file.text()); - if (data.kind !== 'finlens-ai-eval' || !Array.isArray(data.runs) || !data.dataset || !data.dataset.summary) throw new Error('Not a FinLens AI Eval results file.'); + // 'finlens-ai-eval': results published before v2.0.2 (docs/, docs/eval-runs/) still load. + if (!['ai-eval-results', 'finlens-ai-eval'].includes(data.kind) || !Array.isArray(data.runs) || !data.dataset || !data.dataset.summary) throw new Error('Not a FinLens AI Eval results file.'); const files = data.dataset.files || []; const okData = this.SAMPLES.every(s => files.some(f => f.sha256 === s.sha256 || (s.earlier || []).includes(f.sha256))); if (!okData) throw new Error('This results file was not produced from the committed samples/.'); diff --git a/js/views-main.js b/js/views-main.js index d0bc5bd..e4a7418 100644 --- a/js/views-main.js +++ b/js/views-main.js @@ -307,7 +307,7 @@ function renderWelcome(root) {
    ${step(1,'Download your CSVs','From your bank\'s website, export checking and savings transactions as CSV, and download each credit card\'s activity as CSV.')} ${step(2,'Put them in one folder','Any folder works. Drop newer exports in later and FinLens adds only the new rows.')} - ${step(3,'Pick the folder','FinLens saves your budgets, bills and edits back to that folder as finlens-state.json, so clearing the browser loses nothing.')} + ${step(3,'Pick the folder','FinLens saves your budgets, bills and edits back to that folder as app-state.json, so clearing the browser loses nothing.')}
${feat('','No bank login','FinLens never connects to your bank.')} @@ -837,7 +837,7 @@ function showTxDrawer(id) { // you chose, or from the edit when you changed only the type. When none is left, the row // shows its own category (ownCategories(), core.js: Re-categorize all's, else the CSV's), // and nothing is stored, so it keeps following the rules. Before v1.16.26 a type changed - // away from Transfer kept Transfer out / in: in memory, in finlens-state.json, and in the + // away from Transfer kept Transfer out / in: in memory, in app-state.json, and in the // edit when you changed the categories too. if (catsChanged) o.categories=withoutDirection(cats); else if (o.categories) o.categories=withoutDirection(o.categories); diff --git a/js/views-settings.js b/js/views-settings.js index 8e0ef63..ecf32c0 100644 --- a/js/views-settings.js +++ b/js/views-settings.js @@ -64,7 +64,7 @@ function _renderGeneralSettings(root) { /* ---- About (the Theme row is gone: light only for now) ---- */ appendHtml(root,`

About

-
VersionFinLens v1.18.1
+
VersionFinLens v2.0.2
`); /* ---- Anthropic API key ---- */ @@ -73,7 +73,7 @@ function _renderGeneralSettings(root) { const m=takeSettingsMsg('general'); appendHtml(root,`

Anthropic API key

${has?'Key saved':'No key saved'}
-

Needed only for AI Analysis. The key is kept in this browser and is never written to finlens-state.json, exports or logs. Get one at console.anthropic.com.

+

Needed only for AI Analysis. The key is kept in this browser and is never written to app-state.json, exports or logs. Get one at console.anthropic.com.

diff --git a/samples/README.md b/samples/README.md index 9f9e439..dd0ac6f 100644 --- a/samples/README.md +++ b/samples/README.md @@ -16,4 +16,4 @@ Coverage: May 1 – Oct 20, 2026. 2. Go to **Settings → Import → Pick a Folder** and choose this `samples/` folder (or **Pick CSV Files** and select all three). 3. Set the date range in the top bar to **All Time** (the data ends Oct 20, 2026). -FinLens will auto-save a `finlens-state.json` into the folder you pick. It's git-ignored, but delete it before sharing the folder. +FinLens will auto-save a `app-state.json` into the folder you pick. It's git-ignored, but delete it before sharing the folder.