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
+26 -9
View File
@@ -432,6 +432,19 @@ const SharedTxRow = memo(function SharedTxRow({
// ── Main page ─────────────────────────────────────────────────────────────────
type SortCol = "transaction_date" | "created_at" | "amount";
/**
* Sort direction marker for a column header.
*
* Declared at module scope and given the sort state as props, rather than
* closing over it inside SharedPage: a component defined during render is a
* new type on every render, so React unmounts and remounts it each time
* instead of updating it.
*/
function SortIcon({ col, sortCol, sortDir }: { col: SortCol; sortCol: SortCol; sortDir: "asc" | "desc" }) {
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
}
export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]);
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
@@ -478,10 +491,6 @@ export default function SharedPage() {
else { setSortCol(col); setSortDir("desc"); }
}
function SortIcon({ col }: { col: SortCol }) {
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
}
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: allTags = [] } = useTags();
const { data: me } = useCurrentUser();
@@ -504,9 +513,17 @@ export default function SharedPage() {
// table was what made expanding a receipt freeze the browser. "Show more"
// extends the window; changing any filter resets it.
const [visibleCount, setVisibleCount] = useState(100);
useEffect(() => {
// Reset during render behind a previous-value guard rather than in an effect.
// Note the key joins `tagIds` by value: as a raw dependency it was a fresh
// array on many renders, so the window was being reset more often than
// "changing any filter" claims — including after "Show more", which is the
// one interaction this must not undo.
const windowKey = `${search}|${sortCol}|${sortDir}|${tagIds.join(",")}|${participantId ?? ""}`;
const [lastWindowKey, setLastWindowKey] = useState(windowKey);
if (windowKey !== lastWindowKey) {
setLastWindowKey(windowKey);
setVisibleCount(100);
}, [search, sortCol, sortDir, tagIds, participantId]);
}
const visible = (transactions as SharedTransactionRow[]).slice(0, visibleCount);
// Receipt expansion, same pattern as the transactions page. The shared
@@ -667,13 +684,13 @@ export default function SharedPage() {
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("transaction_date")}
>
Date <SortIcon col="transaction_date" />
Date <SortIcon col="transaction_date" sortCol={sortCol} sortDir={sortDir} />
</th>
<th
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("created_at")}
>
Imported <SortIcon col="created_at" />
Imported <SortIcon col="created_at" sortCol={sortCol} sortDir={sortDir} />
</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
@@ -681,7 +698,7 @@ export default function SharedPage() {
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
onClick={() => toggleSort("amount")}
>
Amount <SortIcon col="amount" />
Amount <SortIcon col="amount" sortCol={sortCol} sortDir={sortDir} />
</th>
{/* Paid by sits next to Splits deliberately: together they are the
two halves of the question this page exists to answer — whose