Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/bank-names.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Bank names that must not appear in FinLens's code, samples or current docs.
# One per line, matched case-insensitively as whole words. Lines starting with # are ignored.
# Use "bank" (or a neutral account kind) instead. See CLAUDE.md §0 / product rules.
#
# Names that are also everyday words (e.g. "Popular", "Discover", "Citizens") are
# listed only in a form that can't be an everyday word.
Ally
Chase
Banco Popular
Popular Bank
FirstBank
First Bank
Oriental
OFG
Santander
Wells Fargo
Bank of America
BofA
Citibank
Capital One
American Express
Amex
US Bank
U.S. Bank
PNC
Truist
TD Bank
Charles Schwab
Fidelity
SoFi
Marcus
Navy Federal
Synchrony
Barclays
125 changes: 125 additions & 0 deletions .github/scripts/check_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""FinLens product-rule checks.

1. No bank names in tracked files (list in .github/bank-names.txt).
Past history is left as is, by Luis's rule: only the newest CHANGELOG entry is
checked, and published eval results / archived docs are skipped.
2. The version is the same in every place CLAUDE.md §17 lists.
3. On a pull request: the version is newer than the base branch's, and no other
open PR has already claimed it (a PR picks its version when it's opened).

Env (set by the workflow on pull requests): BASE_REF, PR_NUMBER, GITHUB_REPOSITORY, GH_TOKEN.
"""
import json, os, pathlib, re, subprocess, sys

root = pathlib.Path(__file__).resolve().parents[2]
errors = 0

def error(msg, file=None, line=None):
global errors
errors += 1
loc = f" file={file}" + (f",line={line}" if line else "") if file else ""
print(f"::error{loc}::{msg}")

# 1. Bank names ---------------------------------------------------------------
SKIP_PREFIXES = ("docs/eval-runs/", "docs/archive/", ".git/", "node_modules/")
SKIP_FILES = {".github/bank-names.txt", ".github/scripts/check_rules.py",
"docs/EVAL.md", "docs/eval-results.json",
# The v1.12.0 data migration has to recognize the old bank-named ids
# and account names in saved data to rename them.
"js/migrate.js"}

names = [l.strip() for l in (root / ".github/bank-names.txt").read_text().splitlines()
if l.strip() and not l.startswith("#")]
pattern = re.compile(r"(?<![A-Za-z0-9])(" + "|".join(re.escape(n) for n in names) + r")(?![A-Za-z0-9])", re.I)

def newest_changelog_entry(text):
parts = re.split(r"(?m)^(?=## \[)", text)
return parts[1] if len(parts) > 1 else ""

files = subprocess.run(["git", "ls-files"], cwd=root, capture_output=True, text=True, check=True).stdout.split("\n")
hits = 0
for rel in filter(None, files):
if rel in SKIP_FILES or rel.startswith(SKIP_PREFIXES):
continue
for m in pattern.finditer(rel): # file and folder names count too
hits += 1
error(f'Bank name "{m.group(1)}" in a file name', file=rel)
path = root / rel
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError):
continue
if rel == "CHANGELOG.md":
entry = newest_changelog_entry(text)
offset = text[: text.find(entry)].count("\n") if entry else 0
text_lines = entry.splitlines()
else:
offset, text_lines = 0, text.splitlines()
for i, line in enumerate(text_lines, 1):
for m in pattern.finditer(line):
hits += 1
error(f'Bank name "{m.group(1)}" — use "bank" or a neutral account kind instead', file=rel, line=i + offset)
print(f"Bank names: {hits} found in {len([f for f in files if f])} tracked files")

# 2. Version consistency ------------------------------------------------------
def top_version(changelog):
m = re.search(r"(?m)^## \[(\d+)\.(\d+)\.(\d+)\]", changelog)
return tuple(map(int, m.groups())) if m else None

def vstr(v):
return ".".join(map(str, v)) if v else "none"

ver = top_version((root / "CHANGELOG.md").read_text(encoding="utf-8"))
if not ver:
error("No version entry found at the top of CHANGELOG.md", file="CHANGELOG.md")
else:
v = vstr(ver)
places = {
"index.html": rf"v{re.escape(v)} · FinLens",
"README.md": rf"\*\*Version:\*\* v{re.escape(v)}\b",
"js/views-settings.js": rf"FinLens v{re.escape(v)}(?![0-9.])",
"CLAUDE.md": rf"\*\*Current version:\*\* v{re.escape(v)}\b",
}
for rel, pat in places.items():
if not re.search(pat, (root / rel).read_text(encoding="utf-8")):
error(f"Version v{v} (top of CHANGELOG.md) is missing from {rel} — see CLAUDE.md §17", file=rel)
if not re.search(rf"\*FinLens v{re.escape(v)} —", (root / "README.md").read_text(encoding="utf-8")):
error(f"Version v{v} is missing from the README footer line", file="README.md")
print(f"Version: v{v}")

# 3. Pull requests: newer than base, and not claimed by another open PR -------
base, pr, repo = os.environ.get("BASE_REF"), os.environ.get("PR_NUMBER"), os.environ.get("GITHUB_REPOSITORY")
if ver and base:
r = subprocess.run(["git", "show", f"origin/{base}:CHANGELOG.md"], cwd=root, capture_output=True, text=True)
base_ver = top_version(r.stdout) if r.returncode == 0 else None
if not base_ver:
print(f"::warning::Could not read CHANGELOG.md on origin/{base}; skipped the newer-version check.")
elif ver <= base_ver:
error(f"Version v{vstr(ver)} is not newer than {base}'s v{vstr(base_ver)}. Add a new CHANGELOG entry and version (CLAUDE.md §17).",
file="CHANGELOG.md", line=1)
else:
print(f"Newer than {base} (v{vstr(base_ver)})")
if ver and pr and repo:
def gh(path):
out = subprocess.run(["gh", "api", "--paginate", path], capture_output=True, text=True)
return out.stdout if out.returncode == 0 else None
raw = gh(f"repos/{repo}/pulls?state=open&per_page=100")
if raw is None:
print("::warning::Could not list open pull requests; skipped the duplicate-version check.")
else:
prs = json.loads(raw.replace("][", ","))
taken = []
for p in prs:
if str(p["number"]) == str(pr):
continue
body = subprocess.run(["gh", "api", f"repos/{repo}/contents/CHANGELOG.md?ref={p['head']['sha']}",
"-H", "Accept: application/vnd.github.raw"], capture_output=True, text=True)
if body.returncode == 0 and top_version(body.stdout) == ver:
taken.append(f"#{p['number']}")
if taken:
error(f"Version v{vstr(ver)} is already used by open PR {', '.join(taken)}. Pick the next free version.",
file="CHANGELOG.md", line=1)
else:
print(f"Not used by any of the other {len(prs) - 1} open PRs")

sys.exit(1 if errors else 0)
33 changes: 33 additions & 0 deletions .github/workflows/product-rules.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Product rules

# FinLens house rules that are easy to forget by hand: no bank names, the version
# bumped in every place, and no two open PRs claiming the same version.
on:
pull_request:
push:
branches: [master]
workflow_dispatch:

permissions:
contents: read
pull-requests: read

concurrency:
group: product-rules-${{ github.ref }}
cancel-in-progress: true

jobs:
rules:
name: Bank names and version
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the base branch's CHANGELOG is compared on pull requests
- name: Check
env:
BASE_REF: ${{ github.base_ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
run: python3 .github/scripts/check_rules.py
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [2.0.4] — 2026-11-04

### Technical — CI
- **Product-rule checks on every pull request** (`.github/workflows/product-rules.yml`, `.github/scripts/check_rules.py`). Nothing in the app changes.
- **No bank names** in tracked files or file names, from the list in `.github/bank-names.txt` (whole words, any case). Past history is left as is: only the newest CHANGELOG entry is checked, and `docs/eval-runs/`, `docs/archive/`, `docs/EVAL.md` and `docs/eval-results.json` are skipped. `js/migrate.js` is skipped too, because the data migration has to recognize the old ids.
- **Version in every place** CLAUDE.md §17 lists: the top CHANGELOG version must also be in the `index.html` footer, both README lines, Settings → General and CLAUDE.md §1.
- **On pull requests:** the version must be newer than the base branch's, and no other open PR may already use it. This catches a taken version before merging instead of after.
- **Expected to fail on `master` until the redesign stack (#16–#51) merges**, which removes the bank names. On the top of the stack (#51) it passes.
- Part of the **v2.0 — Public launch** milestone. Left open on purpose until the rename and custom domain are ready.

---

## [1.10.5] — 2026-11-02

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
- **Minor changes may be grouped** into a single branch and PR — typos, doc wording, small refactors, version or changelog housekeeping.
- **Revisions requested in review go on that PR's own branch** (new commits), not into a new PR.
- **Never commit directly to `master`.** Never force-push a branch that has an open PR.
- **Branch names:** short, kebab-case, describing the change, e.g. `fix-chase-reimport-duplicates`, `data-cleanup-and-reset`, `docs-branch-pr-workflow`.

Check failure on line 17 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "chase" — use "bank" or a neutral account kind instead

### What every PR includes

Expand Down Expand Up @@ -59,9 +59,9 @@

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.10.5 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release).
**Current version:** v2.0.4 (updated in `index.html` footer, `README.md`, and `CHANGELOG.md` on every release).

**What it does:** Imports CSV exports from Ally Bank (Checking + Savings) and Chase 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.

Check failure on line 64 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "Chase" — use "bank" or a neutral account kind instead

Check failure on line 64 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "Ally" — use "bank" or a neutral account kind instead

---

Expand Down Expand Up @@ -94,13 +94,13 @@
├── CLAUDE.md ← This file
├── CHANGELOG.md ← Version history (update on every release)
├── data/ ← CSV exports (not committed to git)
│ ├── ally_checking_transactions.csv

Check failure on line 97 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "ally" — use "bank" or a neutral account kind instead
│ ├── ally_savings_transactions.csv

Check failure on line 98 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "ally" — use "bank" or a neutral account kind instead
│ └── chase_transactions.csv

Check failure on line 99 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "chase" — use "bank" or a neutral account kind instead
├── samples/ ← Synthetic CSVs (fake data) for demos, manual testing and the AI eval — committed
│ ├── ally_checking_sample.csv

Check failure on line 101 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "ally" — use "bank" or a neutral account kind instead
│ ├── ally_savings_sample.csv

Check failure on line 102 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "ally" — use "bank" or a neutral account kind instead
│ └── chase_card_sample.csv

Check failure on line 103 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "chase" — use "bank" or a neutral account kind instead
├── docs/
│ ├── EVAL.md ← AI Analysis grounding eval: method + results (exported by the AI Eval view) + hand-written Runs table
│ ├── eval-results.json ← Raw answers + per-figure classifications for that run
Expand Down Expand Up @@ -188,7 +188,7 @@
amount: number, // Positive = income/credit, Negative = expense/debit
categories: string[], // Array; first element accessed via get category()
get category(), // Accessor: returns categories[0] || 'Other'
account: string, // accountId (e.g. 'ally_checking', 'ally_savings', 'chase_...')

Check failure on line 191 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / Bank names and version

Bank name "ally" — use "bank" or a neutral account kind instead
type: 'debit' | 'credit' | 'transfer' | 'payment',
isRecurring: boolean,
isBill: boolean,
Expand Down Expand Up @@ -798,7 +798,7 @@

---

*Last updated: v1.10.5 — 2026-11-02 (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.4 — 2026-11-04 (no section changes: product-rule checks on pull requests; v1.10.5 — 2026-11-02: 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)*

---

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> A zero-install, browser-native finance dashboard. Drop in your bank CSVs, get instant clarity on your money.

**Version:** v1.10.5 &nbsp;·&nbsp; **Stack:** Vanilla JS, HTML, CSS &nbsp;·&nbsp; **Install:** None
**Version:** v2.0.4 &nbsp;·&nbsp; **Stack:** Vanilla JS, HTML, CSS &nbsp;·&nbsp; **Install:** None

---

Expand Down Expand Up @@ -260,4 +260,4 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release history.

---

*FinLens v1.10.5 — Built for one person's finances, designed to be useful for anyone's.*
*FinLens v2.0.4 — Built for one person's finances, designed to be useful for anyone's.*
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@
<div style="margin-top:auto">
<a class="nav" data-view="settings">⚙️ Settings</a>
<div class="footer">
<div style="color:var(--text-3);font-size:11px">v1.10.5 · FinLens</div>
<div style="color:var(--text-3);font-size:11px">v2.0.4 · FinLens</div>
</div>
</div>
</aside>
Expand Down
2 changes: 1 addition & 1 deletion js/views-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function _renderGeneralSettings(root) {
</select>
</div>
</div>
<div class="muted" style="font-size:11.5px;margin-top:14px">FinLens v1.10.5 ·${State.transactions.length} transactions loaded${State.lastRefresh?' · Refreshed '+dayjs(State.lastRefresh).format('MMM D, YYYY h:mm A'):''}</div>`;
<div class="muted" style="font-size:11.5px;margin-top:14px">FinLens v2.0.4 ·${State.transactions.length} transactions loaded${State.lastRefresh?' · Refreshed '+dayjs(State.lastRefresh).format('MMM D, YYYY h:mm A'):''}</div>`;
root.appendChild(displayCard);

/* ---- Anthropic API Key ---- */
Expand Down
Loading