From 5d033b17d616d2b049793393cdd4d132525b56e2 Mon Sep 17 00:00:00 2001 From: DevDev Date: Thu, 25 Jun 2026 16:09:23 +0300 Subject: [PATCH] feat: improve Trading Routes UX with UEX ships and streamlined Loop Planner Fix En Route dropdown overlap, load full cargo ship list from UEX, persist ship selection across modes, and simplify Loop Planner with profile dropdown, essentials strip, and result filters in the results card. --- src/components/ui/searchable-select.tsx | 157 +++++--- src/features/settings/SettingsPage.tsx | 10 +- .../trading-routes/CollapsibleSection.tsx | 14 +- src/features/trading-routes/EnRouteView.tsx | 174 +++++---- .../trading-routes/LoopDetailDialog.tsx | 15 +- .../trading-routes/LoopFiltersPanel.tsx | 358 ++++++++---------- .../trading-routes/LoopPlannerPresetBar.tsx | 48 --- .../trading-routes/LoopPlannerView.tsx | 89 ++--- .../trading-routes/LoopProfileSelect.tsx | 68 ++++ .../trading-routes/LoopResultFiltersPanel.tsx | 104 +++++ .../trading-routes/LoopSystemFilterPanel.tsx | 53 ++- .../trading-routes/RouteFiltersPanel.tsx | 13 +- src/features/trading-routes/ShipSelect.tsx | 83 ++-- .../trading-routes/TradingRoutesPage.tsx | 6 + .../__tests__/loop-checklist.test.ts | 81 ++++ .../__tests__/loop-planner-presets.test.ts | 80 ++++ .../__tests__/loop-planner-summary.test.ts | 40 ++ .../trading-routes/default-en-route.ts | 3 + .../trading-routes/default-loop-planner.ts | 3 + src/features/trading-routes/loop-checklist.ts | 10 +- .../trading-routes/loop-planner-presets.ts | 117 +++--- .../trading-routes/loop-planner-summary.ts | 47 +++ .../trading-routes/route-filter-chips.ts | 10 + src/hooks/useEnRoutePlanner.ts | 19 +- src/hooks/useLoopPlanner.ts | 41 +- src/hooks/useTradingRoutes.ts | 3 - src/hooks/useTradingShips.ts | 33 ++ src/lib/__tests__/formatNetProfit.test.ts | 26 ++ src/lib/formatNetProfit.ts | 5 + .../__tests__/loop-state-machine.test.ts | 8 + .../trading-routes/__tests__/ships.test.ts | 74 ++++ src/lib/trading-routes/loop-state-machine.ts | 8 +- src/lib/trading-routes/ships.ts | 82 +++- src/lib/uex/index.ts | 2 + 34 files changed, 1304 insertions(+), 580 deletions(-) delete mode 100644 src/features/trading-routes/LoopPlannerPresetBar.tsx create mode 100644 src/features/trading-routes/LoopProfileSelect.tsx create mode 100644 src/features/trading-routes/LoopResultFiltersPanel.tsx create mode 100644 src/features/trading-routes/__tests__/loop-checklist.test.ts create mode 100644 src/features/trading-routes/__tests__/loop-planner-presets.test.ts create mode 100644 src/features/trading-routes/__tests__/loop-planner-summary.test.ts create mode 100644 src/features/trading-routes/loop-planner-summary.ts create mode 100644 src/hooks/useTradingShips.ts create mode 100644 src/lib/__tests__/formatNetProfit.test.ts create mode 100644 src/lib/trading-routes/__tests__/ships.test.ts 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,