diff --git a/src/components/ui/searchable-select.tsx b/src/components/ui/searchable-select.tsx index 3cb8fc6..9293728 100644 --- a/src/components/ui/searchable-select.tsx +++ b/src/components/ui/searchable-select.tsx @@ -1,4 +1,14 @@ -import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; import { Check, ChevronDown } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -23,6 +33,12 @@ interface SearchableSelectProps { onValueChange: (value: string) => void; } +interface DropdownPosition { + top: number; + left: number; + width: number; +} + function normalizeQuery(q: string): string { return q.trim().toLowerCase(); } @@ -61,10 +77,13 @@ export function SearchableSelect({ const autoId = useId(); const id = idProp ?? autoId; const containerRef = useRef(null); + const triggerRef = useRef(null); + const dropdownRef = useRef(null); const listRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [highlightIndex, setHighlightIndex] = useState(0); + const [position, setPosition] = useState(null); const selectedLabel = useMemo(() => { if (allOption && value === allOption.value) return allOption.label; @@ -80,17 +99,43 @@ export function SearchableSelect({ return base; }, [allOption, options, query]); + const updatePosition = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + setPosition({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }); + }, []); + useEffect(() => { setHighlightIndex(0); }, [query, open]); + useEffect(() => { + if (!open) { + setPosition(null); + return; + } + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [open, updatePosition]); + useEffect(() => { if (!open) return; function onPointerDown(e: MouseEvent) { - if (!containerRef.current?.contains(e.target as Node)) { - setOpen(false); - setQuery(""); - } + const target = e.target as Node; + if (containerRef.current?.contains(target)) return; + if (dropdownRef.current?.contains(target)) return; + setOpen(false); + setQuery(""); } document.addEventListener("mousedown", onPointerDown); return () => document.removeEventListener("mousedown", onPointerDown); @@ -142,10 +187,68 @@ export function SearchableSelect({ el?.scrollIntoView({ block: "nearest" }); }, [highlightIndex, open]); + const dropdown = + open && position + ? createPortal( +
+
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
    + {filtered.length === 0 && ( +
  • {emptyLabel}
  • + )} + {filtered.map((option, index) => { + const selected = option.value === value; + const q = normalizeQuery(query); + return ( +
  • setHighlightIndex(index)} + onMouseDown={(e) => e.preventDefault()} + onClick={() => selectOption(option)} + > + {highlightMatch(option.label, q)} + {selected && } +
  • + ); + })} +
+
, + document.body, + ) + : null; + return ( -
+
{label && } - - {open && ( -
-
- setQuery(e.target.value)} - onKeyDown={handleKeyDown} - /> -
-
    - {filtered.length === 0 && ( -
  • {emptyLabel}
  • - )} - {filtered.map((option, index) => { - const selected = option.value === value; - const q = normalizeQuery(query); - return ( -
  • setHighlightIndex(index)} - onMouseDown={(e) => e.preventDefault()} - onClick={() => selectOption(option)} - > - {highlightMatch(option.label, q)} - {selected && } -
  • - ); - })} -
-
- )} + {dropdown}
); } diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index ebef7bf..5a1a1ae 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -18,6 +18,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useSettings } from "@/hooks/useSettings"; +import { useTradingShips } from "@/hooks/useTradingShips"; import { useUexData } from "@/hooks/useUexData"; import { clearUexCache, @@ -71,6 +72,8 @@ export function SettingsPage() { saveTradingDefaults, } = useSettings(); const { refetch: refetchUex, status: uexStatus } = useUexData(); + const { ships, status: shipsStatus } = useTradingShips(); + const shipsLoading = shipsStatus === "loading"; const [inputValue, setInputValue] = useState(""); const [ttlInput, setTtlInput] = useState(String(DEFAULT_UEX_CACHE_TTL_MINUTES)); @@ -532,7 +535,12 @@ export function SettingsPage() { <> setDefaultShip(name)} + ships={ships} + loading={shipsLoading} + onShipChange={(name, scu) => { + setDefaultShip(name); + if (scu > 0) setDefaultCargo(String(scu)); + }} />
diff --git a/src/features/trading-routes/CollapsibleSection.tsx b/src/features/trading-routes/CollapsibleSection.tsx index 21fb308..005115c 100644 --- a/src/features/trading-routes/CollapsibleSection.tsx +++ b/src/features/trading-routes/CollapsibleSection.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"; interface CollapsibleSectionProps { title: string; defaultOpen?: boolean; + summary?: string; children: ReactNode; className?: string; } @@ -12,6 +13,7 @@ interface CollapsibleSectionProps { export function CollapsibleSection({ title, defaultOpen = false, + summary, children, className, }: CollapsibleSectionProps) { @@ -27,7 +29,17 @@ export function CollapsibleSection({ {title} - + + {!open && summary && ( + {summary} + )} + + {open && children}
diff --git a/src/features/trading-routes/EnRouteView.tsx b/src/features/trading-routes/EnRouteView.tsx index 89f44a1..ebb6a55 100644 --- a/src/features/trading-routes/EnRouteView.tsx +++ b/src/features/trading-routes/EnRouteView.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/select"; import { SearchableSelect } from "@/components/ui/searchable-select"; import { useEnRoutePlanner } from "@/hooks/useEnRoutePlanner"; +import { useTradingShips } from "@/hooks/useTradingShips"; import { useUexData } from "@/hooks/useUexData"; import { formatAuec } from "@/lib/formatAuec"; import { cn } from "@/lib/utils"; @@ -77,16 +78,19 @@ export function EnRouteView({ pilotMode }: EnRouteViewProps) { isFromSnapshot, planner, filters, + shipName, results, allResults, updatePlanner, updateFilters, + updateShip, refresh, fetchedAt, searching, searchMs, } = useEnRoutePlanner(); + const { ships, status: shipsStatus } = useTradingShips(); const { status: uexStatus } = useUexData(); const { data: marketData } = useUexData(); @@ -100,6 +104,7 @@ export function EnRouteView({ pilotMode }: EnRouteViewProps) { const isLoading = status === "loading"; const displayed = pilotMode ? results.slice(0, 5) : results; + const shipsLoading = shipsStatus === "loading"; const chips = useMemo(() => { const list = []; @@ -177,77 +182,112 @@ export function EnRouteView({ pilotMode }: EnRouteViewProps) { -
- updatePlanner({ originTerminalId: Number(v) })} - /> - updatePlanner({ destinationTerminalId: Number(v) })} - /> -
- - - updatePlanner({ maxStops: Math.min(5, Math.max(0, Number(e.target.value) || 0)) }) - } +
+

Route

+
+ updatePlanner({ originTerminalId: Number(v) })} /> -
-
- - - updatePlanner({ - maxDetourPercent: Math.min(100, Math.max(0, Number(e.target.value) || 0)), - }) - } + updatePlanner({ destinationTerminalId: Number(v) })} />
- { - if (scu > 0) updatePlanner({ cargoScu: scu, shipScu: scu }); - }} - disabled={isLoading} - /> -
- - - updatePlanner({ cargoScu: Math.max(1, Number(e.target.value) || 1) }) - } +
+ +
+

+ Constraints +

+
+
+ + + updatePlanner({ maxStops: Math.min(5, Math.max(0, Number(e.target.value) || 0)) }) + } + /> +
+
+ + + updatePlanner({ + maxDetourPercent: Math.min(100, Math.max(0, Number(e.target.value) || 0)), + }) + } + /> +
+
+
+ +
+

+ Ship & cargo +

