Compare commits

..

7 Commits

Author SHA1 Message Date
calli c37578c4e5 add drag for characters inside an account card. also minimize for easier reorder 2026-04-14 22:14:39 +03:00
calli 48da721980 fix launchpad storage calculations 2026-04-14 22:08:26 +03:00
calli bf31a7e2cb add matrix space link 2026-02-24 19:47:18 +02:00
calli 6b47b34ddf add buy me a beer button 2026-01-07 09:02:16 +02:00
calli e8f69b15a4 fix per cycle calculation 2026-01-07 08:59:34 +02:00
calli ebd39243b2 fix hourly averages 2026-01-02 21:10:58 +02:00
calli 0ee129b3ca remove planet cache logging 2026-01-02 20:53:09 +02:00
8 changed files with 228 additions and 41 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
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/bCdXzU8PHK)
Any questions, feedback or suggestions are welcome at
[EVE PI Matrix](https://matrix.to/#/#eve-pi:calli.fi)
[EVE PI Discord](https://discord.gg/bCdXzU8PHK)
## Partner code
+167 -27
View File
@@ -7,11 +7,14 @@ import { useContext, useState, useEffect } from "react";
import { PlanRow } from "./PlanRow";
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { DragDropContext, Droppable, Draggable, DropResult } from "@hello-pangea/dnd";
import { planetCalculations } from "@/planets";
import { EvePraisalResult } from "@/eve-praisal";
import { STORAGE_IDS, PI_SCHEMATICS, PI_PRODUCT_VOLUMES, STORAGE_CAPACITIES } from "@/const";
import { DateTime } from "luxon";
import { PlanetCalculations, AlertState, StorageContent, StorageInfo } from "@/types/planet";
import { getProgramOutputPrediction } from "../PlanetaryInteraction/ExtractionSimulation";
interface AccountTotals {
monthlyEstimate: number;
@@ -49,18 +52,27 @@ const calculatePlanetDetails = (planet: PlanetWithInfo, piPrices: EvePraisalResu
]));
// 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 cycleTime = e.extractor_details?.cycle_time || 3600;
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: (qtyPerCycle * 3600) / cycleTime
averagePerHour
};
});
const hasLargeExtractorDifference = extractorAverages.length === 2 &&
const hasLargeExtractorDifference = extractorAverages.length === 2 &&
Math.abs(extractorAverages[0].averagePerHour - extractorAverages[1].averagePerHour) > balanceThreshold;
// Calculate storage info
@@ -89,6 +101,7 @@ const calculatePlanetDetails = (planet: PlanetWithInfo, piPrices: EvePraisalResu
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0;
return {
pin_id: storage.pin_id,
type: storageType,
type_id: storage.type_id,
capacity: storageCapacity,
@@ -231,6 +244,69 @@ export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { char
const theme = useTheme();
const [localIsCollapsed, setLocalIsCollapsed] = useState(false);
const { planMode, piPrices, alertMode, balanceThreshold, minExtractionRate } = useContext(SessionContext);
const accountName = characters.length > 0 ? (characters[0].account ?? "-") : "-";
const [characterOrder, setCharacterOrder] = useState<number[]>(() => {
try {
const saved = localStorage.getItem(`characterOrder-${accountName}`);
if (saved) {
const parsed: number[] = JSON.parse(saved);
const ids = characters.map(c => c.character.characterId);
const valid = parsed.filter(id => ids.includes(id));
const newIds = ids.filter(id => !valid.includes(id));
return [...valid, ...newIds];
}
} catch {}
return characters.map(c => c.character.characterId);
});
useEffect(() => {
const ids = characters.map(c => c.character.characterId);
setCharacterOrder(prev => {
const valid = prev.filter(id => ids.includes(id));
const newIds = ids.filter(id => !valid.includes(id));
return [...valid, ...newIds];
});
}, [characters]);
useEffect(() => {
if (characterOrder.length > 0) {
localStorage.setItem(`characterOrder-${accountName}`, JSON.stringify(characterOrder));
}
}, [characterOrder, accountName]);
const [collapsedCharacters, setCollapsedCharacters] = useState<Set<number>>(() => {
try {
const saved = localStorage.getItem(`collapsedCharacters-${accountName}`);
if (saved) return new Set<number>(JSON.parse(saved));
} catch {}
return new Set<number>();
});
const toggleCharacterCollapsed = (characterId: number) => {
setCollapsedCharacters(prev => {
const next = new Set(prev);
if (next.has(characterId)) next.delete(characterId);
else next.add(characterId);
localStorage.setItem(`collapsedCharacters-${accountName}`, JSON.stringify(Array.from(next)));
return next;
});
};
const orderedCharacters = characterOrder
.map(id => characters.find(c => c.character.characterId === id))
.filter((c): c is AccessToken => c !== undefined);
const handleCharacterDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(characterOrder);
const [moved] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, moved);
setCharacterOrder(items);
};
const DragDropContextComponent = DragDropContext as any;
const DroppableComponent = Droppable as any;
const DraggableComponent = Draggable as any;
const { monthlyEstimate, storageValue, planetCount, characterCount, runningExtractors, totalExtractors } = calculateAccountTotals(characters, piPrices);
// Calculate planet details and alert states for each planet
@@ -410,30 +486,94 @@ export const AccountCard = ({ characters, isCollapsed: propIsCollapsed }: { char
{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>
))}
{!localIsCollapsed && (
<DragDropContextComponent onDragEnd={handleCharacterDragEnd}>
<DroppableComponent droppableId={`characters-${accountName}`} direction="vertical">
{(provided: any) => (
<Box ref={provided.innerRef} {...provided.droppableProps}>
{orderedCharacters.map((c, index) => (
<DraggableComponent
key={c.character.characterId}
draggableId={`char-${c.character.characterId}`}
index={index}
>
{(provided: any, snapshot: any) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
direction="row"
alignItems="flex-start"
sx={{
opacity: snapshot.isDragging ? 0.8 : 1,
backgroundColor: snapshot.isDragging ? theme.palette.action.hover : 'transparent',
borderRadius: 1,
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: 1,
gap: 0.5,
}}
>
<Box
{...provided.dragHandleProps}
sx={{
display: 'flex',
alignItems: 'center',
cursor: 'grab',
px: 0.5,
color: theme.palette.text.disabled,
'&:hover': { color: theme.palette.text.secondary },
'&:active': { cursor: 'grabbing' },
}}
>
</Box>
<IconButton
size="small"
onClick={() => toggleCharacterCollapsed(c.character.characterId)}
sx={{
p: 0.25,
color: theme.palette.text.disabled,
'&:hover': { color: theme.palette.text.secondary },
transform: collapsedCharacters.has(c.character.characterId) ? 'rotate(0deg)' : 'rotate(90deg)',
transition: 'transform 0.2s ease-in-out',
}}
>
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
<CharacterRow character={c} />
{!collapsedCharacters.has(c.character.characterId) && (
planMode ? (
<PlanRow character={c} />
) : (
<PlanetaryInteractionRow
character={c}
planetDetails={c.planets.reduce((acc, planet) => {
const details = planetDetails[`${c.character.characterId}-${planet.planet_id}`];
acc[planet.planet_id] = {
...details,
visibility: getAlertVisibility(details.alertState)
};
return acc;
}, {} as Record<number, PlanetCalculations & { visibility: string }>)}
/>
)
)}
</Stack>
)}
</DraggableComponent>
))}
{provided.placeholder}
</Box>
)}
</DroppableComponent>
</DragDropContextComponent>
)}
</Box>
</Paper>
);
+5
View File
@@ -17,6 +17,7 @@ import { GitHubButton } from "../Github/GitHubButton";
import { LoginButton } from "../Login/LoginButton";
import { PartnerCodeButton } from "../PartnerCode/PartnerCodeButton";
import { SettingsButton } from "../Settings/SettingsButtons";
import { BuyMeCoffeeButton } from "../BuyMeCoffee/BuyMeCoffeeButton";
import {
Button,
Dialog,
@@ -132,6 +133,9 @@ function ResponsiveAppBar() {
<MenuItem onClick={handleCloseNavMenu}>
<PartnerCodeButton />
</MenuItem>
<MenuItem onClick={handleCloseNavMenu}>
<BuyMeCoffeeButton />
</MenuItem>
</Menu>
</Box>
<PublicIcon sx={{ display: { xs: "flex", md: "none" }, mr: 1 }} />
@@ -173,6 +177,7 @@ function ResponsiveAppBar() {
</Button>
<CCPButton />
<PartnerCodeButton />
<BuyMeCoffeeButton />
</Box>
</Toolbar>
</Container>
@@ -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>
);
};
@@ -135,7 +135,7 @@ export const ExtractionSimulationTooltip: React.FC<ExtractionSimulationTooltipPr
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Stack spacing={1}>
{extractorPrograms.map(({ typeId, cycleTime, cycles, installTime, expiryTime }, idx) => {
{extractorPrograms.map(({ typeId, cycleTime, cycles, expiryTime }, idx) => {
const prediction = getProgramOutputPrediction(
extractors[idx].baseValue,
CYCLE_TIME,
@@ -169,7 +169,7 @@ export const ExtractionSimulationTooltip: React.FC<ExtractionSimulationTooltipPr
: 'inherit'
}
>
Average per hour: {((extractors[idx].baseValue * 3600) / extractors[idx].cycleTime).toFixed(1)} units
Average per hour: {(totalOutput / cycles * 2).toFixed(1)} units
</Typography>
<Typography variant="body2">
Expires in: <Countdown overtime={true} date={DateTime.fromISO(expiryTime).toMillis()} />
@@ -185,17 +185,24 @@ export const ExtractionSimulationTooltip: React.FC<ExtractionSimulationTooltipPr
</Typography>
<Stack spacing={0.5}>
{extractors.map((extractor, index) => {
const averagePerHour = (extractor.baseValue * 3600) / extractor.cycleTime;
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"
<Typography
variant="body2"
color="error"
sx={{
sx={{
mt: 1,
fontWeight: 'bold',
borderTop: '1px solid',
@@ -203,10 +210,27 @@ export const ExtractionSimulationTooltip: React.FC<ExtractionSimulationTooltipPr
pt: 1
}}
>
Difference: {Math.abs(
(extractors[0].baseValue * 3600 / extractors[0].cycleTime) -
(extractors[1].baseValue * 3600 / extractors[1].cycleTime)
).toFixed(1)} u/h
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>
@@ -438,7 +438,7 @@ export const PlanetTableRow = ({
.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 contents = planet.info.pins.find(p => p.pin_id === storage.pin_id)?.contents || [];
return (
<React.Fragment key={`storage-${character.character.characterId}-${planet.planet_id}-${storage.type}-${idx}`}>
-2
View File
@@ -62,11 +62,9 @@ export const getPlanet = async (
const cached = planetCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION_MS) {
console.log(`[Cache HIT] Planet ${planet.planet_id} for character ${character.character.characterId}`);
return cached.data;
}
console.log(`[Cache MISS] Fetching planet ${planet.planet_id} for character ${character.character.characterId}`);
const api = new Api();
const planetInfo = (
await api.v3.getCharactersCharacterIdPlanetsPlanetId(
+1
View File
@@ -6,6 +6,7 @@ export interface StorageContent {
}
export interface StorageInfo {
pin_id: number;
type: string;
type_id: number;
capacity: number;