feat(historial): comprehensive enrichment of history page
CI / Build Native (push) Failing after 1m21s

Backend:
- RunSummary: add profitMarginPct, costPerShot, profitPerShot, itemCount, tiposUsados, wasProfitable
- HistoryStats: add avgProfitMarginPct, avgCostPerShot, avgProfitPerShot, totalOreUsed, totalItems, tiposBreakdown
- HistoryService: compute all new fields, parse items for best/worst run

Frontend (historial.html):
- 8 stats cards instead of 5: +Margen promedio, +Total invertido, +Shots, +Costo/shot
- SVG sparkline showing last 10 runs profit trend
- Ganancia por tipo breakdown with horizontal bars
- Table: sortable columns (click headers), new cols: Margen%, Costo/shot, Items chips, delta vs avg
- Sortable by: Fecha, Label, Costo, Venta, Ganancia, Margen
- Modal: badge Profit/Loss, 8 metric cards, snapshot of prices at save time, comparison vs current prices
- Export buttons: CSV (resumen) + JSON (completo con items y snapshot)

Format.js:
- Add fmtPercent() with es-CL locale

CSS:
- Add sparkline, tipos-breakdown, sortable columns, delta badges, metric cards, diff badges, modal-lg
This commit is contained in:
2026-08-15 21:30:01 -04:00
parent 209ef9fb05
commit 54fd68aa08
6 changed files with 1472 additions and 10 deletions
@@ -6,7 +6,10 @@ 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;
import java.util.Optional;
import java.util.UUID;
@@ -18,7 +21,10 @@ public class HistoryService {
public List<RunSummary> listForUser(UUID userId) {
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
.stream()
.map(RunSummary::from)
.map(r -> {
List<RunItem> items = parseItems(r.itemsJson);
return RunSummary.from(r, items);
})
.toList();
}
@@ -55,7 +61,7 @@ public class HistoryService {
throw new RuntimeException("Failed to serialize run payload", e);
}
entity.persist();
return RunSummary.from(entity);
return RunSummary.from(entity, input.items());
}
@Transactional
@@ -68,21 +74,37 @@ public class HistoryService {
"userId = ?1 ORDER BY createdAt DESC", userId);
if (runs.isEmpty()) {
return new HistoryStats(0, 0, 0, 0, 0, 0, 0, 0, null, null, 0, 0);
return new HistoryStats(
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.0, 0, 0,
null, null, 0, 0, 0, Map.of());
}
long totalCost = 0;
long totalSale = 0;
long totalProfit = 0;
long totalShots = 0;
long totalCristalesUsed = 0;
long totalOreUsed = 0;
int totalItems = 0;
ProductionRun best = runs.get(0);
ProductionRun worst = runs.get(0);
Map<String, Long> tiposBreakdown = new LinkedHashMap<>();
for (ProductionRun r : runs) {
totalCost += r.totalCost;
totalSale += r.totalSale;
totalProfit += r.totalProfit;
totalShots += r.totalShots;
totalCristalesUsed += r.totalCristalesUsed;
totalOreUsed += r.totalOreUsed;
List<RunItem> items = parseItems(r.itemsJson);
totalItems += items.size();
for (RunItem item : items) {
tiposBreakdown.merge(item.tipo(), item.ganancia(), Long::sum);
}
if (r.totalProfit > best.totalProfit) best = r;
if (r.totalProfit < worst.totalProfit) worst = r;
}
@@ -97,18 +119,44 @@ public class HistoryService {
if (i < n5) sum5 += runs.get(i).totalProfit;
}
double avgMargin = totalCost > 0
? (double) totalProfit * 100.0 / totalCost
: 0.0;
long avgCostPerShot = totalShots > 0 ? totalCost / totalShots : 0;
long avgProfitPerShot = totalShots > 0 ? totalProfit / totalShots : 0;
List<RunItem> bestItems = parseItems(best.itemsJson);
List<RunItem> worstItems = parseItems(worst.itemsJson);
return new HistoryStats(
n,
totalCost,
totalSale,
totalProfit,
totalShots,
totalCristalesUsed,
totalOreUsed,
totalProfit / n,
totalCost / n,
totalSale / n,
RunSummary.from(best),
RunSummary.from(worst),
Math.round(avgMargin * 10.0) / 10.0,
avgCostPerShot,
avgProfitPerShot,
RunSummary.from(best, bestItems),
RunSummary.from(worst, worstItems),
n5 > 0 ? sum5 / n5 : 0,
n10 > 0 ? sum10 / n10 : 0);
n10 > 0 ? sum10 / n10 : 0,
totalItems,
tiposBreakdown);
}
private List<RunItem> parseItems(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return mapper.readValue(json, mapper.getTypeFactory()
.constructCollectionType(List.class, RunItem.class));
} catch (JsonProcessingException e) {
return List.of();
}
}
}
@@ -2,6 +2,9 @@ package com.l2.shots.history;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.util.List;
import java.util.Map;
@RegisterForReflection
public record HistoryStats(
int totalRuns,
@@ -9,11 +12,18 @@ public record HistoryStats(
long totalSale,
long totalProfit,
long totalShots,
long totalCristalesUsed,
long totalOreUsed,
long avgProfit,
long avgCost,
long avgSale,
double avgProfitMarginPct,
long avgCostPerShot,
long avgProfitPerShot,
RunSummary bestRun,
RunSummary worstRun,
long last5Avg,
long last10Avg) {
long last10Avg,
int totalItems,
Map<String, Long> tiposBreakdown) {
}
@@ -3,6 +3,7 @@ package com.l2.shots.history;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@RegisterForReflection
@@ -15,9 +16,25 @@ public record RunSummary(
long totalProfit,
long totalShots,
long totalCristalesUsed,
long totalOreUsed) {
long totalOreUsed,
double profitMarginPct,
long costPerShot,
long profitPerShot,
int itemCount,
List<String> tiposUsados,
boolean wasProfitable) {
public static RunSummary from(ProductionRun r) {
public static RunSummary from(ProductionRun r, List<RunItem> items) {
List<String> tipos = items.stream()
.map(i -> i.tipo())
.distinct()
.toList();
int count = items.size();
double marginPct = r.totalCost > 0
? (double) r.totalProfit * 100.0 / r.totalCost
: 0.0;
long cps = r.totalShots > 0 ? r.totalCost / r.totalShots : 0;
long pps = r.totalShots > 0 ? r.totalProfit / r.totalShots : 0;
return new RunSummary(
r.id,
r.createdAt,
@@ -27,6 +44,12 @@ public record RunSummary(
r.totalProfit,
r.totalShots,
r.totalCristalesUsed,
r.totalOreUsed);
r.totalOreUsed,
Math.round(marginPct * 10.0) / 10.0,
cps,
pps,
count,
tipos,
r.totalProfit > 0);
}
}