<!-- ================================================================
DESPEDIDA DE ALBERTO · app en tiempo real para WordPress
Pega TODO esto en un bloque "HTML personalizado" de la página.
Necesita el snippet PHP (endpoint REST) activo en el mismo sitio.
================================================================ -->
<div id="despApp">
<style>
#despApp {
--ink:#14121c; --surface:#1e1b29; --surface-2:#262233; --line:#332e44;
--text:#ede8f2; --muted:#a29cb4; --faint:#6f6a82;
--rose:#ff476f; --rose-soft:#ff9bb2; --gold:#e8b54d; --green:#43dd9c; --amber:#f5b851;
--shadow:0 18px 50px -20px rgba(0,0,0,.7); --radius:16px; --maxw:640px;
background:
radial-gradient(120% 60% at 80% -10%, rgba(255,71,111,.18), transparent 60%),
radial-gradient(90% 50% at 0% 0%, rgba(232,181,77,.12), transparent 55%),
var(--ink);
color:var(--text); font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-font-smoothing:antialiased; border-radius:20px; overflow:hidden; padding-bottom:34px;
}
#despApp *{box-sizing:border-box;}
#despApp .wrap,#despApp .hero,#despApp .tabs-inner,#despApp main,#despApp .share{max-width:var(--maxw);margin-left:auto;margin-right:auto;}
#despApp .hero{padding:28px 18px 16px;}
#despApp .toprow{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;}
#despApp .eyebrow{font-size:12px;letter-spacing:.22em;text-transform:uppercase;color:var(--gold);font-weight:700;margin:0;display:flex;align-items:center;gap:8px;}
#despApp .eyebrow::before{content:"";width:24px;height:2px;background:var(--gold);border-radius:2px;}
#despApp .sync{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;color:var(--muted);background:var(--surface);border:1px solid var(--line);padding:5px 11px;border-radius:20px;white-space:nowrap;}
#despApp .dot{width:8px;height:8px;border-radius:50%;background:var(--faint);flex:0 0 auto;}
#despApp .sync.live .dot{background:var(--green);box-shadow:0 0 0 0 rgba(67,221,156,.6);animation:pulse 2s infinite;}
#despApp .sync.local .dot{background:var(--amber);}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(67,221,156,.55);}70%{box-shadow:0 0 0 7px rgba(67,221,156,0);}100%{box-shadow:0 0 0 0 rgba(67,221,156,0);}}
@media (prefers-reduced-motion:reduce){#despApp .sync.live .dot{animation:none;}}
#despApp .title{font-size:clamp(34px,9vw,52px);line-height:.98;margin:0;font-weight:800;letter-spacing:-.02em;}
#despApp .title em{font-style:normal;color:var(--rose);}
#despApp .subtitle{color:var(--muted);margin:10px 0 0;font-size:15px;max-width:46ch;}
#despApp .countdown{display:flex;gap:8px;margin-top:20px;flex-wrap:wrap;}
#despApp .cd-box{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:10px 14px;min-width:66px;text-align:center;box-shadow:var(--shadow);}
#despApp .cd-num{font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:-.02em;line-height:1;}
#despApp .cd-lab{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-top:5px;}
#despApp .cd-num.live{color:var(--rose);}
#despApp .tabs{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--ink) 88%,transparent);backdrop-filter:blur(12px);border-bottom:1px solid var(--line);margin-top:16px;}
#despApp .tabs-inner{display:flex;gap:4px;padding:6px;}
#despApp .tab{flex:1;border:0;background:transparent;color:var(--muted);font:inherit;font-weight:600;font-size:14px;padding:10px 6px;border-radius:10px;cursor:pointer;transition:background .18s,color .18s;display:flex;align-items:center;justify-content:center;gap:6px;}
#despApp .tab:hover{color:var(--text);}
#despApp .tab[aria-selected="true"]{background:var(--rose);color:#fff;}
#despApp .tab .badge{background:color-mix(in srgb,currentColor 22%,transparent);border-radius:20px;font-size:11px;padding:1px 7px;font-variant-numeric:tabular-nums;}
#despApp main{padding:20px 18px 0;}
#despApp .panel{display:none;}
#despApp .panel.active{display:block;animation:fade .25s ease;}
@keyframes fade{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
@media (prefers-reduced-motion:reduce){#despApp .panel.active{animation:none;}}
#despApp .section-head{display:flex;align-items:baseline;justify-content:space-between;margin:4px 0 14px;gap:10px;}
#despApp .section-head h2{font-size:20px;margin:0;letter-spacing:-.01em;}
#despApp .section-head .hint{font-size:13px;color:var(--faint);text-align:right;}
#despApp .card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:14px;margin-bottom:10px;box-shadow:var(--shadow);}
#despApp .row{display:flex;gap:12px;align-items:center;}
#despApp .ag-item{display:flex;gap:14px;align-items:flex-start;}
#despApp .ag-time{font-weight:800;font-variant-numeric:tabular-nums;color:var(--gold);background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:6px 10px;font-size:15px;min-width:62px;text-align:center;flex-shrink:0;}
#despApp .ag-body{flex:1;min-width:0;}
#despApp .ag-plan{font-weight:600;font-size:15px;}
#despApp .ag-place{color:var(--muted);font-size:13px;margin-top:3px;}
#despApp .ag-place:not(:empty)::before{content:"📍 ";}
#despApp .ag-place:empty::before{content:"➕ añadir lugar";color:var(--faint);}
#despApp .avatar{width:38px;height:38px;border-radius:50%;flex-shrink:0;display:grid;place-items:center;font-weight:700;color:#fff;font-size:15px;}
#despApp .person{display:flex;align-items:center;gap:10px;}
#despApp .person-main{flex:1;min-width:0;}
#despApp .person-name{font-weight:600;font-size:15px;}
#despApp .person-sub{font-size:12px;color:var(--faint);}
#despApp .status-pills{display:flex;gap:4px;}
#despApp .sp{border:1px solid var(--line);background:var(--surface-2);color:var(--muted);border-radius:20px;font:inherit;font-size:12px;font-weight:600;padding:5px 9px;cursor:pointer;transition:all .15s;}
#despApp .sp[data-on="si"]{background:var(--green);border-color:var(--green);color:#06231a;}
#despApp .sp[data-on="pend"]{background:var(--amber);border-color:var(--amber);color:#2c1c00;}
#despApp .sp[data-on="no"]{background:var(--surface-2);border-color:var(--rose);color:var(--rose);}
#despApp .plus-ctrl{display:flex;align-items:center;gap:6px;}
#despApp .plus-ctrl button{width:26px;height:26px;border-radius:8px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font:inherit;font-size:16px;cursor:pointer;line-height:1;}
#despApp .plus-ctrl span{font-variant-numeric:tabular-nums;min-width:16px;text-align:center;font-size:14px;}
#despApp .tally{display:flex;gap:8px;margin-bottom:14px;}
#despApp .tally-box{flex:1;background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:10px 12px;text-align:center;}
#despApp .tally-num{font-size:24px;font-weight:800;font-variant-numeric:tabular-nums;}
#despApp .tally-lab{font-size:11px;color:var(--faint);text-transform:uppercase;letter-spacing:.1em;margin-top:2px;}
#despApp .tally-box.c-si .tally-num{color:var(--green);}
#despApp .tally-box.c-pend .tally-num{color:var(--amber);}
#despApp .bote-hero{background:linear-gradient(135deg,color-mix(in srgb,var(--rose) 24%,var(--surface)),var(--surface));border:1px solid var(--line);border-radius:var(--radius);padding:18px;margin-bottom:14px;box-shadow:var(--shadow);}
#despApp .bote-total{font-size:40px;font-weight:800;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1;}
#despApp .bote-total small{font-size:20px;font-weight:700;opacity:.7;}
#despApp .bote-sub{color:var(--rose-soft);font-size:13px;margin-top:6px;font-weight:600;}
#despApp .bote-split{display:flex;gap:16px;margin-top:14px;flex-wrap:wrap;}
#despApp .bote-split div{font-size:13px;color:var(--muted);}
#despApp .bote-split b{display:block;font-size:18px;color:var(--text);font-variant-numeric:tabular-nums;margin-top:2px;}
#despApp .exp{display:flex;align-items:center;gap:12px;}
#despApp .exp-main{flex:1;min-width:0;}
#despApp .exp-concept{font-weight:600;font-size:14px;}
#despApp .exp-payer{font-size:12px;color:var(--faint);}
#despApp .exp-amt{font-weight:700;font-variant-numeric:tabular-nums;font-size:15px;}
#despApp .balance{display:flex;align-items:center;justify-content:space-between;padding:9px 0;border-bottom:1px solid var(--line);}
#despApp .balance:last-child{border-bottom:0;}
#despApp .bv{font-variant-numeric:tabular-nums;font-weight:700;font-size:14px;}
#despApp .bv.pos{color:var(--green);}
#despApp .bv.neg{color:var(--rose);}
#despApp .bv.zero{color:var(--faint);}
#despApp .addform{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;}
#despApp input,#despApp select{font:inherit;color:var(--text);background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none;min-width:0;}
#despApp input:focus,#despApp select:focus{border-color:var(--rose);box-shadow:0 0 0 3px color-mix(in srgb,var(--rose) 25%,transparent);}
#despApp input::placeholder{color:var(--faint);}
#despApp .btn{font:inherit;font-weight:700;border:0;border-radius:10px;padding:10px 16px;cursor:pointer;background:var(--rose);color:#fff;transition:filter .15s,transform .05s;}
#despApp .btn:hover{filter:brightness(1.08);}
#despApp .btn:active{transform:translateY(1px);}
#despApp .btn.ghost{background:var(--surface-2);color:var(--text);border:1px solid var(--line);}
#despApp .icon-btn{border:0;background:transparent;color:var(--faint);cursor:pointer;font-size:17px;padding:4px 6px;border-radius:8px;line-height:1;}
#despApp .icon-btn:hover{color:var(--rose);background:var(--surface-2);}
#despApp .empty{text-align:center;color:var(--faint);padding:24px 10px;font-size:14px;}
#despApp [contenteditable]{outline:none;border-radius:6px;}
#despApp [contenteditable]:focus{box-shadow:0 0 0 2px color-mix(in srgb,var(--rose) 40%,transparent);}
#despApp .share{margin-top:22px;padding:16px;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);}
#despApp .share h3{margin:0 0 4px;font-size:15px;}
#despApp .share p{margin:0 0 12px;font-size:13px;color:var(--muted);}
#despApp .share-btns{display:flex;gap:8px;flex-wrap:wrap;}
#despApp .toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%) translateY(20px);background:var(--text);color:var(--ink);padding:11px 18px;border-radius:999px;font-weight:600;font-size:14px;opacity:0;pointer-events:none;transition:all .28s;z-index:9999;box-shadow:var(--shadow);}
#despApp .toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
#despApp .footnote{margin:16px auto 0;max-width:var(--maxw);padding:0 18px;color:var(--faint);font-size:12px;text-align:center;line-height:1.5;}
</style>
<header class="hero">
<div class="toprow">
<p class="eyebrow">Última noche de libertad</p>
<div class="sync local" id="syncPill"><span class="dot"></span><span id="syncTxt">Conectando…</span></div>
</div>
<h1 class="title">Despedida de <em contenteditable="true" id="heName" spellcheck="false">Alberto</em></h1>
<p class="subtitle" contenteditable="true" id="heSub" spellcheck="false">Sábado 8 de agosto · lo damos todo por el que se nos casa 🥂</p>
<div class="countdown" id="countdown" aria-live="polite"></div>
</header>
<nav class="tabs" role="tablist" aria-label="Secciones">
<div class="tabs-inner">
<button class="tab" role="tab" id="tab-agenda" aria-controls="p-agenda" aria-selected="true">🗓️ Plan <span class="badge" id="b-agenda">0</span></button>
<button class="tab" role="tab" id="tab-gente" aria-controls="p-gente" aria-selected="false">👥 Gente <span class="badge" id="b-gente">0</span></button>
<button class="tab" role="tab" id="tab-bote" aria-controls="p-bote" aria-selected="false">💶 Bote <span class="badge" id="b-bote">0€</span></button>
</div>
</nav>
<main>
<section class="panel active" id="p-agenda" role="tabpanel" aria-labelledby="tab-agenda">
<div class="section-head"><h2>Plan del día</h2><span class="hint">toca la hora o el texto para editar</span></div>
<div id="agendaList"></div>
<div class="card"><div class="addform">
<input id="agTime" type="text" inputmode="numeric" placeholder="13:00" style="max-width:90px" aria-label="Hora">
<input id="agPlan" type="text" placeholder="Qué hacemos" style="flex:1 1 120px" aria-label="Plan">
<input id="agPlace" type="text" placeholder="Dónde (opcional)" style="flex:1 1 120px" aria-label="Lugar">
<button class="btn" id="agAdd">Añadir</button>
</div></div>
</section>
<section class="panel" id="p-gente" role="tabpanel" aria-labelledby="tab-gente">
<div class="tally">
<div class="tally-box c-si"><div class="tally-num" id="tSi">0</div><div class="tally-lab">Van seguro</div></div>
<div class="tally-box c-pend"><div class="tally-num" id="tPend">0</div><div class="tally-lab">Pendientes</div></div>
<div class="tally-box"><div class="tally-num" id="tHeads">0</div><div class="tally-lab">Cabezas</div></div>
</div>
<div id="peopleList"></div>
<div class="card"><div class="addform">
<input id="pName" type="text" placeholder="Nombre" style="flex:1 1 140px" aria-label="Nombre">
<button class="btn" id="pAdd">Añadir</button>
</div></div>
</section>
<section class="panel" id="p-bote" role="tabpanel" aria-labelledby="tab-bote">
<div class="bote-hero">
<div class="bote-total" id="boteTotal">0<small>€</small></div>
<div class="bote-sub" id="boteSub">Aún no hay gastos apuntados</div>
<div class="bote-split">
<div>Por cabeza<b id="botePer">0€</b></div>
<div>Cabezas<b id="boteHeads">0</b></div>
<div>Gastos<b id="boteCount">0</b></div>
</div>
</div>
<div class="section-head"><h2>Gastos</h2><span class="hint">quién ha puesto qué</span></div>
<div id="expList"></div>
<div class="card"><div class="addform">
<input id="exConcept" type="text" placeholder="Concepto (cena, karting…)" style="flex:1 1 120px" aria-label="Concepto">
<select id="exPayer" aria-label="Quién pagó"></select>
<input id="exAmt" type="number" inputmode="decimal" min="0" step="0.01" placeholder="€" style="max-width:90px" aria-label="Importe">
<button class="btn" id="exAdd">Añadir</button>
</div></div>
<div class="section-head" style="margin-top:22px"><h2>Cuentas claras</h2><span class="hint">reparto a partes iguales</span></div>
<div class="card" id="balanceCard"></div>
</section>
</main>
<div class="share">
<h3>Compartir con el grupo</h3>
<p>Pasa el enlace de esta página por WhatsApp. Todos editan aquí y se sincroniza en el móvil de cada uno.</p>
<div class="share-btns">
<button class="btn" id="shareLink">🔗 Copiar enlace</button>
<button class="btn ghost" id="shareWa">💬 WhatsApp</button>
<button class="btn ghost" id="shareDl">⬇️ Descargar resumen</button>
</div>
</div>
<p class="footnote" id="footnote">Los cambios se guardan en el sitio y se sincronizan cada pocos segundos.</p>
</div>
<script>
(function () {
"use strict";
/* ====== CONFIG ====== */
var API = "/wp-json/despedida/v1/state"; // endpoint REST (mismo sitio)
var TOKEN = "ALBERTO-8AGO"; // mismo token que en el PHP
var EVENT_ISO = "2026-08-08T13:00:00";
var POLL_MS = 3000;
/* ==================== */
var COLORS = ["#ff476f","#e8b54d","#43dd9c","#5b8cff","#c07bff","#ff8a4c","#3ec8d8","#f5648a"];
var root = document.getElementById("despApp");
var $ = function (id) { return document.getElementById(id); };
var clientId = "c" + Math.floor(Math.random() * 1e9).toString(36);
var now = function () { return Date.now(); };
function baseState() {
var t = 1;
return {
room: "despedida-alberto",
name: "Alberto", nameM: t, sub: "Sábado 8 de agosto · lo damos todo por el que se nos casa 🥂", subM: t,
tomb: {}, updatedAt: t,
agenda: [
{ id:"a1", m:t, time:"13:00", plan:"Vermú de bienvenida", place:"" },
{ id:"a2", m:t, time:"14:30", plan:"Comida (mesa reservada)", place:"" },
{ id:"a3", m:t, time:"18:00", plan:"Actividad sorpresa", place:"" },
{ id:"a4", m:t, time:"21:30", plan:"Cena", place:"" },
{ id:"a5", m:t, time:"00:00", plan:"Fiesta 🎉", place:"" }
],
people: [ { id:"p1", m:t, name:"Alberto", status:"si", plus:0 }, { id:"p2", m:t, name:"Antonio", status:"si", plus:0 } ],
expenses: []
};
}
var state = loadLocal() || baseState();
/* ---------- local cache ---------- */
function loadLocal() { try { var r = localStorage.getItem("despedida-alberto"); return r ? JSON.parse(r) : null; } catch (e) { return null; } }
function saveLocal() { try { localStorage.setItem("despedida-alberto", JSON.stringify(state)); } catch (e) {} }
/* ---------- merge (CRDT-lite) ---------- */
function mergeLists(a, b, tomb) {
var map = {};
(a || []).forEach(function (it) { map[it.id] = it; });
(b || []).forEach(function (it) { var e = map[it.id]; if (!e || (it.m||0) > (e.m||0)) map[it.id] = it; });
var out = [];
Object.keys(map).forEach(function (id) { var it = map[id]; if ((tomb[id]||0) >= (it.m||0)) return; out.push(it); });
return out;
}
function merge(local, remote) {
if (!remote) return local;
var tomb = {};
[local.tomb, remote.tomb].forEach(function (t) { if (t) Object.keys(t).forEach(function (id) { tomb[id] = Math.max(tomb[id]||0, t[id]||0); }); });
var m = {
room: local.room || "despedida-alberto", tomb: tomb,
name: (remote.nameM||0) > (local.nameM||0) ? remote.name : local.name, nameM: Math.max(local.nameM||0, remote.nameM||0),
sub: (remote.subM||0) > (local.subM||0) ? remote.sub : local.sub, subM: Math.max(local.subM||0, remote.subM||0),
agenda: mergeLists(local.agenda, remote.agenda, tomb),
people: mergeLists(local.people, remote.people, tomb),
expenses: mergeLists(local.expenses, remote.expenses, tomb),
updatedAt: Math.max(local.updatedAt||0, remote.updatedAt||0)
};
m.agenda.sort(function (a, b) { return (a.time||"99").localeCompare(b.time||"99"); });
return m;
}
function canon(s) {
if (!s) return "null";
var sl = function (l) { return (l||[]).slice().sort(function (a, b) { return a.id < b.id ? -1 : 1; }); };
var tk = {}; Object.keys(s.tomb||{}).sort().forEach(function (k) { tk[k] = s.tomb[k]; });
return JSON.stringify({ name:s.name, nameM:s.nameM, sub:s.sub, subM:s.subM, tomb:tk, agenda:sl(s.agenda), people:sl(s.people), expenses:sl(s.expenses) });
}
/* ---------- network ---------- */
var syncing = false, lastOk = 0;
function apiGet() {
var ctrl = new AbortController(); var to = setTimeout(function () { ctrl.abort(); }, 6000);
return fetch(API, { method:"GET", headers:{ "Accept":"application/json" }, signal:ctrl.signal, cache:"no-store" })
.then(function (r) { clearTimeout(to); if (!r.ok) throw new Error("http " + r.status); return r.text(); })
.then(function (t) { return t && t !== "null" ? JSON.parse(t) : null; });
}
function apiPost(s) {
var ctrl = new AbortController(); var to = setTimeout(function () { ctrl.abort(); }, 6000);
return fetch(API, { method:"POST", headers:{ "Content-Type":"application/json", "X-Room-Token":TOKEN }, body:JSON.stringify(s), signal:ctrl.signal })
.then(function (r) { clearTimeout(to); if (!r.ok) throw new Error("http " + r.status); return true; });
}
function setStatus(ok) {
var pill = $("syncPill"), txt = $("syncTxt");
if (ok) { pill.className = "sync live"; txt.textContent = "En vivo"; }
else { pill.className = "sync local"; txt.textContent = "Modo local"; }
}
function sync() {
if (syncing) return Promise.resolve();
syncing = true;
return apiGet().then(function (remote) {
lastOk = now(); setStatus(true);
var merged = merge(state, remote);
if (canon(merged) !== canon(state)) { state = merged; saveLocal(); render(); }
if (canon(merged) !== canon(remote)) { return apiPost(state); }
}).catch(function () {
if (now() - lastOk > 7000) setStatus(false);
}).then(function () { syncing = false; });
}
var pushT = null;
function scheduleSync() { clearTimeout(pushT); pushT = setTimeout(sync, 450); }
/* ---------- mutations ---------- */
function changed() { state.updatedAt = now(); saveLocal(); render(); scheduleSync(); }
function uid(p) { return (p||"x") + now().toString(36) + Math.floor(Math.random()*1e4).toString(36); }
function killItem(list, it) { state.tomb[it.id] = now(); state[list] = state[list].filter(function (x) { return x.id !== it.id; }); changed(); }
/* ---------- helpers ---------- */
function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return {"&":"&","<":"<",">":">",'"':"""}[c]; }); }
function money(n) { var r = Math.round(n*100)/100; return (Number.isInteger(r) ? r.toString() : r.toFixed(2)).replace(".", ","); }
function heads() { return state.people.reduce(function (a, p) { return a + (p.status === "si" ? 1 + (p.plus||0) : 0); }, 0); }
function totalSpent() { return state.expenses.reduce(function (a, e) { return a + (Number(e.amt)||0); }, 0); }
function toast(msg) { var t = $("despToast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove("show"); }, 1900); }
/* ---------- countdown ---------- */
function tickCountdown() {
var box = $("countdown"), diff = new Date(EVENT_ISO) - new Date();
if (diff <= 0) { box.innerHTML = '<div class="cd-box"><div class="cd-num live">¡HOY!</div><div class="cd-lab">Es la noche 🔥</div></div>'; return; }
var d = Math.floor(diff/86400000), h = Math.floor(diff%86400000/3600000), m = Math.floor(diff%3600000/60000);
box.innerHTML = [[d,"Días"],[h,"Horas"],[m,"Min"]].map(function (p) {
return '<div class="cd-box"><div class="cd-num">'+p[0]+'</div><div class="cd-lab">'+p[1]+'</div></div>';
}).join("");
}
/* ---------- render ---------- */
function render() {
if (document.activeElement && document.activeElement.isContentEditable) { /* no repintar mientras se edita texto */ }
else { renderHeader(); }
renderAgenda(); renderPeople(); renderBote(); renderBadges();
}
function renderHeader() {
if ($("heName").textContent !== state.name) $("heName").textContent = state.name;
if ($("heSub").textContent !== state.sub) $("heSub").textContent = state.sub;
}
function renderBadges() {
$("b-agenda").textContent = state.agenda.length;
$("b-gente").textContent = state.people.filter(function (p) { return p.status === "si"; }).length;
$("b-bote").textContent = money(totalSpent()) + "€";
}
function renderAgenda() {
var list = $("agendaList"); list.innerHTML = "";
if (!state.agenda.length) { list.appendChild(el("div","empty","Sin planes todavía. Añade el primero abajo 👇")); return; }
state.agenda.forEach(function (it) {
var card = el("div","card"), wrap = el("div","ag-item");
var time = el("div","ag-time"); time.contentEditable = "true"; time.spellcheck = false; time.textContent = it.time || "—";
time.addEventListener("blur", function () { var v = time.textContent.trim(); if (v !== it.time) { it.time = v; it.m = now(); changed(); } });
var body = el("div","ag-body");
var plan = el("div","ag-plan"); plan.contentEditable = "true"; plan.spellcheck = false; plan.textContent = it.plan || "";
plan.addEventListener("blur", function () { var v = plan.textContent.trim(); if (v !== it.plan) { it.plan = v; it.m = now(); changed(); } });
var place = el("div","ag-place"); place.contentEditable = "true"; place.spellcheck = false; place.textContent = it.place || "";
place.addEventListener("blur", function () { var v = place.textContent.trim(); if (v !== it.place) { it.place = v; it.m = now(); changed(); } });
body.appendChild(plan); body.appendChild(place);
var del = el("button","icon-btn","✕"); del.title = "Eliminar"; del.addEventListener("click", function () { killItem("agenda", it); });
wrap.appendChild(time); wrap.appendChild(body); wrap.appendChild(del); card.appendChild(wrap); list.appendChild(card);
});
}
function renderPeople() {
var list = $("peopleList"); list.innerHTML = "";
var si = 0, pend = 0;
state.people.forEach(function (p) { if (p.status === "si") si++; else if (p.status === "pend") pend++; });
$("tSi").textContent = si; $("tPend").textContent = pend; $("tHeads").textContent = heads();
if (!state.people.length) list.appendChild(el("div","empty","Aún no hay nadie apuntado."));
state.people.forEach(function (p, i) {
var card = el("div","card"), row = el("div","person");
var av = el("div","avatar"); av.style.background = COLORS[i % COLORS.length]; av.textContent = (p.name||"?").trim().charAt(0).toUpperCase() || "?";
var main = el("div","person-main");
var nm = el("div","person-name"); nm.contentEditable = "true"; nm.spellcheck = false; nm.textContent = p.name;
nm.addEventListener("blur", function () { var v = nm.textContent.trim() || "¿?"; if (v !== p.name) { p.name = v; p.m = now(); changed(); refreshPayers(); } });
var sub = el("div","person-sub"); sub.textContent = p.plus ? ("+"+p.plus+" acompañante"+(p.plus>1?"s":"")) : "solo";
main.appendChild(nm); main.appendChild(sub);
var pills = el("div","status-pills");
[["si","Va"],["pend","?"],["no","No"]].forEach(function (opt) {
var b = el("button","sp",opt[1]); if (p.status === opt[0]) b.setAttribute("data-on", opt[0]);
b.addEventListener("click", function () { p.status = opt[0]; p.m = now(); changed(); }); pills.appendChild(b);
});
var plus = el("div","plus-ctrl");
var minus = el("button",null,"−"); minus.setAttribute("aria-label","Quitar acompañante");
var cnt = el("span",null,String(p.plus||0));
var addb = el("button",null,"+"); addb.setAttribute("aria-label","Añadir acompañante");
minus.addEventListener("click", function () { p.plus = Math.max(0,(p.plus||0)-1); p.m = now(); changed(); });
addb.addEventListener("click", function () { p.plus = (p.plus||0)+1; p.m = now(); changed(); });
plus.appendChild(minus); plus.appendChild(cnt); plus.appendChild(addb);
var del = el("button","icon-btn","✕"); del.title = "Eliminar"; del.addEventListener("click", function () { killItem("people", p); refreshPayers(); });
row.appendChild(av); row.appendChild(main); card.appendChild(row);
var row2 = el("div","row"); row2.style.marginTop = "12px"; row2.style.justifyContent = "space-between";
row2.appendChild(pills); row2.appendChild(plus); row2.appendChild(del); card.appendChild(row2); list.appendChild(card);
});
}
function refreshPayers() {
var sel = $("exPayer"), cur = sel.value; sel.innerHTML = "";
if (!state.people.length) { var o = el("option"); o.value = ""; o.textContent = "(añade gente)"; sel.appendChild(o); return; }
state.people.forEach(function (p) { var o = el("option"); o.value = p.id; o.textContent = p.name; sel.appendChild(o); });
if (cur && state.people.some(function (p) { return p.id === cur; })) sel.value = cur;
}
function renderBote() {
var total = totalSpent(), h = heads(), per = h > 0 ? total/h : 0;
$("boteTotal").innerHTML = money(total) + '<small>€</small>';
$("botePer").textContent = money(per) + "€"; $("boteHeads").textContent = h; $("boteCount").textContent = state.expenses.length;
$("boteSub").textContent = total === 0 ? "Aún no hay gastos apuntados" : (h > 0 ? "Cada cabeza pone " + money(per) + "€" : "Marca quién va para repartir");
var list = $("expList"); list.innerHTML = "";
if (!state.expenses.length) list.appendChild(el("div","empty","Sin gastos. Apunta lo que se va pagando."));
state.expenses.forEach(function (e) {
var payer = state.people.filter(function (p) { return p.id === e.payer; })[0];
var card = el("div","card"), r = el("div","exp"), main = el("div","exp-main");
main.appendChild(el("div","exp-concept", esc(e.concept)));
main.appendChild(el("div","exp-payer","Pagó " + esc(payer ? payer.name : "¿?")));
var del = el("button","icon-btn","✕"); del.title = "Eliminar"; del.addEventListener("click", function () { killItem("expenses", e); });
r.appendChild(main); r.appendChild(el("div","exp-amt", money(e.amt) + "€")); r.appendChild(del); card.appendChild(r); list.appendChild(card);
});
renderBalances(per);
}
function renderBalances(per) {
var card = $("balanceCard"); card.innerHTML = "";
var going = state.people.filter(function (p) { return p.status === "si"; });
if (!going.length || totalSpent() === 0) { card.appendChild(el("div","empty","Marca quién va y apunta gastos para ver el reparto.")); return; }
going.forEach(function (p) {
var share = per * (1 + (p.plus||0));
var paid = state.expenses.reduce(function (a, e) { return a + (e.payer === p.id ? (Number(e.amt)||0) : 0); }, 0);
var net = paid - share, row = el("div","balance");
row.appendChild(el("div","bn", esc(p.name) + (p.plus ? " (+"+p.plus+")" : "")));
var cls = net > 0.005 ? "pos" : net < -0.005 ? "neg" : "zero";
var txt = net > 0.005 ? "le deben " + money(net) + "€" : net < -0.005 ? "pone " + money(-net) + "€" : "en paz ✓";
row.appendChild(el("div","bv " + cls, txt)); card.appendChild(row);
});
}
/* ---------- summary / share ---------- */
function summaryText() {
var L = [];
L.push("🥳 DESPEDIDA DE " + (state.name||"ALBERTO").toUpperCase());
L.push(state.sub || ""); L.push("");
L.push("🗓️ PLAN");
state.agenda.forEach(function (a) { L.push(" " + (a.time||"—") + " " + a.plan + (a.place ? " (" + a.place + ")" : "")); });
L.push("");
L.push("👥 VAN (" + heads() + " en total)");
state.people.filter(function (p) { return p.status === "si"; }).forEach(function (p) { L.push(" ✓ " + p.name + (p.plus ? " +" + p.plus : "")); });
var pend = state.people.filter(function (p) { return p.status === "pend"; });
if (pend.length) L.push(" ⏳ Pendientes: " + pend.map(function (p) { return p.name; }).join(", "));
var total = totalSpent(), h = heads();
if (total > 0) { L.push(""); L.push("💶 BOTE: " + money(total) + "€" + (h > 0 ? " · " + money(total/h) + "€ por cabeza" : "")); }
L.push(""); L.push(location.origin + location.pathname);
return L.join("\n");
}
function copy(text, ok) {
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(function () { toast(ok); }, function () { fb(text, ok); });
else fb(text, ok);
function fb(t, ok) { var ta = document.createElement("textarea"); ta.value = t; ta.style.position = "fixed"; ta.style.opacity = "0"; root.appendChild(ta); ta.select(); try { document.execCommand("copy"); toast(ok); } catch (e) { toast("Copia manual"); } root.removeChild(ta); }
}
function download() {
var blob = new Blob([summaryText()], { type:"text/plain;charset=utf-8" });
var a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "despedida-alberto.txt";
root.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
}
/* ---------- header edit ---------- */
$("heName").addEventListener("blur", function () { var v = $("heName").textContent.trim() || "Alberto"; if (v !== state.name) { state.name = v; state.nameM = now(); changed(); } });
$("heSub").addEventListener("blur", function () { var v = $("heSub").textContent.trim(); if (v !== state.sub) { state.sub = v; state.subM = now(); changed(); } });
/* ---------- adds ---------- */
function addAgenda() {
var time = $("agTime").value.trim(), plan = $("agPlan").value.trim(), place = $("agPlace").value.trim();
if (!plan) { $("agPlan").focus(); return; }
state.agenda.push({ id:uid("a"), m:now(), time:time, plan:plan, place:place });
state.agenda.sort(function (a, b) { return (a.time||"99").localeCompare(b.time||"99"); });
$("agTime").value = ""; $("agPlan").value = ""; $("agPlace").value = ""; changed(); $("agTime").focus();
}
function addPerson() { var n = $("pName").value.trim(); if (!n) return; state.people.push({ id:uid("p"), m:now(), name:n, status:"pend", plus:0 }); $("pName").value = ""; changed(); refreshPayers(); $("pName").focus(); }
function addExpense() {
var c = $("exConcept").value.trim(), payer = $("exPayer").value, amt = parseFloat($("exAmt").value);
if (!c || !payer || !(amt > 0)) { if (!payer) toast("Añade gente primero"); return; }
state.expenses.push({ id:uid("e"), m:now(), concept:c, payer:payer, amt:amt });
$("exConcept").value = ""; $("exAmt").value = ""; changed(); $("exConcept").focus();
}
/* ---------- tabs ---------- */
var tabs = root.querySelectorAll(".tab");
tabs.forEach(function (t) {
t.addEventListener("click", function () {
tabs.forEach(function (x) { x.setAttribute("aria-selected","false"); }); t.setAttribute("aria-selected","true");
root.querySelectorAll(".panel").forEach(function (p) { p.classList.remove("active"); });
$(t.getAttribute("aria-controls")).classList.add("active");
});
});
/* ---------- toast node ---------- */
var toastEl = el("div","toast"); toastEl.id = "despToast"; document.body.appendChild(toastEl);
/* ---------- wire ---------- */
$("agAdd").addEventListener("click", addAgenda);
$("agPlan").addEventListener("keydown", function (e) { if (e.key === "Enter") addAgenda(); });
$("agPlace").addEventListener("keydown", function (e) { if (e.key === "Enter") addAgenda(); });
$("pAdd").addEventListener("click", addPerson);
$("pName").addEventListener("keydown", function (e) { if (e.key === "Enter") addPerson(); });
$("exAdd").addEventListener("click", addExpense);
$("exAmt").addEventListener("keydown", function (e) { if (e.key === "Enter") addExpense(); });
$("shareLink").addEventListener("click", function () { copy(location.origin + location.pathname, "Enlace copiado ✓"); });
$("shareWa").addEventListener("click", function () { window.open("https://wa.me/?text=" + encodeURIComponent(summaryText()), "_blank"); });
$("shareDl").addEventListener("click", download);
/* ---------- go ---------- */
refreshPayers(); render(); tickCountdown(); setInterval(tickCountdown, 30000);
sync(); setInterval(sync, POLL_MS);
})();
</script>