Files
shot-crafter-calculator/src/main/resources/templates/PageResource/historial.html
T
darroyo e913b5ceb0
CI / Build Native (push) Failing after 39s
fix(historial): clamp sparkline bar height between 15-90% to prevent overflow
When range=0 (1 run or all same profit), use absolute scale with clamped heightPct
2026-08-15 23:11:45 -04:00

698 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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" :title="tipo" x-text="tipoShort(tipo)"></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="tipoShort(key.split('-')[0]) + ' ' + 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);
const useAbsoluteScale = range === 0 || max === 0;
const ref = useAbsoluteScale ? Math.max(Math.abs(max), 1) : range;
return data.map(d => ({
...d,
heightPct: d.profit === 0 ? 50 : Math.max(15, Math.min(90, Math.abs(d.profit) / ref * 90))
}));
},
fmtAdena(n) {
return window.fmt.adena(n);
},
fmtNumber(n) {
return window.fmt.number(n);
},
tipoShort(tipo) {
return { 'Soulshot': 'SS', 'Spiritshot': 'SPS', 'Blessed Spiritshot': 'BSS' }[tipo] || tipo;
},
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>