From 377dc9c91c5a9b1aface6e9ec82980b2548d2061 Mon Sep 17 00:00:00 2001 From: Matt Faltyn Date: Sun, 27 Sep 2026 17:39:11 +0200 Subject: [PATCH 1/2] Clarify invalid filters and route titles --- frontend/src/App.routes.browser.test.tsx | 4 ++++ frontend/src/App.tsx | 6 +++++- frontend/src/FilterControls.tsx | 12 ++++++++---- frontend/src/ItmChain.browser.test.tsx | 7 +++++++ frontend/src/index.css | 1 + 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.routes.browser.test.tsx b/frontend/src/App.routes.browser.test.tsx index df2e01f..26917f6 100644 --- a/frontend/src/App.routes.browser.test.tsx +++ b/frontend/src/App.routes.browser.test.tsx @@ -21,6 +21,7 @@ it("keeps the live chain query on the option chain link", () => { expect(href).toContain("t=CIFR") expect(href).toContain("side=put") expect(href).toContain("m=otm") + expect(document.title).toBe("Option chain · HyperOptions") }) it("restores that chain query after opening the watchlist", async () => { @@ -33,10 +34,12 @@ it("restores that chain query after opening the watchlist", async () => { render() fireEvent.click(screen.getByRole("link", { name: "Watchlist" })) expect(await screen.findByRole("heading", { name: "Watchlist" })).toBeTruthy() + expect(document.title).toBe("Watchlist · HyperOptions") fireEvent.click(screen.getByRole("link", { name: "Option chain" })) await waitFor(() => expect(window.location.search).toContain("t=CIFR")) expect(window.location.search).toContain("side=put") expect(window.location.search).toContain("m=otm") + expect(document.title).toBe("Option chain · HyperOptions") }) it.each([ @@ -68,4 +71,5 @@ it("offers navigation when a route does not exist", () => { expect(main.textContent).toContain("Page not found") expect(main.querySelector('a[href="/"]')?.textContent).toContain("option chain") expect(main.querySelector('a[href="/watchlist"]')?.textContent).toContain("watchlist") + expect(document.title).toBe("Page not found · HyperOptions") }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dfe13b9..100ab0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useState } from "react" +import { lazy, Suspense, useEffect, useState } from "react" import { BrowserRouter, Link, MemoryRouter, Navigate, Route, Routes, useLocation } from "react-router-dom" import ItmChain from "./ItmChain" @@ -10,6 +10,10 @@ function Workspace() { const location = useLocation() const [lastChainUrl, setLastChainUrl] = useState("/") const watchlist = location.pathname === "/watchlist" + useEffect(() => { + const page = location.pathname === "/" ? "Option chain" : watchlist ? "Watchlist" : "Page not found" + document.title = `${page} · HyperOptions` + }, [location.pathname, watchlist]) const liveChainUrl = location.pathname === "/" ? `${location.pathname}${location.search}` : null if (liveChainUrl !== null && liveChainUrl !== lastChainUrl) setLastChainUrl(liveChainUrl) const chainUrl = liveChainUrl ?? lastChainUrl diff --git a/frontend/src/FilterControls.tsx b/frontend/src/FilterControls.tsx index 301559c..b62eb33 100644 --- a/frontend/src/FilterControls.tsx +++ b/frontend/src/FilterControls.tsx @@ -99,9 +99,12 @@ export default function FilterControls({ key: spec.chipKey, label: spec.chipLabel, value: texts[spec.id], + valid: parsedById[spec.id] !== null && !(spec.rowKey === "dte" && invertedDte), clear: () => onTextChange(spec.id, ""), })) .filter((chip) => chip.value.trim() !== "") + const activeCount = chips.filter((chip) => chip.valid).length + const invalidCount = chips.length - activeCount return ( @@ -115,7 +118,8 @@ export default function FilterControls({ @@ -141,10 +145,10 @@ export default function FilterControls({ {chips.length > 0 ? ( -
+
{chips.map((chip) => ( - ))} diff --git a/frontend/src/ItmChain.browser.test.tsx b/frontend/src/ItmChain.browser.test.tsx index 347d96f..ef7d378 100644 --- a/frontend/src/ItmChain.browser.test.tsx +++ b/frontend/src/ItmChain.browser.test.tsx @@ -726,11 +726,16 @@ describe("chain interactions", () => { expect(screen.getAllByText("$50.00")[0]).toBeTruthy() expect(screen.getByText("$40.50")).toBeTruthy() expect(screen.getByLabelText("Min APR net (%)").getAttribute("aria-invalid")).toBe("true") + expect(screen.getByRole("button", { name: "Clear invalid input APR net (%) abc" })).toBeTruthy() + expect(screen.queryByRole("button", { name: "Remove filter APR net (%) abc" })).toBeNull() + expect(screen.getByRole("button", { name: /Filters/ }).textContent).toContain("Fix 1") expect(screen.getByRole("button", { name: "Clear all" })).toBeTruthy() fireEvent.change(screen.getByLabelText("Min Called P&L ($)"), { target: { value: "0" } }) expect(screen.getAllByText("$50.00")[0]).toBeTruthy() expect(screen.queryByText("$40.50")).toBeNull() + expect(screen.getByRole("button", { name: "Remove filter Called P&L ($) 0" })).toBeTruthy() + expect(screen.getByRole("button", { name: /Filters/ }).textContent).toContain("Fix 1") }) it("shows removable active-filter chips and clears filters independently", async () => { @@ -853,6 +858,8 @@ describe("chain interactions", () => { expect(screen.getByText("No rows match the current filters.")).toBeTruthy() expect(screen.getByLabelText("Min DTE").getAttribute("aria-invalid")).toBe("true") expect(screen.getByLabelText("Max DTE").getAttribute("aria-invalid")).toBe("true") + expect(screen.getByRole("button", { name: "Clear invalid input DTE ≥ 40" })).toBeTruthy() + expect(screen.getByRole("button", { name: "Clear invalid input DTE ≤ 10" })).toBeTruthy() }) it("copies displayed row values with headers and context, including contract scaling", async () => { diff --git a/frontend/src/index.css b/frontend/src/index.css index 7e2d506..13f8295 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -752,6 +752,7 @@ font-size: .72rem; } .filter-chip svg { width: 13px; height: 13px; } + .filter-chip[aria-invalid="true"] { border-color: var(--destructive); color: var(--destructive); } .filter-panel { padding: 4px 0 12px; } .filter-cluster { display: grid; From 5c817fd2d1520d413da61138907900ab7ecb3ee0 Mon Sep 17 00:00:00 2001 From: Matt Faltyn Date: Sun, 27 Sep 2026 17:52:44 +0200 Subject: [PATCH 2/2] Explain invalid DTE range in empty state --- README.md | 2 +- frontend/src/ItmChain.browser.test.tsx | 3 ++- frontend/src/ItmChain.tsx | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9eebcc6..f57a1d0 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ One-contract cash-secured put: The Contracts field is a whole number of 100-share lots. Covered-call stock cost, premium, net outlay, and called P&L are multiplied by that count. CSP premium, collateral, and net collateral are multiplied. Bid, ask, volume, APRs, drops/cushions, effective cost, called P&L / sh, breakeven, Greeks, and low comparisons stay one-contract. Leave Contracts empty to keep the one-contract view. `0`, negatives, decimals, values too large for exact integer calculations, or other invalid text show validation and fall back to one contract. -Filters start closed. Optional minimums for Called P&L (calls) or Premium (puts), **APR net**, and **drop/cushion to breakeven**, plus an inclusive Min DTE and Max DTE range, combine with AND. Values compare directly with the API's scaled integers. Enter percent points (`40` means 40%). Invalid tokens show inline validation and are otherwise ignored; null metrics fail only their active filter. An inverted DTE range matches nothing. Active filters appear as individually removable chips, and Clear all restores the full chain and does not clear Contracts. +Filters start closed. Optional minimums for Called P&L (calls) or Premium (puts), **APR net**, and **drop/cushion to breakeven**, plus an inclusive Min DTE and Max DTE range, combine with AND. Values compare directly with the API's scaled integers. Enter percent points (`40` means 40%). Invalid tokens show inline validation and are otherwise ignored; null metrics fail only their active filter. An inverted DTE range matches nothing and asks you to fix the range. Valid filters count as applied; invalid entries show a separate **Fix** count. Each entry has its own clearable chip, and Clear all restores the full chain and does not clear Contracts. Calls or puts with side open interest below 5, or with missing OI, are omitted. diff --git a/frontend/src/ItmChain.browser.test.tsx b/frontend/src/ItmChain.browser.test.tsx index ef7d378..8376199 100644 --- a/frontend/src/ItmChain.browser.test.tsx +++ b/frontend/src/ItmChain.browser.test.tsx @@ -855,7 +855,8 @@ describe("chain interactions", () => { fireEvent.click(screen.getByRole("button", { name: "Expand all" })) fireEvent.change(screen.getByLabelText("Min DTE"), { target: { value: "40" } }) fireEvent.change(screen.getByLabelText("Max DTE"), { target: { value: "10" } }) - expect(screen.getByText("No rows match the current filters.")).toBeTruthy() + expect(screen.getByText("Minimum DTE must not exceed maximum DTE. Fix the range to see results.")).toBeTruthy() + expect(screen.queryByText("No rows match the current filters.")).toBeNull() expect(screen.getByLabelText("Min DTE").getAttribute("aria-invalid")).toBe("true") expect(screen.getByLabelText("Max DTE").getAttribute("aria-invalid")).toBe("true") expect(screen.getByRole("button", { name: "Clear invalid input DTE ≥ 40" })).toBeTruthy() diff --git a/frontend/src/ItmChain.tsx b/frontend/src/ItmChain.tsx index 4a6a900..b7709ea 100644 --- a/frontend/src/ItmChain.tsx +++ b/frontend/src/ItmChain.tsx @@ -309,7 +309,7 @@ export default function ItmChain() { ) : null} {!loading && view.filterMiss ? (
-

No rows match the current filters.

+

{view.invertedDte ? "Minimum DTE must not exceed maximum DTE. Fix the range to see results." : "No rows match the current filters."}

) : null}