Compare commits

..

62 Commits

Author SHA1 Message Date
calli f49cc61da4 up deps and improve login 2026-04-14 22:26:17 +03:00
calli c37578c4e5 add drag for characters inside an account card. also minimize for easier reorder 2026-04-14 22:14:39 +03:00
calli 48da721980 fix launchpad storage calculations 2026-04-14 22:08:26 +03:00
calli bf31a7e2cb add matrix space link 2026-02-24 19:47:18 +02:00
calli 6b47b34ddf add buy me a beer button 2026-01-07 09:02:16 +02:00
calli e8f69b15a4 fix per cycle calculation 2026-01-07 08:59:34 +02:00
calli ebd39243b2 fix hourly averages 2026-01-02 21:10:58 +02:00
calli 0ee129b3ca remove planet cache logging 2026-01-02 20:53:09 +02:00
calli 7a09503ffa add a setting to alert if extraction is under desired level 2025-12-30 17:49:25 +02:00
calli 9de91f9982 Add partner code info 2025-12-27 22:44:59 +02:00
calli 14c2732fa0 some users have so many characters that we cant keep them in localSotrage. use db 2025-12-27 21:49:57 +02:00
calli e47c572423 fix nextjs CVE 2025-12-12 12:22:33 +02:00
calli 5c6f912721 cache planets for one minute so we dont spam ESI 2025-11-09 23:07:23 +02:00
calli 470935fe9d update official instance url 2025-08-01 09:45:25 +03:00
calli 7ce238c9c7 increase batch from 5 to 50 2025-05-17 20:28:01 +03:00
calli 6523000e69 lets batch the requests for users with gazillion characters 2025-05-17 19:51:21 +03:00
calli b993b28840 Add balance and storage alert counts to account card 2025-05-17 17:21:49 +03:00
calli c036bc10e1 Sort storages 2025-05-17 17:21:37 +03:00
calli b743193f46 update discord link 2025-05-17 17:10:58 +03:00
calli 02ebaf6e35 remove unused imports 2025-05-02 23:00:56 +03:00
calli 3a0843e54c make keys unique for the new tooltip 2025-05-02 22:00:40 +03:00
calli e43bd91bef make active filters more visible 2025-05-02 21:54:22 +03:00
calli cc76765278 add a storage tooltip 2025-05-02 21:54:09 +03:00
calli 73b54f6bf5 hoist calculations and alerts to accountCard level 2025-05-02 21:41:48 +03:00
calli cbef0fd39b rename timeColors to alerts to better describe the alert logic file 2025-04-30 17:57:52 +03:00
calli e085fcd59b add import tooltip to show import spesifics 2025-04-30 17:30:36 +03:00
calli 370400ce99 extract launchpad ids to const and recolor storage fill rates 2025-04-28 18:32:26 +03:00
calli 93507ea98e add pino logger configuration correctly 2025-04-28 18:27:35 +03:00
calli 7915d2bd29 update docker compose file 2025-04-28 18:27:18 +03:00
calli 294720f776 use planets last_update to calculate the imports depletion 2025-04-28 18:10:32 +03:00
calli f60003accf add log level to env 2025-04-28 17:57:51 +03:00
calli 6f28cc0093 Add import exhaustion estimate 2025-04-28 17:55:44 +03:00
calli c986884ed5 Improve logging and handle errors beter 2025-04-28 17:55:31 +03:00
calli 4fc97d473e add toggle to show exact date time when extractors need a restart 2025-04-28 17:21:46 +03:00
calli de49595f55 ui tweaks. add FAQ section. move filters to submenu 2025-04-25 09:38:10 +03:00
calli a1f682e9fc Align tables a bit better and add setting to show icons instead of product texts 2025-04-25 09:01:51 +03:00
calli 67acea9be4 increase automatic refresh to 5 minutes 2025-04-23 18:59:34 +03:00
calli 98b450fcc7 rearrange character layout and align tables to character image 2025-04-23 18:52:05 +03:00
calli eb15696241 make account card clickable for easier collapse action 2025-04-23 17:59:50 +03:00
calli 741b2480b9 add account level statistics 2025-04-23 17:41:09 +03:00
calli b185e5d044 hide extraction simulation for planets without extractors 2025-04-23 15:23:57 +03:00
calli f0d4708b43 show extraction tooltip only if we have extractors 2025-04-23 15:23:32 +03:00
calli 0f33a7ff0c Move activity level local storage read to useState 2025-04-23 15:18:32 +03:00
calli bbdcece163 Show imports per planet 2025-04-23 15:15:26 +03:00
calli 2a1e74ca79 Remove sentry tunnel routing 2025-04-23 15:14:59 +03:00
calli 0e141321b6 add realized production to totals and settings for comparing it to estimates 2025-04-23 12:31:43 +03:00
calli d433f5420e Update luxon dependecy 2025-04-23 12:31:19 +03:00
calli fa5f79068f Refactor compact view planet card to be more compact and show extraction estimates 2025-04-23 11:35:22 +03:00
calli c07dba3afc Fix extraction amounts to use correct extractors 2025-04-23 11:33:08 +03:00
calli a871bac35f Move extraction details expand to row click 2025-04-23 11:32:44 +03:00
calli eca35f51ac Use nextjs Image instead of html img 2025-04-23 11:32:19 +03:00
calli 9e9ab359ec Add a cell to table header to even it out 2025-04-23 11:31:51 +03:00
calli 1fc0efd3da Add mass collapse and expand 2025-04-23 11:30:59 +03:00
calli 00a06a9681 add off-balance alert treshold to settings 2025-04-22 18:08:53 +03:00
calli ac56adbcbe Add singleplanet P2 blance alert 2025-04-22 18:03:17 +03:00
calli a738dc4a22 add planet extraction simulation to tooltip 2025-04-22 17:53:51 +03:00
calli efc28f7e36 move exclusion directly to planet list and add more info to simulation 2025-04-22 17:35:16 +03:00
calli 42f95c17de refactor types and add account level totals 2025-04-22 12:53:42 +03:00
calli 8809fec6e0 Improve characters handling and add a collapse to account cards for easier sorting 2025-04-22 12:08:10 +03:00
calli 70fdc4e96f Make account cards draggable and persist order in local storage 2025-04-22 11:50:47 +03:00
calli 09d587c8b0 instead of build lets use an image 2025-04-09 20:51:17 +03:00
calli f836785a02 add github actions to build the containers 2025-04-09 20:46:30 +03:00
47 changed files with 4692 additions and 1861 deletions
+1
View File
@@ -4,3 +4,4 @@ EVE_SSO_SECRET=Secret Key
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000) EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
NEXT_PUBLIC_PRAISAL_URL=https://praisal.avanto.tk/appraisal/structured.json?persist=no NEXT_PUBLIC_PRAISAL_URL=https://praisal.avanto.tk/appraisal/structured.json?persist=no
SENTRY_AUTH_TOKEN=Sentry token for error reporting. SENTRY_AUTH_TOKEN=Sentry token for error reporting.
LOG_LEVEL=warn
+5 -2
View File
@@ -40,6 +40,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -47,5 +50,5 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=gha,mode=max cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
+12 -2
View File
@@ -2,9 +2,19 @@
Simple tool to track your PI planet extractors. Login with your characters and enjoy the PI! Simple tool to track your PI planet extractors. Login with your characters and enjoy the PI!
Any questions, feedback or suggestions are welcome at [EVE PI Discord](https://discord.gg/GPtw5kfuJu) Any questions, feedback or suggestions are welcome at
[EVE PI Matrix](https://matrix.to/#/#eve-pi:calli.fi)
[EVE PI Discord](https://discord.gg/bCdXzU8PHK)
## [Avanto hosted PI tool](https://pi.avanto.tk) ## Partner code
Consider using EVE partner code to support the project:
```
CALLIEVE
```
## [Hosted PI tool](https://pi.calli.fi)
![Screenshot of PI tool](https://github.com/calli-eve/eve-pi/blob/main/images/eve-pi.png) ![Screenshot of PI tool](https://github.com/calli-eve/eve-pi/blob/main/images/eve-pi.png)
![3D render of a planet](https://github.com/calli-eve/eve-pi/blob/main/images/3dplanet.png) ![3D render of a planet](https://github.com/calli-eve/eve-pi/blob/main/images/3dplanet.png)
+2 -3
View File
@@ -1,8 +1,6 @@
---
version: "2.1"
services: services:
eve-pi: eve-pi:
build: . image: ghcr.io/calli-eve/eve-pi:latest
container_name: eve-pi container_name: eve-pi
environment: environment:
- EVE_SSO_CLIENT_ID=${EVE_SSO_CLIENT_ID} - EVE_SSO_CLIENT_ID=${EVE_SSO_CLIENT_ID}
@@ -10,6 +8,7 @@ services:
- EVE_SSO_SECRET=${EVE_SSO_SECRET} - EVE_SSO_SECRET=${EVE_SSO_SECRET}
- NEXT_PUBLIC_PRAISAL_URL=${NEXT_PUBLIC_PRAISAL_URL} - NEXT_PUBLIC_PRAISAL_URL=${NEXT_PUBLIC_PRAISAL_URL}
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
- LOG_LEVEL=warn
ports: ports:
- 3000:3000 - 3000:3000
restart: unless-stopped restart: unless-stopped
-5
View File
@@ -43,11 +43,6 @@ module.exports = withSentryConfig(
// Transpiles SDK to be compatible with IE11 (increases bundle size) // Transpiles SDK to be compatible with IE11 (increases bundle size)
transpileClientSDK: true, transpileClientSDK: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
// Hides source maps from generated client bundles // Hides source maps from generated client bundles
hideSourceMaps: true, hideSourceMaps: true,
+1748 -1147
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -13,19 +13,23 @@
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.3", "@fontsource/roboto": "^5.0.3",
"@hello-pangea/dnd": "^18.0.1",
"@mui/icons-material": "^5.11.16", "@mui/icons-material": "^5.11.16",
"@mui/material": "^5.13.5", "@mui/material": "^5.13.5",
"@sentry/nextjs": "^8.2.1", "@sentry/nextjs": "^8.2.1",
"@types/node": "20.3.1", "@types/node": "20.3.1",
"@types/pino": "^7.0.4",
"@types/react": "18.2.12", "@types/react": "18.2.12",
"@types/react-dom": "18.2.5", "@types/react-dom": "18.2.5",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"chart.js": "^4.4.7", "chart.js": "^4.4.7",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"eslint": "8.42.0", "eslint": "8.42.0",
"luxon": "^3.3.0", "luxon": "^3.6.1",
"next": "^14.2.23", "next": "14.2.35",
"next-plausible": "^3.12.0", "next-plausible": "^3.12.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-color": "^2.19.3", "react-color": "^2.19.3",
@@ -38,7 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/luxon": "^3.3.0", "@types/luxon": "^3.6.1",
"@types/react-color": "^3.0.7", "@types/react-color": "^3.0.7",
"@types/three": "^0.152.1", "@types/three": "^0.152.1",
"eslint-config-next": "^14.2.23", "eslint-config-next": "^14.2.23",
+568 -28
View File
@@ -1,40 +1,580 @@
import { AccessToken } from "@/types"; import { AccessToken, PlanetWithInfo, Pin } from "@/types";
import { Box, Stack, Typography, useTheme } from "@mui/material"; import { Box, Stack, Typography, useTheme, Paper, IconButton, Divider } from "@mui/material";
import { CharacterRow } from "../Characters/CharacterRow"; import { CharacterRow } from "../Characters/CharacterRow";
import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow"; import { PlanetaryInteractionRow } from "../PlanetaryInteraction/PlanetaryInteractionRow";
import { SessionContext } from "@/app/context/Context"; import { SessionContext } from "@/app/context/Context";
import { useContext } from "react"; import { useContext, useState, useEffect } from "react";
import { PlanRow } from "./PlanRow"; import { PlanRow } from "./PlanRow";
export const AccountCard = ({ characters }: { characters: AccessToken[] }) => { import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
const theme = useTheme(); 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<number[]>(() => {
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<Set<number>>(() => {
try {
const saved = localStorage.getItem(`collapsedCharacters-${accountName}`);
if (saved) return new Set<number>(JSON.parse(saved));
} catch (_) { /* ignore corrupt localStorage */ }
return new Set<number>();
});
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<string, PlanetCalculations & { alertState: AlertState }>);
// 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;
}
const { planMode } = useContext(SessionContext);
return ( return (
<Box <Paper
elevation={2}
sx={{ sx={{
padding: 1, padding: theme.custom.compactMode ? theme.spacing(1) : theme.spacing(2),
borderBottom: theme.custom.compactMode ? "" : "solid 1px gray", margin: theme.spacing(1),
display: 'flex',
alignItems: 'flex-start',
gap: 1,
backgroundColor: theme.palette.background.paper,
transition: 'all 0.2s ease-in-out',
cursor: 'grab',
'&:hover': {
boxShadow: theme.shadows[4],
},
'&:active': {
boxShadow: theme.shadows[8],
cursor: 'grabbing',
},
}} }}
> >
<Typography style={{ fontSize: "0.8rem" }} paddingLeft={2}> <Box sx={{ flex: 1 }}>
{characters[0].account !== "-" <Box
? `Account: ${characters[0].account}` sx={{
: "No account name"} backgroundColor: theme.palette.background.default,
</Typography> borderRadius: 1,
{characters.map((c) => ( padding: theme.spacing(1),
<Stack marginBottom: theme.spacing(2),
key={c.character.characterId} display: 'flex',
direction="row" alignItems: 'center',
alignItems="flex-start" justifyContent: 'space-between',
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
}}
onClick={() => setLocalIsCollapsed(!localIsCollapsed)}
> >
<CharacterRow character={c} /> <Box>
{planMode ? ( <Typography
<PlanRow character={c} /> sx={{
) : ( fontSize: "0.9rem",
<PlanetaryInteractionRow character={c} /> fontWeight: 500,
)} color: theme.palette.text.primary,
</Stack> }}
))} >
</Box> {characters.length > 0 && characters[0].account !== "-"
? `Account: ${characters[0].account}`
: "No account name"}
</Typography>
<Box sx={{
display: 'flex',
gap: 2,
flexWrap: 'wrap',
mt: 1,
alignItems: 'center'
}}>
<Typography
sx={{
fontSize: "0.8rem",
color: theme.palette.text.secondary,
}}
>
Monthly: {monthlyEstimate >= 1000
? `${(monthlyEstimate / 1000).toFixed(2)} B`
: `${monthlyEstimate.toFixed(2)} M`} ISK
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: theme.palette.text.secondary,
}}
>
Storage: {storageValue >= 1000
? `${(storageValue / 1000).toFixed(2)} B`
: `${storageValue.toFixed(2)} M`} ISK
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: theme.palette.text.secondary,
}}
>
Planets: {planetCount}
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: theme.palette.text.secondary,
}}
>
Characters: {characterCount}
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: runningExtractors < totalExtractors ? theme.palette.error.main : theme.palette.text.secondary,
}}
>
Extractors: {runningExtractors}/{totalExtractors}
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: Object.values(planetDetails).some(d => d.alertState.hasLowStorage) ? theme.palette.error.main : theme.palette.text.secondary,
}}
>
Storage Alerts: {Object.values(planetDetails).filter(d => d.alertState.hasLowStorage).length}
</Typography>
<Divider orientation="vertical" flexItem sx={{ height: 16, borderColor: theme.palette.divider }} />
<Typography
sx={{
fontSize: "0.8rem",
color: Object.values(planetDetails).some(d => d.alertState.hasLargeExtractorDifference) ? theme.palette.error.main : theme.palette.text.secondary,
}}
>
Balance Alerts: {Object.values(planetDetails).filter(d => d.alertState.hasLargeExtractorDifference).length}
</Typography>
</Box>
</Box>
<IconButton
size="small"
sx={{
transform: localIsCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease-in-out'
}}
>
{localIsCollapsed ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</Box>
{!localIsCollapsed && (
<DragDropContextComponent onDragEnd={handleCharacterDragEnd}>
<DroppableComponent droppableId={`characters-${accountName}`} direction="vertical">
{(provided: any) => (
<Box ref={provided.innerRef} {...provided.droppableProps}>
{orderedCharacters.map((c, index) => (
<DraggableComponent
key={c.character.characterId}
draggableId={`char-${c.character.characterId}`}
index={index}
>
{(provided: any, snapshot: any) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
direction="row"
alignItems="flex-start"
sx={{
opacity: snapshot.isDragging ? 0.8 : 1,
backgroundColor: snapshot.isDragging ? theme.palette.action.hover : 'transparent',
borderRadius: 1,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: 1,
gap: 0.5,
}}
>
<Box
{...provided.dragHandleProps}
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'grab',
px: 0.5,
color: theme.palette.text.disabled,
'&:hover': { color: theme.palette.text.secondary },
'&:active': { cursor: 'grabbing' },
}}
>
</Box>
<IconButton
size="small"
onClick={() => 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',
}}
>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
<CharacterRow character={c} />
{!collapsedCharacters.has(c.character.characterId) && (
planMode ? (
<PlanRow character={c} />
) : (
<PlanetaryInteractionRow
character={c}
planetDetails={c.planets.reduce((acc, planet) => {
const details = planetDetails[`${c.character.characterId}-${planet.planet_id}`];
acc[planet.planet_id] = {
...details,
visibility: getAlertVisibility(details.alertState)
};
return acc;
}, {} as Record<number, PlanetCalculations & { visibility: string }>)}
/>
)
)}
</Stack>
)}
</DraggableComponent>
))}
{provided.placeholder}
</Box>
)}
</DroppableComponent>
</DragDropContextComponent>
)}
</Box>
</Paper>
); );
}; };
@@ -1,21 +0,0 @@
import { SessionContext } from "@/app/context/Context";
import { Button, Tooltip } from "@mui/material";
import { useContext } from "react";
export const AlertModeButton = () => {
const { alertMode, toggleAlertMode } = useContext(SessionContext);
return (
<Tooltip title="Toggle alert mode to show only accounts and planets that need action.">
<Button
style={{
backgroundColor: alertMode
? "rgba(144, 202, 249, 0.08)"
: "inherit",
}}
onClick={toggleAlertMode}
>
Alert mode
</Button>
</Tooltip>
);
};
+157 -22
View File
@@ -12,20 +12,27 @@ import * as React from "react";
import { DowloadButton } from "../Backup/DowloadButton"; import { DowloadButton } from "../Backup/DowloadButton";
import { UploadButton } from "../Backup/UploadButton"; import { UploadButton } from "../Backup/UploadButton";
import { CCPButton } from "../CCP/CCPButton"; import { CCPButton } from "../CCP/CCPButton";
import { CompactModeButton } from "../CompactModeButton/CompactModeButton";
import { DiscordButton } from "../Discord/DiscordButton"; import { DiscordButton } from "../Discord/DiscordButton";
import { GitHubButton } from "../Github/GitHubButton"; import { GitHubButton } from "../Github/GitHubButton";
import { LoginButton } from "../Login/LoginButton"; import { LoginButton } from "../Login/LoginButton";
import { PlanModeButton } from "../PlanModeButton/PlanModeButton"; import { PartnerCodeButton } from "../PartnerCode/PartnerCodeButton";
import { SettingsButton } from "../Settings/SettingsButtons"; import { SettingsButton } from "../Settings/SettingsButtons";
import { AlertModeButton } from "../AlertModeButton/AlertModeButton"; import { BuyMeCoffeeButton } from "../BuyMeCoffee/BuyMeCoffeeButton";
import { SupportButton } from "../SupportButton/SupportButton"; import {
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@mui/material";
import { useState } from "react";
function ResponsiveAppBar() { function ResponsiveAppBar() {
const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>( const [anchorElNav, setAnchorElNav] = React.useState<null | HTMLElement>(
null, null,
); );
const [faqOpen, setFaqOpen] = useState(false);
const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => { const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElNav(event.currentTarget); setAnchorElNav(event.currentTarget);
@@ -102,23 +109,32 @@ function ResponsiveAppBar() {
<MenuItem onClick={handleCloseNavMenu}> <MenuItem onClick={handleCloseNavMenu}>
<GitHubButton /> <GitHubButton />
</MenuItem> </MenuItem>
<MenuItem onClick={handleCloseNavMenu}>
<SettingsButton />
</MenuItem>
<MenuItem
onClick={() => {
handleCloseNavMenu();
setFaqOpen(true);
}}
>
<Button
href=""
style={{ width: "100%" }}
sx={{ color: "white", display: "flex", justifyContent: "flex-start" }}
>
FAQ
</Button>
</MenuItem>
<MenuItem onClick={handleCloseNavMenu}> <MenuItem onClick={handleCloseNavMenu}>
<CCPButton /> <CCPButton />
</MenuItem> </MenuItem>
<MenuItem onClick={handleCloseNavMenu}> <MenuItem onClick={handleCloseNavMenu}>
<SupportButton /> <PartnerCodeButton />
</MenuItem> </MenuItem>
<MenuItem onClick={handleCloseNavMenu}> <MenuItem onClick={handleCloseNavMenu}>
<SettingsButton /> <BuyMeCoffeeButton />
</MenuItem>
<MenuItem onClick={handleCloseNavMenu}>
<CompactModeButton />
</MenuItem>
<MenuItem onClick={handleCloseNavMenu}>
<PlanModeButton />
</MenuItem>
<MenuItem onClick={handleCloseNavMenu}>
<AlertModeButton />
</MenuItem> </MenuItem>
</Menu> </Menu>
</Box> </Box>
@@ -146,7 +162,7 @@ function ResponsiveAppBar() {
flexGrow: 1, flexGrow: 1,
display: { xs: "none", md: "flex" }, display: { xs: "none", md: "flex" },
alignItems: "center", alignItems: "center",
gap: "0.2rem" gap: "0.2rem",
}} }}
> >
<LoginButton /> <LoginButton />
@@ -154,15 +170,134 @@ function ResponsiveAppBar() {
<UploadButton /> <UploadButton />
<DiscordButton /> <DiscordButton />
<GitHubButton /> <GitHubButton />
<CCPButton />
<SupportButton />
<SettingsButton /> <SettingsButton />
<CompactModeButton /> <Button onClick={() => setFaqOpen(true)} color="inherit">
<PlanModeButton /> FAQ
<AlertModeButton /> </Button>
<CCPButton />
<PartnerCodeButton />
<BuyMeCoffeeButton />
</Box> </Box>
</Toolbar> </Toolbar>
</Container> </Container>
<Dialog
open={faqOpen}
onClose={() => setFaqOpen(false)}
aria-labelledby="faq-dialog-title"
>
<DialogTitle id="faq-dialog-title">
Frequently Asked Questions
</DialogTitle>
<DialogContent>
<DialogContentText>
<strong>EVE Online Partner Code</strong>
<br />
Consider using my partner code for CCP related purchases to support
this project:
<Button
href=""
style={{ width: "100%" }}
sx={{ color: "white", display: "block" }}
onClick={() => {
navigator.clipboard.writeText("CALLIEVE");
}}
>
CALLIEVE
</Button>{" "}
Click button above to copy to clipboard.
<br />
<br />
<strong>What is this application?</strong>
<br />
This EVE Online Planetary Interaction tool that helps you track and
manage your colonies and production chains. Main usecase is to see
if your extractors are running.
<br />
<br />
<strong>How do I add characters?</strong>
<br />
You can add characters by clicking the &quot;Add Character&quot;
button in the app bar and following the authentication process.
<br />
<br />
<strong>Why don&apos;t my storage numbers add up?</strong>
<br />
EVE Online API (ESI) provides planetary interaction endpoints. These
are updated when you submit changes to planet. For example after an
extractor restart.
<br />
<br />
<strong>What does exclude mean?</strong>
<br />
Exclude means that this planet will not be counted in totals. This
is useful if you have longer production chains where exports of a
planet are consumed by another planet.
<br />
<br />
<strong>How do see my planets production chains?</strong>
<br />
Click on the planet row to open the extraction simulation.
<br />
<br />
<strong>What is Compact Mode?</strong>
<br />
Compact Mode reduces the size of character cards to show more
information at once. You can toggle it in the settings.
<br />
<br />
<strong>What is Alert Mode?</strong>
<br />
Alert mode shows only the planets that have extractors offline and
need attention.
<br />
<br />
<strong>What does off-blanace mean?</strong>
<br />
Off-blanace alert shows up for planets that have two extractors that
are extracting different amount of material. Treshold can be set in
settings. Generally you want to keep this below 1000.
<br />
<br />
<strong>How do I reorder accounts?</strong>
<br />
You can drag and drop account cards to reorder them. The order will
be saved automatically.
<br />
<br />
<strong>How do I add a character to account groups?</strong>
<br />
You can add a character to an account group by clicking the cog on
the character card and typing in the account/group name.
<br />
<br />
<strong>I like icons! Why so much text?</strong>
<br />
Toggle this option in settings.
<br />
<br />
<strong>I like colors! Why your alert colors are so ugly?</strong>
<br />
Change the color scheme in settings.
<br />
<br />
<strong>What does the 3D view do?</strong>
<br />
Nothing. Just made it for fun
<br />
<br />
<strong>Why are planet settings empty?</strong>
<br />
Its a work in progress feature. Plan is to be able to configuer
planet and planet exports to belong to production chains.
<br />
<br />
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setFaqOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
</AppBar> </AppBar>
); );
} }
@@ -0,0 +1,17 @@
import { Box, Button, Tooltip } from "@mui/material";
export const BuyMeCoffeeButton = () => {
return (
<Box>
<Tooltip title="Support the development of this tool">
<Button
href="https://buymeacoffee.com/evepi"
target="_blank"
style={{ width: "100%" }}
sx={{ color: "white", display: "block" }}
>
By me a beer
</Button>
</Tooltip>
</Box>
);
};
@@ -1,4 +1,4 @@
import { Button, Dialog, DialogActions, DialogTitle } from "@mui/material"; import { Button, Dialog, DialogActions, DialogTitle, Box } from "@mui/material";
import { AccessToken, CharacterUpdate } from "../../../types"; import { AccessToken, CharacterUpdate } from "../../../types";
import { useEffect, useState, KeyboardEvent } from "react"; import { useEffect, useState, KeyboardEvent } from "react";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
@@ -34,7 +34,9 @@ export const CharacterDialog = ({
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter") { if (event.key === "Enter") {
closeDialog(); closeDialog();
character && updateCharacter(character, { account, comment }); if (character) {
updateCharacter(character, { account, comment });
}
} }
}; };
@@ -44,16 +46,27 @@ export const CharacterDialog = ({
onClose={closeDialog} onClose={closeDialog}
fullWidth={true} fullWidth={true}
> >
<DialogTitle>{character && character.character.name}</DialogTitle> <DialogTitle>{character?.character?.name}</DialogTitle>
<TextField <Box sx={{ display: 'flex', alignItems: 'center', margin: 1 }}>
id="outlined-basic" <TextField
label="Account name" id="outlined-basic"
variant="outlined" label="Account name"
value={account ?? ""} variant="outlined"
sx={{ margin: 1 }} value={account ?? ""}
onChange={(event) => setAccount(event.target.value)} sx={{ flex: 1 }}
onKeyDown={handleKeyDown} onChange={(event) => setAccount(event.target.value)}
/> onKeyDown={handleKeyDown}
/>
<Button
onClick={() => {
setAccount("-");
}}
variant="outlined"
sx={{ ml: 1 }}
>
Clear account
</Button>
</Box>
<TextField <TextField
id="outlined-basic" id="outlined-basic"
label="System" label="System"
@@ -77,6 +90,7 @@ export const CharacterDialog = ({
<DialogActions> <DialogActions>
<Button <Button
onClick={() => { onClick={() => {
console.log("Saving character", character, { account, comment, system });
character && character &&
updateCharacter(character, { account, comment, system }); updateCharacter(character, { account, comment, system });
closeDialog(); closeDialog();
+41 -15
View File
@@ -7,16 +7,18 @@ import { styled, useTheme } from "@mui/material/styles";
import React from "react"; import React from "react";
import { CharacterDialog } from "./CharacterDialog"; import { CharacterDialog } from "./CharacterDialog";
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { Box, Button, Tooltip } from "@mui/material"; import { Box, Tooltip, IconButton, Typography } from "@mui/material";
import SettingsIcon from "@mui/icons-material/Settings";
import { EVE_IMAGE_URL } from "@/const"; import { EVE_IMAGE_URL } from "@/const";
import { CharacterContext } from "@/app/context/Context"; import { CharacterContext } from "@/app/context/Context";
const StackItem = styled(Stack)(({ theme }) => ({ const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2, ...theme.typography.body2,
padding: theme.custom.compactMode ? theme.spacing(1) : theme.spacing(2), padding: theme.custom.compactMode ? theme.spacing(1) : theme.spacing(2),
display: "flex",
textAlign: "left", textAlign: "left",
justifyContent: "center", justifyContent: "flex-start",
alignItems: "center", alignItems: "flex-start",
})); }));
export const CharacterRow = ({ character }: { character: AccessToken }) => { export const CharacterRow = ({ character }: { character: AccessToken }) => {
@@ -29,8 +31,6 @@ export const CharacterRow = ({ character }: { character: AccessToken }) => {
return ( return (
<StackItem <StackItem
key={character.character.characterId} key={character.character.characterId}
alignItems="flex-start"
justifyContent="flex-start"
> >
<CharacterDialog <CharacterDialog
character={selectedCharacter} character={selectedCharacter}
@@ -38,13 +38,49 @@ export const CharacterRow = ({ character }: { character: AccessToken }) => {
updateCharacter={updateCharacter} updateCharacter={updateCharacter}
closeDialog={() => setSelectedCharacter(undefined)} closeDialog={() => setSelectedCharacter(undefined)}
/> />
<Typography
sx={{
whiteSpace: "nowrap",
fontSize: theme.custom.smallText,
textAlign: "left",
lineHeight: 1.2,
marginBottom: "0.4rem",
marginLeft: "0.2rem",
overflow: "visible",
textOverflow: "clip",
width: "1rem"
}}
>
{character.character.name}
</Typography>
<Tooltip title={character.comment}> <Tooltip title={character.comment}>
<Box <Box
display="flex" display="flex"
flexDirection="column" flexDirection="column"
maxWidth={120} maxWidth={120}
onClick={() => setSelectedCharacter(character)} onClick={() => setSelectedCharacter(character)}
position="relative"
sx={{ cursor: "pointer" }}
> >
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setSelectedCharacter(character);
}}
sx={{
p: 0,
position: "absolute",
top: 4,
left: 4,
backgroundColor: "rgba(0, 0, 0, 0.5)",
"&:hover": {
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
}}
>
<SettingsIcon fontSize="small" sx={{ color: "white" }} />
</IconButton>
<Image <Image
unoptimized unoptimized
src={`${EVE_IMAGE_URL}/characters/${character.character.characterId}/portrait?size=128`} src={`${EVE_IMAGE_URL}/characters/${character.character.characterId}/portrait?size=128`}
@@ -53,16 +89,6 @@ export const CharacterRow = ({ character }: { character: AccessToken }) => {
height={theme.custom.cardImageSize} height={theme.custom.cardImageSize}
style={{ marginBottom: "0.2rem", borderRadius: 8 }} style={{ marginBottom: "0.2rem", borderRadius: 8 }}
/> />
<Button
style={{
padding: 6,
fontSize: theme.custom.smallText,
lineHeight: 1,
}}
variant="outlined"
>
{character.character.name}
</Button>
</Box> </Box>
</Tooltip> </Tooltip>
</StackItem> </StackItem>
@@ -1,21 +0,0 @@
import { SessionContext } from "@/app/context/Context";
import { Button, Tooltip } from "@mui/material";
import { useContext } from "react";
export const CompactModeButton = () => {
const { compactMode, toggleCompactMode } = useContext(SessionContext);
return (
<Tooltip title="Toggle compact layout for widescreen">
<Button
style={{
backgroundColor: compactMode
? "rgba(144, 202, 249, 0.08)"
: "inherit",
}}
onClick={toggleCompactMode}
>
Compact mode
</Button>
</Tooltip>
);
};
+1 -1
View File
@@ -4,7 +4,7 @@ export const DiscordButton = () => {
<Box> <Box>
<Tooltip title="Come nerd out in discord about PI and this tool"> <Tooltip title="Come nerd out in discord about PI and this tool">
<Button <Button
href="https://discord.gg/GPtw5kfuJu" href="https://discord.gg/bCdXzU8PHK"
target="_blank" target="_blank"
style={{ width: "100%" }} style={{ width: "100%" }}
sx={{ color: "white", display: "block" }} sx={{ color: "white", display: "block" }}
+4 -11
View File
@@ -19,7 +19,6 @@ export const LoginDialog = ({
DEFAULT_SCOPES_TO_SELECT DEFAULT_SCOPES_TO_SELECT
); );
const [ssoUrl, setSsoUrl] = useState<string | undefined>(undefined); const [ssoUrl, setSsoUrl] = useState<string | undefined>(undefined);
const [loginUrl, setLoginUrl] = useState<string | undefined>(undefined);
const { EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL } = const { EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL } =
useContext(SessionContext); useContext(SessionContext);
@@ -30,15 +29,6 @@ export const LoginDialog = ({
}); });
}, []); }, []);
useEffect(() => {
if (!ssoUrl || selectedScopes.length === 0) return;
loginParameters(
selectedScopes,
EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL
).then((res) => setLoginUrl(ssoUrl + "?" + res));
}, [selectedScopes, ssoUrl, EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL]);
return ( return (
<Dialog open={open} onClose={closeDialog}> <Dialog open={open} onClose={closeDialog}>
<DialogTitle>Select scopes to login with</DialogTitle> <DialogTitle>Select scopes to login with</DialogTitle>
@@ -59,8 +49,11 @@ export const LoginDialog = ({
<DialogActions> <DialogActions>
<Button <Button
variant="contained" variant="contained"
disabled={!ssoUrl || selectedScopes.length === 0}
onClick={() => { onClick={() => {
window.open(loginUrl, "_self"); if (!ssoUrl) return;
const params = loginParameters(selectedScopes, EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL);
window.open(ssoUrl + "?" + params, "_self");
}} }}
> >
Login Login
+179 -12
View File
@@ -5,12 +5,22 @@ import {
Grid, Grid,
ThemeProvider, ThemeProvider,
createTheme, createTheme,
Button,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import { AccountCard } from "./Account/AccountCard"; import { AccountCard } from "./Account/AccountCard";
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { CharacterContext, SessionContext } from "../context/Context"; import { CharacterContext, SessionContext } from "../context/Context";
import ResponsiveAppBar from "./AppBar/AppBar"; import ResponsiveAppBar from "./AppBar/AppBar";
import { Summary } from "./Summary/Summary"; import { Summary } from "./Summary/Summary";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
interface Grouped { interface Grouped {
[key: string]: AccessToken[]; [key: string]: AccessToken[];
@@ -39,6 +49,46 @@ declare module "@mui/material/styles" {
export const MainGrid = () => { export const MainGrid = () => {
const { characters } = useContext(CharacterContext); const { characters } = useContext(CharacterContext);
const { compactMode, toggleCompactMode, alertMode, toggleAlertMode, planMode, togglePlanMode, extractionTimeMode, toggleExtractionTimeMode } = useContext(SessionContext);
const [accountOrder, setAccountOrder] = useState<string[]>([]);
const [allCollapsed, setAllCollapsed] = useState(false);
// Initialize account order when characters change
useEffect(() => {
const currentAccounts = Object.keys(
characters.reduce<Grouped>((group, character) => {
const { account } = character;
group[account ?? ""] = group[account ?? ""] ?? [];
group[account ?? ""].push(character);
return group;
}, {}),
);
const savedOrder = localStorage.getItem("accountOrder");
if (savedOrder) {
try {
const parsedOrder = JSON.parse(savedOrder);
const validOrder = parsedOrder.filter((account: string) =>
currentAccounts.includes(account),
);
const newAccounts = currentAccounts.filter(
(account) => !validOrder.includes(account),
);
setAccountOrder([...validOrder, ...newAccounts]);
} catch (e) {
setAccountOrder(currentAccounts);
}
} else {
setAccountOrder(currentAccounts);
}
}, [characters]);
useEffect(() => {
if (accountOrder.length > 0) {
localStorage.setItem("accountOrder", JSON.stringify(accountOrder));
}
}, [accountOrder]);
const groupByAccount = characters.reduce<Grouped>((group, character) => { const groupByAccount = characters.reduce<Grouped>((group, character) => {
const { account } = character; const { account } = character;
group[account ?? ""] = group[account ?? ""] ?? []; group[account ?? ""] = group[account ?? ""] ?? [];
@@ -46,7 +96,6 @@ export const MainGrid = () => {
return group; return group;
}, {}); }, {});
const { compactMode } = useContext(SessionContext);
const [darkTheme, setDarkTheme] = useState( const [darkTheme, setDarkTheme] = useState(
createTheme({ createTheme({
palette: { palette: {
@@ -79,24 +128,142 @@ export const MainGrid = () => {
); );
}, [compactMode]); }, [compactMode]);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(accountOrder);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setAccountOrder(items);
};
const DragDropContextComponent = DragDropContext as any;
const DroppableComponent = Droppable as any;
const DraggableComponent = Draggable as any;
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
<CssBaseline /> <CssBaseline />
<Box sx={{ flexGrow: 1 }}> <Box sx={{ flexGrow: 1 }}>
<ResponsiveAppBar /> <ResponsiveAppBar />
{compactMode ? <></> : <Summary characters={characters} />} {compactMode ? <></> : <Summary characters={characters} />}
<Grid container spacing={1}> <Box
{Object.values(groupByAccount).map((g, id) => ( sx={{
<Grid display: "flex",
item justifyContent: "flex-start",
xs={12} padding: 1,
sm={compactMode ? 6 : 12} gap: 1,
key={`account-${id}-${g[0].account}`} }}
>
<Button
startIcon={
allCollapsed ? <KeyboardArrowDownIcon /> : <KeyboardArrowUpIcon />
}
onClick={() => setAllCollapsed(!allCollapsed)}
size="small"
>
{allCollapsed ? "Expand All" : "Collapse All"}
</Button>
<Tooltip title="Toggle compact layout for widescreen">
<Button
size="small"
style={{
backgroundColor: compactMode
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={toggleCompactMode}
> >
<AccountCard characters={g} /> Compact mode
</Grid> </Button>
))} </Tooltip>
</Grid> <Tooltip title="Toggle alert mode to show only accounts and planets that need action.">
<Button
size="small"
style={{
backgroundColor: alertMode
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={toggleAlertMode}
>
Alert mode
</Button>
</Tooltip>
<Tooltip title="Toggle plan mode that show layout for widescreen">
<Button
size="small"
style={{
backgroundColor: planMode
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={togglePlanMode}
>
Plan mode
</Button>
</Tooltip>
<Tooltip title="Toggle extraction time display mode">
<Button
size="small"
style={{
backgroundColor: extractionTimeMode
? "rgba(144, 202, 249, 0.16)"
: "inherit",
}}
onClick={toggleExtractionTimeMode}
>
Extraction datetime
</Button>
</Tooltip>
</Box>
<DragDropContextComponent onDragEnd={handleDragEnd}>
<DroppableComponent droppableId="accounts">
{(provided: any) => (
<Grid
container
spacing={1}
sx={{ padding: 1, width: "100%" }}
{...provided.droppableProps}
ref={provided.innerRef}
>
{accountOrder.map((account, index) => (
<DraggableComponent
key={account}
draggableId={account}
index={index}
>
{(provided: any) => (
<Grid
item
xs={12}
sm={compactMode ? 6 : 12}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
sx={{
"& > *": {
width: "100%",
},
}}
>
{groupByAccount[account] &&
groupByAccount[account].length > 0 && (
<AccountCard
characters={groupByAccount[account]}
isCollapsed={allCollapsed}
/>
)}
</Grid>
)}
</DraggableComponent>
))}
{provided.placeholder}
</Grid>
)}
</DroppableComponent>
</DragDropContextComponent>
</Box> </Box>
</ThemeProvider> </ThemeProvider>
); );
@@ -0,0 +1,32 @@
import { Box, Button, Tooltip } from "@mui/material";
import { useState } from "react";
export const PartnerCodeButton = () => {
const [copied, setCopied] = useState(false);
const handleClick = () => {
navigator.clipboard.writeText("CALLIEVE");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Box>
<Tooltip
title={
copied
? "Copied to clipboard!"
: "Click to copy partner code - Use for CCP purchases to support this project"
}
>
<Button
onClick={handleClick}
style={{ width: "100%" }}
sx={{ color: "white", display: "block" }}
>
Partner Code: CALLIEVE
</Button>
</Tooltip>
</Box>
);
};
@@ -1,15 +0,0 @@
import { SessionContext } from "@/app/context/Context";
import { Button, Tooltip } from "@mui/material";
import { useContext } from "react";
export const PlanModeButton = () => {
const { planMode, togglePlanMode } = useContext(SessionContext);
return (
<Tooltip title="Toggle plan mode that show layout for widescreen">
<Button onClick={togglePlanMode} style={{backgroundColor: planMode ? 'rgba(144, 202, 249, 0.08)' : 'inherit'}}>
Plan mode
</Button>
</Tooltip>
);
};
@@ -3,8 +3,6 @@ import { planetCalculations } from "@/planets";
import { AccessToken, PlanetWithInfo } from "@/types"; import { AccessToken, PlanetWithInfo } from "@/types";
import { import {
Card, Card,
Checkbox,
FormControlLabel,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -15,17 +13,11 @@ import {
} from "@mui/material"; } from "@mui/material";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import Countdown from "react-countdown"; import Countdown from "react-countdown";
import { timeColor } from "../PlanetaryInteraction/timeColors"; import { timeColor } from "../PlanetaryInteraction/alerts";
import Image from "next/image"; import Image from "next/image";
import { ColorContext, SessionContext } from "@/app/context/Context"; import { ColorContext, SessionContext } from "@/app/context/Context";
import { useContext } from "react"; import { useContext } from "react";
export type PlanetConfig = {
characterId: number;
planetId: number;
excludeFromTotals: boolean;
};
export const PlanetConfigDialog = ({ export const PlanetConfigDialog = ({
planet, planet,
character, character,
@@ -35,14 +27,9 @@ export const PlanetConfigDialog = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { colors } = useContext(ColorContext); const { colors } = useContext(ColorContext);
const { piPrices, readPlanetConfig, updatePlanetConfig } = const { piPrices } = useContext(SessionContext);
useContext(SessionContext);
const { extractors, localProduction, localImports, localExports } = const { extractors, localProduction, localImports, localExports } =
planetCalculations(planet); planetCalculations(planet);
const planetConfig = readPlanetConfig({
characterId: character.character.characterId,
planetId: planet.planet_id,
});
return ( return (
<Card style={{ padding: "1rem", margin: "1rem" }}> <Card style={{ padding: "1rem", margin: "1rem" }}>
@@ -189,23 +176,6 @@ export const PlanetConfigDialog = ({
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<Card style={{ marginTop: "1rem" }}>
<Typography>Planet configuration</Typography>
<FormControlLabel
control={
<Checkbox
checked={planetConfig.excludeFromTotals}
onChange={() =>
updatePlanetConfig({
...planetConfig,
excludeFromTotals: !planetConfig.excludeFromTotals,
})
}
/>
}
label="Consumed by production chain"
/>
</Card>
</Card> </Card>
); );
}; };
@@ -65,6 +65,7 @@ export interface ProductionNode {
quantity: number; quantity: number;
}>; }>;
cycleTime: number; cycleTime: number;
factoryCount: number;
} }
export interface ProductionChainBalance { export interface ProductionChainBalance {
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Box, Paper, Typography, Stack } from '@mui/material'; import { Box, Paper, Typography, Stack } from '@mui/material';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { useTheme } from '@mui/material';
import { getProgramOutputPrediction, ProductionNode } from './ExtractionSimulation'; import { getProgramOutputPrediction, ProductionNode } from './ExtractionSimulation';
import { PI_TYPES_MAP } from '@/const'; import { PI_TYPES_MAP } from '@/const';
import { ProductionChainVisualization } from './ProductionChainVisualization'; import { ProductionChainVisualization } from './ProductionChainVisualization';
@@ -15,6 +14,8 @@ import {
Tooltip, Tooltip,
Legend Legend
} from 'chart.js'; } from 'chart.js';
import { DateTime } from 'luxon';
import Countdown from 'react-countdown';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -43,6 +44,7 @@ export const ExtractionSimulationDisplay: React.FC<ExtractionSimulationDisplayPr
extractors, extractors,
productionNodes productionNodes
}) => { }) => {
const CYCLE_TIME = 30 * 60; // 30 minutes in seconds const CYCLE_TIME = 30 * 60; // 30 minutes in seconds
// Calculate program duration and cycles for each extractor // Calculate program duration and cycles for each extractor
@@ -57,6 +59,7 @@ export const ExtractionSimulationDisplay: React.FC<ExtractionSimulationDisplayPr
}; };
}); });
const maxCycles = Math.max(...extractorPrograms.map(e => e.cycles)); const maxCycles = Math.max(...extractorPrograms.map(e => e.cycles));
// Get output predictions for each extractor // Get output predictions for each extractor
@@ -156,14 +159,18 @@ export const ExtractionSimulationDisplay: React.FC<ExtractionSimulationDisplayPr
const installedSchematicIds = Array.from(new Set(productionNodes.map(node => node.schematicId))); const installedSchematicIds = Array.from(new Set(productionNodes.map(node => node.schematicId)));
// Create factories array with correct counts // Create factories array with correct counts
const factories = installedSchematicIds.map(schematicId => ({ const factories = installedSchematicIds.map(schematicId => {
schematic_id: schematicId, const node = productionNodes.find(n => n.schematicId === schematicId);
count: productionNodes.filter(node => node.schematicId === schematicId).length if (!node) return { schematic_id: schematicId, count: 0 };
})); return {
schematic_id: schematicId,
count: node.factoryCount
};
});
return ( return (
<Box> <Box>
<Paper sx={{ p: 2 }}> {extractors.length > 0 ? <Paper sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Extraction Simulation Extraction Simulation
</Typography> </Typography>
@@ -191,20 +198,27 @@ export const ExtractionSimulationDisplay: React.FC<ExtractionSimulationDisplayPr
<Typography variant="body2" component="div" sx={{ pl: 2 }}> <Typography variant="body2" component="div" sx={{ pl: 2 }}>
Expiry Time: {new Date(expiryTime).toLocaleString()} Expiry Time: {new Date(expiryTime).toLocaleString()}
</Typography> </Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Factory Count: {factories.find(f => f.schematic_id === typeId)?.count ?? 0}
</Typography>
<Typography variant="body2" component="div" sx={{ pl: 2 }}>
Expires in: <Countdown overtime={true} date={DateTime.fromISO(expiryTime).toMillis()} />
</Typography>
</Stack> </Stack>
))} ))}
</Box> </Box>
<div style={{ height: '300px' }}> <div style={{ height: '300px' }}>
<Line data={chartData} options={chartOptions} /> <Line data={chartData} options={chartOptions} />
</div> </div>
</Paper> </Paper> : null}
<ProductionChainVisualization <ProductionChainVisualization
extractedTypeIds={extractedTypeIds} extractedTypeIds={extractedTypeIds}
extractors={extractors.map(e => ({ extractors={extractors.map(e => ({
typeId: e.typeId, typeId: e.typeId,
baseValue: e.baseValue, baseValue: e.baseValue,
cycleTime: CYCLE_TIME cycleTime: CYCLE_TIME,
expiryTime: e.expiryTime
}))} }))}
factories={factories} factories={factories}
extractorTotals={extractorTotals} extractorTotals={extractorTotals}
@@ -0,0 +1,243 @@
import React, { useContext } from 'react';
import { Box, Paper, Typography, Stack } from '@mui/material';
import { Line } from 'react-chartjs-2';
import { getProgramOutputPrediction } from './ExtractionSimulation';
import { PI_TYPES_MAP } from '@/const';
import { SessionContext } from '@/app/context/Context';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
} from 'chart.js';
import { DateTime } from 'luxon';
import Countdown from 'react-countdown';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
interface ExtractorConfig {
typeId: number;
baseValue: number;
cycleTime: number;
installTime: string;
expiryTime: string;
}
interface ExtractionSimulationTooltipProps {
extractors: ExtractorConfig[];
}
export const ExtractionSimulationTooltip: React.FC<ExtractionSimulationTooltipProps> = ({
extractors
}) => {
const { minExtractionRate } = useContext(SessionContext);
const CYCLE_TIME = 30 * 60; // 30 minutes in seconds
// Calculate program duration and cycles for each extractor
const extractorPrograms = extractors.map(extractor => {
const installDate = new Date(extractor.installTime);
const expiryDate = new Date(extractor.expiryTime);
const programDuration = (expiryDate.getTime() - installDate.getTime()) / 1000; // Convert to seconds
return {
...extractor,
programDuration,
cycles: Math.floor(programDuration / CYCLE_TIME)
};
});
const maxCycles = Math.max(...extractorPrograms.map(e => e.cycles));
// Get output predictions for each extractor
const extractorOutputs = extractorPrograms.map(extractor => ({
typeId: extractor.typeId,
cycleTime: CYCLE_TIME,
cycles: extractor.cycles,
prediction: getProgramOutputPrediction(
extractor.baseValue,
CYCLE_TIME,
extractor.cycles
)
}));
// Create datasets for the chart
const datasets = extractorOutputs.map((output, index) => {
const hue = (360 / extractors.length) * index;
return {
label: `${PI_TYPES_MAP[output.typeId]?.name ?? `Resource ${output.typeId}`}`,
data: output.prediction,
borderColor: `hsl(${hue}, 70%, 50%)`,
backgroundColor: `hsl(${hue}, 70%, 80%)`,
tension: 0.4
};
});
const chartData = {
labels: Array.from({ length: maxCycles }, (_, i) => {
return (i % 4 === 0) ? `Cycle ${i + 1}` : '';
}),
datasets
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: 'Extraction Output Prediction'
},
tooltip: {
callbacks: {
title: (context: any) => `Cycle ${context[0].dataIndex + 1}`,
label: (context: any) => `Output: ${context.raw.toFixed(1)} units`
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Units per Cycle'
}
},
x: {
ticks: {
autoSkip: true,
maxTicksLimit: 24
}
}
}
};
return (
<Paper sx={{ p: 1, bgcolor: 'background.paper', minWidth: 800 }}>
<Stack direction="row" spacing={2} sx={{ width: '100%' }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<div style={{ height: '200px' }}>
<Line data={chartData} options={chartOptions} />
</div>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Stack spacing={1}>
{extractorPrograms.map(({ typeId, cycleTime, cycles, expiryTime }, idx) => {
const prediction = getProgramOutputPrediction(
extractors[idx].baseValue,
CYCLE_TIME,
cycles
);
const totalOutput = prediction.reduce((sum, val) => sum + val, 0);
return (
<Paper key={typeId} sx={{ p: 1, bgcolor: 'background.default' }}>
<Typography variant="subtitle2">
{PI_TYPES_MAP[typeId]?.name}
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2">
Total Output: {totalOutput.toFixed(1)} units per program
</Typography>
<Typography variant="body2">
Cycle Time: {(cycleTime / 60).toFixed(1)} minutes
</Typography>
<Typography variant="body2">
Program Cycles: {cycles}
</Typography>
<Typography variant="body2">
Average per Cycle: {(totalOutput / cycles).toFixed(1)} units
</Typography>
<Typography
variant="body2"
color={
minExtractionRate > 0 && (extractors[idx].baseValue * 3600) / extractors[idx].cycleTime < minExtractionRate
? 'error'
: 'inherit'
}
>
Average per hour: {(totalOutput / cycles * 2).toFixed(1)} units
</Typography>
<Typography variant="body2">
Expires in: <Countdown overtime={true} date={DateTime.fromISO(expiryTime).toMillis()} />
</Typography>
</Stack>
</Paper>
);
})}
{extractors.length === 2 && (
<Paper sx={{ p: 1, bgcolor: 'background.default' }}>
<Typography variant="subtitle2" color="error">
Balance
</Typography>
<Stack spacing={0.5}>
{extractors.map((extractor, index) => {
const prediction = getProgramOutputPrediction(
extractor.baseValue,
CYCLE_TIME,
extractorPrograms[index].cycles
);
const totalOutput = prediction.reduce((sum, val) => sum + val, 0);
const cycles = extractorPrograms[index].cycles;
const averagePerHour = totalOutput / cycles * 2;
return (
<Typography key={index} variant="body2">
{PI_TYPES_MAP[extractor.typeId]?.name}: {averagePerHour.toFixed(1)} u/h
</Typography>
);
})}
<Typography
variant="body2"
color="error"
sx={{
mt: 1,
fontWeight: 'bold',
borderTop: '1px solid',
borderColor: 'divider',
pt: 1
}}
>
Difference: {(() => {
const prediction0 = getProgramOutputPrediction(
extractors[0].baseValue,
CYCLE_TIME,
extractorPrograms[0].cycles
);
const totalOutput0 = prediction0.reduce((sum, val) => sum + val, 0);
const cycles0 = extractorPrograms[0].cycles;
const avg0 = totalOutput0 / cycles0 * 2;
const prediction1 = getProgramOutputPrediction(
extractors[1].baseValue,
CYCLE_TIME,
extractorPrograms[1].cycles
);
const totalOutput1 = prediction1.reduce((sum, val) => sum + val, 0);
const cycles1 = extractorPrograms[1].cycles;
const avg1 = totalOutput1 / cycles1 * 2;
return Math.abs(avg0 - avg1).toFixed(1);
})()} u/h
</Typography>
</Stack>
</Paper>
)}
</Stack>
</Box>
</Stack>
</Paper>
);
};
@@ -1,31 +1,24 @@
import { Stack, Typography, styled, useTheme } from "@mui/material"; import { Stack, Typography, styled, useTheme, Tooltip } from "@mui/material";
import Image from "next/image"; import Image from "next/image";
import { import {
AccessToken, AccessToken,
Planet,
PlanetInfo,
PlanetInfoUniverse,
PlanetWithInfo, PlanetWithInfo,
} from "@/types"; } from "@/types";
import React, { forwardRef, useContext, useEffect, useState } from "react"; import { PlanetCalculations } from "@/types/planet";
import React, { useContext } from "react";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { EXTRACTOR_TYPE_IDS } from "@/const";
import Countdown from "react-countdown"; import Countdown from "react-countdown";
import PinsCanvas3D from "./PinsCanvas3D";
import Slide from "@mui/material/Slide";
import { TransitionProps } from "@mui/material/transitions";
import Dialog from "@mui/material/Dialog";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";
import Button from "@mui/material/Button";
import {
alertModeVisibility,
extractorsHaveExpired,
timeColor,
} from "./timeColors";
import { ColorContext, SessionContext } from "@/app/context/Context"; import { ColorContext, SessionContext } from "@/app/context/Context";
import { ExtractionSimulationTooltip } from "./ExtractionSimulationTooltip";
import { timeColor } from "./alerts";
interface ExtractorConfig {
typeId: number;
baseValue: number;
cycleTime: number;
installTime: string;
expiryTime: string;
}
const StackItem = styled(Stack)(({ theme }) => ({ const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2, ...theme.typography.body2,
@@ -36,128 +29,152 @@ const StackItem = styled(Stack)(({ theme }) => ({
alignItems: "center", alignItems: "center",
})); }));
const Transition = forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const PlanetCard = ({ export const PlanetCard = ({
character, character,
planet, planet,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planet: PlanetWithInfo; planet: PlanetWithInfo;
planetDetails: PlanetCalculations;
}) => { }) => {
const { alertMode } = useContext(SessionContext);
const planetInfo = planet.info;
const planetInfoUniverse = planet.infoUniverse;
const [planetRenderOpen, setPlanetRenderOpen] = useState(false);
const theme = useTheme(); const theme = useTheme();
const handle3DrenderOpen = () => {
setPlanetRenderOpen(true);
};
const handle3DrenderClose = () => {
setPlanetRenderOpen(false);
};
const extractorsExpiryTime =
(planetInfo &&
planetInfo.pins
.filter((p) => EXTRACTOR_TYPE_IDS.some((e) => e === p.type_id))
.map((p) => p.expiry_time)) ??
[];
const { colors } = useContext(ColorContext); const { colors } = useContext(ColorContext);
const expired = extractorsHaveExpired(extractorsExpiryTime); const { minExtractionRate } = useContext(SessionContext);
const extractorConfigs: ExtractorConfig[] = planetDetails.extractors
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
.map(e => ({
typeId: e.extractor_details!.product_type_id!,
baseValue: e.extractor_details!.qty_per_cycle!,
cycleTime: e.extractor_details?.cycle_time || 3600,
installTime: e.install_time ?? "",
expiryTime: e.expiry_time ?? ""
}));
const hasLowExtractionRate = planetDetails.extractorAverages.length > 0 && minExtractionRate > 0 && planetDetails.extractorAverages.some(avg => avg.averagePerHour < minExtractionRate);
return ( return (
<StackItem <Tooltip
alignItems="flex-start" title={
height="100%" planetDetails.extractors.length > 0 ? (
position="relative" <ExtractionSimulationTooltip
minHeight={theme.custom.cardMinHeight} extractors={extractorConfigs}
visibility={alertModeVisibility(alertMode, expired)} />
) : null
}
componentsProps={{
tooltip: {
sx: {
bgcolor: 'background.paper',
'& .MuiTooltip-arrow': {
color: 'background.paper',
},
maxWidth: 'none',
width: 'fit-content'
}
}
}}
> >
<Image <StackItem
unoptimized alignItems="flex-start"
src={`/${planet.planet_type}.png`} height="100%"
alt="" position="relative"
width={theme.custom.cardImageSize} minHeight={theme.custom.cardMinHeight}
height={theme.custom.cardImageSize} style={{ visibility: planetDetails.visibility }}
style={{ borderRadius: 8, marginRight: 4 }}
onClick={handle3DrenderOpen}
/>
{expired && (
<Image
width={32}
height={32}
src={`/stopped.png`}
alt=""
style={{ position: "absolute", top: theme.custom.stoppedPosition }}
/>
)}
<div style={{ position: "absolute", top: 5, left: 10 }}>
<Typography fontSize={theme.custom.smallText}>
{planetInfoUniverse?.name}
</Typography>
<Typography fontSize={theme.custom.smallText}>
L{planet.upgrade_level}
</Typography>
</div>
{extractorsExpiryTime.map((e, idx) => {
return (
<Typography
key={`${e}-${idx}-${character.character.characterId}`}
color={timeColor(e, colors)}
fontSize={theme.custom.smallText}
>
{e ? (
<Countdown
overtime={true}
date={DateTime.fromISO(e).toMillis()}
/>
) : (
"STOPPED"
)}
</Typography>
);
})}
<Dialog
fullScreen
open={planetRenderOpen}
onClose={handle3DrenderClose}
TransitionComponent={Transition}
> >
<AppBar sx={{ position: "relative" }}> <div style={{ position: 'relative' }}>
<Toolbar> <Image
<IconButton unoptimized
edge="start" src={`/${planet.planet_type}.png`}
color="inherit" alt=""
onClick={handle3DrenderClose} width={theme.custom.cardImageSize}
aria-label="close" height={theme.custom.cardImageSize}
style={{
borderRadius: 8,
marginRight: 4,
position: 'relative',
zIndex: 0
}}
/>
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: theme.custom.cardImageSize,
height: theme.custom.cardImageSize,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderRadius: 8,
}} />
</div>
{planetDetails.expired && (
<Image
width={32}
height={32}
src={`/stopped.png`}
alt=""
style={{ position: "absolute", top: theme.custom.stoppedPosition }}
/>
)}
<div style={{ position: "absolute", top: 5, left: 10 }}>
<Typography
fontSize={theme.custom.smallText}
color={(planetDetails.hasLargeExtractorDifference || hasLowExtractionRate) ? 'error' : 'inherit'}
>
{planet.infoUniverse?.name}
</Typography>
{planetDetails.hasLargeExtractorDifference && (
<Typography
fontSize={theme.custom.smallText}
color="error"
sx={{ opacity: 0.7 }}
> >
<CloseIcon /> off-balance
</IconButton>
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
{planetInfoUniverse?.name}
</Typography> </Typography>
<Button autoFocus color="inherit" onClick={handle3DrenderClose}> )}
Close {hasLowExtractionRate && (
</Button> <Typography
</Toolbar> fontSize={theme.custom.smallText}
</AppBar> color="error"
<PinsCanvas3D planetInfo={planetInfo} /> sx={{ opacity: 0.7 }}
</Dialog> >
</StackItem> low-extraction
</Typography>
)}
{planetDetails.extractors.map((e, idx) => {
const average = planetDetails.extractorAverages[idx];
return (
<div key={`${e}-${idx}-${character.character.characterId}`}>
<Typography
color={timeColor(e.expiry_time, colors)}
fontSize={theme.custom.smallText}
>
{!planetDetails.expired && e.expiry_time && <Countdown
overtime={true}
date={DateTime.fromISO(e.expiry_time).toMillis()}
/>
}
</Typography>
{!planetDetails.expired && e && average && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Image
unoptimized
src={`https://images.evetech.net/types/${e.extractor_details?.product_type_id}/icon?size=32`}
alt=""
width={16}
height={16}
style={{ borderRadius: 4 }}
/>
<Typography fontSize={theme.custom.smallText}>
{average.averagePerHour.toFixed(1)}/h
</Typography>
</div>
)}
</div>
);
})}
</div>
</StackItem>
</Tooltip>
); );
}; };
@@ -1,10 +1,10 @@
import { ColorContext, SessionContext } from "@/app/context/Context"; import { ColorContext, SessionContext } from "@/app/context/Context";
import { PI_TYPES_MAP, STORAGE_IDS, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES } from "@/const"; import { PI_TYPES_MAP, EVE_IMAGE_URL, LAUNCHPAD_IDS } from "@/const";
import { planetCalculations } from "@/planets";
import { AccessToken, PlanetWithInfo } from "@/types"; import { AccessToken, PlanetWithInfo } from "@/types";
import { PlanetCalculations, StorageInfo } from "@/types/planet";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { Button, Tooltip, Typography, useTheme, Menu, MenuItem, IconButton } from "@mui/material"; import { Button, Tooltip, Typography, useTheme, Menu, MenuItem, IconButton, Checkbox, FormControlLabel } from "@mui/material";
import AppBar from "@mui/material/AppBar"; import AppBar from "@mui/material/AppBar";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import Slide from "@mui/material/Slide"; import Slide from "@mui/material/Slide";
@@ -18,12 +18,13 @@ import React, { forwardRef, useContext, useState } from "react";
import Countdown from "react-countdown"; import Countdown from "react-countdown";
import { PlanetConfigDialog } from "../PlanetConfig/PlanetConfigDialog"; import { PlanetConfigDialog } from "../PlanetConfig/PlanetConfigDialog";
import PinsCanvas3D from "./PinsCanvas3D"; import PinsCanvas3D from "./PinsCanvas3D";
import { alertModeVisibility, timeColor } from "./timeColors"; import { timeColor } from "./alerts";
import { ExtractionSimulationDisplay } from './ExtractionSimulationDisplay'; import { ExtractionSimulationDisplay } from './ExtractionSimulationDisplay';
import { ProductionNode } from './ExtractionSimulation'; import { ExtractionSimulationTooltip } from './ExtractionSimulationTooltip';
import { Collapse, Box, Stack } from "@mui/material"; import { Collapse, Box, Stack } from "@mui/material";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import Table from "@mui/material/Table";
import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import TableHead from "@mui/material/TableHead";
import TableBody from "@mui/material/TableBody";
const Transition = forwardRef(function Transition( const Transition = forwardRef(function Transition(
props: TransitionProps & { props: TransitionProps & {
@@ -34,14 +35,28 @@ const Transition = forwardRef(function Transition(
return <Slide direction="up" ref={ref} {...props} />; return <Slide direction="up" ref={ref} {...props} />;
}); });
interface SchematicInput {
type_id: number;
quantity: number;
}
interface SchematicOutput {
type_id: number;
quantity: number;
}
export const PlanetTableRow = ({ export const PlanetTableRow = ({
planet, planet,
character, character,
planetDetails,
}: { }: {
planet: PlanetWithInfo; planet: PlanetWithInfo;
character: AccessToken; character: AccessToken;
planetDetails: PlanetCalculations;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { showProductIcons, extractionTimeMode, alertMode, minExtractionRate } = useContext(SessionContext);
const { colors } = useContext(ColorContext);
const [planetRenderOpen, setPlanetRenderOpen] = useState(false); const [planetRenderOpen, setPlanetRenderOpen] = useState(false);
const [planetConfigOpen, setPlanetConfigOpen] = useState(false); const [planetConfigOpen, setPlanetConfigOpen] = useState(false);
@@ -72,74 +87,101 @@ export const PlanetTableRow = ({
setPlanetConfigOpen(false); setPlanetConfigOpen(false);
}; };
const { piPrices, alertMode } = useContext(SessionContext); const { piPrices, updatePlanetConfig, readPlanetConfig } = useContext(SessionContext);
const planetInfo = planet.info; const planetInfo = planet.info;
const planetInfoUniverse = planet.infoUniverse; const planetInfoUniverse = planet.infoUniverse;
const { expired, extractors, localProduction, localImports, localExports } = const planetConfig = readPlanetConfig({
planetCalculations(planet); characterId: character.character.characterId,
const planetConfig = character.planetConfig.find( planetId: planet.planet_id,
(p) => p.planetId === planet.planet_id, });
);
const { colors } = useContext(ColorContext);
// Convert local production to ProductionNode array for simulation
const productionNodes: ProductionNode[] = Array.from(localProduction).map(([schematicId, schematic]) => ({
schematicId: schematicId,
typeId: schematic.outputs[0].type_id,
name: schematic.name,
inputs: schematic.inputs.map(input => ({
typeId: input.type_id,
quantity: input.quantity
})),
outputs: schematic.outputs.map(output => ({
typeId: output.type_id,
quantity: output.quantity
})),
cycleTime: schematic.cycle_time
}));
// Convert Map to Array for schematic IDs const handleExcludeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const installedSchematicIds = Array.from(localProduction.values()).map(p => p.schematic_id); updatePlanetConfig({
...planetConfig,
// Get extractor head types safely excludeFromTotals: event.target.checked,
const extractedTypeIds = extractors });
.map(e => e.extractor_details?.product_type_id) };
.filter((id): id is number => id !== undefined);
// Get storage facilities // Check if there are any alerts
const storageFacilities = planetInfo.pins.filter(pin => const hasLowExtractionRate = planetDetails.extractorAverages.length > 0 && minExtractionRate > 0 && planetDetails.extractorAverages.some(avg => avg.averagePerHour < minExtractionRate);
STORAGE_IDS().some(storage => storage.type_id === pin.type_id) const hasAlerts = alertMode && (
planetDetails.expired ||
planetDetails.storageInfo.some(storage => storage.fillRate > 60) ||
planetDetails.importDepletionTimes.some(depletion => depletion.hoursUntilDepletion < 24) ||
planetDetails.hasLargeExtractorDifference ||
hasLowExtractionRate
); );
const getStorageInfo = (pin: any) => { // If in alert mode and no alerts, hide the row
if (!pin || !pin.contents) return null; if (alertMode && !hasAlerts) {
return null;
}
const storageType = PI_TYPES_MAP[pin.type_id].name; const renderProductDisplay = (typeId: number, amount?: number) => {
const storageCapacity = STORAGE_CAPACITIES[pin.type_id] || 0; if (!typeId || !PI_TYPES_MAP[typeId]) {
return (
// Calculate total volume of stored products for this specific pin <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
const totalVolume = (pin.contents || []) <Typography fontSize={theme.custom.smallText} color="text.secondary">
.reduce((sum: number, item: any) => { No product
const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0; </Typography>
return sum + (item.amount * volume); {amount !== undefined && (
}, 0); <Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px", flexShrink: 0 }}>
{amount}
</Typography>
)}
</div>
);
}
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0; if (showProductIcons) {
return (
return { <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
type: storageType, <Image
capacity: storageCapacity, src={`${EVE_IMAGE_URL}/types/${typeId}/icon?size=32`}
used: totalVolume, alt={PI_TYPES_MAP[typeId].name}
fillRate: fillRate width={32}
}; height={32}
style={{ marginRight: "5px" }}
/>
{amount !== undefined && (
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px", flexShrink: 0 }}>
{amount}
</Typography>
)}
</div>
);
}
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
<Typography fontSize={theme.custom.smallText}>
{PI_TYPES_MAP[typeId].name}
</Typography>
{amount !== undefined && (
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px", flexShrink: 0 }}>
{amount}
</Typography>
)}
</div>
);
}; };
return ( return (
<> <>
<TableRow <TableRow
style={{ visibility: alertModeVisibility(alertMode, expired) }} style={{ visibility: planetDetails.visibility }}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }} sx={{
"&:last-child td, &:last-child th": { border: 0 },
cursor: 'pointer',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
onClick={(e: React.MouseEvent<HTMLTableRowElement>) => {
if (!(e.target as HTMLElement).closest('.clickable-cell')) return;
setSimulationOpen(!simulationOpen);
}}
> >
<TableCell component="th" scope="row"> <TableCell component="th" scope="row" className="clickable-cell">
<Tooltip <Tooltip
title={`${ title={`${
planet.planet_type.charAt(0).toUpperCase() + planet.planet_type.charAt(0).toUpperCase() +
@@ -154,18 +196,75 @@ export const PlanetTableRow = ({
height={theme.custom.cardImageSize / 6} height={theme.custom.cardImageSize / 6}
style={{ marginRight: "5px" }} style={{ marginRight: "5px" }}
/> />
{planetInfoUniverse?.name} <Tooltip
placement="right"
title={
planetDetails.extractors.length > 0 ? (
<ExtractionSimulationTooltip
extractors={planetDetails.extractors
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
.map(e => ({
typeId: e.extractor_details!.product_type_id!,
baseValue: e.extractor_details!.qty_per_cycle!,
cycleTime: e.extractor_details!.cycle_time || 3600,
installTime: e.install_time ?? "",
expiryTime: e.expiry_time ?? ""
}))}
/>
) : null
}
componentsProps={{
tooltip: {
sx: {
bgcolor: 'background.paper',
'& .MuiTooltip-arrow': {
color: 'background.paper',
},
maxWidth: 'none',
width: 'fit-content'
}
}
}}
>
<Stack spacing={0}>
<Typography
fontSize={theme.custom.smallText}
color={(planetDetails.hasLargeExtractorDifference || hasLowExtractionRate) ? 'error' : 'inherit'}
>
{planetInfoUniverse?.name}
</Typography>
{planetDetails.hasLargeExtractorDifference && (
<Typography
fontSize={theme.custom.smallText}
color="error"
sx={{ opacity: 0.7 }}
>
off-balance
</Typography>
)}
{hasLowExtractionRate && (
<Typography
fontSize={theme.custom.smallText}
color="error"
sx={{ opacity: 0.7 }}
>
low-extraction
</Typography>
)}
</Stack>
</Tooltip>
</div> </div>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
<TableCell>{planet.upgrade_level}</TableCell> <TableCell className="clickable-cell">{planet.upgrade_level}</TableCell>
<TableCell> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{extractors.map((e, idx) => { {planetDetails.extractors.length === 0 &&<Typography fontSize={theme.custom.smallText}>No extractors</Typography>}
{planetDetails.extractors.map((e, idx) => {
return ( return (
<div <div
key={`${e}-${idx}-${character.character.characterId}`} key={`${e}-${idx}-${character.character.characterId}`}
style={{ display: "flex" }} style={{ display: "flex", alignItems: "center" }}
> >
<Typography <Typography
color={timeColor(e.expiry_time, colors)} color={timeColor(e.expiry_time, colors)}
@@ -173,88 +272,113 @@ export const PlanetTableRow = ({
paddingRight={1} paddingRight={1}
> >
{e ? ( {e ? (
<Countdown extractionTimeMode ? (
overtime={true} DateTime.fromISO(e.expiry_time ?? "").toFormat('yyyy-MM-dd HH:mm:ss')
date={DateTime.fromISO(e.expiry_time ?? "").toMillis()} ) : (
/> <Countdown
overtime={true}
date={DateTime.fromISO(e.expiry_time ?? "").toMillis()}
/>
)
) : ( ) : (
"STOPPED" "STOPPED"
)} )}
</Typography> </Typography>
<Typography fontSize={theme.custom.smallText}> {renderProductDisplay(e.extractor_details?.product_type_id ?? 0)}
{
PI_TYPES_MAP[e.extractor_details?.product_type_id ?? 0]
?.name
}
</Typography>
</div> </div>
); );
})} })}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{Array.from(localProduction).map((schematic, idx) => { {Array.from(planetDetails.localProduction).map((schematic, idx) => {
return ( return (
<Typography <div
key={`prod-${character.character.characterId}-${planet.planet_id}-${idx}`} key={`prod-${character.character.characterId}-${planet.planet_id}-${idx}`}
fontSize={theme.custom.smallText} style={{ display: "flex", alignItems: "center" }}
> >
{schematic[1].name} {renderProductDisplay(schematic[1].outputs[0].type_id)}
</Typography> </div>
); );
})} })}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{localImports.map((i) => ( {planetDetails.localImports.map((i) => {
<Typography const depletionTime = planetDetails.importDepletionTimes.find(d => d.typeId === i.type_id);
key={`import-${character.character.characterId}-${planet.planet_id}-${i.type_id}`} return (
fontSize={theme.custom.smallText} <div
key={`import-${character.character.characterId}-${planet.planet_id}-${i.type_id}`}
style={{ display: "flex", alignItems: "center" }}
>
<Tooltip title={
<>
<div>Will be depleted in {depletionTime?.hoursUntilDepletion.toFixed(1)} hours</div>
<div>Monthly cost: {depletionTime?.monthlyCost.toFixed(2)}M ISK</div>
</>
}>
<div style={{ display: "flex", alignItems: "center" }}>
{renderProductDisplay(i.type_id, i.quantity * i.factoryCount)}
{depletionTime && (
<Typography
fontSize={theme.custom.smallText}
color={depletionTime.hoursUntilDepletion < 24 ? 'error' : depletionTime.hoursUntilDepletion < 48 ? 'warning' : 'success'}
sx={{ ml: 1 }}
>
({depletionTime.hoursUntilDepletion.toFixed(1)}h)
</Typography>
)}
</div>
</Tooltip>
</div>
);
})}
</div>
</TableCell>
<TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}>
{planetDetails.localExports.map((exports) => (
<div
key={`export-${character.character.characterId}-${planet.planet_id}-${exports.type_id}`}
style={{ display: "flex", alignItems: "center" }}
> >
{PI_TYPES_MAP[i.type_id].name} ({i.quantity}/h) {renderProductDisplay(exports.type_id, exports.quantity * exports.factoryCount)}
</Typography> </div>
))} ))}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{localExports.map((exports) => ( {planetDetails.localExports.map((exports) => (
<Typography <FormControlLabel
key={`export-${character.character.characterId}-${planet.planet_id}-${exports.typeId}`} key={`export-excluded-${character.character.characterId}-${planet.planet_id}-${exports.type_id}`}
fontSize={theme.custom.smallText} control={
> <Checkbox
{PI_TYPES_MAP[exports.typeId].name} checked={planetConfig.excludeFromTotals}
</Typography> onChange={handleExcludeChange}
size="small"
/>
}
label=""
/>
))} ))}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column" }}>
{localExports.map((exports) => ( {planetDetails.localExports.map((exports) => (
<Typography <Typography
key={`export-excluded-${character.character.characterId}-${planet.planet_id}-${exports.typeId}`} key={`export-uph-${character.character.characterId}-${planet.planet_id}-${exports.type_id}`}
fontSize={theme.custom.smallText} fontSize={theme.custom.smallText}
> >
{planetConfig?.excludeFromTotals ? "ex" : ""} {exports.quantity * exports.factoryCount}
</Typography> </Typography>
))} ))}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}>
{localExports.map((exports) => (
<Typography
key={`export-uph-${character.character.characterId}-${planet.planet_id}-${exports.typeId}`}
fontSize={theme.custom.smallText}
>
{exports.amount}
</Typography>
))}
</div>
</TableCell>
<TableCell>
<div <div
style={{ style={{
display: "flex", display: "flex",
@@ -263,11 +387,11 @@ export const PlanetTableRow = ({
textAlign: "end", textAlign: "end",
}} }}
> >
{localExports.map((e) => { {planetDetails.localExports.map((e) => {
const valueInMillions = const valueInMillions =
(((piPrices?.appraisal.items.find((a) => a.typeID === e.typeId) (((piPrices?.appraisal.items.find((a) => a.typeID === e.type_id)
?.prices.sell.min ?? 0) * ?.prices.sell.min ?? 0) *
e.amount) / e.quantity * e.factoryCount) /
1000000) * 1000000) *
24 * 24 *
30; 30;
@@ -278,7 +402,7 @@ export const PlanetTableRow = ({
return ( return (
<Typography <Typography
key={`export-praisal-${character.character.characterId}-${planet.planet_id}-${e.typeId}`} key={`export-praisal-${character.character.characterId}-${planet.planet_id}-${e.type_id}`}
fontSize={theme.custom.smallText} fontSize={theme.custom.smallText}
> >
{displayValue} {displayValue}
@@ -287,44 +411,122 @@ export const PlanetTableRow = ({
})} })}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="clickable-cell">
<div style={{ display: "flex", flexDirection: "column" }}> <Tooltip
{storageFacilities title={
.sort((a, b) => { <Box sx={{ p: 1 }}>
const isALaunchpad = a.type_id === 2256 || a.type_id === 2542 || a.type_id === 2543 || a.type_id === 2544 || a.type_id === 2552 || a.type_id === 2555 || a.type_id === 2556 || a.type_id === 2557; <Typography variant="subtitle2" sx={{ mb: 1 }}>
const isBLaunchpad = b.type_id === 2256 || b.type_id === 2542 || b.type_id === 2543 || b.type_id === 2544 || b.type_id === 2552 || b.type_id === 2555 || b.type_id === 2556 || b.type_id === 2557; Storage Facilities
return isALaunchpad === isBLaunchpad ? 0 : isALaunchpad ? -1 : 1; </Typography>
}) <Table size="small">
.map((storage) => { <TableHead>
const storageInfo = getStorageInfo(storage); <TableRow>
if (!storageInfo) return null; <TableCell>Type</TableCell>
<TableCell align="right">Capacity</TableCell>
const isLaunchpad = storage.type_id === 2256 || <TableCell align="right">Used</TableCell>
storage.type_id === 2542 || <TableCell align="right">Fill Rate</TableCell>
storage.type_id === 2543 || <TableCell align="right">Value</TableCell>
storage.type_id === 2544 || </TableRow>
storage.type_id === 2552 || </TableHead>
storage.type_id === 2555 || <TableBody>
storage.type_id === 2556 || {planetDetails.storageInfo
storage.type_id === 2557; .map(storage => ({
...storage,
const fillRate = storageInfo.fillRate; isLaunchpad: LAUNCHPAD_IDS.includes(storage.type_id)
const color = fillRate > 95 ? '#ff0000' : fillRate > 80 ? '#ffd700' : 'inherit'; }))
.sort((a, b) => (b.isLaunchpad ? 1 : 0) - (a.isLaunchpad ? 1 : 0))
return ( .map((storage, idx) => {
<div key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.pin_id}`} style={{ display: "flex", alignItems: "center" }}> const fillRate = storage.fillRate;
<Typography fontSize={theme.custom.smallText} style={{ marginRight: "5px" }}> const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
{isLaunchpad ? 'L' : 'S'} const contents = planet.info.pins.find(p => p.pin_id === storage.pin_id)?.contents || [];
</Typography>
<Typography fontSize={theme.custom.smallText} style={{ color }}> return (
{fillRate.toFixed(1)}% <React.Fragment key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`}>
</Typography> <TableRow>
</div> <TableCell>{storage.isLaunchpad ? 'Launchpad' : 'Storage'}</TableCell>
); <TableCell align="right">{storage.capacity.toFixed(1)} m³</TableCell>
})} <TableCell align="right">{storage.used.toFixed(1)} m³</TableCell>
</div> <TableCell align="right" sx={{ color }}>{fillRate.toFixed(1)}%</TableCell>
<TableCell align="right">
{storage.value > 0 ? (
storage.value >= 1000000000
? `${(storage.value / 1000000000).toFixed(2)} B`
: `${(storage.value / 1000000).toFixed(0)} M`
) : '-'} ISK
</TableCell>
</TableRow>
{contents.length > 0 && (
<TableRow>
<TableCell colSpan={5} sx={{ pt: 0, pb: 0 }}>
<Table size="small">
<TableBody>
{contents.map((content, idy) => (
<TableRow key={`content-${character.character.characterId}-${planet.planet_id}-${storage.type}-${content.type_id}-${idx}-${idy}`}>
<TableCell sx={{ pl: 2 }}>
{PI_TYPES_MAP[content.type_id]?.name}
</TableCell>
<TableCell align="right" colSpan={4}>
{content.amount.toFixed(1)} units
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</Box>
}
componentsProps={{
tooltip: {
sx: {
bgcolor: 'background.paper',
'& .MuiTooltip-arrow': {
color: 'background.paper',
},
maxWidth: 'none',
width: 'fit-content'
}
}
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
{planetDetails.storageInfo.length === 0 &&<Typography fontSize={theme.custom.smallText}>No storage</Typography>}
{planetDetails.storageInfo
.map(storage => ({
...storage,
isLaunchpad: LAUNCHPAD_IDS.includes(storage.type_id)
}))
.sort((a, b) => (b.isLaunchpad ? 1 : 0) - (a.isLaunchpad ? 1 : 0))
.map((storage, idx) => {
const fillRate = storage.fillRate;
const color = fillRate > 90 ? '#ff0000' : fillRate > 80 ? '#ffa500' : fillRate > 60 ? '#ffd700' : 'inherit';
return (
<div key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`} style={{ display: "flex", alignItems: "center" }}>
<Typography fontSize={theme.custom.smallText} style={{ marginRight: "5px" }}>
{storage.isLaunchpad ? 'L' : 'S'}
</Typography>
<Typography fontSize={theme.custom.smallText} style={{ color }}>
{fillRate.toFixed(1)}%
</Typography>
{storage.value > 0 && (
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px" }}>
({Math.round(storage.value / 1000000)}M)
</Typography>
)}
</div>
);
})}
</div>
</Tooltip>
</TableCell> </TableCell>
<TableCell> <TableCell className="menu-cell">
<IconButton <IconButton
aria-label="more" aria-label="more"
aria-controls="planet-menu" aria-controls="planet-menu"
@@ -346,14 +548,6 @@ export const PlanetTableRow = ({
}}> }}>
Configure Planet Configure Planet
</MenuItem> </MenuItem>
{extractors.length > 0 && (
<MenuItem onClick={() => {
setSimulationOpen(!simulationOpen);
handleMenuClose();
}}>
{simulationOpen ? 'Hide Extraction Simulation' : 'Show Extraction Simulation'}
</MenuItem>
)}
<MenuItem onClick={() => { <MenuItem onClick={() => {
handle3DrenderOpen(); handle3DrenderOpen();
handleMenuClose(); handleMenuClose();
@@ -364,11 +558,11 @@ export const PlanetTableRow = ({
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell colSpan={6} style={{ paddingBottom: 0, paddingTop: 0 }}> <TableCell colSpan={6} style={{ paddingBottom: 0, paddingTop: 0, border: 'none' }}>
<Collapse in={simulationOpen} timeout="auto" unmountOnExit> <Collapse in={simulationOpen} timeout="auto" unmountOnExit>
<Box sx={{ my: 2 }}> <Box sx={{ my: 2 }}>
<ExtractionSimulationDisplay <ExtractionSimulationDisplay
extractors={extractors extractors={planetDetails.extractors
.filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle) .filter(e => e.extractor_details?.product_type_id && e.extractor_details?.qty_per_cycle)
.map(e => ({ .map(e => ({
typeId: e.extractor_details!.product_type_id!, typeId: e.extractor_details!.product_type_id!,
@@ -377,7 +571,21 @@ export const PlanetTableRow = ({
installTime: e.install_time ?? "", installTime: e.install_time ?? "",
expiryTime: e.expiry_time ?? "" expiryTime: e.expiry_time ?? ""
}))} }))}
productionNodes={productionNodes} productionNodes={Array.from(planetDetails.localProduction).map(([schematicId, schematic]) => ({
schematicId: schematicId,
typeId: schematic.outputs[0].type_id,
name: schematic.name,
inputs: schematic.inputs.map((input: SchematicInput) => ({
typeId: input.type_id,
quantity: input.quantity
})),
outputs: schematic.outputs.map((output: SchematicOutput) => ({
typeId: output.type_id,
quantity: output.quantity
})),
cycleTime: schematic.cycle_time,
factoryCount: schematic.factoryCount || 1
}))}
/> />
</Box> </Box>
</Collapse> </Collapse>
@@ -1,5 +1,5 @@
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { Stack, Tooltip, Typography, styled, useTheme } from "@mui/material"; import { IconButton, Stack, Tooltip, Typography, styled, useTheme } from "@mui/material";
import { PlanetCard } from "./PlanetCard"; import { PlanetCard } from "./PlanetCard";
import { NoPlanetCard } from "./NoPlanetCard"; import { NoPlanetCard } from "./NoPlanetCard";
import Table from "@mui/material/Table"; import Table from "@mui/material/Table";
@@ -10,7 +10,8 @@ import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow"; import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import { PlanetTableRow } from "./PlanetTableRow"; import { PlanetTableRow } from "./PlanetTableRow";
import Image from "next/image"; import { Settings } from "@mui/icons-material";
import { PlanetCalculations } from "@/types/planet";
const StackItem = styled(Stack)(({ theme }) => ({ const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2, ...theme.typography.body2,
@@ -22,8 +23,10 @@ const StackItem = styled(Stack)(({ theme }) => ({
const PlanetaryIteractionTable = ({ const PlanetaryIteractionTable = ({
character, character,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planetDetails: Record<number, PlanetCalculations>;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@@ -36,8 +39,8 @@ const PlanetaryIteractionTable = ({
return ( return (
<StackItem width="100%"> <StackItem width="100%">
<TableContainer component={Paper}> <TableContainer component={Paper} sx={{ width: '100%' }}>
<Table size="small" aria-label="a dense table"> <Table size="small" aria-label="a dense table" sx={{ width: '100%' }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell width="8%"> <TableCell width="8%">
@@ -84,8 +87,8 @@ const PlanetaryIteractionTable = ({
</Tooltip> </Tooltip>
</TableCell> </TableCell>
<TableCell width="2%"> <TableCell width="2%">
<Tooltip title="How many units per hour factories are producing"> <Tooltip title="How many units per hour factories are producing on this planet">
<Typography fontSize={theme.custom.smallText}>u/h</Typography> <Typography fontSize={theme.custom.smallText}>uph</Typography>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
<TableCell width="4%" align="right"> <TableCell width="4%" align="right">
@@ -98,10 +101,17 @@ const PlanetaryIteractionTable = ({
<TableCell width="10%"> <TableCell width="10%">
<Tooltip title="Storage facility fill rate"> <Tooltip title="Storage facility fill rate">
<Typography fontSize={theme.custom.smallText}> <Typography fontSize={theme.custom.smallText}>
Storage Fill rate Storage%
</Typography> </Typography>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
<TableCell>
<Tooltip title="Planet settings">
<IconButton aria-label="settings">
<Settings fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@@ -110,6 +120,7 @@ const PlanetaryIteractionTable = ({
key={`${character.character.characterId}-${planet.planet_id}`} key={`${character.character.characterId}-${planet.planet_id}`}
planet={planet} planet={planet}
character={character} character={character}
planetDetails={planetDetails[planet.planet_id]}
/> />
))} ))}
</TableBody> </TableBody>
@@ -121,8 +132,10 @@ const PlanetaryIteractionTable = ({
const PlanetaryInteractionIconsRow = ({ const PlanetaryInteractionIconsRow = ({
character, character,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planetDetails: Record<number, PlanetCalculations>;
}) => { }) => {
return ( return (
<StackItem> <StackItem>
@@ -132,6 +145,7 @@ const PlanetaryInteractionIconsRow = ({
key={`${character.character.characterId}-${planet.planet_id}`} key={`${character.character.characterId}-${planet.planet_id}`}
planet={planet} planet={planet}
character={character} character={character}
planetDetails={planetDetails[planet.planet_id]}
/> />
))} ))}
{Array.from(Array(6 - character.planets.length).keys()).map((i, id) => ( {Array.from(Array(6 - character.planets.length).keys()).map((i, id) => (
@@ -146,14 +160,16 @@ const PlanetaryInteractionIconsRow = ({
export const PlanetaryInteractionRow = ({ export const PlanetaryInteractionRow = ({
character, character,
planetDetails,
}: { }: {
character: AccessToken; character: AccessToken;
planetDetails: Record<number, PlanetCalculations>;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
return theme.custom.compactMode ? ( return theme.custom.compactMode ? (
<PlanetaryInteractionIconsRow character={character} /> <div style={{ marginTop: "1.2rem" }}><PlanetaryInteractionIconsRow character={character} planetDetails={planetDetails} /></div>
) : ( ) : (
<PlanetaryIteractionTable character={character} /> <div style={{ marginTop: "1.4rem" }}><PlanetaryIteractionTable character={character} planetDetails={planetDetails} /></div>
); );
}; };
@@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import { Box, Paper, Typography, Grid, Stack, Divider } from '@mui/material'; import { Box, Paper, Typography, Grid, Stack } from '@mui/material';
import { EVE_IMAGE_URL } from '@/const'; import { EVE_IMAGE_URL } from '@/const';
import { PI_TYPES_MAP } from '@/const'; import { PI_TYPES_MAP } from '@/const';
import { DateTime } from 'luxon';
import Countdown from 'react-countdown';
import Image from 'next/image';
interface Factory { interface Factory {
schematic_id: number; schematic_id: number;
@@ -29,6 +32,7 @@ interface ProductionChainVisualizationProps {
typeId: number; typeId: number;
baseValue: number; baseValue: number;
cycleTime: number; cycleTime: number;
expiryTime: string;
}>; }>;
factories: Factory[]; factories: Factory[];
extractorTotals: Map<number, number>; extractorTotals: Map<number, number>;
@@ -39,8 +43,10 @@ export const ProductionChainVisualization: React.FC<ProductionChainVisualization
extractedTypeIds, extractedTypeIds,
factories, factories,
extractorTotals, extractorTotals,
productionNodes productionNodes,
extractors
}) => { }) => {
// Get all type IDs involved in the production chain // Get all type IDs involved in the production chain
const allTypeIds = new Set<number>(); const allTypeIds = new Set<number>();
const requiredInputs = new Set<number>(); const requiredInputs = new Set<number>();
@@ -214,9 +220,15 @@ export const ProductionChainVisualization: React.FC<ProductionChainVisualization
// Get factory count for a type // Get factory count for a type
const getFactoryCount = (typeId: number): number => { const getFactoryCount = (typeId: number): number => {
const node = nodesByOutput.get(typeId); // First find the node that produces this type
if (!node) return 0; const producingNode = productionNodes.find(node =>
return factories.find(f => f.schematic_id === node.schematicId)?.count ?? 0; node.outputs.some(output => output.typeId === typeId)
);
if (!producingNode) return 0;
// Then find the factory count for this schematic
return factories.find(f => f.schematic_id === producingNode.schematicId)?.count ?? 0;
}; };
// Get input requirements for a type // Get input requirements for a type
@@ -232,6 +244,11 @@ export const ProductionChainVisualization: React.FC<ProductionChainVisualization
return node?.cycleTime; return node?.cycleTime;
}; };
// Get extractor expiry time for a type
const getExtractorExpiryTime = (typeId: number): string | undefined => {
return extractors.find(e => e.typeId === typeId)?.expiryTime;
};
return ( return (
<Paper sx={{ p: 2, my: 2 }}> <Paper sx={{ p: 2, my: 2 }}>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
@@ -258,6 +275,7 @@ export const ProductionChainVisualization: React.FC<ProductionChainVisualization
const consumption = consumptionTotals.get(typeId) ?? 0; const consumption = consumptionTotals.get(typeId) ?? 0;
const inputs = getInputRequirements(typeId); const inputs = getInputRequirements(typeId);
const cycleTime = getSchematicCycleTime(typeId); const cycleTime = getSchematicCycleTime(typeId);
const expiryTime = getExtractorExpiryTime(typeId);
return ( return (
<Grid item key={typeId} xs={12} sm={6} md={4}> <Grid item key={typeId} xs={12} sm={6} md={4}>
@@ -274,7 +292,7 @@ export const ProductionChainVisualization: React.FC<ProductionChainVisualization
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<img <Image
src={`${EVE_IMAGE_URL}/types/${typeId}/icon`} src={`${EVE_IMAGE_URL}/types/${typeId}/icon`}
alt={type?.name ?? `Type ${typeId}`} alt={type?.name ?? `Type ${typeId}`}
width={48} width={48}
@@ -292,19 +310,49 @@ export const ProductionChainVisualization: React.FC<ProductionChainVisualization
</Box> </Box>
</Box> </Box>
<Stack spacing={0.5}> <Stack spacing={0.5}>
{production > 0 && ( {factoryCount > 0 && (
<> <Typography variant="caption" color="info.main">
<Typography variant="caption" color="success.main"> Factories: {factoryCount}
Production: {production.toFixed(1)} units total </Typography>
)}
{inputs.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Inputs:
</Typography> </Typography>
</> {inputs.map(input => (
<Typography
key={input.typeId}
variant="caption"
sx={{ display: 'block', ml: 1 }}
>
{PI_TYPES_MAP[input.typeId]?.name}: {input.quantity * factoryCount}/cycle
</Typography>
))}
</Box>
)}
{expiryTime && (
<Box>
<Typography variant="caption" color="text.secondary">
Extractor expires in:
</Typography>
<Typography variant="caption" sx={{ ml: 1 }}>
<Countdown
overtime={true}
date={DateTime.fromISO(expiryTime).toMillis()}
/>
</Typography>
</Box>
)}
{production > 0 && (
<Typography variant="caption" color="success.main">
Production: {production.toFixed(1)} units total
</Typography>
)} )}
{consumption > 0 && ( {consumption > 0 && (
<> <Typography variant="caption" color="error.main">
<Typography variant="caption" color="error.main"> Consumption: {consumption.toFixed(1)} units total
Consumption: {consumption.toFixed(1)} units total </Typography>
</Typography>
</>
)} )}
{isImported && ( {isImported && (
<> <>
@@ -1,6 +1,7 @@
import { import {
ColorContext, ColorContext,
ColorSelectionType, ColorSelectionType,
SessionContext,
} from "@/app/context/Context"; } from "@/app/context/Context";
import { import {
Button, Button,
@@ -10,14 +11,18 @@ import {
DialogTitle, DialogTitle,
Tooltip, Tooltip,
Typography, Typography,
TextField,
Box,
FormControlLabel,
Checkbox,
} from "@mui/material"; } from "@mui/material";
import { ColorChangeHandler, ColorResult, CompactPicker } from "react-color"; import { ColorChangeHandler, ColorResult, CompactPicker } from "react-color";
import React from "react"; import React, { useState, useContext } from "react";
import { useContext } from "react";
export const SettingsButton = () => { export const SettingsButton = () => {
const { colors, setColors } = useContext(ColorContext); const { colors, setColors } = useContext(ColorContext);
const [open, setOpen] = React.useState(false); const { balanceThreshold, setBalanceThreshold, minExtractionRate, setMinExtractionRate, showProductIcons, setShowProductIcons } = useContext(SessionContext);
const [open, setOpen] = useState(false);
const handleClickOpen = () => { const handleClickOpen = () => {
setOpen(true); setOpen(true);
@@ -26,6 +31,7 @@ export const SettingsButton = () => {
const handleClose = () => { const handleClose = () => {
setOpen(false); setOpen(false);
}; };
const handleColorSelection = (key: string, currentColors: ColorSelectionType) => (selection: ColorResult) => { const handleColorSelection = (key: string, currentColors: ColorSelectionType) => (selection: ColorResult) => {
console.log(key, selection.hex) console.log(key, selection.hex)
setColors({ setColors({
@@ -34,10 +40,30 @@ export const SettingsButton = () => {
}) })
}; };
const handleBalanceThresholdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100000) {
setBalanceThreshold(value);
}
};
const handleShowProductIconsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setShowProductIcons(event.target.checked);
};
const handleMinExtractionRateChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100000) {
setMinExtractionRate(value);
}
};
return ( return (
<Tooltip title="Toggle settings dialog"> <Tooltip title="Toggle settings dialog">
<> <>
<Button onClick={handleClickOpen}>Settings</Button> <Button onClick={handleClickOpen} color="inherit">
Settings
</Button>
<Dialog <Dialog
open={open} open={open}
onClose={handleClose} onClose={handleClose}
@@ -45,9 +71,48 @@ export const SettingsButton = () => {
aria-describedby="alert-dialog-description" aria-describedby="alert-dialog-description"
> >
<DialogTitle id="alert-dialog-title"> <DialogTitle id="alert-dialog-title">
{"Override default timer colors"} {"Settings"}
</DialogTitle> </DialogTitle>
<DialogContent style={{ paddingTop: "1rem" }}> <DialogContent style={{ paddingTop: "1rem" }}>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Display Settings</Typography>
<FormControlLabel
control={
<Checkbox
checked={showProductIcons}
onChange={handleShowProductIconsChange}
/>
}
label="Show product icons instead of names"
/>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Balance Threshold</Typography>
<TextField
type="number"
value={balanceThreshold}
onChange={handleBalanceThresholdChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100000 }}
helperText="Set the threshold for balance alerts (0-100,000)"
error={balanceThreshold < 0 || balanceThreshold > 100000}
/>
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Minimum Extraction Rate</Typography>
<TextField
type="number"
value={minExtractionRate}
onChange={handleMinExtractionRateChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100000 }}
helperText="Alert if extraction per hour is below this value (0-100,000, 0 = disabled)"
error={minExtractionRate < 0 || minExtractionRate > 100000}
/>
</Box>
{Object.keys(colors).map((key) => { {Object.keys(colors).map((key) => {
return ( return (
<div key={`color-row-${key}`}> <div key={`color-row-${key}`}>
+207 -18
View File
@@ -1,5 +1,5 @@
import { SessionContext } from "@/app/context/Context"; import { SessionContext } from "@/app/context/Context";
import { PI_TYPES_MAP } from "@/const"; import { PI_TYPES_MAP, STORAGE_IDS } from "@/const";
import { planetCalculations } from "@/planets"; import { planetCalculations } from "@/planets";
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { import {
@@ -19,10 +19,12 @@ import {
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
TableSortLabel, TableSortLabel,
Box,
TextField,
} from "@mui/material"; } from "@mui/material";
import { useContext, useState } from "react"; import { useContext, useState, useEffect } from "react";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { argv0 } from "process"; import { DateTime } from "luxon";
const StackItem = styled(Stack)(({ theme }) => ({ const StackItem = styled(Stack)(({ theme }) => ({
...theme.typography.body2, ...theme.typography.body2,
@@ -41,13 +43,39 @@ const displayValue = (valueInMillions: number): string =>
? `${(valueInMillions / 1000).toFixed(2)} B` ? `${(valueInMillions / 1000).toFixed(2)} B`
: `${valueInMillions.toFixed(2)} M`; : `${valueInMillions.toFixed(2)} M`;
type SortBy = "name" | "perHour" | "price"; type SortBy = "name" | "perHour" | "storage" | "price" | "storagePrice" | "progress";
type SortDirection = "asc" | "desc"; type SortDirection = "asc" | "desc";
export const Summary = ({ characters }: { characters: AccessToken[] }) => { export const Summary = ({ characters }: { characters: AccessToken[] }) => {
const { piPrices } = useContext(SessionContext); const { piPrices } = useContext(SessionContext);
const [sortDirection, setSortDirection] = useState<SortDirection>("asc"); const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [sortBy, setSortBy] = useState<SortBy>("name"); const [sortBy, setSortBy] = useState<SortBy>("name");
const [startDate, setStartDate] = useState<string>(DateTime.now().startOf('day').toISO());
const [activityPercentage, setActivityPercentage] = useState<number>(100);
// Load saved values from localStorage on mount
useEffect(() => {
const savedDate = localStorage.getItem('productionStartDate');
if (savedDate) {
setStartDate(savedDate);
}
const savedActivity = localStorage.getItem('activityPercentage');
if (savedActivity) {
setActivityPercentage(parseFloat(savedActivity));
}
}, []);
// Save values to localStorage when they change
useEffect(() => {
localStorage.setItem('productionStartDate', startDate);
}, [startDate]);
useEffect(() => {
localStorage.setItem('activityPercentage', activityPercentage.toString());
}, [activityPercentage]);
// Calculate exports and storage amounts
const exports = characters.flatMap((char) => { const exports = characters.flatMap((char) => {
return char.planets return char.planets
.filter( .filter(
@@ -62,6 +90,28 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
}); });
}); });
// Calculate storage amounts
const storageAmounts = characters.reduce<Grouped>((totals, character) => {
character.planets
.filter(
(p) =>
!character.planetConfig.some(
(c) => c.planetId == p.planet_id && c.excludeFromTotals,
),
)
.forEach((planet) => {
planet.info.pins
.filter(pin => STORAGE_IDS().some(storage => storage.type_id === pin.type_id))
.forEach(storage => {
storage.contents?.forEach(content => {
const current = totals[content.type_id] || 0;
totals[content.type_id] = current + content.amount;
});
});
});
return totals;
}, {});
const groupedByMaterial = exports.reduce<Grouped>((totals, material) => { const groupedByMaterial = exports.reduce<Grouped>((totals, material) => {
const { typeId, amount } = material; const { typeId, amount } = material;
const newTotal = isNaN(totals[typeId]) ? amount : totals[typeId] + amount; const newTotal = isNaN(totals[typeId]) ? amount : totals[typeId] + amount;
@@ -69,27 +119,44 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
return totals; return totals;
}, {}); }, {});
const startDateTime = DateTime.fromISO(startDate);
const hoursSinceStart = DateTime.now().diff(startDateTime, 'hours').hours;
const withProductNameAndPrice = Object.keys(groupedByMaterial).map( const withProductNameAndPrice = Object.keys(groupedByMaterial).map(
(typeIdString) => { (typeIdString) => {
const typeId = parseInt(typeIdString); const typeId = parseInt(typeIdString);
const amount = groupedByMaterial[typeId]; const amount = groupedByMaterial[typeId];
const storageAmount = storageAmounts[typeId] || 0;
const adjustedAmount = amount * (activityPercentage / 100);
const valueInMillions = const valueInMillions =
(((piPrices?.appraisal.items.find((a) => a.typeID === typeId)?.prices (((piPrices?.appraisal.items.find((a) => a.typeID === typeId)?.prices
.sell.min ?? 0) * .sell.min ?? 0) *
amount) / adjustedAmount) /
1000000) * 1000000) *
24 * 24 *
30; 30;
const storageValueInMillions =
((piPrices?.appraisal.items.find((a) => a.typeID === typeId)?.prices
.sell.min ?? 0) *
storageAmount) /
1000000;
// Calculate expected production and progress
const expectedProduction = adjustedAmount * hoursSinceStart;
const progress = hoursSinceStart > 0 ? (storageAmount / expectedProduction) * 100 : 0;
return { return {
typeId, typeId,
amount, amount: adjustedAmount,
storageAmount,
materialName: PI_TYPES_MAP[typeId].name, materialName: PI_TYPES_MAP[typeId].name,
price: valueInMillions, price: valueInMillions,
storageValue: storageValueInMillions,
progress,
}; };
}, },
); );
const theme = useTheme();
return ( return (
<StackItem width="100%"> <StackItem width="100%">
@@ -98,14 +165,48 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
<h2>Totals</h2> <h2>Totals</h2>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<TableContainer component={Paper}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Table size="small" aria-label="a dense table"> <TextField
label="Production Start Date"
type="date"
value={DateTime.fromISO(startDate).toFormat('yyyy-MM-dd')}
onChange={(e) => {
const newDate = DateTime.fromFormat(e.target.value, 'yyyy-MM-dd').startOf('day').toISO();
if (newDate) setStartDate(newDate);
}}
InputLabelProps={{
shrink: true,
}}
size="small"
/>
<TextField
label="Activity %"
type="number"
value={activityPercentage}
onChange={(e) => {
const value = Math.min(100, Math.max(0, parseFloat(e.target.value) || 0));
setActivityPercentage(value);
}}
InputLabelProps={{
shrink: true,
}}
inputProps={{
min: 0,
max: 100,
step: 1,
}}
size="small"
sx={{ width: '100px' }}
/>
</Box>
<TableContainer component={Paper} sx={{ width: '100%' }}>
<Table size="small" aria-label="a dense table" sx={{ width: '100%' }}>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell width="40%"> <TableCell width="20%">
<Tooltip title="What exports factories are producing"> <Tooltip title="What exports factories are producing">
<TableSortLabel <TableSortLabel
active={true} active={sortBy === "name"}
direction={sortDirection} direction={sortDirection}
onClick={() => { onClick={() => {
setSortDirection( setSortDirection(
@@ -118,10 +219,10 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
</TableSortLabel> </TableSortLabel>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
<TableCell width="10%"> <TableCell width="10%" align="right">
<Tooltip title="How many units per hour factories are producing"> <Tooltip title={`Adjusted production rate (${activityPercentage}% activity)`}>
<TableSortLabel <TableSortLabel
active={true} active={sortBy === "perHour"}
direction={sortDirection} direction={sortDirection}
onClick={() => { onClick={() => {
setSortDirection( setSortDirection(
@@ -135,9 +236,41 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
</Tooltip> </Tooltip>
</TableCell> </TableCell>
<TableCell width="10%" align="right"> <TableCell width="10%" align="right">
<Tooltip title="How many million ISK per month this planet is exporting (Jita sell min)"> <Tooltip title="Amount currently in storage">
<TableSortLabel <TableSortLabel
active={true} active={sortBy === "storage"}
direction={sortDirection}
onClick={() => {
setSortDirection(
sortDirection === "asc" ? "desc" : "asc",
);
setSortBy("storage");
}}
>
Units in storage
</TableSortLabel>
</Tooltip>
</TableCell>
<TableCell width="15%" align="right">
<Tooltip title="Current ISK value of storage">
<TableSortLabel
active={sortBy === "storagePrice"}
direction={sortDirection}
onClick={() => {
setSortDirection(
sortDirection === "asc" ? "desc" : "asc",
);
setSortBy("storagePrice");
}}
>
Produced ISK value
</TableSortLabel>
</Tooltip>
</TableCell>
<TableCell width="15%" align="right">
<Tooltip title="Monthly ISK value from production">
<TableSortLabel
active={sortBy === "price"}
direction={sortDirection} direction={sortDirection}
onClick={() => { onClick={() => {
setSortDirection( setSortDirection(
@@ -146,7 +279,23 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
setSortBy("price"); setSortBy("price");
}} }}
> >
ISK/M Maximum possible ISK/M
</TableSortLabel>
</Tooltip>
</TableCell>
<TableCell width="15%" align="right">
<Tooltip title="Progress towards monthly production target">
<TableSortLabel
active={sortBy === "progress"}
direction={sortDirection}
onClick={() => {
setSortDirection(
sortDirection === "asc" ? "desc" : "asc",
);
setSortBy("progress");
}}
>
Progress from start date
</TableSortLabel> </TableSortLabel>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@@ -167,12 +316,30 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
if (sortDirection === "desc") if (sortDirection === "desc")
return a.amount > b.amount ? -1 : 1; return a.amount > b.amount ? -1 : 1;
} }
if (sortBy === "storage") {
if (sortDirection === "asc")
return a.storageAmount > b.storageAmount ? 1 : -1;
if (sortDirection === "desc")
return a.storageAmount > b.storageAmount ? -1 : 1;
}
if (sortBy === "price") { if (sortBy === "price") {
if (sortDirection === "asc") if (sortDirection === "asc")
return a.price > b.price ? 1 : -1; return a.price > b.price ? 1 : -1;
if (sortDirection === "desc") if (sortDirection === "desc")
return a.price > b.price ? -1 : 1; return a.price > b.price ? -1 : 1;
} }
if (sortBy === "storagePrice") {
if (sortDirection === "asc")
return a.storageValue > b.storageValue ? 1 : -1;
if (sortDirection === "desc")
return a.storageValue > b.storageValue ? -1 : 1;
}
if (sortBy === "progress") {
if (sortDirection === "asc")
return a.progress > b.progress ? 1 : -1;
if (sortDirection === "desc")
return a.progress > b.progress ? -1 : 1;
}
return 0; return 0;
}) })
.map((product) => ( .map((product) => (
@@ -180,7 +347,10 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
key={product.materialName} key={product.materialName}
material={product.materialName} material={product.materialName}
amount={product.amount} amount={product.amount}
storageAmount={product.storageAmount}
price={product.price} price={product.price}
storageValue={product.storageValue}
progress={product.progress}
/> />
))} ))}
<SummaryRow <SummaryRow
@@ -189,6 +359,10 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
(amount, p) => amount + p.price, (amount, p) => amount + p.price,
0, 0,
)} )}
storageValue={withProductNameAndPrice.reduce(
(amount, p) => amount + p.storageValue,
0,
)}
/> />
</TableBody> </TableBody>
</Table> </Table>
@@ -202,17 +376,32 @@ export const Summary = ({ characters }: { characters: AccessToken[] }) => {
const SummaryRow = ({ const SummaryRow = ({
material, material,
amount, amount,
storageAmount,
price, price,
storageValue,
progress,
}: { }: {
material: string; material: string;
amount?: number; amount?: number;
storageAmount?: number;
price: number; price: number;
storageValue?: number;
progress?: number;
}) => ( }) => (
<TableRow> <TableRow>
<TableCell component="th" scope="row"> <TableCell component="th" scope="row">
{material} {material}
</TableCell> </TableCell>
<TableCell>{amount}</TableCell> <TableCell align="right">{amount?.toFixed(1)}</TableCell>
<TableCell align="right">{storageAmount?.toFixed(1)}</TableCell>
<TableCell align="right">{storageValue !== undefined && displayValue(storageValue)}</TableCell>
<TableCell align="right">{displayValue(price)}</TableCell> <TableCell align="right">{displayValue(price)}</TableCell>
<TableCell align="right">
{progress !== undefined && (
<Typography color={progress >= 100 ? 'success.main' : progress >= 75 ? 'warning.main' : 'error.main'}>
{progress.toFixed(1)}%
</Typography>
)}
</TableCell>
</TableRow> </TableRow>
); );
@@ -1,23 +0,0 @@
import { Box, Button, Tooltip } from "@mui/material";
export const SupportButton = () => {
return (
<Box>
<Tooltip
title={`
Consider using code 'CALLIEVE' on EVE store checkout to support the project! Click to copy to clipboard ;)
`}
>
<Button
href=""
style={{ width: "100%" }}
sx={{ color: "white", display: "block" }}
onClick={() => {
navigator.clipboard.writeText("CALLIEVE");
}}
>
CALLIEVE
</Button>
</Tooltip>
</Box>
);
};
+19 -2
View File
@@ -1,7 +1,6 @@
import { EvePraisalResult } from "@/eve-praisal"; import { EvePraisalResult } from "@/eve-praisal";
import { AccessToken, CharacterUpdate } from "@/types"; import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
import { Dispatch, SetStateAction, createContext } from "react"; import { Dispatch, SetStateAction, createContext } from "react";
import { PlanetConfig } from "../components/PlanetConfig/PlanetConfigDialog";
export const CharacterContext = createContext<{ export const CharacterContext = createContext<{
characters: AccessToken[]; characters: AccessToken[];
@@ -27,6 +26,8 @@ export const SessionContext = createContext<{
togglePlanMode: () => void; togglePlanMode: () => void;
alertMode: boolean; alertMode: boolean;
toggleAlertMode: () => void; toggleAlertMode: () => void;
extractionTimeMode: boolean;
toggleExtractionTimeMode: () => void;
piPrices: EvePraisalResult | undefined; piPrices: EvePraisalResult | undefined;
updatePlanetConfig: (config: PlanetConfig) => void; updatePlanetConfig: (config: PlanetConfig) => void;
readPlanetConfig: ({ readPlanetConfig: ({
@@ -36,6 +37,12 @@ export const SessionContext = createContext<{
characterId: number; characterId: number;
planetId: number; planetId: number;
}) => PlanetConfig; }) => PlanetConfig;
balanceThreshold: number;
setBalanceThreshold: Dispatch<SetStateAction<number>>;
minExtractionRate: number;
setMinExtractionRate: Dispatch<SetStateAction<number>>;
showProductIcons: boolean;
setShowProductIcons: (show: boolean) => void;
}>({ }>({
sessionReady: false, sessionReady: false,
refreshSession: () => {}, refreshSession: () => {},
@@ -48,6 +55,8 @@ export const SessionContext = createContext<{
togglePlanMode: () => {}, togglePlanMode: () => {},
alertMode: false, alertMode: false,
toggleAlertMode: () => {}, toggleAlertMode: () => {},
extractionTimeMode: false,
toggleExtractionTimeMode: () => {},
piPrices: undefined, piPrices: undefined,
updatePlanetConfig: () => {}, updatePlanetConfig: () => {},
readPlanetConfig: ({ readPlanetConfig: ({
@@ -59,7 +68,14 @@ export const SessionContext = createContext<{
}) => { }) => {
return { characterId, planetId, excludeFromTotals: true }; return { characterId, planetId, excludeFromTotals: true };
}, },
balanceThreshold: 1000,
setBalanceThreshold: () => {},
minExtractionRate: 0,
setMinExtractionRate: () => {},
showProductIcons: false,
setShowProductIcons: () => {},
}); });
export type ColorSelectionType = { export type ColorSelectionType = {
defaultColor: string; defaultColor: string;
expiredColor: string; expiredColor: string;
@@ -81,6 +97,7 @@ export const defaultColors = {
dayColor: "#2F695A", dayColor: "#2F695A",
twoDaysColor: "#2F695A", twoDaysColor: "#2F695A",
}; };
export const ColorContext = createContext<{ export const ColorContext = createContext<{
colors: ColorSelectionType; colors: ColorSelectionType;
setColors: (colors: ColorSelectionType) => void; setColors: (colors: ColorSelectionType) => void;
+99 -43
View File
@@ -3,7 +3,7 @@ import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css"; import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
import { memo, useCallback, useEffect, useState, Suspense } from "react"; import { memo, useCallback, useEffect, useRef, useState, Suspense } from "react";
import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types"; import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types";
import { MainGrid } from "./components/MainGrid"; import { MainGrid } from "./components/MainGrid";
import { refreshToken } from "@/esi-sso"; import { refreshToken } from "@/esi-sso";
@@ -17,10 +17,27 @@ import {
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal"; import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets"; import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
import { PlanetConfig } from "./components/PlanetConfig/PlanetConfigDialog"; import { PlanetConfig } from "@/types";
import { saveCharacters as saveCharactersDB, loadCharacters } from "@/storage";
// Add batch processing utility
const processInBatches = async <T, R>(
items: T[],
batchSize: number,
processFn: (item: T) => Promise<R>
): Promise<R[]> => {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processFn));
results.push(...batchResults);
}
return results;
};
const Home = () => { const Home = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackHandled = useRef(false);
const [characters, setCharacters] = useState<AccessToken[]>([]); const [characters, setCharacters] = useState<AccessToken[]>([]);
const [sessionReady, setSessionReady] = useState(false); const [sessionReady, setSessionReady] = useState(false);
const [environment, setEnvironment] = useState<Env | undefined>(undefined); const [environment, setEnvironment] = useState<Env | undefined>(undefined);
@@ -29,6 +46,10 @@ const Home = () => {
const [piPrices, setPiPrices] = useState<EvePraisalResult | undefined>( const [piPrices, setPiPrices] = useState<EvePraisalResult | undefined>(
undefined, undefined,
); );
const [balanceThreshold, setBalanceThreshold] = useState(1000);
const [minExtractionRate, setMinExtractionRate] = useState(0);
const [showProductIcons, setShowProductIcons] = useState(false);
const [extractionTimeMode, setExtractionTimeMode] = useState(false);
const [colors, setColors] = useState<ColorSelectionType>(defaultColors); const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
const [alertMode, setAlertMode] = useState(false); const [alertMode, setAlertMode] = useState(false);
@@ -60,22 +81,29 @@ const Home = () => {
}; };
const refreshSession = async (characters: AccessToken[]) => { const refreshSession = async (characters: AccessToken[]) => {
return Promise.all( return processInBatches(characters, 50, async (c) => {
characters.map((c) => { try {
try { return await refreshToken(c);
return refreshToken(c); } catch {
} catch { return { ...c, needsLogin: true };
return { ...c, needsLogin: true }; }
} });
}),
);
}; };
const handleCallback = async ( const handleCallback = async (
characters: AccessToken[], characters: AccessToken[],
): Promise<AccessToken[]> => { ): Promise<AccessToken[]> => {
const code = searchParams?.get("code"); const code = searchParams?.get("code");
if (code) { const returnedState = searchParams?.get("state");
if (code && !callbackHandled.current) {
callbackHandled.current = true;
const expectedState = localStorage.getItem("oauth_state");
localStorage.removeItem("oauth_state");
if (!expectedState || returnedState !== expectedState) {
console.error("OAuth state mismatch — possible CSRF attack");
window.history.replaceState(null, "", "/");
return Promise.resolve(characters);
}
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
const res = await fetch(`api/token?code=${code}`); const res = await fetch(`api/token?code=${code}`);
const newCharacter: AccessToken = await res.json(); const newCharacter: AccessToken = await res.json();
@@ -92,40 +120,34 @@ const Home = () => {
return Promise.resolve(characters); return Promise.resolve(characters);
}; };
const initializeCharacters = useCallback((): AccessToken[] => { const initializeCharacters = useCallback(async (): Promise<AccessToken[]> => {
const localStorageCharacters = localStorage.getItem("characters"); return await loadCharacters();
if (localStorageCharacters) {
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
return characterArray.filter((c) => c.access_token && c.character);
}
return [];
}, []); }, []);
const initializeCharacterPlanets = ( const initializeCharacterPlanets = (
characters: AccessToken[], characters: AccessToken[],
): Promise<AccessToken[]> => ): Promise<AccessToken[]> =>
Promise.all( processInBatches(characters, 50, async (c) => {
characters.map(async (c) => { if (c.needsLogin || c.character === undefined)
if (c.needsLogin || c.character === undefined) return { ...c, planets: [] };
return { ...c, planets: [] }; const planets = await getPlanets(c);
const planets = await getPlanets(c); const planetsWithInfo: PlanetWithInfo[] = await processInBatches(
const planetsWithInfo: PlanetWithInfo[] = await Promise.all( planets,
planets.map(async (p) => ({ 3,
...p, async (p) => ({
info: await getPlanet(c, p), ...p,
infoUniverse: await getPlanetUniverse(p), info: await getPlanet(c, p),
})), infoUniverse: await getPlanetUniverse(p),
); })
return { );
...c, return {
planets: planetsWithInfo, ...c,
}; planets: planetsWithInfo,
}), };
); });
const saveCharacters = (characters: AccessToken[]): AccessToken[] => { const saveCharacters = async (characters: AccessToken[]): Promise<AccessToken[]> => {
localStorage.setItem("characters", JSON.stringify(characters)); return await saveCharactersDB(characters);
return characters;
}; };
const restoreCharacters = (characters: AccessToken[]) => { const restoreCharacters = (characters: AccessToken[]) => {
@@ -147,6 +169,10 @@ const Home = () => {
setAlertMode(!alertMode); setAlertMode(!alertMode);
}; };
const toggleExtractionTimeMode = () => {
setExtractionTimeMode(!extractionTimeMode);
};
const updatePlanetConfig = (config: PlanetConfig) => { const updatePlanetConfig = (config: PlanetConfig) => {
const charactersToSave = characters.map((c) => { const charactersToSave = characters.map((c) => {
if (c.character.characterId === config.characterId) { if (c.character.characterId === config.characterId) {
@@ -199,6 +225,17 @@ const Home = () => {
setAlertMode(JSON.parse(storedAlertMode)); setAlertMode(JSON.parse(storedAlertMode));
}, []); }, []);
useEffect(() => {
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
if (storedBalanceThreshold) {
setBalanceThreshold(parseInt(storedBalanceThreshold));
}
}, []);
useEffect(() => {
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
}, [balanceThreshold]);
useEffect(() => { useEffect(() => {
localStorage.setItem("compactMode", compactMode ? "true" : "false"); localStorage.setItem("compactMode", compactMode ? "true" : "false");
}, [compactMode]); }, [compactMode]);
@@ -211,6 +248,17 @@ const Home = () => {
localStorage.setItem("colors", JSON.stringify(colors)); localStorage.setItem("colors", JSON.stringify(colors));
}, [colors]); }, [colors]);
useEffect(() => {
const savedMode = localStorage.getItem('extractionTimeMode');
if (savedMode) {
setExtractionTimeMode(savedMode === 'true');
}
}, []);
useEffect(() => {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
}, [extractionTimeMode]);
useEffect(() => { useEffect(() => {
fetch("api/env") fetch("api/env")
.then((r) => r.json()) .then((r) => r.json())
@@ -236,9 +284,9 @@ const Home = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
const ESI_CACHE_TIME_MS = 600000; const ESI_CACHE_TIME_MS = 3000000;
const interval = setInterval(() => { const interval = setInterval(async () => {
const characters = initializeCharacters(); const characters = await initializeCharacters();
refreshSession(characters) refreshSession(characters)
.then(saveCharacters) .then(saveCharacters)
.then(initializeCharacterPlanets) .then(initializeCharacterPlanets)
@@ -262,8 +310,16 @@ const Home = () => {
piPrices, piPrices,
alertMode, alertMode,
toggleAlertMode, toggleAlertMode,
extractionTimeMode,
toggleExtractionTimeMode,
updatePlanetConfig, updatePlanetConfig,
readPlanetConfig, readPlanetConfig,
balanceThreshold,
setBalanceThreshold,
minExtractionRate,
setMinExtractionRate,
showProductIcons,
setShowProductIcons,
}} }}
> >
<CharacterContext.Provider <CharacterContext.Provider
+2
View File
@@ -1080,3 +1080,5 @@ export const STORAGE_CAPACITIES: Record<number, number> = {
2556: 10000, // Plasma Launchpad 2556: 10000, // Plasma Launchpad
2557: 10000, // Storm Launchpad 2557: 10000, // Storm Launchpad
}; };
export const LAUNCHPAD_IDS = [2256, 2542, 2543, 2544, 2552, 2555, 2556, 2557];
+4 -2
View File
@@ -37,17 +37,19 @@ export const revokeToken = async (
}); });
}; };
export const loginParameters = async ( export const loginParameters = (
selectedScopes: string[], selectedScopes: string[],
EVE_SSO_CLIENT_ID: string, EVE_SSO_CLIENT_ID: string,
EVE_SSO_CALLBACK_URL: string, EVE_SSO_CALLBACK_URL: string,
) => { ) => {
const state = crypto.randomUUID();
localStorage.setItem("oauth_state", state);
return new URLSearchParams({ return new URLSearchParams({
response_type: "code", response_type: "code",
redirect_uri: EVE_SSO_CALLBACK_URL, redirect_uri: EVE_SSO_CALLBACK_URL,
client_id: EVE_SSO_CLIENT_ID, client_id: EVE_SSO_CLIENT_ID,
scope: selectedScopes.join(" "), scope: selectedScopes.join(" "),
state: "asfe", state,
}).toString(); }).toString();
}; };
+43 -3
View File
@@ -1,11 +1,51 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import logger from "@/utils/logger";
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") { if (req.method === "GET") {
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL; logger.info({
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID; event: 'env_request_start'
res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL }); });
try {
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
if (!EVE_SSO_CALLBACK_URL || !EVE_SSO_CLIENT_ID) {
logger.error({
event: 'env_request_failed',
reason: 'missing_env_vars',
vars: {
hasCallbackUrl: !!EVE_SSO_CALLBACK_URL,
hasClientId: !!EVE_SSO_CLIENT_ID
}
});
return res.status(500).json({ error: 'Missing required environment variables' });
}
logger.info({
event: 'env_request_success',
vars: {
hasCallbackUrl: true,
hasClientId: true
}
});
return res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
} catch (e) {
logger.error({
event: 'env_request_failed',
reason: 'unexpected_error',
error: e
});
return res.status(500).json({ error: 'Internal server error' });
}
} else { } else {
logger.warn({
event: 'invalid_method',
method: req.method,
path: req.url
});
res.status(404).end(); res.status(404).end();
} }
}; };
+44 -5
View File
@@ -1,19 +1,58 @@
import { getPraisal } from "@/eve-praisal"; import { getPraisal } from "@/eve-praisal";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import logger from "@/utils/logger";
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") { if (req.method === "POST") {
const praisalRequest: { quantity: number; type_id: number }[] = JSON.parse( logger.info({
req.body event: 'praisal_request_start'
); });
try { try {
const parsed = JSON.parse(req.body);
if (!Array.isArray(parsed)) {
return res.status(400).json({ error: 'Invalid input' });
}
const praisalRequest: { quantity: number; type_id: number }[] = parsed.filter(
(item): item is { quantity: number; type_id: number } =>
item !== null &&
typeof item === 'object' &&
typeof item.quantity === 'number' &&
Number.isFinite(item.quantity) &&
item.quantity >= 0 &&
typeof item.type_id === 'number' &&
Number.isInteger(item.type_id) &&
item.type_id > 0
);
logger.info({
event: 'praisal_request_parsed',
items: praisalRequest.length
});
const praisal = await getPraisal(praisalRequest); const praisal = await getPraisal(praisalRequest);
logger.info({
event: 'praisal_request_success',
items: praisalRequest.length
});
return res.json(praisal); return res.json(praisal);
} catch (e) { } catch (e) {
console.log(e); logger.error({
res.status(404).end(); event: 'praisal_request_failed',
error: e,
});
return res.status(500).json({ error: 'Failed to get praisal' });
} }
} else { } else {
logger.warn({
event: 'invalid_method',
method: req.method,
path: req.url
});
res.status(404).end(); res.status(404).end();
} }
}; };
+40 -4
View File
@@ -1,7 +1,8 @@
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { extractCharacterFromToken } from "@/utils"; import { extractCharacterFromToken } from "@/utils/utils";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto-js"; import crypto from "crypto-js";
import logger from "@/utils/logger";
const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token"; const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token";
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? ""; const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
@@ -10,6 +11,14 @@ const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") { if (req.method === "POST") {
const accessToken: AccessToken = req.body; const accessToken: AccessToken = req.body;
logger.info({
event: 'token_refresh_start',
character: {
name: accessToken.character.name,
characterId: accessToken.character.characterId
}
});
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: crypto.AES.decrypt( refresh_token: crypto.AES.decrypt(
@@ -33,7 +42,20 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
body: params, body: params,
headers, headers,
}).then((res) => res.json()); }).then((res) => res.json());
const character = extractCharacterFromToken(response); const character = extractCharacterFromToken(response);
if (!character) {
logger.error({
event: 'token_refresh_failed',
reason: 'character_extraction_failed',
character: {
name: accessToken.character.name,
characterId: accessToken.character.characterId
}
});
return res.json({ ...accessToken, needsLogin: true });
}
const token: AccessToken = { const token: AccessToken = {
access_token: response.access_token, access_token: response.access_token,
token_type: response.token_type, token_type: response.token_type,
@@ -51,12 +73,26 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
planetConfig: accessToken.planetConfig ?? [], planetConfig: accessToken.planetConfig ?? [],
}; };
console.log("Refresh", character.name, character.characterId); logger.info({
event: 'token_refresh_success',
character: {
name: character.name,
characterId: character.characterId
}
});
return res.json(token); return res.json(token);
} catch (e) { } catch (e) {
console.log(e); logger.error({
res.json({ ...accessToken, needsLogin: true }); event: 'token_refresh_failed',
reason: 'api_error',
error: e,
character: {
name: accessToken.character.name,
characterId: accessToken.character.characterId
}
});
return res.json({ ...accessToken, needsLogin: true });
} }
} else { } else {
res.status(404).end(); res.status(404).end();
+35 -8
View File
@@ -1,6 +1,7 @@
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto-js"; import crypto from "crypto-js";
import logger from "@/utils/logger";
const EVE_SSO_REVOKE_URL = "https://login.eveonline.com/v2/oauth/revoke"; const EVE_SSO_REVOKE_URL = "https://login.eveonline.com/v2/oauth/revoke";
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? ""; const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
@@ -9,6 +10,14 @@ const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") { if (req.method === "POST") {
const accessToken: AccessToken = req.body; const accessToken: AccessToken = req.body;
logger.info({
event: 'token_revoke_start',
character: {
name: accessToken.character.name,
characterId: accessToken.character.characterId
}
});
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: crypto.AES.decrypt( refresh_token: crypto.AES.decrypt(
@@ -27,24 +36,42 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}; };
try { try {
await fetch(EVE_SSO_REVOKE_URL, { const response = await fetch(EVE_SSO_REVOKE_URL, {
method: "POST", method: "POST",
body: params, body: params,
headers, headers,
}).then((res) => res.json()); });
console.log( if (!response.ok) {
"Revoke", throw new Error(`HTTP error! status: ${response.status}`);
accessToken.character.name, }
accessToken.character.characterId
); logger.info({
event: 'token_revoke_success',
character: {
name: accessToken.character.name,
characterId: accessToken.character.characterId
}
});
return res.end(); return res.end();
} catch (e) { } catch (e) {
console.log(e); logger.error({
event: 'token_revoke_failed',
error: e,
character: {
name: accessToken.character.name,
characterId: accessToken.character.characterId
}
});
return res.status(500).end(); return res.status(500).end();
} }
} else { } else {
logger.warn({
event: 'invalid_method',
method: req.method,
path: req.url
});
res.status(404).end(); res.status(404).end();
} }
}; };
+72 -26
View File
@@ -1,7 +1,8 @@
import { AccessToken } from "@/types"; import { AccessToken } from "@/types";
import { extractCharacterFromToken } from "@/utils"; import { extractCharacterFromToken } from "@/utils/utils";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto-js"; import crypto from "crypto-js";
import logger from "@/utils/logger";
const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token"; const EVE_SSO_TOKEN_URL = "https://login.eveonline.com/v2/oauth/token";
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? ""; const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID ?? "";
@@ -10,7 +11,18 @@ const EVE_SSO_SECRET = process.env.EVE_SSO_SECRET ?? "";
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") { if (req.method === "GET") {
const code = req.query.code as string; const code = req.query.code as string;
if (!code || code === undefined) return res.status(404).end(); if (!code || code === undefined) {
logger.warn({
event: 'token_request_failed',
reason: 'missing_code',
query: req.query
});
return res.status(404).end();
}
logger.info({
event: 'token_request_start',
});
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
@@ -26,34 +38,68 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
"User-Agent": "https://github.com/calli-eve/eve-pi", "User-Agent": "https://github.com/calli-eve/eve-pi",
}; };
const response = await fetch(EVE_SSO_TOKEN_URL, { try {
method: "POST", const response = await fetch(EVE_SSO_TOKEN_URL, {
body: params, method: "POST",
headers, body: params,
}).then((res) => res.json()); headers,
});
const character = extractCharacterFromToken(response); if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
console.log("Login", character.name, character.characterId); const data = await response.json();
const character = extractCharacterFromToken(data);
const token: AccessToken = { if (!character) {
access_token: response.access_token, logger.error({
token_type: response.token_type, event: 'token_request_failed',
refresh_token: crypto.AES.encrypt( reason: 'character_extraction_failed',
response.refresh_token, data
EVE_SSO_SECRET, });
).toString(), return res.status(500).end();
expires_at: Date.now() + response.expires_in * 1000, }
character,
needsLogin: false, logger.info({
account: "-", event: 'token_request_success',
comment: "", character: {
system: "", name: character.name,
planets: [], characterId: character.characterId
planetConfig: [], }
}; });
res.json(token);
const token: AccessToken = {
access_token: data.access_token,
token_type: data.token_type,
refresh_token: crypto.AES.encrypt(
data.refresh_token,
EVE_SSO_SECRET,
).toString(),
expires_at: Date.now() + data.expires_in * 1000,
character,
needsLogin: false,
account: "-",
comment: "",
system: "",
planets: [],
planetConfig: [],
};
return res.json(token);
} catch (e) {
logger.error({
event: 'token_request_failed',
reason: 'api_error',
error: e,
});
return res.status(500).end();
}
} else { } else {
logger.warn({
event: 'invalid_method',
method: req.method,
path: req.url
});
res.status(404).end(); res.status(404).end();
} }
}; };
+69 -5
View File
@@ -7,7 +7,7 @@ import {
} from "@/types"; } from "@/types";
import { Api } from "@/esi-api"; import { Api } from "@/esi-api";
import { EXTRACTOR_TYPE_IDS, FACTORY_IDS, PI_SCHEMATICS } from "@/const"; import { EXTRACTOR_TYPE_IDS, FACTORY_IDS, PI_SCHEMATICS } from "@/const";
import { extractorsHaveExpired } from "./app/components/PlanetaryInteraction/timeColors"; import { extractorsHaveExpired } from "./app/components/PlanetaryInteraction/alerts";
export const getPlanets = async (character: AccessToken): Promise<Planet[]> => { export const getPlanets = async (character: AccessToken): Promise<Planet[]> => {
const api = new Api(); const api = new Api();
@@ -22,10 +22,51 @@ export const getPlanets = async (character: AccessToken): Promise<Planet[]> => {
return planets; return planets;
}; };
interface CachedPlanetData {
data: PlanetInfo;
timestamp: number;
}
const CACHE_DURATION_MS = 60_000; // 1 minute
const CACHE_STORAGE_KEY = "planet_cache";
const loadCacheFromStorage = (): Map<string, CachedPlanetData> => {
if (typeof window === "undefined") return new Map();
try {
const stored = localStorage.getItem(CACHE_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return new Map(Object.entries(parsed));
}
} catch (error) {
console.error("Failed to load planet cache from localStorage:", error);
}
return new Map();
};
const saveCacheToStorage = (cache: Map<string, CachedPlanetData>) => {
if (typeof window === "undefined") return;
try {
const obj = Object.fromEntries(cache);
localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj));
} catch (error) {
console.error("Failed to save planet cache to localStorage:", error);
}
};
const planetCache = loadCacheFromStorage();
export const getPlanet = async ( export const getPlanet = async (
character: AccessToken, character: AccessToken,
planet: Planet, planet: Planet,
): Promise<PlanetInfo> => { ): Promise<PlanetInfo> => {
const cacheKey = `${character.character.characterId}-${planet.planet_id}`;
const cached = planetCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION_MS) {
return cached.data;
}
const api = new Api(); const api = new Api();
const planetInfo = ( const planetInfo = (
await api.v3.getCharactersCharacterIdPlanetsPlanetId( await api.v3.getCharactersCharacterIdPlanetsPlanetId(
@@ -36,6 +77,14 @@ export const getPlanet = async (
}, },
) )
).data; ).data;
planetCache.set(cacheKey, {
data: planetInfo,
timestamp: Date.now(),
});
saveCacheToStorage(planetCache);
return planetInfo; return planetInfo;
}; };
@@ -51,7 +100,7 @@ export const getPlanetUniverse = async (
export const planetCalculations = (planet: PlanetWithInfo) => { export const planetCalculations = (planet: PlanetWithInfo) => {
const planetInfo = planet.info; const planetInfo = planet.info;
type SchematicId = number; type SchematicId = number;
const extractors: PlanetInfo["pins"] = planetInfo.pins.filter((p) => const extractors = planetInfo.pins.filter((p) =>
EXTRACTOR_TYPE_IDS.some((e) => e === p.type_id), EXTRACTOR_TYPE_IDS.some((e) => e === p.type_id),
); );
const localProduction = planetInfo.pins const localProduction = planetInfo.pins
@@ -61,10 +110,19 @@ export const planetCalculations = (planet: PlanetWithInfo) => {
const schematic = PI_SCHEMATICS.find( const schematic = PI_SCHEMATICS.find(
(s) => s.schematic_id == f.schematic_id, (s) => s.schematic_id == f.schematic_id,
); );
if (schematic) acc.set(f.schematic_id, schematic); if (schematic) {
const existing = acc.get(f.schematic_id);
if (existing) {
// If we already have this schematic, increment its count
existing.count = (existing.count || 0) + 1;
} else {
// First time seeing this schematic, set count to 1
acc.set(f.schematic_id, { ...schematic, count: 1 });
}
}
} }
return acc; return acc;
}, new Map<SchematicId, (typeof PI_SCHEMATICS)[number]>()); }, new Map<SchematicId, (typeof PI_SCHEMATICS)[number] & { count: number }>());
const locallyProduced = Array.from(localProduction) const locallyProduced = Array.from(localProduction)
.flatMap((p) => p[1].outputs) .flatMap((p) => p[1].outputs)
@@ -85,7 +143,13 @@ export const planetCalculations = (planet: PlanetWithInfo) => {
![...locallyProduced, ...locallyExcavated].some( ![...locallyProduced, ...locallyExcavated].some(
(lp) => lp === p.type_id, (lp) => lp === p.type_id,
), ),
); ).map((p) => ({
...p,
factoryCount: planetInfo.pins
.filter((f) => f.schematic_id === p.schematic_id)
.length,
}));
const localExports = locallyProduced const localExports = locallyProduced
.filter((p) => !locallyConsumed.some((lp) => lp === p)) .filter((p) => !locallyConsumed.some((lp) => lp === p))
+107
View File
@@ -0,0 +1,107 @@
import { AccessToken } from "./types";
const DB_NAME = "eve-pi-db";
const DB_VERSION = 1;
const STORE_NAME = "characters";
// Initialize IndexedDB
const initDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
};
// Save characters to IndexedDB
export const saveCharacters = async (
characters: AccessToken[]
): Promise<AccessToken[]> => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
store.put(characters, "characters");
return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
db.close();
resolve(characters);
};
transaction.onerror = () => {
db.close();
reject(transaction.error);
};
});
} catch (error) {
console.error("Failed to save to IndexedDB:", error);
// Fallback: save minimal data to localStorage
try {
const minimalCharacters = characters.map((c) => ({
...c,
planets: [], // Strip planet data to reduce size
}));
localStorage.setItem("characters", JSON.stringify(minimalCharacters));
console.warn("Saved minimal character data to localStorage fallback");
} catch (storageError) {
console.error("Failed to save to localStorage fallback:", storageError);
}
return characters;
}
};
// Load characters from IndexedDB
export const loadCharacters = async (): Promise<AccessToken[]> => {
try {
const db = await initDB();
const transaction = db.transaction([STORE_NAME], "readonly");
const store = transaction.objectStore(STORE_NAME);
const request = store.get("characters");
return new Promise((resolve, reject) => {
request.onsuccess = () => {
db.close();
const characters = request.result as AccessToken[] | undefined;
if (characters && characters.length > 0) {
resolve(characters);
} else {
// Try localStorage migration
resolve(migrateFromLocalStorage());
}
};
request.onerror = () => {
db.close();
reject(request.error);
};
});
} catch (error) {
console.error("Failed to load from IndexedDB:", error);
// Fallback to localStorage
return migrateFromLocalStorage();
}
};
// Migrate data from localStorage to IndexedDB
const migrateFromLocalStorage = (): AccessToken[] => {
try {
const localStorageCharacters = localStorage.getItem("characters");
if (localStorageCharacters) {
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
const filtered = characterArray.filter((c) => c.access_token && c.character);
// Don't delete from localStorage yet - keep as backup
return filtered;
}
} catch (error) {
console.error("Failed to migrate from localStorage:", error);
}
return [];
};
+83 -26
View File
@@ -1,6 +1,3 @@
import { PlanetConfig } from "./app/components/PlanetConfig/PlanetConfigDialog";
import { Api } from "./esi-api";
export interface AccessToken { export interface AccessToken {
access_token: string; access_token: string;
expires_at: number; expires_at: number;
@@ -20,10 +17,50 @@ export interface Character {
characterId: number; characterId: number;
} }
export interface Planet {
planet_id: number;
solar_system_id: number;
planet_type: "temperate" | "barren" | "oceanic" | "ice" | "gas" | "lava" | "storm" | "plasma";
last_update: string;
num_pins: number;
owner_id: number;
upgrade_level: number;
}
export interface PlanetInfo {
links: Array<{
destination_pin_id: number;
link_level: number;
source_pin_id: number;
}>;
pins: Pin[];
routes: Array<{
content_type_id: number;
destination_pin_id: number;
quantity: number;
route_id: number;
source_pin_id: number;
waypoints?: number[];
}>;
}
export interface PlanetInfoUniverse {
name: string;
planet_id: number;
system_id: number;
type_id: number;
position: {
x: number;
y: number;
z: number;
};
}
export interface PlanetWithInfo extends Planet { export interface PlanetWithInfo extends Planet {
info: PlanetInfo; info: PlanetInfo;
infoUniverse: PlanetInfoUniverse; infoUniverse: PlanetInfoUniverse;
} }
export interface CharacterPlanets { export interface CharacterPlanets {
name: string; name: string;
characterId: number; characterId: number;
@@ -38,31 +75,51 @@ export interface CharacterUpdate {
system?: string; system?: string;
} }
export type Planet = EsiType<"v1", "getCharactersCharacterIdPlanets">[number];
export type PlanetInfoUniverse = EsiType<"v1", "getUniversePlanetsPlanetId">;
export type PlanetInfo = EsiType<
"v3",
"getCharactersCharacterIdPlanetsPlanetId"
>;
export interface Env { export interface Env {
EVE_SSO_CALLBACK_URL: string; EVE_SSO_CALLBACK_URL: string;
EVE_SSO_CLIENT_ID: string; EVE_SSO_CLIENT_ID: string;
} }
type EsiApiVersionType = keyof InstanceType<typeof Api<unknown>>; export interface EvePraisalResult {
type EsiApiPathType<V extends EsiApiVersionType> = keyof InstanceType< appraisal: {
typeof Api<unknown> items: Array<{
>[V]; typeID: number;
type EsiApiResponseType< prices: {
V extends EsiApiVersionType, sell: {
T extends EsiApiPathType<V>, min: number;
> = Awaited<ReturnType<InstanceType<typeof Api<unknown>>[V][T]>>; };
export type EsiType< };
V extends EsiApiVersionType, }>;
T extends EsiApiPathType<V>, };
> = EsiApiResponseType<V, T> extends { data: any } }
? EsiApiResponseType<V, T>["data"]
: never; export interface Pin {
pin_id: number;
type_id: number;
schematic_id?: number;
expiry_time?: string;
install_time?: string;
latitude: number;
longitude: number;
extractor_details?: {
cycle_time?: number;
head_radius?: number;
heads: Array<{
head_id: number;
latitude: number;
longitude: number;
}>;
product_type_id?: number;
qty_per_cycle?: number;
};
contents?: Array<{
type_id: number;
amount: number;
}>;
}
export interface PlanetConfig {
characterId: number;
planetId: number;
excludeFromTotals: boolean;
}
+85
View File
@@ -0,0 +1,85 @@
import { Pin, PlanetWithInfo } from '../types';
export interface StorageContent {
type_id: number;
amount: number;
}
export interface StorageInfo {
pin_id: number;
type: string;
type_id: number;
capacity: number;
used: number;
fillRate: number;
value: number;
}
export interface PlanetCalculations {
expired: boolean;
extractors: Pin[];
localProduction: Map<number, LocalProductionInfo>;
localImports: LocalImport[];
localExports: LocalExport[];
storageInfo: StorageInfo[];
extractorAverages: ExtractorAverage[];
hasLargeExtractorDifference: boolean;
importDepletionTimes: ImportDepletionTime[];
visibility: 'visible' | 'hidden';
}
export interface AlertState {
expired: boolean;
hasLowStorage: boolean;
hasLowImports: boolean;
hasLargeExtractorDifference: boolean;
hasLowExtractionRate: boolean;
}
export interface ExtractorAverage {
typeId: number;
averagePerHour: number;
}
export interface ImportDepletionTime {
typeId: number;
hoursUntilDepletion: number;
monthlyCost: number;
}
export interface LocalProductionInfo {
name: string;
cycle_time: number;
schematic_id: number;
inputs: SchematicInput[];
outputs: SchematicOutput[];
factoryCount: number;
}
export interface LocalImport {
type_id: number;
schematic_id: number;
quantity: number;
factoryCount: number;
}
export interface LocalExport {
type_id: number;
schematic_id: number;
quantity: number;
factoryCount: number;
}
export interface SchematicInput {
schematic_id: number;
type_id: number;
quantity: number;
is_input: number;
}
export interface SchematicOutput {
schematic_id: number;
type_id: number;
quantity: number;
is_input: number;
}
-13
View File
@@ -1,13 +0,0 @@
import { AccessToken, Character } from "./types";
export const extractCharacterFromToken = (token: AccessToken): Character => {
const decodedToken = parseJwt(token.access_token);
return {
name: decodedToken.name,
characterId: decodedToken.sub.split(":")[2],
};
};
const parseJwt = (token: string) => {
return JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
};
+16
View File
@@ -0,0 +1,16 @@
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => {
return { level: label };
},
},
timestamp: () => `,"time":"${new Date().toISOString()}"`,
base: {
env: process.env.NODE_ENV,
},
});
export default logger;
+22
View File
@@ -0,0 +1,22 @@
import { AccessToken, Character } from "../types";
export const extractCharacterFromToken = (token: AccessToken): Character | null => {
const decodedToken = parseJwt(token.access_token);
if (!decodedToken || !decodedToken.name || !decodedToken.sub) {
return null;
}
return {
name: decodedToken.name,
characterId: decodedToken.sub.split(":")[2],
};
};
const parseJwt = (token: string | undefined) => {
if (!token) return null;
try {
return JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
} catch (error) {
console.error('Failed to parse JWT token:', error);
return null;
}
};