feat: Pin Due toggle to float urgent bills to top, keyboard nav for tracker rows
This commit is contained in:
parent
e271c54ac6
commit
2a2ebd4b28
15
HISTORY.md
15
HISTORY.md
|
|
@ -6,6 +6,16 @@
|
||||||
|
|
||||||
- **Bump** — `0.35.1` → `0.36.0`
|
- **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.
|
- **`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
|
### 🔒 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.
|
- **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
|
||||||
|
|
||||||
|

|
||||||
|
---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.35.0
|
## v0.35.0
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,48 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
performTogglePaid();
|
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() {
|
async function handleMarkFullAmount() {
|
||||||
const newActual = summary.paidTowardDue;
|
const newActual = summary.paidTowardDue;
|
||||||
setOptimisticActual(newActual);
|
setOptimisticActual(newActual);
|
||||||
|
|
@ -238,6 +280,11 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableRow
|
<TableRow
|
||||||
|
data-tracker-row
|
||||||
|
tabIndex={0}
|
||||||
|
aria-rowindex={index + 1}
|
||||||
|
aria-label={`${row.name}, ${effectiveStatus}, ${row.due_day ? `due ${row.due_day}` : ''}`}
|
||||||
|
onKeyDown={handleRowKeyDown}
|
||||||
draggable={dragProps?.draggable}
|
draggable={dragProps?.draggable}
|
||||||
onDragStart={dragProps?.onDragStart}
|
onDragStart={dragProps?.onDragStart}
|
||||||
onDragEnter={dragProps?.onDragEnter}
|
onDragEnter={dragProps?.onDragEnter}
|
||||||
|
|
@ -246,6 +293,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
||||||
onDrop={dragProps?.onDrop}
|
onDrop={dragProps?.onDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group border-border/65 transition-colors duration-150',
|
'group border-border/65 transition-colors duration-150',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 focus-visible:ring-inset',
|
||||||
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
|
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
|
||||||
rowBg,
|
rowBg,
|
||||||
dragProps?.isDragging && 'opacity-45',
|
dragProps?.isDragging && 'opacity-45',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw, Landmark } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw, Landmark, ArrowUpToLine } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api.js';
|
import { api } from '@/api.js';
|
||||||
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
||||||
|
|
@ -63,6 +63,7 @@ export default function TrackerPage() {
|
||||||
// Edit Bill modal: { bill, categories } when open, null when closed
|
// Edit Bill modal: { bill, categories } when open, null when closed
|
||||||
const [bankSyncStatus, setBankSyncStatus] = useState(null);
|
const [bankSyncStatus, setBankSyncStatus] = useState(null);
|
||||||
const [bankSyncing, setBankSyncing] = useState(false);
|
const [bankSyncing, setBankSyncing] = useState(false);
|
||||||
|
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
|
||||||
const [editBillData, setEditBillData] = useState(null);
|
const [editBillData, setEditBillData] = useState(null);
|
||||||
// Edit Starting Amounts modal: true when open, false when closed
|
// Edit Starting Amounts modal: true when open, false when closed
|
||||||
const [editStartingOpen, setEditStartingOpen] = useState(false);
|
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
|
// Show sync button when SimpleFIN is enabled, connected, and user has matching rules
|
||||||
const showBankSync = bankSyncStatus?.enabled &&
|
const showBankSync = bankSyncStatus?.enabled &&
|
||||||
bankSyncStatus?.has_connections &&
|
bankSyncStatus?.has_connections &&
|
||||||
|
|
@ -212,9 +221,21 @@ export default function TrackerPage() {
|
||||||
return haystack.includes(q);
|
return haystack.includes(q);
|
||||||
});
|
});
|
||||||
}, [filters, rows, search]);
|
}, [filters, rows, search]);
|
||||||
const first = filteredRows.filter(r => r.bucket === '1st');
|
// When pin-upcoming is on, sort by urgency so overdue/due-soon bills surface
|
||||||
const second = filteredRows.filter(r => r.bucket === '15th');
|
// at the top of each bucket. Bucket split runs after so each bucket is sorted independently.
|
||||||
const reorderEnabled = !hasFilters && !loading && !isError;
|
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) {
|
async function persistTrackerOrder(nextRows, movedBillId) {
|
||||||
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
|
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
|
||||||
|
|
@ -262,6 +283,17 @@ export default function TrackerPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={pinUpcoming ? 'default' : 'outline'}
|
||||||
|
onClick={togglePinUpcoming}
|
||||||
|
className="h-9 gap-1.5 px-3"
|
||||||
|
title={pinUpcoming ? 'Showing urgent bills first — click to restore normal order' : 'Pin overdue and due-soon bills to the top'}
|
||||||
|
>
|
||||||
|
<ArrowUpToLine className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">{pinUpcoming ? 'Pinned' : 'Pin Due'}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{showBankSync && (
|
{showBankSync && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue