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);
}
}
@@ -0,0 +1,673 @@
/* ============================================
PICO BASE — Flat size, no viewport scaling
============================================ */
:root {
color-scheme: light;
--pico-font-size: 100%;
--pico-primary: #059669;
--pico-primary-background: #059669;
--pico-primary-border: #059669;
--pico-primary-color: #ffffff;
--pico-primary-underline: rgba(5, 150, 105, 0.5);
--pico-primary-hover: #047857;
--pico-primary-hover-background: #047857;
--pico-primary-hover-border: #047857;
--pico-primary-hover-color: #ffffff;
--pico-primary-focus: rgba(5, 150, 105, 0.25);
--pico-primary-inverse: #ffffff;
--pico-background-color: #ffffff;
--pico-color: #1f2937;
--pico-card-background-color: #ffffff;
--pico-card-sectioning-background-color: #f9fafb;
--pico-card-border-color: #e5e7eb;
--pico-h1-color: #111827;
--pico-h2-color: #1f2937;
--pico-h3-color: #1f2937;
--pico-h4-color: #1f2937;
--pico-h5-color: #1f2937;
--pico-h6-color: #1f2937;
--pico-muted-color: #6b7280;
--pico-muted-border-color: #e5e7eb;
--pico-form-element-background-color: #ffffff;
--pico-form-element-border-color: #d1d5db;
--pico-form-element-color: #1f2937;
--pico-form-element-placeholder-color: #9ca3af;
--pico-form-element-focus-color: #059669;
--pico-form-element-active-border-color: #059669;
--pico-form-element-disabled-background-color: #f9fafb;
--pico-form-element-disabled-border-color: #d1d5db;
--pico-form-element-disabled-opacity: 0.5;
--pico-table-border-color: #e5e7eb;
--pico-table-row-stripped-background-color: #f9fafb;
--pico-code-background-color: #f3f4f6;
--pico-code-color: #1f2937;
--pico-spacing: 0.75rem;
--pico-block-spacing-vertical: 1rem;
--pico-block-spacing-horizontal: 1rem;
--pico-typography-spacing-vertical: 0.75rem;
--pico-typography-spacing-top: 0.5rem;
--pico-card-box-shadow: none;
}
@media (prefers-color-scheme: dark) {
:root {
--pico-primary-background: #059669;
--pico-primary-border: #059669;
--pico-primary-color: #ffffff;
--pico-background-color: #ffffff;
--pico-color: #1f2937;
--pico-card-background-color: #ffffff;
--pico-card-sectioning-background-color: #f9fafb;
--pico-card-border-color: #e5e7eb;
--pico-h1-color: #111827;
--pico-h2-color: #1f2937;
--pico-muted-color: #6b7280;
--pico-muted-border-color: #e5e7eb;
--pico-form-element-background-color: #ffffff;
--pico-form-element-border-color: #d1d5db;
--pico-form-element-color: #1f2937;
--pico-table-border-color: #e5e7eb;
}
}
/* ============================================
TYPOGRAPHY — Compact heading scale
============================================ */
h1 { font-size: 1.5rem; margin: 0 0 0.75rem; line-height: 1.25; font-weight: 700; }
h2 { font-size: 1.125rem; margin: 0 0 0.5rem; line-height: 1.3; font-weight: 600; }
h3 { font-size: 1rem; margin: 0 0 0.375rem; line-height: 1.35; font-weight: 600; }
h4 { font-size: 0.875rem; margin: 0 0 0.25rem; line-height: 1.4; font-weight: 600; }
h5, h6 { font-size: 0.875rem; margin: 0 0 0.25rem; line-height: 1.4; font-weight: 600; }
/* ============================================
FORM ELEMENTS — Compact
============================================ */
input, select, textarea {
font-size: 0.875rem;
padding: 0.375rem 0.625rem;
margin: 0;
height: auto;
line-height: 1.4;
}
label {
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 0.25rem;
line-height: 1.3;
}
label > span {
font-size: 0.8125rem;
color: var(--pico-muted-color);
}
input[type="number"] {
font-family: monospace;
}
/* ============================================
BUTTONS — Compact
============================================ */
button {
font-size: 0.875rem;
padding: 0.375rem 0.875rem;
margin: 0;
font-weight: 500;
border-radius: 0.25rem;
}
/* ============================================
ARTICLES — Flat, no card shadow
============================================ */
article {
background: transparent;
box-shadow: none;
padding: 0;
margin-bottom: 1.5rem;
border: none;
}
article > header {
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
article > header > h2 {
margin-bottom: 0.125rem;
}
article > header > p {
margin-bottom: 0;
font-size: 0.8125rem;
color: var(--pico-muted-color);
}
/* ============================================
TABLES — Compact
============================================ */
table {
font-size: 0.875rem;
width: 100%;
}
table th, table td {
padding: 0.375rem 0.5rem;
}
table th {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--pico-muted-color);
border-bottom: 1px solid var(--pico-muted-border-color);
background: var(--pico-card-sectioning-background-color);
}
table tbody tr:hover { background: var(--pico-tr-hover-background-color); }
table tfoot tr { background: var(--pico-muted-border-color); }
/* ============================================
CONTAINER — Wider for data tables
============================================ */
.container {
max-width: 1200px;
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes sparkle {
0%, 100% { opacity: 0.6; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.15); }
}
.sparkle {
animation: sparkle 2.5s ease-in-out infinite;
transform-origin: center;
}
/* ============================================
UTILITIES
============================================ */
[x-cloak] { display: none !important; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-mono { font-family: monospace; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-muted { color: var(--pico-muted-color); }
.text-success { color: #059669; }
.text-error { color: #dc2626; }
.text-bold { font-weight: 600; }
.flex-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.flex-col { display: flex; flex-direction: column; gap: 8px; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; }
.grid-5 { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.75rem; }
.grid-8 { display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.75rem; }
.grid-auto { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.mb-0 { margin-bottom: 0; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.w-full { width: 100%; }
.max-w-md { max-width: 28rem; margin-inline: auto; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ============================================
HEADER — Compact global header
============================================ */
header h1 {
font-size: 1.125rem !important;
margin-top: 0 !important;
margin-bottom: 0.25rem !important;
line-height: 1.2;
font-weight: 600;
}
header hgroup {
margin-bottom: 0 !important;
}
header {
margin-bottom: 1rem;
}
.header-meta {
font-size: 0.8125rem;
color: var(--pico-muted-color);
margin: 0;
}
.header-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
width: 100%;
box-sizing: border-box;
}
@media (min-width: 640px) {
.header-nav {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.header-actions {
display: flex;
width: auto;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
@media (min-width: 640px) {
.header-actions {
justify-content: flex-end;
}
}
.header-actions button.link-action {
background: none;
border: none;
color: var(--pico-primary);
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: 0.8125rem;
}
.header-actions button.link-action:hover {
color: var(--pico-primary-hover);
}
.link-action {
background: none;
border: none;
color: var(--pico-primary);
cursor: pointer;
font-size: 0.875rem;
text-decoration: underline;
padding: 0;
}
.link-action:hover {
color: var(--pico-primary-hover);
}
/* ============================================
MODAL
============================================ */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
z-index: 50; padding: 1rem;
}
.modal-content {
background: var(--pico-card-background-color);
border-radius: 8px;
max-width: 90vw; max-height: 90vh;
overflow-y: auto;
width: 100%;
}
/* ============================================
BADGES
============================================ */
.badge {
display: inline-block;
padding: 2px 8px;
font-size: 0.75rem;
border-radius: 4px;
font-weight: 500;
}
.badge-admin { background: #dbeafe; color: #1e3a8a; }
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-success { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
/* ============================================
TABS
============================================ */
.tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--pico-muted-border-color);
padding: 0;
margin-bottom: 1rem;
}
.tabs li { display: contents; }
.tabs a {
padding: 8px 12px;
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
color: var(--pico-muted-color);
font-size: 0.875rem;
}
.tabs a.active {
border-bottom-color: var(--pico-primary);
color: var(--pico-primary);
font-weight: 500;
}
/* ============================================
TABLE CELLS — For grid-based inputs
============================================ */
.grade-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 1.75rem; height: 1.75rem;
border-radius: 50%;
background: var(--pico-muted-border-color);
font-size: 0.75rem; font-weight: 600;
}
.cell-label {
font-size: 0.75rem; font-weight: 500;
color: var(--pico-muted-color);
text-transform: uppercase; letter-spacing: 0.05em;
}
.cell-input { padding: 0.375rem 0.5rem; }
.cell-calculated { font-family: monospace; }
/* ============================================
ALERTS & MESSAGES
============================================ */
.bg-warning { background: #fef3c7; border: 1px solid #fcd34d; color: #92400e; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.bg-success-light { background: #d1fae5; border: 1px solid #6ee7b7; color: #065f46; padding: 0.75rem 1rem; border-radius: 4px; }
.bg-error-light { background: #fee2e2; border: 1px solid #fca5a5; color: #991b1b; padding: 0.75rem 1rem; border-radius: 4px; }
.error-msg { color: #991b1b; background: #fee2e2; border: 1px solid #fca5a5; padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.875rem; }
.success-msg { color: #065f46; background: #d1fae5; border: 1px solid #6ee7b7; padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.875rem; }
/* ============================================
STATS GRID
============================================ */
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
@media (max-width: 768px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } }
.w-max { width: max-content; }
/* ============================================
LOGIN PAGE — centered card with branding
============================================ */
.login-page {
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 50%, #f0f9ff 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 26rem;
padding: 2.5rem 2rem;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05);
background: white;
margin: 0;
}
.login-logo {
width: 72px;
height: 72px;
margin: 0 auto 1rem;
display: block;
filter: drop-shadow(0 2px 4px rgba(5, 150, 105, 0.2));
}
.login-footer {
text-align: center;
margin-top: 1.5rem;
font-size: 0.75rem;
color: var(--pico-muted-color);
}
/* ============================================
HISTORIAL — SPARKLINE
============================================ */
.sparkline-container {
background: var(--pico-card-sectioning-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.sparkline {
display: flex;
align-items: flex-end;
gap: 4px;
height: 64px;
width: 100%;
}
.sparkline-bar-wrap {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
cursor: default;
}
.sparkline-bar {
width: 100%;
min-height: 4px;
border-radius: 2px 2px 0 0;
transition: height 0.3s ease;
}
.bar-positive { background: #059669; }
.bar-negative { background: #dc2626; }
.sparkline-labels {
display: flex;
justify-content: space-between;
margin-top: 4px;
}
/* ============================================
HISTORIAL — TIPO BREAKDOWN
============================================ */
.tipos-breakdown {
background: var(--pico-card-sectioning-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
}
.tipos-bars { display: flex; flex-direction: column; gap: 0.5rem; }
.tipo-bar-row {
display: grid;
grid-template-columns: 120px 1fr 80px;
align-items: center;
gap: 0.75rem;
}
.tipo-bar-track {
height: 8px;
background: var(--pico-muted-border-color);
border-radius: 4px;
overflow: hidden;
}
.tipo-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.fill-positive { background: #059669; }
.fill-negative { background: #dc2626; }
/* ============================================
HISTORIAL — SORTABLE COLUMNS
============================================ */
.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.sortable:hover { color: var(--pico-primary); }
.sort-icon {
font-size: 0.65rem;
opacity: 0.6;
margin-left: 2px;
}
/* ============================================
HISTORIAL — DELTA / VARIANCE
============================================ */
.delta-badge {
display: inline-block;
font-size: 0.65rem;
padding: 1px 4px;
border-radius: 3px;
margin-left: 4px;
font-family: monospace;
}
.delta-up { background: #d1fae5; color: #065f46; }
.delta-down { background: #fee2e2; color: #991b1b; }
/* ============================================
HISTORIAL — TIPO CHIPS
============================================ */
.tipo-chip {
display: inline-block;
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 3px;
background: var(--pico-muted-border-color);
color: var(--pico-muted-color);
font-weight: 500;
}
/* ============================================
HISTORIAL — METRIC CARDS
============================================ */
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
.metric-card {
background: var(--pico-card-sectioning-background-color);
border: 1px solid var(--pico-muted-border-color);
padding: 0.625rem 0.75rem;
border-radius: 6px;
}
.metric-card-sm {
background: transparent;
border: none;
border-top: 1px solid var(--pico-muted-border-color);
border-radius: 0;
padding: 0.375rem 0;
}
.text-md { font-size: 1rem; }
/* ============================================
HISTORIAL — BADGE RUN
============================================ */
.badge-run {
display: inline-block;
padding: 2px 8px;
font-size: 0.7rem;
border-radius: 4px;
font-weight: 600;
}
.badge-profit { background: #d1fae5; color: #065f46; }
.badge-loss { background: #fee2e2; color: #991b1b; }
/* ============================================
HISTORIAL — SNAPSHOT SECTION
============================================ */
.snapshot-section {
background: var(--pico-card-sectioning-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
}
.snapshot-grid {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.snapshot-row {
display: grid;
grid-template-columns: 80px 60px auto;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
/* ============================================
HISTORIAL — DIFF BADGES
============================================ */
.diff-badge {
font-size: 0.65rem;
padding: 1px 4px;
border-radius: 3px;
font-family: monospace;
white-space: nowrap;
}
.diff-up { background: #fee2e2; color: #991b1b; }
.diff-down { background: #d1fae5; color: #065f46; }
.diff-same { background: var(--pico-muted-border-color); color: var(--pico-muted-color); }
/* ============================================
MODAL — LARGER
============================================ */
.modal-lg .modal-content {
max-width: 900px;
}
/* ============================================
GAPS
============================================ */
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
@@ -0,0 +1,17 @@
window.fmt = {
adena(n) {
if (n == null) return '—';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toLocaleString('es-CL');
},
number(n) {
if (n == null) return '—';
return n.toLocaleString('es-CL');
},
percent(pct) {
if (pct == null) return '—';
const sign = pct > 0 ? '+' : '';
return sign + pct.toFixed(1).replace('.', ',') + '%';
},
};
@@ -0,0 +1,691 @@
<!doctype html>
<html lang="es" data-theme="light">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Historial — Calculadora de Craft de Shots</title>
<link rel="stylesheet" href="/static/css/pico.min.css"/>
<link rel="stylesheet" href="/static/css/app.css"/>
</head>
<body x-data="appShell()">
{#include partials/header.html /}
{#include partials/tab-bar.html /}
<main class="container" x-data="historialSection()">
<div x-show="loading && !stats" class="text-center p-6">
<p class="text-sm text-muted">Cargando historial…</p>
</div>
<template x-if="stats && stats.totalRuns > 0">
<div>
<div class="stats-grid" style="margin-bottom:1.5rem">
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Total ganado</p>
<p class="text-xl text-mono text-bold"
:class="stats.totalProfit > 0 ? 'text-success' : stats.totalProfit < 0 ? 'text-error' : ''"
x-text="fmtAdena(stats.totalProfit)"></p>
<p class="text-xs text-muted" x-text="stats.totalRuns + ' corrida' + (stats.totalRuns === 1 ? '' : 's')"></p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Promedio</p>
<p class="text-xl text-mono text-bold"
:class="stats.avgProfit > 0 ? 'text-success' : stats.avgProfit < 0 ? 'text-error' : ''"
x-text="fmtAdena(stats.avgProfit)"></p>
<p class="text-xs text-muted">por corrida</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Mejor corrida</p>
<p class="text-xl text-mono text-bold text-success"
x-text="stats.bestRun ? fmtAdena(stats.bestRun.totalProfit) : '—'"></p>
<p class="text-xs text-muted truncate" x-text="stats.bestRun?.label || (stats.bestRun?.createdAt ? fmtDate(stats.bestRun.createdAt) : '')"></p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Peor corrida</p>
<p class="text-xl text-mono text-bold"
:class="stats.worstRun && stats.worstRun.totalProfit < 0 ? 'text-error' : ''"
x-text="stats.worstRun ? fmtAdena(stats.worstRun.totalProfit) : '—'"></p>
<p class="text-xs text-muted truncate" x-text="stats.worstRun?.label || (stats.worstRun?.createdAt ? fmtDate(stats.worstRun.createdAt) : '')"></p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Margen promedio</p>
<p class="text-xl text-mono text-bold"
:class="stats.avgProfitMarginPct > 0 ? 'text-success' : stats.avgProfitMarginPct < 0 ? 'text-error' : ''"
x-text="fmtPct(stats.avgProfitMarginPct)"></p>
<p class="text-xs text-muted">rentabilidad</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Total invertido</p>
<p class="text-xl text-mono text-bold" x-text="fmtAdena(stats.totalCost)"></p>
<p class="text-xs text-muted">costo total</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Shots</p>
<p class="text-xl text-mono text-bold" x-text="fmtNumber(stats.totalShots)"></p>
<p class="text-xs text-muted">producidos</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo por shot</p>
<p class="text-xl text-mono text-bold" x-text="fmtAdena(stats.avgCostPerShot)"></p>
<p class="text-xs text-muted">promedio</p>
</div>
</div>
<div class="sparkline-container" x-show="runs.length > 0">
<p class="text-xs uppercase tracking-wide text-muted mb-2">Tendencia reciente</p>
<div class="sparkline">
<template x-for="(bar, idx) in sparklineBars" :key="idx">
<div class="sparkline-bar-wrap" :title="bar.label + ': ' + fmtAdena(bar.profit)">
<div class="sparkline-bar"
:class="bar.profit >= 0 ? 'bar-positive' : 'bar-negative'"
:style="'height:' + bar.heightPct + '%'"></div>
</div>
</template>
</div>
<div class="sparkline-labels">
<span class="text-xs text-muted">más antigua</span>
<span class="text-xs text-muted">más reciente</span>
</div>
</div>
<template x-if="stats.tiposBreakdown && Object.keys(stats.tiposBreakdown).length > 0">
<div class="tipos-breakdown">
<p class="text-xs uppercase tracking-wide text-muted mb-2">Ganancia por tipo</p>
<div class="tipos-bars">
<template x-for="[tipo, ganancia] in Object.entries(stats.tiposBreakdown)" :key="tipo">
<div class="tipo-bar-row">
<span class="text-sm text-bold" x-text="tipo"></span>
<div class="tipo-bar-track">
<div class="tipo-bar-fill"
:class="ganancia >= 0 ? 'fill-positive' : 'fill-negative'"
:style="'width:' + Math.min(100, Math.abs(ganancia) / (Math.max(...Object.values(stats.tiposBreakdown).map(v => Math.abs(v))) || 1) * 100) + '%'"></div>
</div>
<span class="text-mono text-sm" :class="ganancia >= 0 ? 'text-success' : 'text-error'"
x-text="fmtAdena(ganancia)"></span>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
<template x-if="stats && stats.totalRuns === 0">
<div class="bg-warning mb-4">
<p class="text-sm">
Aún no hay producciones guardadas. Ve a la pestaña <strong>Calculadora</strong>,
configura los cristales disponibles y presiona <strong>Guardar producción</strong>.
</p>
</div>
</template>
<section>
<header class="flex-row justify-between mb-3" style="align-items:center">
<h2 class="text-sm text-bold">Corridas guardadas</h2>
<div class="flex-row gap-2" style="align-items:center">
<span x-show="runs.length > 0" class="text-xs text-muted"
x-text="runs.length + ' en total · click en fila para ver detalle'"></span>
<template x-if="runs.length > 0">
<div class="flex-row gap-1">
<button type="button" @click="exportCSV()" class="outline contrast" style="font-size:0.7rem;padding:0.2rem 0.5rem" title="Exportar a CSV">
CSV
</button>
<button type="button" @click="exportJSON()" class="outline contrast" style="font-size:0.7rem;padding:0.2rem 0.5rem" title="Exportar a JSON">
JSON
</button>
</div>
</template>
</div>
</header>
<div x-show="runs.length === 0 && !loading" class="text-center p-6">
<p class="text-sm text-muted">No hay corridas para mostrar.</p>
</div>
<div x-show="runs.length > 0" class="table-wrapper">
<table>
<thead>
<tr>
<th class="cell-label sortable" @click="sortBy('createdAt')">
Fecha <span class="sort-icon" x-text="sortIcon('createdAt')"></span>
</th>
<th class="cell-label sortable" @click="sortBy('label')">
Label <span class="sort-icon" x-text="sortIcon('label')"></span>
</th>
<th class="cell-label text-right sortable" @click="sortBy('totalCost')">
Costo <span class="sort-icon" x-text="sortIcon('totalCost')"></span>
</th>
<th class="cell-label text-right sortable" @click="sortBy('totalSale')">
Venta <span class="sort-icon" x-text="sortIcon('totalSale')"></span>
</th>
<th class="cell-label text-right sortable" @click="sortBy('totalProfit')">
Ganancia <span class="sort-icon" x-text="sortIcon('totalProfit')"></span>
</th>
<th class="cell-label text-right sortable" @click="sortBy('profitMarginPct')">
Margen <span class="sort-icon" x-text="sortIcon('profitMarginPct')"></span>
</th>
<th class="cell-label text-right">Costo/shot</th>
<th class="cell-label text-right">Items</th>
<th class="cell-label text-center">Accion</th>
</tr>
</thead>
<tbody>
<template x-for="r in sortedRuns" :key="r.id">
<tr style="cursor:pointer" @click="openRun(r.id)">
<td class="text-mono text-xs" x-text="fmtDateTime(r.createdAt)"></td>
<td>
<span x-text="r.label || '(sin label)'"></span>
<template x-if="r.tiposUsados && r.tiposUsados.length > 0">
<div class="flex-row gap-1 mt-1">
<template x-for="tipo in r.tiposUsados" :key="tipo">
<span class="tipo-chip" x-text="tipo.replace('Spiritshot','SS').replace('Blessed Spiritshot','BSS').replace('Soulshot','SS')"></span>
</template>
</div>
</template>
</td>
<td class="text-mono text-right" x-text="fmtAdena(r.totalCost)"></td>
<td class="text-mono text-right" x-text="fmtAdena(r.totalSale)"></td>
<td class="text-mono text-right">
<span class="text-bold"
:class="r.totalProfit > 0 ? 'text-success' : r.totalProfit < 0 ? 'text-error' : ''"
x-text="fmtAdena(r.totalProfit)"></span>
<span class="delta-badge" :class="deltaClass(r.totalProfit)"
x-show="deltaVsAvg(r.totalProfit) !== null"
x-text="deltaIcon(r.totalProfit) + ' ' + fmtAdena(Math.abs(deltaVsAvg(r.totalProfit)))"></span>
</td>
<td class="text-mono text-right">
<span :class="r.profitMarginPct > 0 ? 'text-success' : r.profitMarginPct < 0 ? 'text-error' : ''"
x-text="fmtPct(r.profitMarginPct)"></span>
</td>
<td class="text-mono text-right text-muted" x-text="fmtAdena(r.costPerShot)"></td>
<td class="text-mono text-right" x-text="r.itemCount"></td>
<td class="text-center">
<button type="button"
@click.stop="deleteRun(r.id)"
class="outline contrast"
style="font-size:0.75rem;padding:0.25rem 0.5rem"
title="Borrar corrida">
Borrar
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
<div x-show="modalRunId"
x-cloak
class="modal-backdrop"
@click.self="closeModal()">
<div class="modal-content modal-lg" @click.stop>
<header class="flex-row justify-between" style="padding:1rem 1.5rem;border-bottom:1px solid var(--pico-muted-border-color);align-items:flex-start">
<div>
<div class="flex-row gap-2" style="align-items:center;margin-bottom:0.25rem">
<h2 style="font-size:1rem;margin:0" x-text="modalDetails?.label || 'Detalle de producción'"></h2>
<template x-if="modalDetails">
<span class="badge-run" :class="modalDetails.totalProfit >= 0 ? 'badge-profit' : 'badge-loss'"
x-text="modalDetails.totalProfit >= 0 ? 'Rentable' : 'Con pérdida'"></span>
</template>
</div>
<p x-show="modalDetails" class="text-xs text-mono text-muted"
x-text="modalDetails ? fmtDateTime(modalDetails.createdAt) : ''"></p>
</div>
<button type="button" @click="closeModal()" class="close" aria-label="Cerrar">×</button>
</header>
<div style="padding:1.5rem;overflow-y:auto;max-height:80vh">
<div x-show="modalLoading" class="text-sm text-muted">Cargando…</div>
<div x-show="modalError" class="error-msg mb-4" x-text="modalError"></div>
<template x-if="modalDetails">
<div>
<div class="grid-4 mb-6" style="gap:0.5rem">
<div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo total</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(modalDetails.totalCost)"></p>
</div>
<div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Venta total</p>
<p class="text-lg text-mono text-bold" x-text="fmtAdena(modalDetails.totalSale)"></p>
</div>
<div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Ganancia</p>
<p class="text-lg text-mono text-bold"
:class="modalDetails.totalProfit > 0 ? 'text-success' : modalDetails.totalProfit < 0 ? 'text-error' : ''"
x-text="fmtAdena(modalDetails.totalProfit)"></p>
</div>
<div class="metric-card">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Margen</p>
<p class="text-lg text-mono text-bold"
:class="marginPct(modalDetails) > 0 ? 'text-success' : marginPct(modalDetails) < 0 ? 'text-error' : ''"
x-text="fmtPct(marginPct(modalDetails))"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Costo/shot</p>
<p class="text-md text-mono text-bold" x-text="fmtAdena(costPerShot(modalDetails))"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Gan./shot</p>
<p class="text-md text-mono text-bold"
:class="profitPerShot(modalDetails) > 0 ? 'text-success' : profitPerShot(modalDetails) < 0 ? 'text-error' : ''"
x-text="fmtAdena(profitPerShot(modalDetails))"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Crafteos</p>
<p class="text-md text-mono text-bold" x-text="totalCrafteos()"></p>
</div>
<div class="metric-card metric-card-sm">
<p class="text-xs uppercase tracking-wide text-muted" style="margin-bottom:0.25rem">Ore total</p>
<p class="text-md text-mono text-bold" x-text="modalDetails.totalOreUsed"></p>
</div>
</div>
<template x-if="modalDetails.snapshot">
<div class="snapshot-section">
<h3 class="text-sm text-bold mb-3">
Snapshot de precios
<span class="text-xs text-muted" style="font-weight:normal"> (precios al momento de guardar)</span>
</h3>
<div class="grid-3 mb-4" style="gap:0.75rem">
<div>
<p class="text-xs uppercase tracking-wide text-muted mb-2">Cristales</p>
<div class="snapshot-grid">
<template x-for="[grado, precio] in Object.entries(snapshotCristales())" :key="grado">
<div class="snapshot-row">
<span class="text-xs text-muted" x-text="'Cristal ' + grado"></span>
<span class="text-mono text-xs" x-text="fmtAdena(precio)"></span>
<template x-if="currentCristal(grado) !== null">
<span class="diff-badge" :class="currentCristal(grado) > precio ? 'diff-up' : currentCristal(grado) < precio ? 'diff-down' : 'diff-same'"
x-text="currentCristal(grado) !== precio ? (currentCristal(grado) > precio ? '+' : '') + pctDiff(currentCristal(grado), precio) : '=='"></span>
</template>
</div>
</template>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted mb-2">Ores</p>
<div class="snapshot-grid">
<div class="snapshot-row">
<span class="text-xs text-muted">Soul Ore</span>
<span class="text-mono text-xs" x-text="fmtAdena(snapshotOre('soulOre'))"></span>
<template x-if="currentOre('soulOre') !== null">
<span class="diff-badge" :class="currentOre('soulOre') > snapshotOre('soulOre') ? 'diff-up' : currentOre('soulOre') < snapshotOre('soulOre') ? 'diff-down' : 'diff-same'"
x-text="currentOre('soulOre') !== snapshotOre('soulOre') ? (currentOre('soulOre') > snapshotOre('soulOre') ? '+' : '') + pctDiff(currentOre('soulOre'), snapshotOre('soulOre')) : '=='"></span>
</template>
</div>
<div class="snapshot-row">
<span class="text-xs text-muted">Spirit Ore</span>
<span class="text-mono text-xs" x-text="fmtAdena(snapshotOre('spiritOre'))"></span>
<template x-if="currentOre('spiritOre') !== null">
<span class="diff-badge" :class="currentOre('spiritOre') > snapshotOre('spiritOre') ? 'diff-up' : currentOre('spiritOre') < snapshotOre('spiritOre') ? 'diff-down' : 'diff-same'"
x-text="currentOre('spiritOre') !== snapshotOre('spiritOre') ? (currentOre('spiritOre') > snapshotOre('spiritOre') ? '+' : '') + pctDiff(currentOre('spiritOre'), snapshotOre('spiritOre')) : '=='"></span>
</template>
</div>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-muted mb-2">Precios de venta</p>
<div class="snapshot-grid" style="font-size:0.7rem">
<template x-for="[key, precio] in Object.entries(snapshotVenta())" :key="key">
<div class="snapshot-row">
<span class="text-xs text-muted" x-text="key.replace('Soulshot','SS ').replace('Spiritshot','SS ').replace('Blessed Spiritshot','BSS ') + key.split('-')[1]"></span>
<span class="text-mono text-xs" x-text="fmtAdena(precio)"></span>
<template x-if="currentVenta(key) !== null">
<span class="diff-badge" :class="currentVenta(key) > precio ? 'diff-up' : currentVenta(key) < precio ? 'diff-down' : 'diff-same'"
x-text="currentVenta(key) !== precio ? (currentVenta(key) > precio ? '+' : '') + pctDiff(currentVenta(key), precio) : '=='"></span>
</template>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<h3 class="text-sm text-bold mb-2">Desglose por grado</h3>
<div class="table-wrapper" style="border:1px solid var(--pico-muted-border-color);border-radius:4px;overflow:hidden">
<table style="font-size:0.75rem">
<thead>
<tr>
<th class="cell-label text-left">Tipo</th>
<th class="cell-label text-center">Grado</th>
<th class="cell-label text-right">Cristales</th>
<th class="cell-label text-right">Ore</th>
<th class="cell-label text-right">Crafteos</th>
<th class="cell-label text-right">Shots</th>
<th class="cell-label text-right">Costo</th>
<th class="cell-label text-right">Venta</th>
<th class="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
<template x-for="item in allItems()" :key="item.key">
<tr :style="item.ganancia < 0 ? 'background:#fff5f5' : ''">
<td class="text-bold" x-text="item.tipo"></td>
<td class="text-center">
<span class="grade-badge" x-text="item.grado"></span>
</td>
<td class="text-mono text-right" x-text="item.cristalesUsados + ' / ' + item.cristalesDisponibles"></td>
<td class="text-mono text-right" x-text="item.oreNecesario"></td>
<td class="text-mono text-right" x-text="item.crafteosPosibles"></td>
<td class="text-mono text-right" x-text="item.shotsObtenidos"></td>
<td class="text-mono text-right" x-text="fmtAdena(item.costoTotal)"></td>
<td class="text-mono text-right" x-text="fmtAdena(item.valorVenta)"></td>
<td class="text-mono text-right text-bold"
:class="item.ganancia > 0 ? 'text-success' : item.ganancia < 0 ? 'text-error' : ''"
x-text="fmtAdena(item.ganancia)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
<footer style="padding:0.75rem 1.5rem;border-top:1px solid var(--pico-muted-border-color);display:flex;justify-content:flex-end">
<button type="button" @click="closeModal()" class="secondary">Cerrar</button>
</footer>
</div>
</div>
</main>
<footer class="container">
<p class="text-center text-xs text-muted">
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
</p>
</footer>
<script src="/static/js/defaults.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/format.js"></script>
<script defer src="/static/js/alpine.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('historialSection', () => ({
store() { return Alpine.store('app'); },
runs: [],
stats: null,
loading: true,
modalRunId: null,
modalDetails: null,
modalLoading: false,
modalError: null,
sort: { field: 'createdAt', dir: 'desc' },
async init() {
await this.load();
},
async load() {
this.loading = true;
try {
const [runs, stats] = await Promise.all([
window.api.getHistoryRuns(),
window.api.getHistoryStats(),
]);
this.runs = runs || [];
this.stats = stats;
} catch (e) {
console.error('history load error', e);
} finally {
this.loading = false;
}
},
async openRun(id) {
this.modalRunId = id;
this.modalDetails = null;
this.modalError = null;
this.modalLoading = true;
try {
this.modalDetails = await window.api.getHistoryRun(id);
} catch (e) {
this.modalError = e.message || 'Error al cargar detalle';
} finally {
this.modalLoading = false;
}
},
closeModal() {
this.modalRunId = null;
this.modalDetails = null;
},
async deleteRun(id) {
const run = this.runs.find(r => r.id === id);
if (!confirm('Borrar la corrida "' + (run?.label || 'sin label') + '"?')) return;
try {
await window.api.deleteHistoryRun(id);
this.runs = this.runs.filter(r => r.id !== id);
if (this.modalRunId === id) this.closeModal();
await this.load();
} catch (e) {
alert('Error al borrar: ' + (e.message || e));
}
},
allItems() {
if (!this.modalDetails) return [];
const tipos = ['Soulshot', 'Spiritshot', 'Blessed Spiritshot'];
const grados = ['D', 'C', 'B', 'A', 'S'];
const items = [];
for (const tipo of tipos) {
for (const grado of grados) {
const item = this.modalDetails.items.find(
i => i.tipo === tipo && i.grado === grado
);
if (item) {
items.push({ ...item, key: tipo + '-' + grado });
} else {
items.push({ tipo, grado, key: tipo + '-' + grado,
ganancia: 0, costoTotal: 0, valorVenta: 0, shotsObtenidos: 0,
crafteosPosibles: 0, oreNecesario: 0, cristalesUsados: 0, cristalesDisponibles: 0 });
}
}
}
return items;
},
get sortedRuns() {
const field = this.sort.field;
const dir = this.sort.dir === 'asc' ? 1 : -1;
return [...this.runs].sort((a, b) => {
let va = a[field];
let vb = b[field];
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === 'string') {
return dir * va.localeCompare(vb);
}
if (va < vb) return -1 * dir;
if (va > vb) return 1 * dir;
return 0;
});
},
sortBy(field) {
if (this.sort.field === field) {
this.sort.dir = this.sort.dir === 'asc' ? 'desc' : 'asc';
} else {
this.sort.field = field;
this.sort.dir = 'desc';
}
},
sortIcon(field) {
if (this.sort.field !== field) return '↕';
return this.sort.dir === 'asc' ? '↑' : '↓';
},
deltaVsAvg(profit) {
if (!this.stats || !this.stats.avgProfit) return null;
return profit - this.stats.avgProfit;
},
deltaClass(delta) {
if (!delta) return '';
return delta > 0 ? 'delta-up' : 'delta-down';
},
deltaIcon(delta) {
if (!delta) return '';
return delta > 0 ? '↑' : '↓';
},
get sparklineBars() {
const data = (this.runs || []).slice(0, 10).map(r => ({ profit: r.totalProfit, label: this.fmtDate(r.createdAt) }));
if (!data.length) return [];
const max = Math.max(...data.map(d => d.profit));
const min = Math.min(...data.map(d => d.profit));
const range = Math.abs(max - min) || 1;
return data.map(d => ({
...d,
heightPct: d.profit === 0 ? 5 : Math.max(10, Math.abs(d.profit) / range * 90)
}));
},
fmtAdena(n) {
return window.fmt.adena(n);
},
fmtNumber(n) {
return window.fmt.number(n);
},
fmtPct(pct) {
return window.fmt.percent(pct);
},
fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('es-CL', { year: 'numeric', month: '2-digit', day: '2-digit' });
},
fmtDateTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString('es-CL', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
},
marginPct(details) {
if (!details || !details.totalCost) return 0;
return Math.round((details.totalProfit / details.totalCost) * 1000) / 10;
},
costPerShot(details) {
if (!details || !details.totalShots) return 0;
return Math.round(details.totalCost / details.totalShots);
},
profitPerShot(details) {
if (!details || !details.totalShots) return 0;
return Math.round(details.totalProfit / details.totalShots);
},
totalCrafteos() {
if (!this.modalDetails?.items) return 0;
return this.modalDetails.items.reduce((s, i) => s + (i.crafteosPosibles || 0), 0);
},
snapshotCristales() {
if (!this.modalDetails?.snapshot?.insumos?.cristales) return {};
return this.modalDetails.snapshot.insumos.cristales;
},
snapshotOre(key) {
if (!this.modalDetails?.snapshot?.insumos) return 0;
return this.modalDetails.snapshot.insumos[key] || 0;
},
snapshotVenta() {
if (!this.modalDetails?.snapshot?.insumos?.venta) return {};
const v = this.modalDetails.snapshot.insumos.venta;
const result = {};
for (const [tipo, grados] of Object.entries(v)) {
for (const [grado, precio] of Object.entries(grados)) {
result[tipo + '-' + grado] = precio;
}
}
return result;
},
currentCristal(grado) {
const state = this.store()?.state;
if (!state?.insumos?.cristales?.[grado]) return null;
return state.insumos.cristales[grado];
},
currentOre(key) {
const state = this.store()?.state;
if (!state?.insumos?.[key]) return null;
return state.insumos[key];
},
currentVenta(key) {
const state = this.store()?.state;
if (!state?.insumos?.venta) return null;
const [tipo, grado] = key.split('-');
if (!state.insumos.venta[tipo]?.[grado]) return null;
return state.insumos.venta[tipo][grado];
},
pctDiff(current, saved) {
if (!saved) return '?';
return Math.round((current - saved) / saved * 100) + '%';
},
exportCSV() {
const headers = ['Fecha', 'Label', 'Costo', 'Venta', 'Ganancia', 'Margen%', 'Costo/shot', 'Shots', 'Cristales', 'Ore', 'Items'];
const rows = this.runs.map(r => [
this.fmtDateTime(r.createdAt),
r.label || '',
r.totalCost,
r.totalSale,
r.totalProfit,
r.profitMarginPct,
r.costPerShot,
r.totalShots,
r.totalCristalesUsed,
r.totalOreUsed,
r.itemCount,
]);
const csv = [headers, ...rows].map(row => row.join(',')).join('\n');
this.downloadFile('corridas_' + this.store()?.user?.username + '_' + new Date().toISOString().slice(0, 10) + '.csv', csv, 'text/csv');
},
exportJSON() {
const data = { exportedAt: new Date().toISOString(), runs: this.runs, stats: this.stats };
const json = JSON.stringify(data, null, 2);
this.downloadFile('corridas_' + this.store()?.user?.username + '_' + new Date().toISOString().slice(0, 10) + '.json', json, 'application/json');
},
downloadFile(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
}));
});
</script>
</body>
</html>