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/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..8376199 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 () => { @@ -850,9 +855,12 @@ 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() + 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/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} 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;