diff --git a/HISTORY.md b/HISTORY.md index ed3cfda..09d8f12 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,16 @@ - **Bump** — `0.35.1` → `0.36.0` +- **`payments.js` SQL fragment renamed for clarity** — `const LIVE = 'deleted_at IS NULL'` was renamed to `const SQL_NOT_DELETED` and given a 4-line comment explaining why SQL fragment interpolation is safe here, why parameterisation is not applicable to SQL fragments (only values can be bound, not column conditions), and explicitly warning future developers not to replace the pattern with dynamic input. + +- **Migration version sync assertion** — `_runMigrationVersions` module-level variable is now populated by `runMigrations()` before its loop runs. `reconcileLegacyMigrations()` — which runs after `runMigrations()` on legacy-DB upgrade paths — compares its own version array against the stored list and throws a descriptive error if any version appears in one array but not the other. Catches drift between the two migration arrays at startup rather than silently misconfiguring a legacy schema. + +- **Pin Due — urgent bills float to top of tracker** — A "Pin Due" toggle button in the TrackerPage header sorts overdue and due-soon bills to the top of each bucket when enabled. Priority order: `missed` → `late` → `due_soon` → `upcoming` → everything else; ties broken by `due_day`. The sort runs after filtering but before the bucket split, so each half-month bucket is sorted independently. The button uses `variant="default"` (solid) when active and `variant="outline"` when off so the current mode is always visible. Preference persists across sessions via `localStorage` under `tracker_pin_upcoming`. Drag reorder is automatically disabled while the toggle is on (`reorderEnabled` now also requires `!pinUpcoming`) since the two modes conflict. + +- **Tracker row keyboard navigation** — Tracker rows (desktop table view) are now keyboard navigable. Each row has `tabIndex={0}`, `data-tracker-row`, `aria-rowindex`, and an `aria-label` announcing the bill name, status, and due day. A `focus-visible:ring-2 ring-primary/60 ring-inset` focus ring appears on keyboard focus only. Key bindings: `↓`/`j` focuses the next row, `↑`/`k` the previous (both cross bucket boundaries via `querySelectorAll('[data-tracker-row]')`), `Enter` opens the edit modal, `P` toggles paid/unpaid (skipped bills ignored, `Ctrl+P`/`Cmd+P` passes through to the browser), `Esc` blurs the row. The `onKeyDown` handler guards against firing on nested interactive elements with `if (e.target !== e.currentTarget) return`. + +- **Bills list query optimised and merchant rule index added** — `GET /api/bills` replaced a correlated `EXISTS(SELECT 1 FROM bill_history_ranges WHERE bill_id = b.id)` per bill row with a single `LEFT JOIN (SELECT DISTINCT bill_id FROM bill_history_ranges) hr`. DB migration `v0.81` adds composite index `idx_bill_merchant_rules_user_bill ON bill_merchant_rules(user_id, bill_id)` — the existing index only covered `user_id`, making the EXISTS check in `GET /api/bills/:id` scan by user then filter by bill; the composite index makes it a direct point lookup. Pagination was not added — the UI depends on all bills being loaded at once and personal-scale data volumes don't warrant it. + - **`rotateSessionId` uses `db.transaction()` instead of raw SQL** — `rotateSessionId()` in `authService.js` managed its DELETE + INSERT pair with explicit `db.prepare('BEGIN').run()` / `COMMIT` / `ROLLBACK` calls. This is fragile: if the rollback itself throws (e.g. connection in a bad state), the transaction is left open. Replaced with better-sqlite3's `db.transaction()` wrapper, which commits automatically on success and rolls back automatically on any thrown error with no manual try/catch required. ### 🔒 Security @@ -60,6 +70,11 @@ - **OIDC login error logging improved** — `Issuer.discover()` failures previously produced a blank log line because the error was an `AggregateError` (empty `.message`, real causes in `.errors[]`). Both the `/login` and `/callback` handlers now log the full error, expand `err.errors[]` entries, and surface `err.cause` so network-level failures (e.g. `ETIMEDOUT`, `ENOTFOUND`) are visible in the server log. +### Release Image + +![Doing my part](/img/doingmypart.jpg) +--- + --- ## v0.35.0 diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index c9c2940..8ef8fb0 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -116,6 +116,48 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC performTogglePaid(); } + function handleRowKeyDown(e) { + // Only act on the row element itself, not on interactive children + if (e.target !== e.currentTarget) return; + + switch (e.key) { + case 'ArrowDown': + case 'j': { + e.preventDefault(); + const all = [...document.querySelectorAll('[data-tracker-row]')]; + const next = all[all.indexOf(e.currentTarget) + 1]; + next?.focus(); + break; + } + case 'ArrowUp': + case 'k': { + e.preventDefault(); + const all = [...document.querySelectorAll('[data-tracker-row]')]; + const prev = all[all.indexOf(e.currentTarget) - 1]; + prev?.focus(); + break; + } + case 'Enter': { + e.preventDefault(); + onEditBill?.(row); + break; + } + case 'p': + case 'P': { + if (e.ctrlKey || e.metaKey) break; // don't intercept Ctrl+P (print) + e.preventDefault(); + if (!isSkipped) handleTogglePaid(); + break; + } + case 'Escape': { + e.currentTarget.blur(); + break; + } + default: + break; + } + } + async function handleMarkFullAmount() { const newActual = summary.paidTowardDue; setOptimisticActual(newActual); @@ -238,6 +280,11 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC return ( <> localStorage.getItem('tracker_pin_upcoming') === 'true'); const [editBillData, setEditBillData] = useState(null); // Edit Starting Amounts modal: true when open, false when closed const [editStartingOpen, setEditStartingOpen] = useState(false); @@ -117,6 +118,14 @@ export default function TrackerPage() { } } + function togglePinUpcoming() { + setPinUpcoming(prev => { + const next = !prev; + localStorage.setItem('tracker_pin_upcoming', String(next)); + return next; + }); + } + // Show sync button when SimpleFIN is enabled, connected, and user has matching rules const showBankSync = bankSyncStatus?.enabled && bankSyncStatus?.has_connections && @@ -212,9 +221,21 @@ export default function TrackerPage() { return haystack.includes(q); }); }, [filters, rows, search]); - const first = filteredRows.filter(r => r.bucket === '1st'); - const second = filteredRows.filter(r => r.bucket === '15th'); - const reorderEnabled = !hasFilters && !loading && !isError; + // When pin-upcoming is on, sort by urgency so overdue/due-soon bills surface + // at the top of each bucket. Bucket split runs after so each bucket is sorted independently. + const URGENCY_ORDER = { missed: 0, late: 1, due_soon: 2, upcoming: 3 }; + const sortedRows = pinUpcoming + ? [...filteredRows].sort((a, b) => { + const ua = URGENCY_ORDER[a.status] ?? 99; + const ub = URGENCY_ORDER[b.status] ?? 99; + if (ua !== ub) return ua - ub; + return (a.due_day ?? 99) - (b.due_day ?? 99); + }) + : filteredRows; + + const first = sortedRows.filter(r => r.bucket === '1st'); + const second = sortedRows.filter(r => r.bucket === '15th'); + const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming; async function persistTrackerOrder(nextRows, movedBillId) { const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index])); @@ -262,6 +283,17 @@ export default function TrackerPage() {
+ + {showBankSync && (