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
@@ -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>