58 Commits
push ... main

Author SHA1 Message Date
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
45 changed files with 3473 additions and 813 deletions

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

54
.github/workflows/container.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Build and Push Container
on:
push:
tags:
- 'v*' # This will match tags like v1.0.0, v2.1.0, etc.
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

View File

@@ -2,9 +2,17 @@
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 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)

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

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,

762
package-lock.json generated

File diff suppressed because it is too large Load Diff

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",

View File

@@ -1,40 +1,450 @@
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 { 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 {
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 { 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 && characters.map((c) => (
<Stack
key={c.character.characterId}
direction="row"
alignItems="flex-start"
>
<CharacterRow character={c} />
{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>
))}
</Box>
</Paper>
); );
}; };

View File

@@ -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>
);
};

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>
); );
} }

View File

@@ -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>
);
};

View File

@@ -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();

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>

View File

@@ -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>
);
};

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" }}

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>
); );

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
); );
}; };

View File

@@ -65,6 +65,7 @@ export interface ProductionNode {
quantity: number; quantity: number;
}>; }>;
cycleTime: number; cycleTime: number;
factoryCount: number;
} }
export interface ProductionChainBalance { export interface ProductionChainBalance {

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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>
); );
}; };

View File

@@ -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,
excludeFromTotals: event.target.checked,
});
};
// Get extractor head types safely // Check if there are any alerts
const extractedTypeIds = extractors const hasLowExtractionRate = planetDetails.extractorAverages.length > 0 && minExtractionRate > 0 && planetDetails.extractorAverages.some(avg => avg.averagePerHour < minExtractionRate);
.map(e => e.extractor_details?.product_type_id) const hasAlerts = alertMode && (
.filter((id): id is number => id !== undefined); planetDetails.expired ||
planetDetails.storageInfo.some(storage => storage.fillRate > 60) ||
// Get storage facilities planetDetails.importDepletionTimes.some(depletion => depletion.hoursUntilDepletion < 24) ||
const storageFacilities = planetInfo.pins.filter(pin => planetDetails.hasLargeExtractorDifference ||
STORAGE_IDS().some(storage => storage.type_id === pin.type_id) 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 (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
<Typography fontSize={theme.custom.smallText} color="text.secondary">
No product
</Typography>
{amount !== undefined && (
<Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px", flexShrink: 0 }}>
{amount}
</Typography>
)}
</div>
);
}
// Calculate total volume of stored products for this specific pin if (showProductIcons) {
const totalVolume = (pin.contents || []) return (
.reduce((sum: number, item: any) => { <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0; <Image
return sum + (item.amount * volume); src={`${EVE_IMAGE_URL}/types/${typeId}/icon?size=32`}
}, 0); alt={PI_TYPES_MAP[typeId].name}
width={32}
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0; height={32}
style={{ marginRight: "5px" }}
return { />
type: storageType, {amount !== undefined && (
capacity: storageCapacity, <Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px", flexShrink: 0 }}>
used: totalVolume, {amount}
fillRate: fillRate </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>
<TableCell align="right">Used</TableCell>
<TableCell align="right">Fill Rate</TableCell>
<TableCell align="right">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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';
const contents = planet.info.pins.find(p => p.type_id === storage.type_id)?.contents || [];
const isLaunchpad = storage.type_id === 2256 || return (
storage.type_id === 2542 || <React.Fragment key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`}>
storage.type_id === 2543 || <TableRow>
storage.type_id === 2544 || <TableCell>{storage.isLaunchpad ? 'Launchpad' : 'Storage'}</TableCell>
storage.type_id === 2552 || <TableCell align="right">{storage.capacity.toFixed(1)} m³</TableCell>
storage.type_id === 2555 || <TableCell align="right">{storage.used.toFixed(1)} m³</TableCell>
storage.type_id === 2556 || <TableCell align="right" sx={{ color }}>{fillRate.toFixed(1)}%</TableCell>
storage.type_id === 2557; <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';
const fillRate = storageInfo.fillRate; return (
const color = fillRate > 95 ? '#ff0000' : fillRate > 80 ? '#ffd700' : 'inherit'; <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" }}>
return ( {storage.isLaunchpad ? 'L' : 'S'}
<div key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.pin_id}`} style={{ display: "flex", alignItems: "center" }}> </Typography>
<Typography fontSize={theme.custom.smallText} style={{ marginRight: "5px" }}> <Typography fontSize={theme.custom.smallText} style={{ color }}>
{isLaunchpad ? 'L' : 'S'} {fillRate.toFixed(1)}%
</Typography> </Typography>
<Typography fontSize={theme.custom.smallText} style={{ color }}> {storage.value > 0 && (
{fillRate.toFixed(1)}% <Typography fontSize={theme.custom.smallText} style={{ marginLeft: "5px" }}>
</Typography> ({Math.round(storage.value / 1000000)}M)
</div> </Typography>
); )}
})} </div>
</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>

View File

@@ -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>
); );
}; };

View File

@@ -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 && (
<> <>

View File

@@ -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}`}>

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>
); );

View File

@@ -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>
);
};

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;

View File

@@ -17,7 +17,23 @@ 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();
@@ -29,6 +45,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,15 +80,13 @@ 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 (
@@ -92,40 +110,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 +159,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 +215,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 +238,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 +274,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 +300,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

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];

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();
} }
}; };

View File

@@ -1,19 +1,45 @@
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 praisalRequest: { quantity: number; type_id: number }[] = JSON.parse(
req.body
);
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,
body: req.body
});
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();
} }
}; };

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();

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();
} }
}; };

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,19 @@ 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',
code: code
});
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
@@ -26,34 +39,69 @@ 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,
code: code
});
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();
} }
}; };

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,49 @@ 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> => {
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>) => {
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 +75,14 @@ export const getPlanet = async (
}, },
) )
).data; ).data;
planetCache.set(cacheKey, {
data: planetInfo,
timestamp: Date.now(),
});
saveCacheToStorage(planetCache);
return planetInfo; return planetInfo;
}; };
@@ -51,7 +98,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 +108,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 +141,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
src/storage.ts Normal file
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 [];
};

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;
}

84
src/types/planet.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Pin, PlanetWithInfo } from '../types';
export interface StorageContent {
type_id: number;
amount: number;
}
export interface StorageInfo {
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;
}

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
src/utils/logger.ts Normal file
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
src/utils/utils.ts Normal file
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;
}
};