fix(client): fix ESLint errors — real latent bugs (R2a)

ESLint surfaced 6 errors, incl. real bugs invisible before:
- ImportTransactionCsvSection called importErrorState() without importing it —
  its own error handler would throw ReferenceError on a failed CSV import.
- client/components/MobileTrackerRow.jsx was a dead duplicate (unused; the live
  one is tracker/MobileTrackerRow.jsx) with undefined-setter refs → deleted.
- StatusPage dbOk had a dead '?? true' (constant boolean LHS) — restored the
  intended default-true-when-unknown.
- MobileBillRow redundant !! in a ternary condition.
Lint is now 0 errors; wired 'npm run lint' into 'npm run ci'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 19:42:52 -05:00
parent c679022592
commit 5e267e4fa7
5 changed files with 4 additions and 310 deletions

View File

@ -24,7 +24,7 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
const autopayClass = useMemo(() => {
return cn(
'rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300',
!!bill.autopay_enabled ? 'opacity-100' : 'opacity-0',
bill.autopay_enabled ? 'opacity-100' : 'opacity-0',
);
}, [bill.autopay_enabled]);

View File

@ -1,306 +0,0 @@
import React, { useMemo, useRef, useState } from 'react';
import { AlertCircle, Pencil, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
import { cn, fmt, fmtDate, localDateString } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { StatusBadge } from './StatusBadge';
import { api } from '@/api.js';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
const ROW_STATUS_CLS = {
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
upcoming: '',
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
};
function paymentDateForTrackerMonth(year, month, dueDay) {
const now = new Date();
if (year === now.getFullYear() && month === now.getMonth() + 1) {
return localDateString();
}
const daysInMonth = new Date(year, month, 0).getDate();
const day = Number.isInteger(Number(dueDay))
? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
: 1;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState('');
const inputRef = useRef(null);
const displayVal = useMemo(() => {
if (field === 'amount') {
return row.total_paid > 0 ? fmt(row.total_paid) : '—';
}
return row.last_paid_date ? fmtDate(row.last_paid_date) : '—';
}, [field, row]);
const isEmpty = useMemo(() => {
if (field === 'amount') return row.total_paid <= 0;
return !row.last_paid_date;
}, [field, row]);
const mismatch = useMemo(() => {
if (field === 'amount') {
return row.total_paid > 0 && row.total_paid !== threshold;
}
return false;
}, [field, row, threshold]);
function startEdit() {
if (editing) return;
setValue(field === 'amount'
? (row.total_paid > 0 ? String(row.total_paid) : '')
: (row.last_paid_date || ''));
setEditing(true);
setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0);
}
async function commit() {
setEditing(false);
const val = value.trim();
if (!val) return;
try {
if (row.payments && row.payments.length > 0) {
const update = {};
if (field === 'amount') update.amount = parseFloat(val);
if (field === 'date') update.paid_date = val;
await api.updatePayment(row.payments[0].id, update);
} else {
await api.createPayment({
bill_id: row.id,
amount: field === 'amount' ? parseFloat(val) : threshold,
paid_date: field === 'date' ? val : defaultPaymentDate,
});
}
toast.success('Saved');
refresh();
} catch (err) {
toast.error(err.message);
}
}
function onKeyDown(e) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') { setValue(''); setEditing(false); }
}
if (editing) {
return (
<Input
ref={inputRef}
type={field === 'date' ? 'date' : 'number'}
step={field === 'amount' ? '0.01' : undefined}
min={field === 'amount' ? '0' : undefined}
value={value}
onChange={e => setValue(e.target.value)}
onBlur={commit}
onKeyDown={onKeyDown}
className="tracker-number h-7 w-28 text-right text-sm font-medium bg-background/80 border-border/60"
/>
);
}
return (
<span
onClick={startEdit}
title={`Click to edit ${field === 'amount' ? 'payment amount' : 'paid date'}`}
className={cn(
'tracker-number cursor-pointer rounded-md px-1.5 py-0.5 text-sm font-semibold',
'transition-all duration-150 hover:bg-accent hover:ring-1 hover:ring-border',
isEmpty && 'text-muted-foreground',
mismatch && 'text-amber-500',
!isEmpty && !mismatch && 'text-emerald-500',
)}
>
{displayVal}
</span>
);
}
export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null);
const threshold = useMemo(() => row.actual_amount != null ? row.actual_amount : row.expected_amount, [row]);
const defaultPaymentDate = useMemo(() => paymentDateForTrackerMonth(year, month, row.due_day), [year, month, row.due_day]);
const isPaidByThreshold = useMemo(() => row.total_paid > 0 && row.total_paid >= threshold, [row, threshold]);
const isPaid = useMemo(() => row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold, [row.status, isPaidByThreshold]);
const isSkipped = useMemo(() => !!row.is_skipped, [row.is_skipped]);
const effectiveStatus = useMemo(() => {
if (isSkipped) return 'skipped';
if (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') return 'paid';
return row.status;
}, [isSkipped, isPaidByThreshold, row.status]);
const rowBg = useMemo(() => isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''), [isSkipped, effectiveStatus]);
const isUrgent = effectiveStatus === 'late' || effectiveStatus === 'missed';
const remaining = useMemo(() => Math.max((threshold || 0) - (row.total_paid || 0), 0), [threshold, row.total_paid]);
async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Marked as paid');
refresh();
} catch (err) {
toast.error(err.message);
}
}
return (
<>
<div
className={cn(
'rounded-lg border border-border/70 bg-card/90 p-3 shadow-sm shadow-black/10',
'space-y-3 transition-colors',
isUrgent && 'border-border/80 shadow-md shadow-rose-950/10',
isSkipped ? 'opacity-55' : rowBg,
)}
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => onEditBill?.(row)}
className={cn(
'min-w-0 truncate text-left text-[15px] font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
title="Edit bill"
>
{row.name}
</button>
{row.autopay_enabled && (
<span
className="inline-flex shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-sky-600 dark:text-sky-300"
title="Autopay"
>
AP
</span>
)}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
S
</span>
)}
</div>
{row.monthly_notes && (
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
{row.monthly_notes}
</p>
)}
{isUrgent && (
<p className="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-rose-500 dark:text-rose-200">
<AlertCircle className="h-3.5 w-3.5" />
Needs attention
</p>
)}
</div>
<StatusBadge status={effectiveStatus} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
<p className="tracker-number mt-0.5 text-sm font-medium text-foreground/90">{fmtDate(row.due_date)}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}>
{fmt(threshold)}
</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', remaining > 0 ? 'text-foreground' : 'text-emerald-300')}>
{fmt(remaining)}
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Paid </span>
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
</div>
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Date </span>
<span className="tracker-number font-medium text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{!isPaid && !isSkipped && (
<div className="flex items-center gap-1.5">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={threshold}
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
title="Payment amount"
aria-label={`${row.name} payment amount`}
/>
<Button
size="sm" variant="default"
onClick={handleQuickPay}
className="h-8 px-3 text-xs font-semibold"
>
Pay
</Button>
</div>
)}
{row.payments && row.payments.length > 0 && (
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title="Edit payment"
onClick={() => setEditPayment(row.payments[0])}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Payment
</Button>
)}
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
Month
</Button>
</div>
</div>
</div>
</>
);
});
MobileTrackerRow.displayName = 'MobileTrackerRow';

View File

@ -7,7 +7,7 @@ import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { SectionCard } from './dataShared';
import { SectionCard, importErrorState } from './dataShared';
const CSV_MAPPING_FIELDS = [
'posted_date',

View File

@ -301,7 +301,7 @@ export default function StatusPage() {
const bankSync = data?.bank_sync ?? data?.simplefin ?? {};
const errors = data?.errors ?? data?.recent_errors ?? [];
const dbOk = db.connected ?? (db.status === 'connected') ?? true;
const dbOk = db.connected ?? (db.status ? db.status === 'connected' : true);
const workerOk = !worker.enabled ? null : worker.last_error ? false : true;
const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null;
const backupsEnabled = backups.enabled ?? null;

View File

@ -19,7 +19,7 @@
"test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots",
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
"smoke:prod": "bash scripts/prod-smoke.sh",
"ci": "npm run check:server && npm run test:all && npm run build",
"ci": "npm run lint && npm run check:server && npm run test:all && npm run build",
"start": "node server.js"
},
"dependencies": {