+
+ +
+ + + updatePlanner({ cargoScu: Math.max(1, Number(e.target.value) || 1) }) + } + /> + {planner.shipScu ? ( +

+ Using ship capacity: {planner.shipScu} SCU +

+ ) : ( +

Custom cargo — enter SCU manually

+ )} +
-
+
+ + + + + + {searching && ( +

Searching en-route paths…

+ )} +
+
-
-
- - - - {searching && ( -

Searching en-route paths…

- )} void; } -function formatSignedAuec(value: number): string { - const formatted = formatAuec(Math.abs(value)); - return value >= 0 ? `+${formatted}` : `-${formatted}`; -} - function roiClassName(roi: number): string { if (roi >= 30) return "text-emerald-400"; if (roi >= 15) return "text-amber-400/90"; @@ -77,8 +73,13 @@ function StepRow({ leg, nextLeg }: StepRowProps) { {actionLabel} {" "} {leg.scuUsed} SCU {leg.commodity} @ {leg.terminal.terminal}{" "} - - ({formatSignedAuec(leg.costOrRevenue)} aUEC) + + ({formatTradeLegCashFlow(leg.action, leg.costOrRevenue)})

{leg.terminal.location}

diff --git a/src/features/trading-routes/LoopFiltersPanel.tsx b/src/features/trading-routes/LoopFiltersPanel.tsx index e118e9b..943e209 100644 --- a/src/features/trading-routes/LoopFiltersPanel.tsx +++ b/src/features/trading-routes/LoopFiltersPanel.tsx @@ -1,5 +1,16 @@ import { useMemo } from "react"; import { Minus, Plus } from "lucide-react"; +import { mergeLoopPlannerInput } from "@/features/trading-routes/default-loop-planner"; +import { + applyLoopPlannerPreset, + isStantonOnly, + resolveLoopProfileValue, + stantonOnlyPatch, + type LoopPlannerPresetId, +} from "@/features/trading-routes/loop-planner-presets"; +import { buildLoopPlannerSummary } from "@/features/trading-routes/loop-planner-summary"; +import type { TradingShip } from "@/lib/trading-routes/ships"; +import type { LoopPlannerInput } from "@/types/trading-route"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -11,14 +22,8 @@ import { SelectValue, } from "@/components/ui/select"; import { SearchableSelect } from "@/components/ui/searchable-select"; -import { - applyLoopPlannerPreset, - detectMatchingLoopPlannerPresetId, - type LoopPlannerPresetId, -} from "@/features/trading-routes/loop-planner-presets"; -import type { LoopPlannerFilters, LoopPlannerInput } from "@/types/trading-route"; import { CollapsibleSection } from "./CollapsibleSection"; -import { LoopPlannerPresetBar } from "./LoopPlannerPresetBar"; +import { LoopProfileSelect } from "./LoopProfileSelect"; import { LoopSystemFilterPanel, type StarSystemOption } from "./LoopSystemFilterPanel"; import { ShipSelect } from "./ShipSelect"; @@ -32,15 +37,15 @@ export interface TerminalOption { interface LoopFiltersPanelProps { planner: LoopPlannerInput; - filters: LoopPlannerFilters; shipName: string; + ships: TradingShip[]; + shipsLoading?: boolean; terminals: TerminalOption[]; systems: StarSystemOption[]; - commodities: string[]; stantonSystemId?: number; disabled?: boolean; onPlannerChange: (patch: Partial) => void; - onFiltersChange: (patch: Partial) => void; + onPlannerReplace: (planner: LoopPlannerInput) => void; onShipChange: (name: string, scu: number) => void; } @@ -121,23 +126,33 @@ function PlannerToggle({ export function LoopFiltersPanel({ planner, - filters, shipName, + ships, + shipsLoading, terminals, systems, - commodities, stantonSystemId, disabled, onPlannerChange, - onFiltersChange, + onPlannerReplace, onShipChange, }: LoopFiltersPanelProps) { const minLegs = planner.minLegs ?? LEG_MIN; const maxLegs = planner.maxLegs ?? LEG_MAX; - const activePresetId = useMemo( - () => detectMatchingLoopPlannerPresetId(planner, shipName, stantonSystemId), - [planner, shipName, stantonSystemId], + const profileValue = useMemo( + () => resolveLoopProfileValue(planner, shipName), + [planner, shipName], + ); + + const systemNames = useMemo( + () => new Map(systems.map((s) => [s.id, s.name])), + [systems], + ); + + const advancedSummary = useMemo( + () => buildLoopPlannerSummary(planner, { stantonSystemId, systemNames }), + [planner, stantonSystemId, systemNames], ); const terminalOptions = useMemo( @@ -148,6 +163,8 @@ export function LoopFiltersPanel({ [terminals], ); + const stantonOnly = isStantonOnly(planner, stantonSystemId); + function handleMinLegsChange(next: number) { const clamped = clampLegs(next); onPlannerChange({ minLegs: clamped, maxLegs: Math.max(clamped, maxLegs) }); @@ -159,87 +176,31 @@ export function LoopFiltersPanel({ } function handlePresetSelect(id: LoopPlannerPresetId) { - const applied = applyLoopPlannerPreset( - id, - { planner, shipName }, - (partial) => ({ ...planner, ...partial }), - { stantonSystemId }, - ); + const applied = applyLoopPlannerPreset(id, { planner, shipName }, mergeLoopPlannerInput); onShipChange(applied.shipName, applied.planner.shipScu ?? applied.planner.cargoScu); - onPlannerChange(applied.planner); + onPlannerReplace(applied.planner); } return (
- - - -
-

Route

-
- - - - - onPlannerChange({ - startTerminalId: v === "__any__" ? undefined : Number(v), - }) - } - /> - -
- - { - const raw = e.target.value; - onPlannerChange({ - maxTotalTimeMinutes: raw === "" ? undefined : Math.max(0, Number(raw) || 0), - }); - }} - /> -
-
-
-

- Ship & cargo + Essentials

- +
@@ -247,62 +208,38 @@ export function LoopFiltersPanel({ id="lp-cargo" type="number" min={1} + max={planner.shipScu ?? undefined} disabled={disabled} value={planner.cargoScu} onChange={(e) => onPlannerChange({ cargoScu: Math.max(1, Number(e.target.value) || 1) }) } /> + {planner.shipScu ? ( +

+ Using ship capacity: {planner.shipScu} SCU +

+ ) : ( +

Custom cargo — enter SCU manually

+ )}
-
- - - onPlannerChange({ budgetAuec: Math.max(0, Number(e.target.value) || 0) }) - } - /> -
+ -
- - -
+
-
-
-

Options

- onPlannerChange({ returnToStart: checked })} - /> onPlannerChange({ sameSystemOnly: checked })} /> onPlannerChange({ allowMixedCommodities: checked })} + id="lp-stanton-only" + label="Stanton only" + checked={stantonOnly} + disabled={disabled || stantonSystemId == null} + onChange={(checked) => onPlannerChange(stantonOnlyPatch(checked, stantonSystemId))} /> onPlannerChange({ excludeIllegal: checked })} /> - onPlannerChange({ requireCargoCenter: checked })} - />
- -
- ({ value: c, label: c }))} - placeholder="Search commodities…" - disabled={disabled} - onValueChange={(v) => onFiltersChange({ commodity: v === "__all__" ? "" : v })} - /> + +
+
+
+ + + onPlannerChange({ budgetAuec: Math.max(0, Number(e.target.value) || 0) }) + } + /> +
- ({ value: s.name, label: s.name }))} - placeholder="Search systems…" - disabled={disabled} - onValueChange={(v) => onFiltersChange({ system: v === "__all__" ? "" : v })} - /> +
+ + +
-
- - onFiltersChange({ terminal: e.target.value })} + onValueChange={(v) => + onPlannerChange({ + startTerminalId: v === "__any__" ? undefined : Number(v), + }) + } /> + +
+ + { + const raw = e.target.value; + onPlannerChange({ + maxTotalTimeMinutes: raw === "" ? undefined : Math.max(0, Number(raw) || 0), + }); + }} + /> +
-
- - + +
+ { - const raw = e.target.value; - onFiltersChange({ - minProfit: raw === "" ? undefined : Math.max(0, Number(raw) || 0), - }); - }} + onChange={(checked) => onPlannerChange({ returnToStart: checked })} /> -
- -
- - onPlannerChange({ allowMixedCommodities: checked })} + /> + { - const raw = e.target.value; - onFiltersChange({ - maxTime: raw === "" ? undefined : Math.max(0, Number(raw) || 0), - }); - }} + onChange={(checked) => onPlannerChange({ requireCargoCenter: checked })} />
diff --git a/src/features/trading-routes/LoopPlannerPresetBar.tsx b/src/features/trading-routes/LoopPlannerPresetBar.tsx deleted file mode 100644 index b76b356..0000000 --- a/src/features/trading-routes/LoopPlannerPresetBar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { - LOOP_PLANNER_PRESET_IDS, - LOOP_PLANNER_PRESETS, - type LoopPlannerPresetId, -} from "./loop-planner-presets"; - -interface LoopPlannerPresetBarProps { - activePresetId: LoopPlannerPresetId | null; - disabled?: boolean; - onPresetSelect: (id: LoopPlannerPresetId) => void; -} - -export function LoopPlannerPresetBar({ - activePresetId, - disabled, - onPresetSelect, -}: LoopPlannerPresetBarProps) { - return ( -
- {LOOP_PLANNER_PRESET_IDS.map((id) => { - const isActive = activePresetId === id; - return ( - - ); - })} -
- ); -} diff --git a/src/features/trading-routes/LoopPlannerView.tsx b/src/features/trading-routes/LoopPlannerView.tsx index 6e928bc..81d59b0 100644 --- a/src/features/trading-routes/LoopPlannerView.tsx +++ b/src/features/trading-routes/LoopPlannerView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { Clock, Coins, RefreshCw, TrendingUp } from "lucide-react"; import { StatCard } from "@/components/shared/StatCard"; @@ -6,14 +6,15 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { useLoopPlanner } from "@/hooks/useLoopPlanner"; import { useSavedLoops } from "@/hooks/useSavedLoops"; +import { useTradingShips } from "@/hooks/useTradingShips"; import { useUexData } from "@/hooks/useUexData"; -import { applyLoopPlannerPreset } from "@/features/trading-routes/loop-planner-presets"; -import { TRADING_SHIPS } from "@/lib/trading-routes/ships"; +import { applyQuickSearchPatch } from "@/features/trading-routes/loop-planner-presets"; import { formatAuec } from "@/lib/formatAuec"; import type { SavedTradeLoop, TradeLoop } from "@/types/trading-route"; import { ActiveFilterChips } from "./ActiveFilterChips"; import { LoopDetailDialog } from "./LoopDetailDialog"; import { LoopFiltersPanel } from "./LoopFiltersPanel"; +import { LoopResultFiltersPanel } from "./LoopResultFiltersPanel"; import { LoopResultsCards } from "./LoopResultsCards"; import { LoopSortBar } from "./LoopSortBar"; import { SavedLoopsPanel } from "./SavedLoopsPanel"; @@ -24,12 +25,6 @@ import { SearchDiagnostics } from "./SearchDiagnostics"; import { TradingRoutesSkeleton } from "./TradingRoutesSkeleton"; import { buildLoopFilterChips } from "./route-filter-chips"; -function shipNameFromScu(scu: number | undefined): string { - if (!scu) return ""; - const ship = TRADING_SHIPS.find((s) => s.scu === scu); - return ship?.name ?? ""; -} - interface LoopPlannerViewProps { pilotMode?: boolean; onLogAsIncome?: (loop: TradeLoop, shipName: string) => void; @@ -48,11 +43,14 @@ export function LoopPlannerView({ isFromSnapshot, planner, filters, + shipName, loops, allLoops, stats, updatePlanner, + replacePlanner, updateFilters, + updateShip, refresh, fetchedAt, searching, @@ -61,10 +59,10 @@ export function LoopPlannerView({ } = useLoopPlanner(); const { savedLoops, saveLoop, renameLoop, deleteLoop } = useSavedLoops(); + const { ships, status: shipsStatus } = useTradingShips(); const { status: uexStatus } = useUexData(); - const [shipName, setShipName] = useState(() => shipNameFromScu(planner.shipScu)); const [selectedLoop, setSelectedLoop] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [selectedSavedLoop, setSelectedSavedLoop] = useState(null); @@ -72,12 +70,6 @@ export function LoopPlannerView({ const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [saving, setSaving] = useState(false); - useEffect(() => { - if (!shipName && planner.shipScu) { - setShipName(shipNameFromScu(planner.shipScu)); - } - }, [planner.shipScu, shipName]); - const { data: marketData } = useUexData(); const terminals = useMemo( () => @@ -125,19 +117,16 @@ export function LoopPlannerView({ ); const loopFilterChips = useMemo( - () => buildLoopFilterChips(filters, updateFilters), - [filters, updateFilters], + () => buildLoopFilterChips(filters, updateFilters, { shipName, cargoScu: planner.cargoScu }), + [filters, updateFilters, shipName, planner.cargoScu], ); const isLoading = status === "loading"; const isSearching = searching && !isLoading; + const shipsLoading = shipsStatus === "loading"; function handleShipChange(name: string, scu: number) { - setShipName(name); - updatePlanner({ - shipScu: scu || undefined, - cargoScu: scu > 0 ? Math.min(planner.cargoScu, scu) : planner.cargoScu, - }); + updateShip(name, scu); } function handleLoopSelect(loop: TradeLoop) { @@ -175,14 +164,7 @@ export function LoopPlannerView({ } function applyQuickSearchPreset() { - const applied = applyLoopPlannerPreset( - "quick-search", - { planner, shipName }, - (partial) => ({ ...planner, ...partial }), - { stantonSystemId }, - ); - handleShipChange(applied.shipName, applied.planner.shipScu ?? applied.planner.cargoScu); - updatePlanner(applied.planner); + updatePlanner(applyQuickSearchPatch(planner)); } return ( @@ -312,33 +294,17 @@ export function LoopPlannerView({ -
- - updateFilters({ - query: "", - commodity: "", - system: "", - terminal: "", - minProfit: undefined, - maxTime: undefined, - }) - } - /> -
)} @@ -346,6 +312,29 @@ export function LoopPlannerView({ updateFilters({ sort })} /> + +
+ + updateFilters({ + query: "", + commodity: "", + system: "", + terminal: "", + minProfit: undefined, + maxTime: undefined, + }) + } + /> +
void; +} + +export function LoopProfileSelect({ value, disabled, onPresetSelect }: LoopProfileSelectProps) { + const activePreset = value === "custom" ? null : LOOP_PLANNER_PRESETS[value]; + + return ( +
+ + + {value === "custom" && ( +

+ Custom settings — pick a profile to reset search options. +

+ )} + {activePreset && ( +

{activePreset.description}

+ )} +
+ ); +} diff --git a/src/features/trading-routes/LoopResultFiltersPanel.tsx b/src/features/trading-routes/LoopResultFiltersPanel.tsx new file mode 100644 index 0000000..6196318 --- /dev/null +++ b/src/features/trading-routes/LoopResultFiltersPanel.tsx @@ -0,0 +1,104 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SearchableSelect } from "@/components/ui/searchable-select"; +import type { LoopPlannerFilters } from "@/types/trading-route"; +import { CollapsibleSection } from "./CollapsibleSection"; +import type { StarSystemOption } from "./LoopSystemFilterPanel"; + +interface LoopResultFiltersPanelProps { + filters: LoopPlannerFilters; + systems: StarSystemOption[]; + commodities: string[]; + disabled?: boolean; + onFiltersChange: (patch: Partial) => void; +} + +export function LoopResultFiltersPanel({ + filters, + systems, + commodities, + disabled, + onFiltersChange, +}: LoopResultFiltersPanelProps) { + const hasActiveFilters = + Boolean(filters.commodity?.trim()) || + Boolean(filters.system?.trim()) || + Boolean(filters.terminal?.trim()) || + (filters.minProfit != null && filters.minProfit > 0) || + (filters.maxTime != null && filters.maxTime > 0); + + const summary = hasActiveFilters ? "Filters active" : undefined; + + return ( + +
+ ({ value: c, label: c }))} + placeholder="Search commodities…" + disabled={disabled} + onValueChange={(v) => onFiltersChange({ commodity: v === "__all__" ? "" : v })} + /> + + ({ value: s.name, label: s.name }))} + placeholder="Search systems…" + disabled={disabled} + onValueChange={(v) => onFiltersChange({ system: v === "__all__" ? "" : v })} + /> + +
+ + onFiltersChange({ terminal: e.target.value })} + /> +
+ +
+ + { + const raw = e.target.value; + onFiltersChange({ + minProfit: raw === "" ? undefined : Math.max(0, Number(raw) || 0), + }); + }} + /> +
+ +
+ + { + const raw = e.target.value; + onFiltersChange({ + maxTime: raw === "" ? undefined : Math.max(0, Number(raw) || 0), + }); + }} + /> +
+
+
+ ); +} diff --git a/src/features/trading-routes/LoopSystemFilterPanel.tsx b/src/features/trading-routes/LoopSystemFilterPanel.tsx index fa44b63..338e75e 100644 --- a/src/features/trading-routes/LoopSystemFilterPanel.tsx +++ b/src/features/trading-routes/LoopSystemFilterPanel.tsx @@ -1,5 +1,4 @@ import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import { systemFilterSummary } from "@/lib/trading-routes/loop-system-filter"; import type { LoopPlannerInput } from "@/types/trading-route"; @@ -65,8 +64,7 @@ export function LoopSystemFilterPanel({ return (
-
- +
{summary}
@@ -94,32 +92,29 @@ export function LoopSystemFilterPanel({ ))}
-
- {sortedSystems.map((system) => { - const selected = activeIds.has(system.id); - return ( - - ); - })} - {sortedSystems.length === 0 && ( - No systems in market data - )} -
+ {mode !== "all" && ( +
+ {sortedSystems.map((system) => { + const selected = activeIds.has(system.id); + return ( + + ); + })} + {sortedSystems.length === 0 && ( + No systems in market data + )} +
+ )}
); } diff --git a/src/features/trading-routes/RouteFiltersPanel.tsx b/src/features/trading-routes/RouteFiltersPanel.tsx index 3afae3f..b3acb98 100644 --- a/src/features/trading-routes/RouteFiltersPanel.tsx +++ b/src/features/trading-routes/RouteFiltersPanel.tsx @@ -8,6 +8,7 @@ import { SelectValue, } from "@/components/ui/select"; import { SearchableSelect } from "@/components/ui/searchable-select"; +import type { TradingShip } from "@/lib/trading-routes/ships"; import type { TradingRouteFilters, TradingRoutePlannerInput } from "@/types/trading-route"; import { FilterPresetBar } from "./FilterPresetBar"; import { MarketFiltersSection } from "./MarketFiltersSection"; @@ -18,6 +19,8 @@ interface RouteFiltersPanelProps { planner: TradingRoutePlannerInput; filters: TradingRouteFilters; shipName: string; + ships: TradingShip[]; + shipsLoading?: boolean; activePresetId: FilterPresetId | null; systems: string[]; commodities: string[]; @@ -32,6 +35,8 @@ export function RouteFiltersPanel({ planner, filters, shipName, + ships, + shipsLoading, activePresetId, systems, commodities, @@ -48,7 +53,13 @@ export function RouteFiltersPanel({

Quick

- +
diff --git a/src/features/trading-routes/ShipSelect.tsx b/src/features/trading-routes/ShipSelect.tsx index 7c1cd7f..b4d6f5b 100644 --- a/src/features/trading-routes/ShipSelect.tsx +++ b/src/features/trading-routes/ShipSelect.tsx @@ -1,60 +1,53 @@ -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { useMemo } from "react"; +import { SearchableSelect } from "@/components/ui/searchable-select"; import { getQuantumSpeedClassLabel, - TRADING_SHIPS, + type TradingShip, } from "@/lib/trading-routes/ships"; +const CUSTOM_CARGO_VALUE = "__none__"; + interface ShipSelectProps { shipName: string; + ships: TradingShip[]; + loading?: boolean; onShipChange: (name: string, scu: number) => void; disabled?: boolean; } -export function ShipSelect({ shipName, onShipChange, disabled }: ShipSelectProps) { - const sortedShips = useMemo( - () => [...TRADING_SHIPS].sort((a, b) => a.scu - b.scu), - [], +export function ShipSelect({ shipName, ships, loading, onShipChange, disabled }: ShipSelectProps) { + const options = useMemo( + () => + [...ships] + .sort((a, b) => a.scu - b.scu || a.name.localeCompare(b.name)) + .map((s) => ({ + value: s.name, + label: `${s.name} (${s.scu} SCU) · ${getQuantumSpeedClassLabel(s.quantumSpeedClass)}`, + keywords: `${s.scu} ${getQuantumSpeedClassLabel(s.quantumSpeedClass)}`, + })), + [ships], ); + + const value = shipName || CUSTOM_CARGO_VALUE; + const placeholder = loading ? "Loading ships…" : "Search ship…"; + return ( -
- - -
+ { + if (v === CUSTOM_CARGO_VALUE) { + onShipChange("", 0); + return; + } + const ship = ships.find((s) => s.name === v); + if (ship) onShipChange(ship.name, ship.scu); + }} + /> ); } diff --git a/src/features/trading-routes/TradingRoutesPage.tsx b/src/features/trading-routes/TradingRoutesPage.tsx index 1f95b75..6b06212 100644 --- a/src/features/trading-routes/TradingRoutesPage.tsx +++ b/src/features/trading-routes/TradingRoutesPage.tsx @@ -13,6 +13,7 @@ import { } from "@/features/transactions/TransactionFormDialog"; import { useFarmingSessions } from "@/hooks/useFarmingSessions"; import { useTradingRoutes } from "@/hooks/useTradingRoutes"; +import { useTradingShips } from "@/hooks/useTradingShips"; import { useTransactions } from "@/hooks/useTransactions"; import { createSessionTransaction } from "@/lib/createSessionTransaction"; import { formatAuec } from "@/lib/formatAuec"; @@ -92,6 +93,9 @@ export function TradingRoutesPage() { logProfitBasis, } = useTradingRoutes(); + const { ships, status: shipsStatus } = useTradingShips(); + const shipsLoading = shipsStatus === "loading"; + const { status: uexStatus } = useUexData(); const isSingleMode = mode === "single"; @@ -289,6 +293,8 @@ export function TradingRoutesPage() { planner={planner} filters={filters} shipName={shipName} + ships={ships} + shipsLoading={shipsLoading} activePresetId={activePresetId} systems={filterOptions.systems} commodities={filterOptions.commodities} diff --git a/src/features/trading-routes/__tests__/loop-checklist.test.ts b/src/features/trading-routes/__tests__/loop-checklist.test.ts new file mode 100644 index 0000000..d38b034 --- /dev/null +++ b/src/features/trading-routes/__tests__/loop-checklist.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { buildLoopChecklistText } from "@/features/trading-routes/loop-checklist"; +import type { TradeLeg, TradeLoop } from "@/types/trading-route"; + +const ZERO_TRAVEL = { qt: 0, load: 0, unload: 0, overhead: 0, total: 0 }; + +function makeLeg( + step: number, + action: "buy" | "sell", + costOrRevenue: number, +): TradeLeg { + return { + step, + action, + commodityId: 1, + commodity: "Iron", + terminal: { + terminalId: step, + terminal: action === "buy" ? "Levski" : "TDD Orison", + location: "Levski, Levski", + planet: "Crusader", + system: "Stanton", + systemId: 1, + orbitId: 100, + hasCargoCenter: false, + hasFreightElevator: false, + hasLoadingDock: true, + hasDockingPort: true, + isRefuel: false, + isNqa: false, + maxContainerSize: 32, + price: costOrRevenue / 198, + priceAvg: costOrRevenue / 198, + scu: 198, + scuStock: 198, + }, + scuUsed: 198, + price: costOrRevenue / 198, + costOrRevenue, + profitThisLeg: action === "sell" ? 208_098 : 0, + travelFromPrev: ZERO_TRAVEL, + }; +} + +function makeLoop(legs: TradeLeg[]): TradeLoop { + return { + id: "test-loop", + legs, + startTerminalId: 1, + returnsToStart: true, + totalProfit: 208_098, + totalTime: 55, + profitPerMin: 3783, + roiPercent: 22.5, + totalScuTurnover: 198, + finalBudget: 1_208_098, + commoditiesUsed: ["Iron"], + systemsVisited: ["Stanton"], + }; +} + +describe("buildLoopChecklistText", () => { + it("shows minus for BUY and plus for SELL with a single aUEC suffix", () => { + const text = buildLoopChecklistText( + makeLoop([makeLeg(1, "buy", 465_102), makeLeg(2, "sell", 673_200)]), + ); + + expect(text).toContain("1. BUY 198 SCU Iron @ Levski (-465,102 aUEC)"); + expect(text).toContain("2. SELL 198 SCU Iron @ TDD Orison (+673,200 aUEC)"); + expect(text).not.toContain("aUEC aUEC"); + expect(text).not.toContain("+465,102"); + }); + + it("includes loop header and totals", () => { + const text = buildLoopChecklistText(makeLoop([makeLeg(1, "buy", 465_102)])); + + expect(text).toContain("Loop route (1 legs)"); + expect(text).toContain("Start: Levski"); + expect(text).toContain("Totals:"); + }); +}); diff --git a/src/features/trading-routes/__tests__/loop-planner-presets.test.ts b/src/features/trading-routes/__tests__/loop-planner-presets.test.ts new file mode 100644 index 0000000..a1a99bd --- /dev/null +++ b/src/features/trading-routes/__tests__/loop-planner-presets.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { mergeLoopPlannerInput } from "@/features/trading-routes/default-loop-planner"; +import { + applyLoopPlannerPreset, + applyQuickSearchPatch, + detectMatchingLoopPlannerPresetId, + isStantonOnly, + stantonOnlyPatch, +} from "@/features/trading-routes/loop-planner-presets"; +import type { LoopPlannerInput } from "@/types/trading-route"; + +const STANTON_ID = 42; + +const BASE_PLANNER: LoopPlannerInput = mergeLoopPlannerInput({ + cargoScu: 46, + budgetAuec: 100_000, + shipScu: 46, +}); + +describe("loop-planner-presets", () => { + it("applyLoopPlannerPreset replaces from defaults, not current planner", () => { + const current = mergeLoopPlannerInput({ + cargoScu: 696, + budgetAuec: 500_000, + shipScu: 696, + startTerminalId: 99, + }); + + const applied = applyLoopPlannerPreset("starter", { planner: current, shipName: "" }); + + expect(applied.shipName).toBe("Nomad"); + expect(applied.planner.cargoScu).toBe(24); + expect(applied.planner.budgetAuec).toBe(50_000); + expect(applied.planner.sameSystemOnly).toBe(true); + expect(applied.planner.excludeIllegal).toBe(true); + expect(applied.planner.startTerminalId).toBeUndefined(); + }); + + it("detects starter and quick-search profiles", () => { + const starter = applyLoopPlannerPreset("starter", { + planner: BASE_PLANNER, + shipName: "Cutlass Black", + }); + expect( + detectMatchingLoopPlannerPresetId(starter.planner, starter.shipName), + ).toBe("starter"); + + const quick = applyLoopPlannerPreset("quick-search", { + planner: BASE_PLANNER, + shipName: "", + }); + expect(detectMatchingLoopPlannerPresetId(quick.planner, quick.shipName)).toBe("quick-search"); + }); + + it("returns null when settings diverge from presets", () => { + expect(detectMatchingLoopPlannerPresetId(BASE_PLANNER, "")).toBeNull(); + }); + + it("stantonOnlyPatch toggles allow list", () => { + expect(isStantonOnly(stantonOnlyPatch(true, STANTON_ID) as LoopPlannerInput, STANTON_ID)).toBe( + true, + ); + expect( + isStantonOnly( + mergeLoopPlannerInput(stantonOnlyPatch(false, STANTON_ID)), + STANTON_ID, + ), + ).toBe(false); + }); + + it("applyQuickSearchPatch caps legs and enables same system", () => { + const patch = applyQuickSearchPatch( + mergeLoopPlannerInput({ minLegs: 4, maxLegs: 6, sameSystemOnly: false }), + ); + expect(patch.maxLegs).toBe(3); + expect(patch.minLegs).toBe(3); + expect(patch.sameSystemOnly).toBe(true); + expect(patch.systemFilterMode).toBe("all"); + }); +}); diff --git a/src/features/trading-routes/__tests__/loop-planner-summary.test.ts b/src/features/trading-routes/__tests__/loop-planner-summary.test.ts new file mode 100644 index 0000000..a5ac57f --- /dev/null +++ b/src/features/trading-routes/__tests__/loop-planner-summary.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { mergeLoopPlannerInput } from "@/features/trading-routes/default-loop-planner"; +import { buildLoopPlannerSummary } from "@/features/trading-routes/loop-planner-summary"; + +describe("buildLoopPlannerSummary", () => { + it("returns undefined when advanced settings are default", () => { + const planner = mergeLoopPlannerInput({}); + expect(buildLoopPlannerSummary(planner)).toBeUndefined(); + }); + + it("summarizes non-default advanced options", () => { + const planner = mergeLoopPlannerInput({ + budgetAuec: 250_000, + crew: 2, + startTerminalId: 10, + maxTotalTimeMinutes: 90, + requireCargoCenter: true, + }); + + const summary = buildLoopPlannerSummary(planner); + expect(summary).toContain("250k budget"); + expect(summary).toContain("2 crew"); + expect(summary).toContain("Start terminal"); + expect(summary).toContain("≤90m search"); + expect(summary).toContain("Cargo center"); + }); + + it("omits stanton-only from summary when essentials toggle handles it", () => { + const planner = mergeLoopPlannerInput({ + systemFilterMode: "allow", + allowedSystemIds: [42], + excludedSystemIds: [], + }); + const summary = buildLoopPlannerSummary(planner, { + stantonSystemId: 42, + systemNames: new Map([[42, "Stanton"]]), + }); + expect(summary).toBeUndefined(); + }); +}); diff --git a/src/features/trading-routes/default-en-route.ts b/src/features/trading-routes/default-en-route.ts index 2ed26de..aed85fc 100644 --- a/src/features/trading-routes/default-en-route.ts +++ b/src/features/trading-routes/default-en-route.ts @@ -19,6 +19,7 @@ export const DEFAULT_EN_ROUTE_FILTERS: EnRouteFilters = { export interface PersistedEnRouteState { planner?: Partial; filters?: Partial; + shipName?: string; } function settingsDefaultsToPersisted( @@ -26,6 +27,7 @@ function settingsDefaultsToPersisted( ): PersistedEnRouteState { if (!settingsDefaults) return {}; const partial: PersistedEnRouteState = {}; + if (settingsDefaults.shipName) partial.shipName = settingsDefaults.shipName; if ( settingsDefaults.cargoScu != null || settingsDefaults.budgetAuec != null || @@ -58,6 +60,7 @@ export function loadPersistedEnRouteState( return { planner: { ...baseline.planner, ...stored.planner }, filters: { ...stored.filters }, + shipName: stored.shipName ?? baseline.shipName, }; } diff --git a/src/features/trading-routes/default-loop-planner.ts b/src/features/trading-routes/default-loop-planner.ts index 702dbed..58aec57 100644 --- a/src/features/trading-routes/default-loop-planner.ts +++ b/src/features/trading-routes/default-loop-planner.ts @@ -23,6 +23,7 @@ export const DEFAULT_LOOP_PLANNER_FILTERS: LoopPlannerFilters = { export interface PersistedLoopPlannerState { planner?: Partial; filters?: Partial; + shipName?: string; } function settingsDefaultsToPersisted( @@ -30,6 +31,7 @@ function settingsDefaultsToPersisted( ): PersistedLoopPlannerState { if (!settingsDefaults) return {}; const partial: PersistedLoopPlannerState = {}; + if (settingsDefaults.shipName) partial.shipName = settingsDefaults.shipName; if ( settingsDefaults.cargoScu != null || settingsDefaults.budgetAuec != null || @@ -62,6 +64,7 @@ export function loadPersistedLoopPlannerState( return { planner: { ...baseline.planner, ...stored.planner }, filters: { ...stored.filters }, + shipName: stored.shipName ?? baseline.shipName, }; } diff --git a/src/features/trading-routes/loop-checklist.ts b/src/features/trading-routes/loop-checklist.ts index 1b41745..ba1211e 100644 --- a/src/features/trading-routes/loop-checklist.ts +++ b/src/features/trading-routes/loop-checklist.ts @@ -1,15 +1,11 @@ import { formatAuec } from "@/lib/formatAuec"; +import { formatTradeLegCashFlow } from "@/lib/formatNetProfit"; import type { TradeLoop } from "@/types/trading-route"; function hopCount(loop: TradeLoop): number { return loop.legs.filter((leg) => leg.action === "buy").length; } -function formatSignedAuec(value: number): string { - const formatted = formatAuec(Math.abs(value)); - return value >= 0 ? `+${formatted}` : `-${formatted}`; -} - export function buildLoopChecklistText(loop: TradeLoop): string { const hops = hopCount(loop); const startTerminal = loop.legs[0]?.terminal.terminal ?? "Unknown"; @@ -22,9 +18,9 @@ export function buildLoopChecklistText(loop: TradeLoop): string { for (let i = 0; i < loop.legs.length; i++) { const leg = loop.legs[i]; const action = leg.action.toUpperCase(); - const sign = formatSignedAuec(leg.costOrRevenue); + const cashFlow = formatTradeLegCashFlow(leg.action, leg.costOrRevenue); lines.push( - `${leg.step}. ${action} ${leg.scuUsed} SCU ${leg.commodity} @ ${leg.terminal.terminal} (${sign} aUEC)`, + `${leg.step}. ${action} ${leg.scuUsed} SCU ${leg.commodity} @ ${leg.terminal.terminal} (${cashFlow})`, ); const nextLeg = loop.legs[i + 1]; if (nextLeg && nextLeg.travelFromPrev.total > 0) { diff --git a/src/features/trading-routes/loop-planner-presets.ts b/src/features/trading-routes/loop-planner-presets.ts index e25ca1f..5ba5fe7 100644 --- a/src/features/trading-routes/loop-planner-presets.ts +++ b/src/features/trading-routes/loop-planner-presets.ts @@ -1,23 +1,23 @@ +import { mergeLoopPlannerInput } from "@/features/trading-routes/default-loop-planner"; import type { LoopPlannerInput } from "@/types/trading-route"; -export type LoopPlannerPresetId = "starter" | "stanton-only" | "same-system" | "quick-search"; +export type LoopPlannerPresetId = "starter" | "quick-search"; + +export type LoopPlannerProfileValue = LoopPlannerPresetId | "custom"; export interface LoopPlannerPresetDefinition { label: string; + description: string; shipName?: string; planner: Partial; } -export const LOOP_PLANNER_PRESET_IDS: LoopPlannerPresetId[] = [ - "starter", - "stanton-only", - "same-system", - "quick-search", -]; +export const LOOP_PLANNER_PRESET_IDS: LoopPlannerPresetId[] = ["starter", "quick-search"]; export const LOOP_PLANNER_PRESETS: Record = { starter: { label: "Starter", + description: "Nomad 24 SCU, same system, legal only, 3–5 legs", shipName: "Nomad", planner: { cargoScu: 24, @@ -33,62 +33,75 @@ export const LOOP_PLANNER_PRESETS: Record { + if (enabled && stantonSystemId != null) { + return { + systemFilterMode: "allow", + allowedSystemIds: [stantonSystemId], + excludedSystemIds: [], + }; + } + return { + systemFilterMode: "all", + allowedSystemIds: [], + excludedSystemIds: [], + }; } export function applyLoopPlannerPreset( id: LoopPlannerPresetId, current: { planner: LoopPlannerInput; shipName: string }, - mergePlanner: (partial?: Partial) => LoopPlannerInput, - options: ApplyLoopPlannerPresetOptions = {}, + mergePlanner: (partial?: Partial) => LoopPlannerInput = mergeLoopPlannerInput, ): { shipName: string; planner: LoopPlannerInput } { const preset = LOOP_PLANNER_PRESETS[id]; const shipName = preset.shipName ?? current.shipName; - - let plannerPatch = { ...preset.planner }; - if (id === "stanton-only" && options.stantonSystemId != null) { - plannerPatch = { - ...plannerPatch, - systemFilterMode: "allow", - allowedSystemIds: [options.stantonSystemId], - excludedSystemIds: [], - }; - } - - const planner = mergePlanner({ ...current.planner, ...plannerPatch }); + const planner = mergePlanner(preset.planner); return { shipName, planner }; } +export function applyQuickSearchPatch(planner: LoopPlannerInput): Partial { + const minLegs = Math.min(planner.minLegs ?? 3, 3); + return { + minLegs, + maxLegs: 3, + sameSystemOnly: true, + systemFilterMode: "all", + allowedSystemIds: [], + excludedSystemIds: [], + }; +} + function presetMatches( id: LoopPlannerPresetId, planner: LoopPlannerInput, shipName: string, - stantonSystemId?: number, ): boolean { const preset = LOOP_PLANNER_PRESETS[id]; if (preset.shipName != null && preset.shipName !== shipName) return false; @@ -102,26 +115,32 @@ function presetMatches( if (p.sameSystemOnly != null && planner.sameSystemOnly !== p.sameSystemOnly) return false; if (p.minLegs != null && planner.minLegs !== p.minLegs) return false; if (p.maxLegs != null && planner.maxLegs !== p.maxLegs) return false; + if (p.systemFilterMode != null && planner.systemFilterMode !== p.systemFilterMode) return false; - if (id === "stanton-only") { - if (planner.systemFilterMode !== "allow") return false; - if (stantonSystemId == null) return false; - const allowed = planner.allowedSystemIds ?? []; - return allowed.length === 1 && allowed[0] === stantonSystemId; + const allowed = planner.allowedSystemIds ?? []; + const excluded = planner.excludedSystemIds ?? []; + if ((p.allowedSystemIds ?? []).length !== allowed.length) return false; + if ((p.excludedSystemIds ?? []).length !== excluded.length) return false; + for (let i = 0; i < allowed.length; i++) { + if (allowed[i] !== (p.allowedSystemIds ?? [])[i]) return false; } - if (p.systemFilterMode != null && planner.systemFilterMode !== p.systemFilterMode) return false; - return true; } export function detectMatchingLoopPlannerPresetId( planner: LoopPlannerInput, shipName: string, - stantonSystemId?: number, ): LoopPlannerPresetId | null { for (const id of LOOP_PLANNER_PRESET_IDS) { - if (presetMatches(id, planner, shipName, stantonSystemId)) return id; + if (presetMatches(id, planner, shipName)) return id; } return null; } + +export function resolveLoopProfileValue( + planner: LoopPlannerInput, + shipName: string, +): LoopPlannerProfileValue { + return detectMatchingLoopPlannerPresetId(planner, shipName) ?? "custom"; +} diff --git a/src/features/trading-routes/loop-planner-summary.ts b/src/features/trading-routes/loop-planner-summary.ts new file mode 100644 index 0000000..7d62a61 --- /dev/null +++ b/src/features/trading-routes/loop-planner-summary.ts @@ -0,0 +1,47 @@ +import { DEFAULT_LOOP_PLANNER_INPUT } from "@/features/trading-routes/default-loop-planner"; +import { isStantonOnly } from "@/features/trading-routes/loop-planner-presets"; +import { systemFilterSummary } from "@/lib/trading-routes/loop-system-filter"; +import type { LoopPlannerInput } from "@/types/trading-route"; + +export interface LoopPlannerSummaryOptions { + stantonSystemId?: number; + systemNames?: Map; +} + +export function buildLoopPlannerSummary( + planner: LoopPlannerInput, + options: LoopPlannerSummaryOptions = {}, +): string | undefined { + const parts: string[] = []; + + if (planner.budgetAuec !== DEFAULT_LOOP_PLANNER_INPUT.budgetAuec) { + parts.push(`${(planner.budgetAuec / 1000).toFixed(0)}k budget`); + } + if (planner.crew !== DEFAULT_LOOP_PLANNER_INPUT.crew) { + parts.push(`${planner.crew} crew`); + } + if (planner.startTerminalId != null) { + parts.push("Start terminal"); + } + if (planner.maxTotalTimeMinutes != null && planner.maxTotalTimeMinutes > 0) { + parts.push(`≤${planner.maxTotalTimeMinutes}m search`); + } + + const mode = planner.systemFilterMode ?? "all"; + if (mode !== "all" && !isStantonOnly(planner, options.stantonSystemId)) { + const names = options.systemNames ?? new Map(); + parts.push(systemFilterSummary(planner, names)); + } + + if (!(planner.returnToStart ?? DEFAULT_LOOP_PLANNER_INPUT.returnToStart)) { + parts.push("Open loop"); + } + if (!(planner.allowMixedCommodities ?? DEFAULT_LOOP_PLANNER_INPUT.allowMixedCommodities)) { + parts.push("Single commodity"); + } + if (planner.requireCargoCenter) { + parts.push("Cargo center"); + } + + return parts.length > 0 ? parts.join(" · ") : undefined; +} diff --git a/src/features/trading-routes/route-filter-chips.ts b/src/features/trading-routes/route-filter-chips.ts index 39374d7..8a046c6 100644 --- a/src/features/trading-routes/route-filter-chips.ts +++ b/src/features/trading-routes/route-filter-chips.ts @@ -85,9 +85,16 @@ export function buildRouteFilterChips( export function buildLoopFilterChips( filters: LoopPlannerFilters, onPatch: (patch: Partial) => void, + plannerContext?: { shipName?: string; cargoScu?: number }, ): { chips: ActiveFilterChip[]; remove: (id: string) => void } { const chips: ActiveFilterChip[] = []; + if (plannerContext?.shipName?.trim()) { + chips.push({ id: "ship", label: plannerContext.shipName }); + } else if (plannerContext?.cargoScu != null && plannerContext.cargoScu !== 46) { + chips.push({ id: "cargo", label: `${plannerContext.cargoScu} SCU` }); + } + if (filters.query?.trim()) chips.push({ id: "query", label: `“${filters.query.trim()}”` }); if (filters.commodity?.trim()) chips.push({ id: "commodity", label: filters.commodity }); if (filters.system?.trim()) chips.push({ id: "system", label: filters.system }); @@ -120,6 +127,9 @@ export function buildLoopFilterChips( case "maxTime": patch.maxTime = undefined; break; + case "ship": + case "cargo": + return; default: return; } diff --git a/src/hooks/useEnRoutePlanner.ts b/src/hooks/useEnRoutePlanner.ts index 29ddeb4..280b953 100644 --- a/src/hooks/useEnRoutePlanner.ts +++ b/src/hooks/useEnRoutePlanner.ts @@ -54,6 +54,7 @@ export function useEnRoutePlanner() { const [filters, setFilters] = useState(() => mergeEnRouteFilters(persisted.filters), ); + const [shipName, setShipName] = useState(persisted.shipName ?? ""); const debouncedPlanner = useDebouncedValue(planner, 300); const debouncedFilters = useDebouncedValue(filters, 300); @@ -64,11 +65,12 @@ export function useEnRoutePlanner() { const merged = loadPersistedEnRouteState(settings.tradingDefaults); setPlanner(mergeEnRoutePlanner(merged.planner)); setFilters(mergeEnRouteFilters(merged.filters)); + setShipName(merged.shipName ?? ""); }, [settings]); useEffect(() => { - schedulePersistedEnRouteState({ planner, filters }); - }, [planner, filters]); + schedulePersistedEnRouteState({ planner, filters, shipName: shipName || undefined }); + }, [planner, filters, shipName]); useEffect(() => { if (!marketData) { @@ -166,6 +168,17 @@ export function useEnRoutePlanner() { setFilters((prev) => mergeEnRouteFilters({ ...prev, ...patch })); }, []); + const updateShip = useCallback((name: string, scu: number) => { + setShipName(name); + if (scu > 0) { + setPlanner((prev) => + mergeEnRoutePlanner({ ...prev, cargoScu: scu, shipScu: scu }), + ); + } else { + setPlanner((prev) => mergeEnRoutePlanner({ ...prev, shipScu: undefined })); + } + }, []); + const isLoading = (uexStatus === "loading" || uexStatus === "idle") && !marketData ? true @@ -178,10 +191,12 @@ export function useEnRoutePlanner() { isFromSnapshot, planner, filters, + shipName, results, allResults, updatePlanner, updateFilters, + updateShip, refresh, fetchedAt: marketData?.fetchedAt ?? null, searching, diff --git a/src/hooks/useLoopPlanner.ts b/src/hooks/useLoopPlanner.ts index 0f358bb..a215da0 100644 --- a/src/hooks/useLoopPlanner.ts +++ b/src/hooks/useLoopPlanner.ts @@ -17,6 +17,7 @@ import { } from "@/lib/uex/cache"; import { collectSystemIdsFromGraph, fetchOrbitDistances } from "@/lib/uex/orbit-distances"; import { buildTransitionGraph } from "@/lib/trading-routes/build-transition-graph"; +import { getTradingShipsList } from "@/lib/trading-routes/ships"; import { searchLoopsFromMarket } from "@/lib/trading-routes/loop-worker-client"; import type { OrbitDistanceMap } from "@/lib/uex/types"; import type { @@ -28,6 +29,11 @@ import type { type LoadStatus = "idle" | "loading" | "ready" | "error"; +function getShipNameByScu(scu: number): string { + const ship = getTradingShipsList().find((s) => s.scu === scu); + return ship?.name ?? ""; +} + function computeLoopPlannerStats(loops: TradeLoop[]): LoopPlannerStats { const count = loops.length; if (count === 0) { @@ -86,6 +92,12 @@ export function useLoopPlanner() { const [filters, setFilters] = useState(() => mergeLoopPlannerFilters(persisted.filters ?? DEFAULT_LOOP_PLANNER_FILTERS), ); + const [shipName, setShipName] = useState(() => { + if (persisted.shipName) return persisted.shipName; + const scu = persisted.planner?.shipScu; + if (scu) return getShipNameByScu(scu); + return ""; + }); const debouncedPlanner = useDebouncedValue(planner, 300); const debouncedFilters = useDebouncedValue(filters, 300); @@ -96,11 +108,18 @@ export function useLoopPlanner() { const merged = loadPersistedLoopPlannerState(settings.tradingDefaults); setPlanner(mergeLoopPlannerInput(merged.planner)); setFilters(mergeLoopPlannerFilters(merged.filters)); + setShipName( + merged.shipName ?? (merged.planner?.shipScu ? getShipNameByScu(merged.planner.shipScu) : ""), + ); }, [settings]); useEffect(() => { - schedulePersistedLoopPlannerState({ planner, filters }); - }, [planner, filters]); + schedulePersistedLoopPlannerState({ + planner, + filters, + shipName: shipName || undefined, + }); + }, [planner, filters, shipName]); // Load orbit distances once per market + planner change (do NOT depend on orbitDistances state). useEffect(() => { @@ -234,10 +253,25 @@ export function useLoopPlanner() { setPlanner((prev) => mergeLoopPlannerInput({ ...prev, ...patch })); }, []); + const replacePlanner = useCallback((next: LoopPlannerInput) => { + setPlanner(next); + }, []); + const updateFilters = useCallback((patch: Partial) => { setFilters((prev) => mergeLoopPlannerFilters({ ...prev, ...patch })); }, []); + const updateShip = useCallback((name: string, scu: number) => { + setShipName(name); + if (scu > 0) { + setPlanner((prev) => + mergeLoopPlannerInput({ ...prev, shipScu: scu, cargoScu: scu }), + ); + } else { + setPlanner((prev) => mergeLoopPlannerInput({ ...prev, shipScu: undefined })); + } + }, []); + return { status: isLoading ? "loading" : status, error: error ?? uexError, @@ -245,11 +279,14 @@ export function useLoopPlanner() { isFromSnapshot, planner, filters, + shipName, loops, allLoops, stats, updatePlanner, + replacePlanner, updateFilters, + updateShip, refresh, fetchedAt: marketData?.fetchedAt ?? null, searching, diff --git a/src/hooks/useTradingRoutes.ts b/src/hooks/useTradingRoutes.ts index 0f3a1a0..24e9c32 100644 --- a/src/hooks/useTradingRoutes.ts +++ b/src/hooks/useTradingRoutes.ts @@ -18,14 +18,12 @@ import { mergeOrbitDistances, setCachedOrbitDistances, } from "@/lib/uex/cache"; -import { fetchCargoVehiclesForShips } from "@/lib/uex/endpoints"; import { collectSystemIdsFromCandidates, fetchOrbitDistances } from "@/lib/uex/orbit-distances"; import { applyFilterPreset, detectMatchingPresetId, type FilterPresetId, } from "@/lib/trading-routes/filter-presets"; -import { fetchCargoShipsFromUex } from "@/lib/trading-routes/ships"; import { buildRouteSnapshotFromMarket, scoreRouteCandidates, @@ -128,7 +126,6 @@ export function useTradingRoutes() { }, [planner, filters, shipName, presetId, logProfitBasis]); useEffect(() => { - void fetchCargoShipsFromUex(fetchCargoVehiclesForShips); return () => terminateTradingRoutesWorker(); }, []); diff --git a/src/hooks/useTradingShips.ts b/src/hooks/useTradingShips.ts new file mode 100644 index 0000000..5c58c35 --- /dev/null +++ b/src/hooks/useTradingShips.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from "react"; +import { fetchCargoVehiclesForShips } from "@/lib/uex/endpoints"; +import { + fetchCargoShipsFromUex, + getTradingShipsList, + TRADING_SHIPS, + type TradingShip, +} from "@/lib/trading-routes/ships"; + +type ShipsStatus = "loading" | "ready" | "error"; + +export function useTradingShips() { + const [ships, setShips] = useState(() => getTradingShipsList()); + const [status, setStatus] = useState("loading"); + + const load = useCallback(async () => { + setStatus("loading"); + try { + const list = await fetchCargoShipsFromUex(fetchCargoVehiclesForShips); + setShips(list); + setStatus("ready"); + } catch { + setShips(TRADING_SHIPS); + setStatus("error"); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return { ships, status, refetch: load }; +} diff --git a/src/lib/__tests__/formatNetProfit.test.ts b/src/lib/__tests__/formatNetProfit.test.ts new file mode 100644 index 0000000..84e40a7 --- /dev/null +++ b/src/lib/__tests__/formatNetProfit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { formatSignedAuec, formatTradeLegCashFlow } from "@/lib/formatNetProfit"; + +describe("formatTradeLegCashFlow", () => { + it("formats buy as negative cash outflow", () => { + expect(formatTradeLegCashFlow("buy", 465_102)).toBe("-465,102 aUEC"); + }); + + it("formats sell as positive cash inflow", () => { + expect(formatTradeLegCashFlow("sell", 673_200)).toBe("+673,200 aUEC"); + }); + + it("does not duplicate aUEC suffix", () => { + const buy = formatTradeLegCashFlow("buy", 465_102); + const sell = formatTradeLegCashFlow("sell", 673_200); + expect(buy).not.toContain("aUEC aUEC"); + expect(sell).not.toContain("aUEC aUEC"); + }); +}); + +describe("formatSignedAuec", () => { + it("includes sign and single aUEC suffix", () => { + expect(formatSignedAuec(-100)).toBe("-100 aUEC"); + expect(formatSignedAuec(100)).toBe("+100 aUEC"); + }); +}); diff --git a/src/lib/formatNetProfit.ts b/src/lib/formatNetProfit.ts index f80b73f..9bb28e0 100644 --- a/src/lib/formatNetProfit.ts +++ b/src/lib/formatNetProfit.ts @@ -9,6 +9,11 @@ export function formatSignedAuec(amount: number): string { return `${formatted} aUEC`; } +export function formatTradeLegCashFlow(action: "buy" | "sell", amount: number): string { + const signed = action === "buy" ? -Math.abs(amount) : Math.abs(amount); + return formatSignedAuec(signed); +} + export function netProfitClassName(netProfit: number): string { if (netProfit > 0) return "text-emerald-400 font-medium"; if (netProfit < 0) return "text-rose-400 font-medium"; diff --git a/src/lib/trading-routes/__tests__/loop-state-machine.test.ts b/src/lib/trading-routes/__tests__/loop-state-machine.test.ts index a9cf0b4..d39b1d6 100644 --- a/src/lib/trading-routes/__tests__/loop-state-machine.test.ts +++ b/src/lib/trading-routes/__tests__/loop-state-machine.test.ts @@ -5,6 +5,7 @@ import { canExecuteLeg, createInitialState, estimateRepositionTime, + resolveCargoScu, } from "@/lib/trading-routes/loop-state-machine"; import type { LoopPlannerInput, RouteLegOffer, RouteTimeEstimate } from "@/types/trading-route"; @@ -122,4 +123,11 @@ describe("loop-state-machine", () => { expect(time.total).toBeGreaterThan(0); }); + + it("resolveCargoScu uses min of cargo and ship capacity", () => { + expect(resolveCargoScu({ ...DEFAULT_PLANNER, cargoScu: 100, shipScu: 696 })).toBe(100); + expect(resolveCargoScu({ ...DEFAULT_PLANNER, cargoScu: 500, shipScu: 696 })).toBe(500); + expect(resolveCargoScu({ ...DEFAULT_PLANNER, cargoScu: 800, shipScu: 696 })).toBe(696); + expect(resolveCargoScu({ ...DEFAULT_PLANNER, cargoScu: 50 })).toBe(50); + }); }); diff --git a/src/lib/trading-routes/__tests__/ships.test.ts b/src/lib/trading-routes/__tests__/ships.test.ts new file mode 100644 index 0000000..05aae0d --- /dev/null +++ b/src/lib/trading-routes/__tests__/ships.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + buildFullShipListFromUex, + inferQuantumSpeedClass, + TRADING_SHIPS, +} from "@/lib/trading-routes/ships"; + +describe("inferQuantumSpeedClass", () => { + it("classifies small ships as starter", () => { + expect(inferQuantumSpeedClass("Aurora CL", 6)).toBe("starter"); + expect(inferQuantumSpeedClass("Nomad", 24)).toBe("starter"); + }); + + it("classifies haulers by name", () => { + expect(inferQuantumSpeedClass("C2 Hercules", 696)).toBe("hauler"); + expect(inferQuantumSpeedClass("Hull C", 4608)).toBe("hauler"); + expect(inferQuantumSpeedClass("Caterpillar", 576)).toBe("hauler"); + }); + + it("classifies fast ships", () => { + expect(inferQuantumSpeedClass("Mercury Star Runner", 114)).toBe("fast"); + expect(inferQuantumSpeedClass("Hull A", 64)).toBe("fast"); + }); + + it("defaults to standard for mid-size unknown ships", () => { + expect(inferQuantumSpeedClass("Cutlass Black", 46)).toBe("standard"); + }); +}); + +describe("buildFullShipListFromUex", () => { + it("merges static ships and preserves quantum class", () => { + const list = buildFullShipListFromUex([ + { name: "Cutlass Black", slug: "cutlass-black", scu: 46 }, + { name: "C2 Hercules", slug: "c2-hercules", scu: 696 }, + ]); + + const cutlass = list.find((s) => s.name === "Cutlass Black"); + const c2 = list.find((s) => s.name === "C2 Hercules"); + expect(cutlass?.quantumSpeedClass).toBe("standard"); + expect(c2?.quantumSpeedClass).toBe("hauler"); + }); + + it("adds unknown UEX ships with inferred class", () => { + const list = buildFullShipListFromUex([ + { name: "MISC Fortune", slug: "misc-fortune", scu: 120 }, + ]); + + expect(list.some((s) => s.name === "MISC Fortune")).toBe(true); + expect(list.find((s) => s.name === "MISC Fortune")?.quantumSpeedClass).toBe("standard"); + }); + + it("deduplicates by slug", () => { + const list = buildFullShipListFromUex([ + { name: "Cutlass Black", slug: "cutlass-black", scu: 46 }, + { name: "Cutlass Black Mk II", slug: "cutlass-black", scu: 50 }, + ]); + + expect(list.filter((s) => s.uexSlug === "cutlass-black")).toHaveLength(1); + }); + + it("sorts by SCU ascending", () => { + const list = buildFullShipListFromUex([ + { name: "Hull C", slug: "hull-c", scu: 4608 }, + { name: "Aurora CL", slug: "aurora-cl", scu: 6 }, + ]); + + expect(list[0].scu).toBeLessThanOrEqual(list[1].scu); + }); + + it("falls back to static list when input is empty", () => { + const list = buildFullShipListFromUex([]); + expect(list).toEqual(TRADING_SHIPS); + }); +}); diff --git a/src/lib/trading-routes/loop-state-machine.ts b/src/lib/trading-routes/loop-state-machine.ts index c7e66c8..e2d6cd2 100644 --- a/src/lib/trading-routes/loop-state-machine.ts +++ b/src/lib/trading-routes/loop-state-machine.ts @@ -14,8 +14,12 @@ export interface LoopState { totalTimeMinutes: number; } -function resolveCargoScu(planner: LoopPlannerInput): number { - return planner.shipScu != null && planner.shipScu > 0 ? planner.shipScu : planner.cargoScu; +export function resolveCargoScu(planner: LoopPlannerInput): number { + const cargo = planner.cargoScu; + if (planner.shipScu != null && planner.shipScu > 0) { + return Math.min(cargo, planner.shipScu); + } + return cargo; } function resolveScuUsed( diff --git a/src/lib/trading-routes/ships.ts b/src/lib/trading-routes/ships.ts index a09cc60..c61a696 100644 --- a/src/lib/trading-routes/ships.ts +++ b/src/lib/trading-routes/ships.ts @@ -35,7 +35,10 @@ export const TRADING_SHIPS: TradingShip[] = [ { name: "Mercury Star Runner", scu: 114, quantumSpeedClass: "fast", uexSlug: "mercury-star-runner" }, ]; -const SHIPS_BY_NAME = new Map(TRADING_SHIPS.map((s) => [s.name, s])); +const STATIC_BY_SLUG = new Map( + TRADING_SHIPS.map((s) => [s.uexSlug ?? s.name.toLowerCase().replace(/\s+/g, "-"), s]), +); +const STATIC_BY_NAME = new Map(TRADING_SHIPS.map((s) => [s.name.toLowerCase(), s])); const QUANTUM_SPEED_CLASS_LABELS: Record = { starter: "Starter", @@ -44,8 +47,61 @@ const QUANTUM_SPEED_CLASS_LABELS: Record = { hauler: "Hauler", }; +export function inferQuantumSpeedClass(name: string, scu: number): QuantumSpeedClass { + const lower = name.toLowerCase(); + if (/mercury|hull\s*a\b/.test(lower)) return "fast"; + if (/hull|hercules|caterpillar|\braft\b|c2\b|m2\b|taurus|starlifter|galaxy|merchantman|nomad/i.test(lower)) { + if (/hull\s*a\b|mercury/.test(lower)) return "fast"; + if (/hull|hercules|caterpillar|taurus|starlifter|galaxy|merchantman|c2|m2/.test(lower)) return "hauler"; + } + if (scu <= 24) return "starter"; + return "standard"; +} + +function matchStaticShip(name: string, slug?: string): TradingShip | undefined { + if (slug) { + const bySlug = STATIC_BY_SLUG.get(slug); + if (bySlug) return bySlug; + } + return STATIC_BY_NAME.get(name.toLowerCase()); +} + +export function buildFullShipListFromUex( + vehicles: { name: string; slug?: string; scu: number }[], +): TradingShip[] { + const byKey = new Map(); + + for (const v of vehicles) { + const scu = Math.round(v.scu); + if (scu <= 0) continue; + const key = (v.slug ?? v.name.toLowerCase().replace(/\s+/g, "-")).toLowerCase(); + if (byKey.has(key)) continue; + + const staticMatch = matchStaticShip(v.name, v.slug); + if (staticMatch) { + byKey.set(key, { + ...staticMatch, + name: v.name, + scu, + uexSlug: v.slug ?? staticMatch.uexSlug, + }); + } else { + byKey.set(key, { + name: v.name, + scu, + quantumSpeedClass: inferQuantumSpeedClass(v.name, scu), + uexSlug: v.slug, + }); + } + } + + const ships = [...byKey.values()].sort((a, b) => a.scu - b.scu || a.name.localeCompare(b.name)); + return ships.length > 0 ? ships : [...TRADING_SHIPS]; +} + export function getShipByName(name: string): TradingShip | undefined { - return SHIPS_BY_NAME.get(name); + const list = getTradingShipsList(); + return list.find((s) => s.name === name); } export function getQuantumSpeedGmPerSec(speedClass: QuantumSpeedClass = "standard"): number { @@ -65,14 +121,6 @@ const SHIPS_CACHE_TTL_MS = 12 * 60 * 60 * 1000; let mergedShipsCache: { ships: TradingShip[]; fetchedAt: number } | null = null; let mergeInflight: Promise | null = null; -function mergeUexScuIntoStatic(uexNames: Map): TradingShip[] { - return TRADING_SHIPS.map((ship) => { - const slug = ship.uexSlug ?? ship.name.toLowerCase().replace(/\s+/g, "-"); - const uexScu = uexNames.get(slug) ?? uexNames.get(ship.name.toLowerCase()); - return uexScu != null && uexScu > 0 ? { ...ship, scu: Math.round(uexScu) } : ship; - }); -} - export async function fetchCargoShipsFromUex( fetchVehicles: () => Promise<{ name: string; slug?: string; scu: number }[]>, ): Promise { @@ -84,14 +132,7 @@ export async function fetchCargoShipsFromUex( mergeInflight = (async () => { try { const vehicles = await fetchVehicles(); - const uexNames = new Map(); - for (const v of vehicles) { - const scu = Math.round(v.scu); - if (scu <= 0) continue; - if (v.slug) uexNames.set(v.slug, scu); - uexNames.set(v.name.toLowerCase(), scu); - } - const ships = mergeUexScuIntoStatic(uexNames); + const ships = buildFullShipListFromUex(vehicles); mergedShipsCache = { ships, fetchedAt: Date.now() }; return ships; } catch { @@ -107,3 +148,8 @@ export async function fetchCargoShipsFromUex( export function getTradingShipsList(): TradingShip[] { return mergedShipsCache?.ships ?? TRADING_SHIPS; } + +export function clearTradingShipsCache(): void { + mergedShipsCache = null; + mergeInflight = null; +} diff --git a/src/lib/uex/index.ts b/src/lib/uex/index.ts index 7cb2a7e..00e2c1c 100644 --- a/src/lib/uex/index.ts +++ b/src/lib/uex/index.ts @@ -34,9 +34,11 @@ export { export { fetchCommodities, fetchCommoditiesPricesAll, + fetchCargoVehiclesForShips, fetchMarketDataBundle, fetchOrbitDistancesForSystems, fetchTerminals, + fetchVehicles, } from "@/lib/uex/endpoints"; export { estimateTime,