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