import { AccessToken, PlanetWithInfo, Pin } from "@/types"; import { Box, Stack, Typography, useTheme, Paper, IconButton, Divider } from "@mui/material"; import { CharacterRow } from "../Characters/CharacterRow"; import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow"; import { SessionContext } from "@/app/context/Context"; import { useContext, useState, useEffect } from "react"; import { PlanRow } from "./PlanRow"; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { DragDropContext, Droppable, Draggable, DropResult } from "@hello-pangea/dnd"; import { planetCalculations } from "@/planets"; import { EvePraisalResult } from "@/eve-praisal"; import { STORAGE_IDS, PI_SCHEMATICS, PI_PRODUCT_VOLUMES, STORAGE_CAPACITIES } from "@/const"; import { DateTime } from "luxon"; import { PlanetCalculations, AlertState, StorageContent, StorageInfo } from "@/types/planet"; import { getProgramOutputPrediction } from "../PlanetaryInteraction/ExtractionSimulation"; interface AccountTotals { monthlyEstimate: number; storageValue: number; planetCount: number; characterCount: number; runningExtractors: number; totalExtractors: number; } const calculateAlertState = (planetDetails: PlanetCalculations, minExtractionRate: number): AlertState => { const hasLowStorage = planetDetails.storageInfo.some(storage => storage.fillRate > 60); const hasLowImports = planetDetails.importDepletionTimes.some(depletion => depletion.hoursUntilDepletion < 24); const hasLowExtractionRate = planetDetails.extractorAverages.length > 0 && minExtractionRate > 0 && planetDetails.extractorAverages.some(avg => avg.averagePerHour < minExtractionRate); return { expired: planetDetails.expired, hasLowStorage, hasLowImports, hasLargeExtractorDifference: planetDetails.hasLargeExtractorDifference, hasLowExtractionRate }; }; const calculatePlanetDetails = (planet: PlanetWithInfo, piPrices: EvePraisalResult | undefined, balanceThreshold: number): PlanetCalculations => { const { expired, extractors, localProduction: rawProduction, localImports, localExports: rawExports } = planetCalculations(planet); // Convert localProduction to include factoryCount const localProduction = new Map(Array.from(rawProduction).map(([key, value]) => [ key, { ...value, factoryCount: value.count || 1 } ])); // Calculate extractor averages and check for large differences const CYCLE_TIME = 30 * 60; // 30 minutes in seconds const extractorAverages = extractors .filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle) .map(e => { const installDate = new Date(e.install_time ?? ""); const expiryDate = new Date(e.expiry_time ?? ""); const programDuration = (expiryDate.getTime() - installDate.getTime()) / 1000; const cycles = Math.floor(programDuration / CYCLE_TIME); const qtyPerCycle = e.extractor_details?.qty_per_cycle || 0; const prediction = getProgramOutputPrediction(qtyPerCycle, CYCLE_TIME, cycles); const totalOutput = prediction.reduce((sum, val) => sum + val, 0); const averagePerHour = totalOutput / cycles * 2; return { typeId: e.extractor_details!.product_type_id!, averagePerHour }; }); const hasLargeExtractorDifference = extractorAverages.length === 2 && Math.abs(extractorAverages[0].averagePerHour - extractorAverages[1].averagePerHour) > balanceThreshold; // Calculate storage info const storageFacilities = planet.info.pins.filter((pin: Pin) => STORAGE_IDS().some(storage => storage.type_id === pin.type_id) ); const storageInfo = storageFacilities.map((storage: Pin) => { if (!storage || !storage.contents) return null; const storageType = STORAGE_IDS().find(s => s.type_id === storage.type_id)?.name || 'Unknown'; const storageCapacity = STORAGE_CAPACITIES[storage.type_id] || 0; const totalVolume = (storage.contents || []) .reduce((sum: number, item: StorageContent) => { const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0; return sum + (item.amount * volume); }, 0); const totalValue = (storage.contents || []) .reduce((sum: number, item: StorageContent) => { const price = piPrices?.appraisal.items.find((a) => a.typeID === item.type_id)?.prices.sell.min ?? 0; return sum + (item.amount * price); }, 0); const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0; return { pin_id: storage.pin_id, type: storageType, type_id: storage.type_id, capacity: storageCapacity, used: totalVolume, fillRate: fillRate, value: totalValue }; }).filter(Boolean) as StorageInfo[]; // Calculate import depletion times const importDepletionTimes = localImports.map(i => { // Find all storage facilities containing this import const storagesWithImport = storageFacilities.filter((storage: Pin) => storage.contents?.some((content: StorageContent) => content.type_id === i.type_id) ); // Get the total amount in all storage facilities const totalAmount = storagesWithImport.reduce((sum: number, storage: Pin) => { const content = storage.contents?.find((content: StorageContent) => content.type_id === i.type_id); return sum + (content?.amount ?? 0); }, 0); // Calculate consumption rate per hour const schematic = PI_SCHEMATICS.find(s => s.schematic_id === i.schematic_id); const cycleTime = schematic?.cycle_time ?? 3600; const consumptionPerHour = i.quantity * i.factoryCount * (3600 / cycleTime); // Calculate time until depletion in hours, starting from last_update const lastUpdate = DateTime.fromISO(planet.last_update); const now = DateTime.now(); const hoursSinceUpdate = now.diff(lastUpdate, 'hours').hours; const remainingAmount = Math.max(0, totalAmount - (consumptionPerHour * hoursSinceUpdate)); const hoursUntilDepletion = consumptionPerHour > 0 ? remainingAmount / consumptionPerHour : 0; // Calculate monthly cost const price = piPrices?.appraisal.items.find((a) => a.typeID === i.type_id)?.prices.sell.min ?? 0; const monthlyCost = (consumptionPerHour * 24 * 30 * price) / 1000000; // Cost in millions return { typeId: i.type_id, hoursUntilDepletion, monthlyCost }; }); // Convert localExports to match the LocalExport interface const localExports = rawExports.map(e => { const schematic = PI_SCHEMATICS.flatMap(s => s.outputs) .find(s => s.type_id === e.typeId)?.schematic_id ?? 0; const factoryCount = planet.info.pins .filter(p => p.schematic_id === schematic) .length; return { type_id: e.typeId, schematic_id: schematic, quantity: e.amount / factoryCount, // Convert total amount back to per-factory quantity factoryCount }; }); return { expired, extractors, localProduction, localImports, localExports, storageInfo, extractorAverages, hasLargeExtractorDifference, importDepletionTimes, visibility: 'visible' as const }; }; const calculateAccountTotals = (characters: AccessToken[], piPrices: EvePraisalResult | undefined): AccountTotals => { let totalMonthlyEstimate = 0; let totalStorageValue = 0; let totalPlanetCount = 0; let totalCharacterCount = characters.length; let runningExtractors = 0; let totalExtractors = 0; characters.forEach((character) => { totalPlanetCount += character.planets.length; character.planets.forEach((planet) => { const { localExports, extractors } = planetCalculations(planet); const planetConfig = character.planetConfig.find(p => p.planetId === planet.planet_id); // Count running and total extractors if (!planetConfig?.excludeFromTotals) { extractors.forEach(extractor => { totalExtractors++; if (extractor.expiry_time && new Date(extractor.expiry_time) > new Date()) { runningExtractors++; } }); } // Calculate monthly estimate if (!planetConfig?.excludeFromTotals) { localExports.forEach((exportItem) => { const valueInMillions = (((piPrices?.appraisal.items.find( (a) => a.typeID === exportItem.typeId, )?.prices.sell.min ?? 0) * exportItem.amount) / 1000000) * 24 * 30; totalMonthlyEstimate += valueInMillions; }); } if (!planetConfig?.excludeFromTotals) { planet.info.pins .filter(pin => STORAGE_IDS().some(storage => storage.type_id === pin.type_id)) .forEach(storage => { storage.contents?.forEach(content => { const valueInMillions = (piPrices?.appraisal.items.find( (a) => a.typeID === content.type_id, )?.prices.sell.min ?? 0) * content.amount / 1000000; totalStorageValue += valueInMillions; }); }); } }); }); return { monthlyEstimate: totalMonthlyEstimate, storageValue: totalStorageValue, planetCount: totalPlanetCount, characterCount: totalCharacterCount, runningExtractors, totalExtractors }; }; export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { characters: AccessToken[], isCollapsed?: boolean }) => { const theme = useTheme(); const [localIsCollapsed, setLocalIsCollapsed] = useState(false); const { planMode, piPrices, alertMode, balanceThreshold, minExtractionRate } = useContext(SessionContext); const accountName = characters.length > 0 ? (characters[0].account ?? "-") : "-"; const [characterOrder, setCharacterOrder] = useState(() => { try { const saved = localStorage.getItem(`characterOrder-${accountName}`); if (saved) { const parsed: number[] = JSON.parse(saved); const ids = characters.map(c => c.character.characterId); const valid = parsed.filter(id => ids.includes(id)); const newIds = ids.filter(id => !valid.includes(id)); return [...valid, ...newIds]; } } catch (_) { /* ignore corrupt localStorage */ } return characters.map(c => c.character.characterId); }); useEffect(() => { const ids = characters.map(c => c.character.characterId); setCharacterOrder(prev => { const valid = prev.filter(id => ids.includes(id)); const newIds = ids.filter(id => !valid.includes(id)); return [...valid, ...newIds]; }); }, [characters]); useEffect(() => { if (characterOrder.length > 0) { localStorage.setItem(`characterOrder-${accountName}`, JSON.stringify(characterOrder)); } }, [characterOrder, accountName]); const [collapsedCharacters, setCollapsedCharacters] = useState>(() => { try { const saved = localStorage.getItem(`collapsedCharacters-${accountName}`); if (saved) return new Set(JSON.parse(saved)); } catch (_) { /* ignore corrupt localStorage */ } return new Set(); }); const toggleCharacterCollapsed = (characterId: number) => { setCollapsedCharacters(prev => { const next = new Set(prev); if (next.has(characterId)) next.delete(characterId); else next.add(characterId); localStorage.setItem(`collapsedCharacters-${accountName}`, JSON.stringify(Array.from(next))); return next; }); }; const orderedCharacters = characterOrder .map(id => characters.find(c => c.character.characterId === id)) .filter((c): c is AccessToken => c !== undefined); const handleCharacterDragEnd = (result: DropResult) => { if (!result.destination) return; const items = Array.from(characterOrder); const [moved] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, moved); setCharacterOrder(items); }; const DragDropContextComponent = DragDropContext as any; const DroppableComponent = Droppable as any; const DraggableComponent = Draggable as any; const { monthlyEstimate, storageValue, planetCount, characterCount, runningExtractors, totalExtractors } = calculateAccountTotals(characters, piPrices); // Calculate planet details and alert states for each planet const planetDetails = characters.reduce((acc, character) => { character.planets.forEach(planet => { const details = calculatePlanetDetails(planet, piPrices, balanceThreshold); acc[`${character.character.characterId}-${planet.planet_id}`] = { ...details, alertState: calculateAlertState(details, minExtractionRate) }; }); return acc; }, {} as Record); // Update local collapse state when prop changes useEffect(() => { setLocalIsCollapsed(propIsCollapsed ?? false); }, [propIsCollapsed]); const getAlertVisibility = (alertState: AlertState) => { if (!alertMode) return 'visible'; if (alertState.expired) return 'visible'; if (alertState.hasLowStorage) return 'visible'; if (alertState.hasLowImports) return 'visible'; if (alertState.hasLargeExtractorDifference) return 'visible'; if (alertState.hasLowExtractionRate) return 'visible'; return 'hidden'; }; // Check if any planet in the account has alerts const hasAnyAlerts = Object.values(planetDetails).some(details => { const alertState = calculateAlertState(details, minExtractionRate); return alertState.expired || alertState.hasLowStorage || alertState.hasLowImports || alertState.hasLargeExtractorDifference || alertState.hasLowExtractionRate; }); // If in alert mode and no alerts, hide the entire card if (alertMode && !hasAnyAlerts) { return null; } return ( setLocalIsCollapsed(!localIsCollapsed)} > {characters.length > 0 && characters[0].account !== "-" ? `Account: ${characters[0].account}` : "No account name"} Monthly: {monthlyEstimate >= 1000 ? `${(monthlyEstimate / 1000).toFixed(2)} B` : `${monthlyEstimate.toFixed(2)} M`} ISK Storage: {storageValue >= 1000 ? `${(storageValue / 1000).toFixed(2)} B` : `${storageValue.toFixed(2)} M`} ISK Planets: {planetCount} Characters: {characterCount} Extractors: {runningExtractors}/{totalExtractors} d.alertState.hasLowStorage) ? theme.palette.error.main : theme.palette.text.secondary, }} > Storage Alerts: {Object.values(planetDetails).filter(d => d.alertState.hasLowStorage).length} d.alertState.hasLargeExtractorDifference) ? theme.palette.error.main : theme.palette.text.secondary, }} > Balance Alerts: {Object.values(planetDetails).filter(d => d.alertState.hasLargeExtractorDifference).length} {localIsCollapsed ? : } {!localIsCollapsed && ( {(provided: any) => ( {orderedCharacters.map((c, index) => ( {(provided: any, snapshot: any) => ( toggleCharacterCollapsed(c.character.characterId)} sx={{ p: 0.25, color: theme.palette.text.disabled, '&:hover': { color: theme.palette.text.secondary }, transform: collapsedCharacters.has(c.character.characterId) ? 'rotate(0deg)' : 'rotate(90deg)', transition: 'transform 0.2s ease-in-out', }} > {!collapsedCharacters.has(c.character.characterId) && ( planMode ? ( ) : ( { const details = planetDetails[`${c.character.characterId}-${planet.planet_id}`]; acc[planet.planet_id] = { ...details, visibility: getAlertVisibility(details.alertState) }; return acc; }, {} as Record)} /> ) )} )} ))} {provided.placeholder} )} )} ); };