From d73f946cb2ba536da378ee9f98970aa4bc00d986 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Thu, 20 Aug 2026 19:15:42 -0400 Subject: [PATCH] feat(calculadora): cost split breakdown + plan por cristales mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../com/l2/shots/history/HistoryService.java | 27 +- .../com/l2/shots/history/HistoryStats.java | 8 +- src/main/java/com/l2/shots/history/RunIn.java | 5 +- .../java/com/l2/shots/history/RunItem.java | 5 +- .../java/com/l2/shots/history/RunSummary.java | 24 +- .../META-INF/resources/static/css/app.css | 43 ++ .../META-INF/resources/static/js/calc.js | 19 +- .../templates/PageResource/calculadora.html | 497 ++++++++++++++---- .../templates/PageResource/historial.html | 109 +++- 9 files changed, 637 insertions(+), 100 deletions(-) diff --git a/src/main/java/com/l2/shots/history/HistoryService.java b/src/main/java/com/l2/shots/history/HistoryService.java index abf25a7..907f4bf 100644 --- a/src/main/java/com/l2/shots/history/HistoryService.java +++ b/src/main/java/com/l2/shots/history/HistoryService.java @@ -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 bestItems = parseItems(best.itemsJson); List 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 parseItems(String json) { if (json == null || json.isBlank()) return List.of(); try { @@ -159,4 +180,4 @@ public class HistoryService { return List.of(); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/l2/shots/history/HistoryStats.java b/src/main/java/com/l2/shots/history/HistoryStats.java index 92084db..fcc639b 100644 --- a/src/main/java/com/l2/shots/history/HistoryStats.java +++ b/src/main/java/com/l2/shots/history/HistoryStats.java @@ -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 tiposBreakdown) { -} +} \ No newline at end of file diff --git a/src/main/java/com/l2/shots/history/RunIn.java b/src/main/java/com/l2/shots/history/RunIn.java index 0f0bf22..dbb285e 100644 --- a/src/main/java/com/l2/shots/history/RunIn.java +++ b/src/main/java/com/l2/shots/history/RunIn.java @@ -13,6 +13,9 @@ public record RunIn( long totalShots, long totalCristalesUsed, long totalOreUsed, + long totalCostCristales, + long totalCostOre, + long totalCostDwarven, List items, RunSnapshot snapshot) { -} +} \ No newline at end of file diff --git a/src/main/java/com/l2/shots/history/RunItem.java b/src/main/java/com/l2/shots/history/RunItem.java index 77781e4..379727a 100644 --- a/src/main/java/com/l2/shots/history/RunItem.java +++ b/src/main/java/com/l2/shots/history/RunItem.java @@ -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) { -} +} \ No newline at end of file diff --git a/src/main/java/com/l2/shots/history/RunSummary.java b/src/main/java/com/l2/shots/history/RunSummary.java index 7fe572d..d267e94 100644 --- a/src/main/java/com/l2/shots/history/RunSummary.java +++ b/src/main/java/com/l2/shots/history/RunSummary.java @@ -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 items) { + return from(r, items, sumItems(items)); + } + + public static RunSummary from(ProductionRun r, List items, ItemCostSplit itemCosts) { List 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 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) {} +} \ No newline at end of file diff --git a/src/main/resources/META-INF/resources/static/css/app.css b/src/main/resources/META-INF/resources/static/css/app.css index c3293c4..cc9006d 100644 --- a/src/main/resources/META-INF/resources/static/css/app.css +++ b/src/main/resources/META-INF/resources/static/css/app.css @@ -691,3 +691,46 @@ header { .card-expired { opacity: 0.7; } .card-urgent { border-left: 3px solid #dc2626; } +/* ============================================ + COST SPLIT BAR — distribución de costo (cristales / ore / fee) + ============================================ */ +.cost-split-bar { + display: flex; + height: 0.75rem; + background: var(--pico-muted-border-color); + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--pico-muted-border-color); +} + +.cost-split-segment-cristales { + background: #6366f1; + height: 100%; + transition: width 0.2s ease-out; +} + +.cost-split-segment-ore { + background: #f59e0b; + height: 100%; + transition: width 0.2s ease-out; +} + +.cost-split-segment-fee { + background: #94a3b8; + height: 100%; + transition: width 0.2s ease-out; +} + +.cost-legend-dot { + display: inline-block; + width: 0.6rem; + height: 0.6rem; + border-radius: 2px; + vertical-align: middle; + margin-right: 0.25rem; +} + +.cost-split-section { + /* container for the cost split bar + legend */ +} + diff --git a/src/main/resources/META-INF/resources/static/js/calc.js b/src/main/resources/META-INF/resources/static/js/calc.js index 53e32b2..6a36c24 100644 --- a/src/main/resources/META-INF/resources/static/js/calc.js +++ b/src/main/resources/META-INF/resources/static/js/calc.js @@ -14,6 +14,9 @@ window.calc = { oreLabel, crafteosPosibles: 0, shotsObtenidos: 0, + costoCristales: 0, + costoOre: 0, + costoDwarven: 0, costoTotal: 0, valorVenta: 0, ganancia: 0, @@ -33,7 +36,9 @@ window.calc = { const precioVenta = insumos.venta[formula.tipo]?.[formula.grado] || 0; const dwarvenFee = crafteosPosibles * (insumos.dwarvenFeePerCraft || 0); - const costoTotal = cristalesUsados * precioCristal + oreNecesario * precioOre + dwarvenFee; + const costoCristales = cristalesUsados * precioCristal; + const costoOre = oreNecesario * precioOre; + const costoTotal = costoCristales + costoOre + dwarvenFee; const valorVenta = shotsObtenidos * precioVenta; const ganancia = valorVenta - costoTotal; @@ -46,6 +51,9 @@ window.calc = { oreLabel, crafteosPosibles, shotsObtenidos, + costoCristales, + costoOre, + costoDwarven: dwarvenFee, costoTotal, valorVenta, ganancia, @@ -59,12 +67,17 @@ window.calc = { oreNecesario: acc.oreNecesario + c.oreNecesario, crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles, shotsObtenidos: acc.shotsObtenidos + c.shotsObtenidos, + costoCristales: acc.costoCristales + c.costoCristales, + costoOre: acc.costoOre + c.costoOre, + costoDwarven: acc.costoDwarven + c.costoDwarven, costoTotal: acc.costoTotal + c.costoTotal, valorVenta: acc.valorVenta + c.valorVenta, ganancia: acc.ganancia + c.ganancia, }), { cristalesUsados: 0, oreNecesario: 0, crafteosPosibles: 0, - shotsObtenidos: 0, costoTotal: 0, valorVenta: 0, ganancia: 0, + shotsObtenidos: 0, + costoCristales: 0, costoOre: 0, costoDwarven: 0, + costoTotal: 0, valorVenta: 0, ganancia: 0, }); }, -}; +}; \ No newline at end of file diff --git a/src/main/resources/templates/PageResource/calculadora.html b/src/main/resources/templates/PageResource/calculadora.html index cf61169..f20d36a 100644 --- a/src/main/resources/templates/PageResource/calculadora.html +++ b/src/main/resources/templates/PageResource/calculadora.html @@ -41,6 +41,8 @@ Presupuesto: Dado un presupuesto, qué craftear.  |  Objetivo: Quiero X shots, cuánto cuesta. +  |  + Plan cristales: Tengo N cristales, dame el mejor plan.

@@ -56,6 +58,10 @@ @click="modo = 'objetivo'" :class="modo === 'objetivo' ? 'primary' : 'outline secondary'" style="font-size:0.75rem;padding:0.3rem 0.6rem">Objetivo +
@@ -92,19 +98,22 @@
- - - - - - - - - - - - - + + + + + + + + + + + + + + + + -
+

Totales globales

@@ -170,12 +185,26 @@

Shots obtenidos

-

+

+
+
+

C. Cristales

+

+
+
+

C. Ore

+

+
+
+

C. Fee

+

Costo total

+
+

Valor venta

@@ -187,6 +216,19 @@ x-text="fmtAdena(totalesGlobal().ganancia)">

+
+

Distribución del costo

+
+
+
+
+
+
+ Cristales + Ore + Fee +
+
@@ -378,7 +420,10 @@
- + + + + @@ -391,7 +436,10 @@ - + + + +
GradoCristales disp.Cristales usadosCrafteosShotsCostoVentaGanancia
GradoCristales disp.Cristales usadosCrafteosShotsC. CristalesC. OreC. FeeCosto totalVentaGanancia
Shots Cristales Ore nec.CostoC. CristalesC. OreC. FeeCosto total Venta Ganancia
- - - - - - - - - - - - +
TipoGradoCrafteosShotsCristalesOre nec.CostoVentaGanancia
+ + + + + + + + + + + + + + + + + + + +
TipoGradoCrafteosShotsCristalesOre nec.C. CristalesC. OreC. FeeCosto totalVentaGanancia
@@ -484,40 +538,46 @@