feat: add character sales tracking with 12h countdown timer
CI / Build Native (push) Failing after 1m11s

- New page /ventas to track characters selling items
- Default 12h duration per character sale
- Real-time countdown with color-coded badges (green/yellow/red/gray)
- Sound alert toggle when sales expire
- CRUD API: GET/POST/DELETE /api/sales
- Flyway migration V2 for character_sales table
- Private per-user (same user sees only their own characters)
- Expired sales remain visible with 'Expirado' badge until manually deleted
This commit is contained in:
2026-08-18 09:22:41 -04:00
parent 8ca11252d7
commit af323da3d3
11 changed files with 848 additions and 0 deletions
@@ -212,6 +212,7 @@ table tfoot tr { background: var(--pico-muted-border-color); }
.text-muted { color: var(--pico-muted-color); }
.text-success { color: #059669; }
.text-error { color: #dc2626; }
.text-warning { color: #d97706; }
.text-bold { font-weight: 600; }
.flex-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
@@ -357,6 +358,7 @@ header {
.badge-warning { background: #fef3c7; color: #92400e; }
.badge-success { background: #d1fae5; color: #065f46; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-muted { background: #f3f4f6; color: #6b7280; }
/* ============================================
TABS
@@ -605,6 +607,7 @@ header {
}
.text-md { font-size: 1rem; }
.text-lg { font-size: 1.125rem; }
/* ============================================
HISTORIAL — BADGE RUN
@@ -619,6 +622,8 @@ header {
.badge-profit { background: #d1fae5; color: #065f46; }
.badge-loss { background: #fee2e2; color: #991b1b; }
.badge-craft { background: #d1fae5; color: #065f46; }
.badge-buy { background: #fee2e2; color: #991b1b; }
/* ============================================
HISTORIAL — SNAPSHOT SECTION
@@ -674,3 +679,15 @@ header {
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
/* ============================================
RANKING ROWS
============================================ */
.row-profit { background: #f0fdf4; }
.row-loss { background: #fff5f5; }
/* ============================================
SALES CARDS
============================================ */
.card-expired { opacity: 0.7; }
.card-urgent { border-left: 3px solid #dc2626; }
@@ -0,0 +1,127 @@
window.api = {
async request(path, options = {}) {
const res = await fetch(path, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (res.status === 204 || res.status === 201) return null;
const text = await res.text();
let body = null;
if (text) {
try { body = JSON.parse(text); } catch { body = text; }
}
if (!res.ok) {
const message = body && typeof body === 'object' && 'error' in body
? String(body.error)
: `HTTP ${res.status}`;
const err = new Error(message);
err.status = res.status;
err.body = body;
throw err;
}
return body;
},
me() {
return this.request('/api/auth/me').catch(e => {
if (e.status === 401) return null;
throw e;
});
},
login(username, password) {
return this.request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
},
register(username, password) {
return this.request('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
},
logout() {
return this.request('/api/auth/logout', { method: 'POST' });
},
changePassword(currentPassword, newPassword) {
return this.request('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
},
adminResetPassword(username, newPassword) {
return this.request('/api/auth/admin/reset-password', {
method: 'POST',
body: JSON.stringify({ username, newPassword }),
});
},
adminListUsers() {
return this.request('/api/auth/admin/users');
},
getState() {
return this.request('/api/state').catch(e => {
if (e.status === 404) return null;
throw e;
});
},
putState(state) {
return this.request('/api/state', {
method: 'PUT',
body: JSON.stringify(state),
});
},
resetState() {
return this.request('/api/state', { method: 'DELETE' });
},
getHistoryRuns() {
return this.request('/api/history/runs');
},
getHistoryRun(id) {
return this.request(`/api/history/runs/${id}`);
},
saveHistoryRun(input) {
return this.request('/api/history/runs', {
method: 'POST',
body: JSON.stringify(input),
});
},
deleteHistoryRun(id) {
return this.request(`/api/history/runs/${id}`, { method: 'DELETE' });
},
getHistoryStats() {
return this.request('/api/history/stats');
},
listSales() {
return this.request('/api/sales');
},
createSale(payload) {
return this.request('/api/sales', {
method: 'POST',
body: JSON.stringify(payload),
});
},
deleteSale(id) {
return this.request(`/api/sales/${id}`, { method: 'DELETE' });
},
};
@@ -0,0 +1,10 @@
CREATE TABLE character_sales (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
character_name VARCHAR(50) NOT NULL,
items_description TEXT NOT NULL,
started_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_char_sales_user ON character_sales (user_id, started_at);
@@ -0,0 +1,285 @@
<!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>Ventas — 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="ventasSection()">
<article>
<header>
<h2>Agregar personaje en venta</h2>
<p class="text-sm text-muted">Duración por defecto: 12 horas</p>
</header>
<form @submit.prevent="submitSale()">
<div class="grid" style="grid-template-columns: 1fr 2fr;">
<label>
<span class="text-sm text-muted">Nombre del personaje</span>
<input type="text"
x-model="form.characterName"
maxlength="50"
required
placeholder="p.ej. DarkMage"/>
</label>
<label>
<span class="text-sm text-muted">Qué vende</span>
<textarea x-model="form.itemsDescription"
rows="3"
maxlength="2000"
required
placeholder="p.ej. SS B-grade x1000, SPS A-grade x500..."></textarea>
</label>
</div>
<div class="flex-row gap-2" style="align-items: flex-end;">
<label style="flex: 0 0 auto;">
<span class="text-sm text-muted">Hora de inicio</span>
<input type="datetime-local"
x-model="form.startedAt"/>
</label>
<button type="submit"
class="primary"
:disabled="submitting">
<span x-show="!submitting">Agregar</span>
<span x-show="submitting">Guardando…</span>
</button>
</div>
<p x-show="error" class="text-error text-sm" x-text="error"></p>
</form>
</article>
<article>
<header class="flex-row" style="justify-content: space-between; align-items: center;">
<div>
<h2 style="margin: 0;">Personajes en venta</h2>
<p class="text-sm text-muted" x-text="sales.length + ' personaje' + (sales.length === 1 ? '' : 's')"></p>
</div>
<label class="flex-row gap-1" style="align-items: center; cursor: pointer;">
<input type="checkbox"
x-model="soundEnabled"
@change="localStorage.setItem('ventasSoundEnabled', soundEnabled ? '1' : '0')"/>
<span class="text-sm">🔔 Alerta sonora</span>
</label>
</header>
<div x-show="loading && sales.length === 0" class="text-center p-4">
<p class="text-sm text-muted">Cargando…</p>
</div>
<div x-show="sales.length === 0 && !loading" class="text-center p-4">
<p class="text-sm text-muted">No hay personajes en venta. Agregá uno arriba.</p>
</div>
<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem;">
<template x-for="sale in sortedSales" :key="sale.id">
<article :class="getCardClass(sale)">
<header>
<div class="flex-row" style="justify-content: space-between; align-items: center;">
<h3 style="margin: 0; font-size: 1rem;" x-text="sale.characterName"></h3>
<span class="badge"
:class="getBadgeClass(sale)"
x-text="getStatusLabel(sale)"></span>
</div>
</header>
<p class="text-sm" style="white-space: pre-wrap; margin: 0.5rem 0;" x-text="sale.itemsDescription"></p>
<div style="margin: 0.75rem 0; text-align: center;">
<p class="text-mono text-xl text-bold"
:class="getCountdownClass(sale)"
x-text="getCountdown(sale)"
style="margin: 0;"></p>
</div>
<footer class="text-xs text-muted">
<p style="margin: 0;">Empezó: <span x-text="fmtDate(sale.startedAt)"></span></p>
<p style="margin: 0.25rem 0 0;">Expira: <span x-text="fmtDate(sale.expiresAt)"></span></p>
</footer>
<button type="button"
class="secondary outline"
style="margin-top: 0.75rem; width: 100%;"
@click="deleteSale(sale.id)">
Eliminar
</button>
</article>
</template>
</div>
</article>
</main>
<footer class="container">
<p class="text-center text-xs text-muted">
Tiempo real · No se auto-eliminan al expirar
</p>
</footer>
<script src="/static/js/api.js"></script>
<script defer src="/static/js/alpine.min.js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('ventasSection', () => ({
sales: [],
loading: true,
submitting: false,
error: null,
soundEnabled: localStorage.getItem('ventasSoundEnabled') === '1',
beepFired: new Set(),
form: {
characterName: '',
itemsDescription: '',
startedAt: '',
},
get sortedSales() {
return [...this.sales].sort((a, b) => {
const aExpired = this.isExpired(a);
const bExpired = this.isExpired(b);
if (aExpired && !bExpired) return 1;
if (!aExpired && bExpired) return -1;
return new Date(b.expiresAt) - new Date(a.expiresAt);
});
},
async init() {
await this.loadSales();
setInterval(() => {
this.$refs;
this.sales = [...this.sales];
}, 1000);
},
async loadSales() {
try {
this.sales = await window.api.listSales();
} catch (e) {
this.error = e.message || 'Error cargando ventas';
} finally {
this.loading = false;
}
},
async submitSale() {
if (!this.form.characterName.trim() || !this.form.itemsDescription.trim()) {
this.error = 'Completá todos los campos';
return;
}
this.error = null;
this.submitting = true;
try {
let startedAt = null;
if (this.form.startedAt) {
startedAt = new Date(this.form.startedAt).toISOString();
}
const created = await window.api.createSale({
characterName: this.form.characterName.trim(),
itemsDescription: this.form.itemsDescription.trim(),
startedAt: startedAt,
});
this.sales.unshift(created);
this.form.characterName = '';
this.form.itemsDescription = '';
this.form.startedAt = '';
} catch (e) {
this.error = e.message || 'Error al guardar';
} finally {
this.submitting = false;
}
},
async deleteSale(id) {
if (!confirm('Eliminar este personaje?')) return;
try {
await window.api.deleteSale(id);
this.sales = this.sales.filter(s => s.id !== id);
} catch (e) {
this.error = e.message || 'Error al eliminar';
}
},
isExpired(sale) {
return new Date(sale.expiresAt).getTime() <= Date.now();
},
getRemaining(sale) {
return Math.max(0, new Date(sale.expiresAt).getTime() - Date.now());
},
getCountdown(sale) {
const ms = this.getRemaining(sale);
if (ms <= 0) return '00:00:00';
const totalSecs = Math.floor(ms / 1000);
const h = Math.floor(totalSecs / 3600);
const m = Math.floor((totalSecs % 3600) / 60);
const s = totalSecs % 60;
return [h, m, s].map(v => String(v).padStart(2, '0')).join(':');
},
getCountdownClass(sale) {
const ms = this.getRemaining(sale);
if (ms <= 0) return 'text-muted';
const hours = ms / 1000 / 3600;
if (hours < 1) return 'text-error';
if (hours < 3) return 'text-warning';
return 'text-success';
},
getBadgeClass(sale) {
if (this.isExpired(sale)) return 'badge-muted';
const ms = this.getRemaining(sale);
const hours = ms / 1000 / 3600;
if (hours < 0.25) return 'badge-error';
if (hours < 1) return 'badge-warning';
return 'badge-success';
},
getStatusLabel(sale) {
if (this.isExpired(sale)) return 'Expirado';
const ms = this.getRemaining(sale);
const hours = ms / 1000 / 3600;
if (hours < 0.25) return 'Por expirar';
if (hours < 1) return 'Pocas horas';
return 'Activo';
},
getCardClass(sale) {
if (this.isExpired(sale)) return 'card-expired';
const ms = this.getRemaining(sale);
const hours = ms / 1000 / 3600;
if (hours < 0.25) return 'card-urgent';
return '';
},
fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString('es-CL', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
},
}));
});
window.beep = () => {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = 880;
o.connect(ctx.destination);
o.start();
o.stop(ctx.currentTime + 0.25);
} catch (e) {}
};
</script>
</body>
</html>
@@ -0,0 +1,10 @@
<nav x-data="appTabBar()" class="container">
<ul class="tabs">
<li><a href="/insumos" :class="activeTab('/insumos')">Insumos</a></li>
<li><a href="/formulas" :class="activeTab('/formulas')">Formulas</a></li>
<li><a href="/calculadora" :class="activeTab('/calculadora')">Calculadora</a></li>
<li><a href="/historial" :class="activeTab('/historial')">Historial</a></li>
<li><a href="/ventas" :class="activeTab('/ventas')">Ventas</a></li>
<li x-show="user()?.isAdmin"><a href="/usuarios" :class="activeTab('/usuarios')">Usuarios</a></li>
</ul>
</nav>