feat(calculadora): cost split breakdown + plan por cristales mode
CI / Build Native (push) Has been cancelled

- Add costoCristales/costoOre/costoDwarven fields to calc.js results and
  RunItem/RunIn/RunSummary/HistoryStats records (no DB migration needed —
  items stored as JSON in TEXT column).
- Show 4 explicit columns (C. Cristales / C. Ore / C. Fee / Costo total)
  in all calculator tables: Cristales mode, Presupuesto (simple/maximizado/
  mezcla), and Objetivo. Totales globales now includes a 3-segment
  distribution bar with %.
- Historial: 3 new stats cards + global distribution bar; per-run modal
  shows breakdown + bar; per-item table has 3 sub-columns; CSV export
  extended. CSS: cost-split-bar, cost-split-segment-{cristales,ore,fee},
  cost-legend-dot.
- New 'Plan por cristales' mode (4th): single-grade + quantity input with
  greedy max-ROI/cristal algorithm and shared cost split columns.
  Verificado end-to-end en navegador.
This commit is contained in:
2026-08-20 19:15:42 -04:00
parent 34e524616d
commit d73f946cb2
9 changed files with 637 additions and 100 deletions
@@ -6,7 +6,6 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -75,7 +74,9 @@ public class HistoryService {
if (runs.isEmpty()) {
return new HistoryStats(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0.0, 0.0, 0.0,
0, 0, 0, 0.0, 0, 0,
null, null, 0, 0, 0, Map.of());
}
@@ -85,6 +86,9 @@ public class HistoryService {
long totalShots = 0;
long totalCristalesUsed = 0;
long totalOreUsed = 0;
long totalCostCristales = 0;
long totalCostOre = 0;
long totalCostDwarven = 0;
int totalItems = 0;
ProductionRun best = runs.get(0);
ProductionRun worst = runs.get(0);
@@ -103,6 +107,9 @@ public class HistoryService {
for (RunItem item : items) {
tiposBreakdown.merge(item.tipo(), item.ganancia(), Long::sum);
totalCostCristales += item.costoCristales();
totalCostOre += item.costoOre();
totalCostDwarven += item.costoDwarven();
}
if (r.totalProfit > best.totalProfit) best = r;
@@ -125,6 +132,10 @@ public class HistoryService {
long avgCostPerShot = totalShots > 0 ? totalCost / totalShots : 0;
long avgProfitPerShot = totalShots > 0 ? totalProfit / totalShots : 0;
double pctC = totalCost > 0 ? (double) totalCostCristales * 100.0 / totalCost : 0.0;
double pctO = totalCost > 0 ? (double) totalCostOre * 100.0 / totalCost : 0.0;
double pctD = totalCost > 0 ? (double) totalCostDwarven * 100.0 / totalCost : 0.0;
List<RunItem> bestItems = parseItems(best.itemsJson);
List<RunItem> worstItems = parseItems(worst.itemsJson);
@@ -136,6 +147,12 @@ public class HistoryService {
totalShots,
totalCristalesUsed,
totalOreUsed,
totalCostCristales,
totalCostOre,
totalCostDwarven,
round1(pctC),
round1(pctO),
round1(pctD),
totalProfit / n,
totalCost / n,
totalSale / n,
@@ -150,6 +167,10 @@ public class HistoryService {
tiposBreakdown);
}
private static double round1(double v) {
return Math.round(v * 10.0) / 10.0;
}
private List<RunItem> parseItems(String json) {
if (json == null || json.isBlank()) return List.of();
try {
@@ -159,4 +180,4 @@ public class HistoryService {
return List.of();
}
}
}
}
@@ -14,6 +14,12 @@ public record HistoryStats(
long totalShots,
long totalCristalesUsed,
long totalOreUsed,
long totalCostCristales,
long totalCostOre,
long totalCostDwarven,
double costCristalesPct,
double costOrePct,
double costDwarvenPct,
long avgProfit,
long avgCost,
long avgSale,
@@ -26,4 +32,4 @@ public record HistoryStats(
long last10Avg,
int totalItems,
Map<String, Long> tiposBreakdown) {
}
}
@@ -13,6 +13,9 @@ public record RunIn(
long totalShots,
long totalCristalesUsed,
long totalOreUsed,
long totalCostCristales,
long totalCostOre,
long totalCostDwarven,
List<RunItem> items,
RunSnapshot snapshot) {
}
}
@@ -11,7 +11,10 @@ public record RunItem(
int oreNecesario,
int crafteosPosibles,
int shotsObtenidos,
long costoCristales,
long costoOre,
long costoDwarven,
long costoTotal,
long valorVenta,
long ganancia) {
}
}
@@ -17,6 +17,9 @@ public record RunSummary(
long totalShots,
long totalCristalesUsed,
long totalOreUsed,
long costCristales,
long costOre,
long costDwarven,
double profitMarginPct,
long costPerShot,
long profitPerShot,
@@ -25,6 +28,10 @@ public record RunSummary(
boolean wasProfitable) {
public static RunSummary from(ProductionRun r, List<RunItem> items) {
return from(r, items, sumItems(items));
}
public static RunSummary from(ProductionRun r, List<RunItem> items, ItemCostSplit itemCosts) {
List<String> tipos = items.stream()
.map(i -> i.tipo())
.distinct()
@@ -45,6 +52,9 @@ public record RunSummary(
r.totalShots,
r.totalCristalesUsed,
r.totalOreUsed,
itemCosts.cristales(),
itemCosts.ore(),
itemCosts.dwarven(),
Math.round(marginPct * 10.0) / 10.0,
cps,
pps,
@@ -52,4 +62,16 @@ public record RunSummary(
tipos,
r.totalProfit > 0);
}
}
private static ItemCostSplit sumItems(List<RunItem> items) {
long c = 0, o = 0, d = 0;
for (RunItem i : items) {
c += i.costoCristales();
o += i.costoOre();
d += i.costoDwarven();
}
return new ItemCostSplit(c, o, d);
}
public record ItemCostSplit(long cristales, long ore, long dwarven) {}
}