chore(ci): clear the lint debt and make lint blocking (#79)

CI ran lint with continue-on-error because 23 errors predated it. All are
fixed, so the flag is gone and a typecheck step is added beside it — nothing
else in CI compiles the test files, so a type error in a fixture could only
ever be found on a developer's machine.

Real fixes, no blanket disables and no suppressions:

- react-hooks/set-state-in-effect (6): each was "reset or seed state when an
  input changes", so each becomes a state adjustment during render behind a
  previous-value guard — the documented React pattern for it. Two were also
  latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter()
  every render, so the effect already ran every render, one commit late.
  shared/page.tsx depended on the `tagIds` array identity, so the row window
  was reset more often than "changing any filter" claims — including after
  "Show more", the one interaction it must not undo; the key now joins by
  value. csv-import-modal reads localStorage once at mount, which is a lazy
  useState initializer, not an effect (SSR-guarded, though the modal is
  mounted from client state and never prerenders).
- react-hooks/static-components (3): SortIcon was declared inside SharedPage,
  making it a new component type on every render. Hoisted to module scope with
  the sort state passed as props.
- @typescript-eslint/no-explicit-any (9): the dynamically imported route
  handlers in two integration tests. Typed as their real signatures with one
  `as unknown as NextRequest` cast at the fixture boundary, so the call sites
  and response bodies get checked instead of opted out.
- react/no-unescaped-entities (5): exact-equivalent entities (', "),
  so no rendered text changes.

Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511):
`options` is optional, and `?? 0` keeps the assertion failing on an absent
list rather than silencing it with a non-null bang.

9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on
warnings, so lint blocks on errors only — deliberately, since none of the
remaining warnings should stop a push.
This commit is contained in:
2026-08-21 00:52:14 +10:00
parent 9f1be74330
commit fc9ffaba25
12 changed files with 193 additions and 86 deletions
+13 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef } from "react";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { useImportCSV, useStatementCoverage } from "@/lib/hooks";
import {
@@ -54,13 +54,23 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
const [savePreset, setSavePreset] = useState(false);
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
const [error, setError] = useState("");
const [presets, setPresets] = useState<BankPreset[]>([]);
// Read once, at mount, from localStorage. A lazy initializer rather than an
// effect: it runs exactly once per mount — the same semantics as
// `useEffect(…, [])` — but before the first paint, so the preset dropdown
// does not appear empty and then populate. Nothing in this component writes
// `presets` afterwards, so there is no state to keep in sync.
//
// The `typeof window` guard is for safety only: this modal is mounted from
// client state and so is never server-rendered. If that ever changes, it
// degrades to an empty list instead of throwing on `localStorage`.
const [presets] = useState<BankPreset[]>(() =>
typeof window === "undefined" ? [] : loadBankPresets()
);
const [insertedCount, setInsertedCount] = useState(0);
const [coveredRows, setCoveredRows] = useState<ParsedTransaction[]>([]);
const [showCovered, setShowCovered] = useState(false);
const coverage = useStatementCoverage();
useEffect(() => { setPresets(loadBankPresets()); }, []);
function handleFile(file: File) {
setError("");
+12 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
@@ -39,9 +39,17 @@ export function RulePreviewModal({
// Default to everything the rule would change — the common case is "yes, all
// of these", with deselection as the exception.
useEffect(() => {
if (data?.transactions) setSelected(new Set(data.transactions.map((t) => t.id)));
}, [data]);
//
// Adjusted during render behind a previous-value guard rather than in an
// effect. In an effect the modal painted once with nothing ticked and then
// again with everything ticked; here the seeded selection is part of the
// first commit. The guard is on `data` identity, so a user deselecting a row
// is not undone on the next render — only a genuinely new preview reseeds.
const [seededFrom, setSeededFrom] = useState<typeof data>(undefined);
if (data !== seededFrom) {
setSeededFrom(data);
setSelected(new Set(data?.transactions?.map((t) => t.id) ?? []));
}
const rows = data?.transactions ?? [];
const allSelected = rows.length > 0 && selected.size === rows.length;
+12 -3
View File
@@ -82,10 +82,19 @@ export function Sidebar() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// Close drawer on route change
useEffect(() => {
// Close the drawer on route change.
//
// Adjusting state during render behind a previous-value guard — the
// documented React pattern for "reset state when a prop changes" — rather
// than in an effect: React re-runs this component immediately and never
// commits the intermediate DOM, so there is no cascading render. Putting it
// in each Link's onClick is the other candidate fix and is worse here: it
// misses browser back/forward, where the route changes with no click.
const [lastPathname, setLastPathname] = useState(pathname);
if (pathname !== lastPathname) {
setLastPathname(pathname);
setOpen(false);
}, [pathname]);
}
// Prevent body scroll when drawer is open
useEffect(() => {
+27 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks";
interface Split {
@@ -33,22 +33,32 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
const [saveAsRule, setSaveAsRule] = useState(false);
const [ruleSaved, setRuleSaved] = useState(false);
// Initialise: bulk always defaults to 100% Me; single loads existing splits
useEffect(() => {
if (!participants || participants.length === 0 || !currentUser) return;
if (isBulk) {
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
} else if (existingSplits && existingSplits.length > 0) {
setSplitsState(
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
participant_id: s.participant_id,
share_percent: Number(s.share_percent),
}))
);
} else {
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
}
}, [existingSplits, participants, isBulk, currentUser]);
// Initialise: bulk always defaults to 100% Me; single loads existing splits.
//
// Seeded during render behind a previous-value guard rather than in an
// effect. Both queries resolve after the first paint, so as an effect this
// rendered an empty split table and then replaced it — and, worse, it re-ran
// on every identity change of `participants`/`existingSplits`, which would
// discard edits in progress if either query refetched while the modal was
// open. Keying on the inputs that actually define the seed fixes both.
const seedKey =
!participants || participants.length === 0 || !currentUser
? null
: `${isBulk ? "bulk" : singleId}:${currentUser.id}:${
existingSplits ? JSON.stringify(existingSplits) : "none"
}`;
const [seededFrom, setSeededFrom] = useState<string | null>(null);
if (seedKey !== null && seedKey !== seededFrom) {
setSeededFrom(seedKey);
setSplitsState(
!isBulk && existingSplits && existingSplits.length > 0
? existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
participant_id: s.participant_id,
share_percent: Number(s.share_percent),
}))
: [{ participant_id: currentUser!.id, share_percent: 100 }]
);
}
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);