Resumen

Ingresos y gastos
Últimos 6 meses, en pesos
Ingresos Gastos
Gastos por categoría
Cuánto guardas de lo que entra
Tasa de ahorro mes a mes
En qué días gastas
Menos Más

Contra tu promedio

Gastos más grandes del mes

Últimos movimientos

Movimientos

Inversiones

Mi cartera

Composición de la cartera
Valor de mercado de cada posición, en pesos
Ganancia y pérdida por instrumento
Realizado + dividendos + no realizado, en pesos
A favor En contra

Historial de operaciones

Patrimonio

Estado de patrimonio

Tu cartera de acciones entra sola en esta lista, con el valor que sale de la sección Inversiones. El resto lo actualizas tú: son saldos de hoy, no un historial.

De qué está hecho tu patrimonio
Cada activo sobre el total, en pesos
Evolución del patrimonio neto
Una foto por mes, guardada automáticamente

Historial

Mes a mes

Los ingresos y gastos se calculan cada vez desde tus movimientos, así que si corriges algo de un mes viejo el historial se corrige solo. El patrimonio es distinto: los saldos se sobreescriben al actualizarlos, así que la app guarda una foto por mes y esas quedan congeladas — es el único modo de tener historial de algo que no lleva fecha.

Balance de cada mes
Lo que quedó después de gastar, mes a mes

Ajustes

Dónde se guardan tus datos

Tipo de cambio

Saldo inicial de tu caja

La app calcula tu efectivo sumando y restando lo que anotas, pero no sabe con cuánto partiste. Pon aquí lo que tenías el día que empezaste a registrar y el patrimonio queda cuadrado. Si lo dejas en cero, el efectivo va a salir corto.

Cada movimiento y cada operación en dólares guarda el tipo de cambio que estaba vigente el día que lo anotaste, así que cambiar este número no altera tu historial. El valor actual de la cartera sí se convierte con este número, porque es plata que todavía tienes invertida hoy.

Categorías de gasto

Categorías de ingreso

Respaldo de tus datos

Todo vive en este navegador y en este equipo. Si borras los datos de navegación, se pierde. Exporta de vez en cuando y guarda el archivo donde guardas lo importante.

+ new Intl.NumberFormat('es-CL',{maximumFractionDigits:2}).format(db.ajustes.tc) + ')'; } function pieInforme(){ const p = HOY.split("-"); return '
Generado por Mis Finanzas (' + VERSION + ') el ' + Number(p[2]) + '-' + p[1] + '-' + p[0] + '. Los montos en dólares de operaciones pasadas usan el tipo de ' + 'cambio con que se registró cada una; los saldos actuales, el dólar vigente.
'; } const tarjeta = (t, v, n, color) => '
' + (color ? '' : '') + esc(t) + '
' + v + '
' + (n ? '
' + n + '
' : '') + '
'; const pctTxt = p => { const a = Math.abs(p), signo = p >= 0 ? "+" : "−"; if (a < 0.05) return "0%"; return signo + (a < 10 ? a.toFixed(1).replace(".", ",") : Math.round(a)) + "%"; }; /* ---------- 1. RESUMEN, en formato tablero ---------- */ function informeResumen(ym){ const b = balanceMes(ym), prev = balanceMes(mesAnterior(ym)); const t = totales(ym), tp = totales(mesAnterior(ym)); const pat = calcularPatrimonio(); const cart = calcularCartera().total; let h = encabezadoInforme("Resumen · " + mesLargo(ym), "Cómo te fue en el mes"); /* cifras del mes */ h += '

El mes en una mirada

' + tarjeta("Ingresos", clp(t.ingreso), tp.ingreso ? pctTxt((t.ingreso-tp.ingreso)/tp.ingreso*100) + " vs. mes anterior" : " ", "#0f7d57") + tarjeta("Gastos", clp(t.gasto), tp.gasto ? pctTxt((t.gasto-tp.gasto)/tp.gasto*100) + " vs. mes anterior" : " ", "#c04c1f") + tarjeta("Quedó en caja", clpFirmado(t.balance), t.ingreso ? Math.round(t.balance/t.ingreso*100) + "% de lo que entró" : " ") + tarjeta("Ahorrado", clpFirmado(t.ahorro), t.ahorro ? "Movido a Ahorro" : "Nada este mes", "#0f7d57") + tarjeta("Patrimonio neto", clpFirmado(pat.neto), "Todo lo tuyo, menos deudas") + '
'; /* dos donas: en qué se fue el mes, y cómo está repartido lo que tienes */ const cats = b.cats.filter(c => !esAhorro({categoria:c.cat})); const paleta = ["#2a78d6","#c04c1f","#0f7d57","#b57a00","#c4527c","#4a3aa7","#b32a2a","#777"]; let trozosGasto = cats.map((c,i) => ({ nombre:c.cat, valor:c.monto, color:paleta[i % paleta.length] })); if (trozosGasto.length > 6){ const resto = trozosGasto.slice(5).reduce((s,x) => s + x.valor, 0); trozosGasto = trozosGasto.slice(0,5).concat([{nombre:"Otras", valor:resto, color:"#777"}]); } const trozosPat = pat.bolsas.filter(x => x.valor > 0) .map(x => ({ nombre:x.nombre, valor:x.valor, color:COLOR_BOLSA[x.id] })); h += '
' + '

En qué se te fue el mes

' + svgDona(trozosGasto, {centro:"Gastos", titulo:"Gastos por categoría"}) + '
' + '

Cómo está repartido lo que tienes

' + svgDona(trozosPat, {centro:"Activos", titulo:"Distribución del patrimonio"}) + '
' + '
'; /* seis meses de ingresos y gastos + tasa de ahorro */ const meses = []; let m = ym; for (let i = 0; i < 6; i++){ meses.unshift(m); m = mesAnterior(m); } const serie = meses.map(x => { const tt = totales(x); return { ym:x, ingreso:tt.ingreso, gasto:tt.gasto, tasa: tt.ingreso > 0 ? tt.noConsumido/tt.ingreso*100 : 0 }; }); h += '
' + '

Ingresos y gastos · 6 meses

' + '
' + ' Ingresos   ' + ' Gastos
' + svgBarrasMes(serie, [{campo:"ingreso",color:"#0f7d57"},{campo:"gasto",color:"#c04c1f"}]) + '
' + '

Cuánto no consumiste, mes a mes

' + svgBarrasMes(serie, [{campo:"tasa", color:"#2a78d6", colorNeg:"#b32a2a", porcentaje:true}]) + '
' + '
'; /* el patrimonio, desglosado como pidió: cash, ahorro, inversiones */ h += '

Tu patrimonio, por bolsa

' + '' + pat.bolsas.map(x => '' + '' + '' + '').join("") + (pat.sumaDeudas ? '' + '' : '') + '' + '
BolsaDe dónde saleMonto%
' + esc(x.nombre) + '' + esc({ efectivo:"Saldo inicial + tus movimientos", ahorro:"Movimientos en la categoría Ahorro", inversion:"Valor de mercado de tu cartera", otros:"Propiedades, vehículos y otros" }[x.id]) + '' + clpFirmado(x.valor) + '' + (pat.sumaActivos ? (x.valor/pat.sumaActivos*100).toFixed(1).replace(".", ",") + "%" : "—") + '
DeudasCréditos y tarjetas−' + clp(pat.sumaDeudas) + '
Patrimonio neto' + clpFirmado(pat.neto) + '
'; /* los golpes grandes y contra qué se comparan */ const top = db.movimientos.filter(x => x.tipo === "gasto" && !esAhorro(x) && mesDe(x.fecha) === ym) .sort((a,c) => enCLP(c) - enCLP(a)).slice(0,5); h += '
' + '

Los cinco gastos más grandes

' + (top.length ? svgBarrasH(top.map(x => ({ nombre: x.descripcion || x.categoria, valor: enCLP(x) })), "#c04c1f") : '
Sin gastos este mes.
') + '
' + '

Cartera de inversiones

' + '' + '' + '' + '' + '' + '' + '' + '
Valor de mercado' + clp(cart.valorCLP) + '
Invertido' + clp(cart.costoCLP) + '
Ganancia no realizada' + clpFirmado(cart.noRealizadoCLP) + '
Dividendos cobrados' + clp(cart.dividendosCLP) + '
Rentabilidad solo por precio' + (cart.rentPrecio==null?"—":pctTxt(cart.rentPrecio)) + '
Rentabilidad con dividendos' + (cart.rentTotal==null?"—":pctTxt(cart.rentTotal)) + '
' + '
'; return h + pieInforme(); } /* ---------- 2. MOVIMIENTOS ---------- */ function informeMovimientos(ym){ const b = balanceMes(ym), prev = balanceMes(mesAnterior(ym)); const t = totales(ym); let h = encabezadoInforme("Movimientos · " + mesLargo(ym), "Todo lo que entró y salió"); h += '
' + tarjeta("Ingresos", clp(t.ingreso), " ", "#0f7d57") + tarjeta("Gastos", clp(t.gasto), " ", "#c04c1f") + tarjeta("Ahorrado", clpFirmado(t.ahorro), " ", "#0f7d57") + tarjeta("Quedó en caja", clpFirmado(t.balance), " ") + '
'; h += '

Gastos por categoría

'; const cats = b.cats.filter(c => !esAhorro({categoria:c.cat})); if (!cats.length) h += '
No se registraron gastos.
'; else { const maxCat = cats[0].monto, totalG = cats.reduce((s,c) => s+c.monto, 0); const prevCat = c => { const f = prev.cats.find(x => x.cat === c); return f ? f.monto : 0; }; h += '' + '' + cats.map(c => '' + '' + '' + '').join("") + '' + '
CategoríaMonto%PesoMes anterior
' + esc(c.cat) + '' + clp(c.monto) + '' + (c.monto/totalG*100).toFixed(1).replace(".", ",") + '%
' + (prevCat(c.cat) ? clp(prevCat(c.cat)) : "—") + '
Total' + clp(totalG) + '100%' + clp(prev.gasto) + '
'; } h += '
'; if (b.catsIng.length){ h += '

De dónde vinieron los ingresos

' + '' + b.catsIng.map(c => '' + '').join("") + '
CategoríaMonto%
' + esc(c.cat) + '' + clp(c.monto) + '' + (c.monto/b.ingreso*100).toFixed(1).replace(".", ",") + '%
'; } h += '

Detalle

'; h += !b.movs.length ? '
Sin movimientos en el mes.
' : '' + '' + b.movs.map(m => '' + '' + '' + '' + '').join("") + '' + '
FechaTipoCategoríaDescripciónOriginalEn CLP
' + fechaCorta(m.fecha) + '' + (esAhorro(m) ? "Ahorro" : m.tipo === "ingreso" ? "Ingreso" : "Gasto") + '' + esc(m.categoria) + '' + (esc(m.descripcion) || "—") + '' + (m.moneda === "USD" ? montoTxt(m) : "—") + '' + (m.tipo === "ingreso" ? "+" : "−") + clp(enCLP(m)) + '
Quedó en caja' + clpFirmado(t.balance) + '
'; h += '
'; return h + pieInforme(); } /* ---------- 3. INVERSIONES ---------- */ function informeInversiones(){ const c = calcularCartera(), t = c.total; let h = encabezadoInforme("Inversiones", "Tu cartera al día de hoy"); h += '
' + tarjeta("Valor de la cartera", clp(t.valorCLP), "Invertido " + clp(t.costoCLP)) + tarjeta("No realizada", clpFirmado(t.noRealizadoCLP), t.rentPrecio==null?" ":pctTxt(t.rentPrecio) + " solo por precio") + tarjeta("Realizada", clpFirmado(t.realizadoCLP), "De lo ya vendido") + tarjeta("Dividendos", clp(t.dividendosCLP), "Cobrados en total") + tarjeta("Rentabilidad", t.rentTotal==null?"—":pctTxt(t.rentTotal), "Con dividendos, sobre lo invertido") + '
'; const abiertas = c.filas.filter(p => p.valorCLP > 0); if (abiertas.length){ const paleta = ["#2a78d6","#c04c1f","#0f7d57","#b57a00","#c4527c","#4a3aa7","#b32a2a","#777"]; let trozos = abiertas.map((p,i) => ({nombre:p.ticker, valor:p.valorCLP, color:paleta[i % paleta.length]})); if (trozos.length > 7){ const resto = trozos.slice(6).reduce((s,x)=>s+x.valor,0); trozos = trozos.slice(0,6).concat([{nombre:"Otras", valor:resto, color:"#777"}]); } h += '

Composición de la cartera

' + svgDona(trozos, {centro:"Cartera", titulo:"Composición"}) + '
'; } h += '

Posiciones

'; h += !c.filas.length ? '
Sin operaciones registradas.
' : '' + '' + '' + '' + c.filas.map(p => { const cerrada = p.cantidad === 0; const cu = p.cantidad > 0 ? p.costo / p.cantidad : null; return '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join("") + '
InstrumentoCantidadCosto prom.PrecioValorNo realiz.% precioDividendos% c/divid.En CLP
' + esc(p.ticker) + '' + (cerrada ? ' cerrada' : '') + '' + (cerrada ? "—" : nfQty.format(p.cantidad)) + '' + (cu == null ? "—" : precioTxt(cu, p.moneda)) + '' + (p.precio == null ? "—" : precioTxt(p.precio, p.moneda)) + '' + (p.valor == null ? "—" : dinero(p.valor, p.moneda)) + '' + (p.noRealizado == null ? "—" : dineroFirmado(p.noRealizado, p.moneda)) + '' + (p.rentPrecio == null ? "—" : pctTxt(p.rentPrecio)) + '' + (p.dividendos ? dinero(p.dividendos, p.moneda) : "—") + '' + (p.rentTotal == null ? "—" : pctTxt(p.rentTotal)) + '' + clp(p.valorCLP) + '
Valor total' + clp(t.valorCLP) + '
'; h += '
'; if (db.operaciones.length){ const NOM = { compra:"Compra", venta:"Venta", dividendo:"Dividendo" }; const ops = db.operaciones.slice().sort((a,b) => b.fecha.localeCompare(a.fecha)).slice(0, 40); h += '

Últimas operaciones

' + '' + '' + ops.map(o => { const esDiv = o.tipo === "dividendo"; const bruto = esDiv ? o.monto : o.cantidad * o.precio; const total = (esDiv || o.tipo === "venta") ? bruto - (o.comision||0) : bruto + (o.comision||0); const entra = esDiv || o.tipo === "venta"; return '' + '' + '' + ''; }).join("") + '
FechaInstrumentoOperaciónDetalleTotalEn CLP
' + fechaCorta(o.fecha) + '' + esc(o.ticker) + '' + NOM[o.tipo] + '' + (esDiv ? (esc(o.nota) || "—") : nfQty.format(o.cantidad) + " × " + precioTxt(o.precio, o.moneda)) + '' + (entra ? "+" : "−") + dinero(total, o.moneda) + '' + (entra ? "+" : "−") + clp(total * tcDe(o)) + '
' + (db.operaciones.length > 40 ? '

Se muestran las 40 más recientes de ' + db.operaciones.length + '.

' : '') + '
'; } return h + pieInforme(); } /* ---------- 4. PATRIMONIO ---------- */ function informePatrimonio(){ const p = calcularPatrimonio(); let h = encabezadoInforme("Patrimonio", "Todo lo que tienes y lo que debes"); h += '
' + tarjeta("Patrimonio neto", clpFirmado(p.neto), "Activos menos deudas") + p.bolsas.map(b => tarjeta(b.nombre, clpFirmado(b.valor), p.sumaActivos ? (b.valor/p.sumaActivos*100).toFixed(1).replace(".", ",") + "% de tus activos" : " ", COLOR_BOLSA[b.id])).join("") + '
'; const trozos = p.bolsas.filter(x => x.valor > 0) .map(x => ({ nombre:x.nombre, valor:x.valor, color:COLOR_BOLSA[x.id] })); h += '

Cómo está repartido

' + svgDona(trozos, {centro:"Activos", titulo:"Distribución del patrimonio"}) + '
'; const origen = { efectivo:"Saldo inicial de caja " + clp(p.efectivoInicial) + " + tus movimientos (" + clpFirmado(p.movido) + ")", ahorro:"Movimientos anotados en la categoría Ahorro", inversion:"Valor de mercado de tu cartera de acciones", otros:"Propiedades, vehículos y demás bienes cargados a mano" }; h += '

Detalle por bolsa

' + '' + p.bolsas.map(b => { const propias = p.manuales.filter(x => x.bolsa === b.id); return '' + '' + '' + propias.map(x => '' + '' + '').join(""); }).join("") + '
BolsaDe dónde saleMonto%
' + esc(b.nombre) + '' + esc(origen[b.id]) + '' + clpFirmado(b.valor) + '' + (p.sumaActivos ? (b.valor/p.sumaActivos*100).toFixed(1).replace(".", ",") + "%" : "—") + '
' + esc(x.bien.nombre) + '' + esc(x.bien.tipo) + '' + clp(x.clp) + '
Total de activos' + clp(p.sumaActivos) + '
'; if (p.deudas.length){ h += '

Deudas

' + '' + p.deudas.map(x => '' + '').join("") + '
NombreTipoSaldo
' + esc(x.bien.nombre) + '' + esc(x.bien.tipo) + '' + clp(x.clp) + '
Total' + clp(p.sumaDeudas) + '
'; } if (db.fotos.length > 1){ h += '

Evolución

' + '' + '' + db.fotos.slice(-12).map((f,i,arr) => { const ant = arr[i-1]; return '' + '' + '' + ''; }).join("") + '
MesActivosDeudasNetoCambio
' + mesLargo(f.ym) + '' + clp(f.activos) + '' + clp(f.deudas) + '' + clpFirmado(f.neto) + '' + (ant ? clpFirmado(f.neto - ant.neto) : "—") + '
'; } return h + pieInforme(); } /* ---------- 5. HISTORIAL ---------- */ function informeHistorial(){ const meses = mesesConDatos(); let h = encabezadoInforme("Historial", "Mes a mes, desde que empezaste"); if (!meses.length) return h + '
Todavía no hay meses registrados.
' + pieInforme(); const filas = meses.map(balanceMes); const tot = filas.reduce((s,f) => ({ ingreso:s.ingreso+f.ingreso, gasto:s.gasto+f.gasto }), {ingreso:0, gasto:0}); h += '
' + tarjeta("Meses registrados", String(meses.length), " ") + tarjeta("Ingresos acumulados", clp(tot.ingreso), " ", "#0f7d57") + tarjeta("Gastos acumulados", clp(tot.gasto), " ", "#c04c1f") + '
'; const serie = filas.slice().reverse().slice(-12) .map(f => ({ ym:f.ym, ingreso:f.ingreso, gasto:f.gasto })); h += '

Ingresos y gastos, mes a mes

' + '
' + ' Ingresos   ' + ' Gastos
' + svgBarrasMes(serie, [{campo:"ingreso",color:"#0f7d57"},{campo:"gasto",color:"#c04c1f"}]) + '
'; h += '

Detalle mes a mes

' + '' + '' + filas.map(f => { const t = totales(f.ym); return '' + '' + '' + '' + '' + ''; }).join("") + '' + '' + '
MesIngresosGastosAhorradoQuedó en cajaPatrimonio neto
' + mesLargo(f.ym) + '' + (t.ingreso ? clp(t.ingreso) : "—") + '' + (t.gasto ? clp(t.gasto) : "—") + '' + (t.ahorro ? clpFirmado(t.ahorro) : "—") + '' + ((t.ingreso||t.gasto) ? clpFirmado(t.balance) : "—") + '' + (f.foto ? clpFirmado(f.foto.neto) : "—") + '
' + meses.length + ' meses' + clp(tot.ingreso) + '' + clp(tot.gasto) + '
'; return h + pieInforme(); } /* ---------- despachador ---------- */ function construirInforme(seccion, ym){ const mes = ym || mesDe(HOY); if (seccion === "movimientos") return informeMovimientos(mes); if (seccion === "inversiones") return informeInversiones(); if (seccion === "patrimonio") return informePatrimonio(); if (seccion === "historial") return informeHistorial(); return informeResumen(mes); } function imprimirInforme(seccion, ym){ document.getElementById("informe").innerHTML = construirInforme(seccion, ym); /* el navegador abre su propio diálogo; ahí se elige "Guardar como PDF" */ setTimeout(() => window.print(), 60); } /* ===================== resumen ===================== */ /* Guardar no es gastar. Un movimiento en la categoría "Ahorro" no desaparece: sale de la caja y entra a la bolsa de ahorro, así que se cuenta aparte de los gastos de consumo. Si se mezclara con ellos, la app diría que gastaste plata que en realidad todavía tienes. */ const CAT_AHORRO = "Ahorro"; const esAhorro = m => (m.categoria || "").trim().toLowerCase() === CAT_AHORRO.toLowerCase(); function totales(ym){ const del = ym ? db.movimientos.filter(m => mesDe(m.fecha) === ym) : db.movimientos; const i = del.filter(m => m.tipo === "ingreso" && !esAhorro(m)).reduce((s,m) => s + enCLP(m), 0); const g = del.filter(m => m.tipo === "gasto" && !esAhorro(m)).reduce((s,m) => s + enCLP(m), 0); /* un gasto en Ahorro es un aporte; un ingreso en Ahorro es un retiro */ const aporta = del.filter(m => m.tipo === "gasto" && esAhorro(m)).reduce((s,m) => s + enCLP(m), 0); const retira = del.filter(m => m.tipo === "ingreso" && esAhorro(m)).reduce((s,m) => s + enCLP(m), 0); const ahorro = aporta - retira; return { ingreso: i, gasto: g, ahorro: ahorro, balance: i - g - ahorro, /* lo que quedó en la caja */ noConsumido: i - g /* caja + ahorro: lo que no se te fue */ }; } function deltaTxt(actual, previo, invertir){ if (!previo) return actual ? "sin comparación del mes anterior" : " "; const p = (actual - previo) / previo * 100; const bueno = invertir ? p < 0 : p > 0; const flecha = p >= 0 ? "▲" : "▼"; const clase = Math.abs(p) < 0.5 ? "" : (bueno ? "pos" : "neg"); return '' + flecha + " " + Math.abs(p).toFixed(0) + '% vs. mes anterior'; } function pintarResumen(){ const ym = document.getElementById("mesResumen").value || mesDe(hoy()); const t = totales(ym), p = totales(mesAnterior(ym)), acum = totales(null); const cartera = calcularCartera().total; const pat = calcularPatrimonio(); document.getElementById("kpis").innerHTML = '
Ingresos · ' + mesLabel(ym, true) + '
' + '
' + clp(t.ingreso) + '
' + deltaTxt(t.ingreso, p.ingreso, false) + '
' + '
Gastos · ' + mesLabel(ym, true) + '
' + '
' + clp(t.gasto) + '
' + deltaTxt(t.gasto, p.gasto, true) + '
' + '
Balance del mes
' + '
' + clpFirmado(t.balance) + '
' + '
' + (t.ingreso > 0 ? "No consumiste el " + Math.round(t.noConsumido/t.ingreso*100) + "% de lo que entró" : " ") + '
' + '
Ahorrado · ' + mesLabel(ym, true) + '
' + '
' + clpFirmado(t.ahorro) + '
' + '
' + (t.ahorro ? "Movido a la categoría Ahorro" : "Nada movido a Ahorro este mes") + '
' + '
Balance acumulado
' + '
' + clpFirmado(acum.balance) + '
' + '
Todo lo registrado · ' + db.movimientos.length + ' mov.
' + '
Valor de la cartera
' + '
' + clp(cartera.valorCLP) + '
' + '
' + (db.operaciones.length ? '' + clpFirmado(cartera.totalCLP) + ' de resultado' : "Sin inversiones registradas") + '
' + '
Patrimonio neto
' + '
' + clpFirmado(pat.neto) + '
' + '
' + (pat.sumaActivos || pat.sumaDeudas ? clp(pat.sumaActivos) + " en activos, " + clp(pat.sumaDeudas) + " en deudas" : "Cárgalo en la sección Patrimonio") + '
'; pintarProyeccion(ym); chartMeses(ym); chartCats(ym); chartAhorro(ym); pintarCalendario(ym); pintarComparacion(ym); pintarTopGastos(ym); const ult = db.movimientos.slice() .sort((a,b) => b.fecha.localeCompare(a.fecha) || b.id.localeCompare(a.id)) .slice(0,6); document.getElementById("tblUltimos").innerHTML = CABECERA(false) + (ult.length ? '' + ult.map(m => filaMov(m, false)).join("") + '' : 'Nada registrado todavía.'); } /* ===================== ajustes ===================== */ const inpTC = document.getElementById("inpTC"); document.getElementById("btnCaja").onclick = () => { const n = parseMonto(document.getElementById("inpCaja").value); if (!Number.isFinite(n) || n < 0){ alert("Saldo no válido."); return; } db.ajustes.efectivoInicial = n; guardar({ t:"ajustes", fila: filaAjustes() }); }; document.getElementById("btnTC").onclick = () => { const n = parseMonto(inpTC.value); if (!Number.isFinite(n) || n <= 0){ alert("Tipo de cambio no válido."); return; } db.ajustes.tc = n; guardar({ t:"ajustes", fila: filaAjustes() }); refrescarHint(); }; function pintarChips(){ ["gasto","ingreso"].forEach(tipo => { const cont = document.getElementById(tipo === "gasto" ? "chipsGasto" : "chipsIngreso"); cont.innerHTML = db.categorias[tipo].map(c => '' + esc(c) + '' ).join("") || 'Sin categorías.'; }); } document.querySelectorAll("[data-add]").forEach(b => { b.onclick = () => { const tipo = b.getAttribute("data-add"); const inp = document.getElementById(tipo === "gasto" ? "nuevaGasto" : "nuevaIngreso"); const v = inp.value.trim(); if (!v) return; if (db.categorias[tipo].some(c => c.toLowerCase() === v.toLowerCase())){ alert("Esa categoría ya existe."); return; } db.categorias[tipo].push(v); inp.value = ""; guardar({ t:"ajustes", fila: filaAjustes() }); }; }); document.getElementById("tab-ajustes").addEventListener("click", e => { const b = e.target.closest("[data-del-cat]"); if (!b) return; const cat = b.getAttribute("data-del-cat"), tipo = b.getAttribute("data-tipo"); const usos = db.movimientos.filter(m => m.tipo === tipo && m.categoria === cat).length; const msg = usos ? '"' + cat + '" está usada en ' + usos + ' movimiento' + (usos > 1 ? "s" : "") + '. Si la quitas, esos movimientos la conservan pero ya no podrás elegirla para nuevos. ¿Quitarla?' : '¿Quitar la categoría "' + cat + '"?'; if (!confirm(msg)) return; db.categorias[tipo] = db.categorias[tipo].filter(c => c !== cat); guardar({ t:"ajustes", fila: filaAjustes() }); }); document.getElementById("btnExport").onclick = () => { const blob = new Blob([JSON.stringify(db, null, 2)], {type:"application/json"}); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "finanzas-" + hoy() + ".json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); }; document.getElementById("btnImport").onclick = () => document.getElementById("fileImport").click(); document.getElementById("fileImport").addEventListener("change", e => { const f = e.target.files[0]; if (!f) return; const fr = new FileReader(); fr.onload = () => { try { const j = JSON.parse(fr.result); if (!Array.isArray(j.movimientos)) throw new Error("el archivo no tiene movimientos"); const ops = Array.isArray(j.operaciones) ? j.operaciones : []; const bienes = Array.isArray(j.bienes) ? j.bienes : []; if (!confirm("El respaldo trae " + j.movimientos.length + " movimientos, " + ops.length + " operaciones y " + bienes.length + " partidas de patrimonio." + " Esto reemplaza todo lo que tienes ahora. ¿Continuar?")) return; db = { version: 3, ajustes: j.ajustes || { tc:950 }, categorias: j.categorias || JSON.parse(JSON.stringify(CATS_DEFAULT)), movimientos: j.movimientos, operaciones: ops, instrumentos: j.instrumentos || {}, bienes: bienes, fotos: Array.isArray(j.fotos) ? j.fotos : [] }; /* en la nube el respaldo REEMPLAZA: primero se vacía y después se sube todo, si no quedarían mezcladas las filas viejas con las nuevas */ guardar.apply(null, MODO_NUBE ? opsVaciarNube().concat(opsSubirTodo()) : []); alert("Respaldo importado." + (MODO_NUBE ? " Subiéndolo a la nube…" : "")); } catch (err) { alert("No se pudo leer el archivo: " + err.message); } }; fr.readAsText(f); e.target.value = ""; }); document.getElementById("btnBorrar").onclick = () => { if (!confirm("Vas a borrar " + db.movimientos.length + " movimientos, " + db.operaciones.length + " operaciones de inversión y " + db.bienes.length + " partidas de patrimonio (con su historial)," + " y volver a las categorías de fábrica. Esto no se puede deshacer.")) return; if (!confirm("¿Seguro? Exporta un respaldo antes si tienes cualquier duda.")) return; db = VACIO(); guardar.apply(null, MODO_NUBE ? opsVaciarNube().concat([{ t:"ajustes", fila: filaAjustes() }]) : []); }; /* ===================== pintado general ===================== */ function pintar(){ inpTC.value = db.ajustes.tc; document.getElementById("inpCaja").value = db.ajustes.efectivoInicial || 0; llenarCats(); pintarChips(); pintarTabla(); pintarInversiones(); pintarPatrimonio(); pintarHistorial(); pintarResumen(); pintarAjustesNube(); const bytes = new Blob([JSON.stringify(db)]).size; document.getElementById("infoDatos").textContent = db.movimientos.length + " movimientos y " + db.operaciones.length + " operaciones · " + (bytes/1024).toFixed(1) + " KB usados en este navegador · Versión del archivo: " + VERSION + "."; document.getElementById("pieLateral").innerHTML = '' + VERSION + '' + '
Dólar $' + nfCLP.format(db.ajustes.tc) + '
'; } /* ============================================================ NUBE: cargar, entrar y salir ============================================================ */ const nAcceso = document.getElementById("acceso"); const nMsg = document.getElementById("accMsg"); function mostrarAcceso(msg){ nAcceso.hidden = false; document.getElementById("accPie").textContent = "Tus datos se guardan en tu propia base. " + VERSION; if (msg){ nMsg.className = "msg mal"; nMsg.textContent = msg; } setTimeout(() => document.getElementById("accCorreo").focus(), 50); } const ocultarAcceso = () => { nAcceso.hidden = true; nMsg.className = "msg"; }; const num = v => v == null ? null : Number(v); async function cargarDeLaNube(){ estadoNube("trabajando", "Cargando…"); /* lo que hay en este equipo, antes de que la nube lo reemplace */ const local = JSON.parse(JSON.stringify(db)); const [movs, ops, ins, bns, fts, ajs] = await Promise.all([ rest("GET", "movimientos?select=id,fecha,tipo,categoria,descripcion,moneda,monto,tc&order=fecha.asc"), rest("GET", "operaciones?select=id,fecha,ticker,tipo,moneda,cantidad,precio,monto,comision,nota,tc&order=fecha.asc"), rest("GET", "instrumentos?select=ticker,moneda,precio,fecha_precio"), rest("GET", "bienes?select=id,clase,tipo,nombre,moneda,valor,nota,actualizado"), rest("GET", "fotos?select=ym,activos,deudas,cartera,neto&order=ym.asc"), rest("GET", "ajustes?select=*") ]); db.movimientos = (movs || []).map(m => ({ id:m.id, fecha:m.fecha, tipo:m.tipo, categoria:m.categoria, descripcion:m.descripcion || "", moneda:m.moneda, monto:num(m.monto), tc:num(m.tc) })); db.operaciones = (ops || []).map(o => ({ id:o.id, fecha:o.fecha, ticker:o.ticker, tipo:o.tipo, moneda:o.moneda, cantidad:num(o.cantidad), precio:num(o.precio), monto:num(o.monto), comision:num(o.comision) || 0, nota:o.nota || "", tc:num(o.tc) })); db.instrumentos = {}; (ins || []).forEach(i => { db.instrumentos[i.ticker] = { moneda:i.moneda, precio:num(i.precio), fechaPrecio:i.fecha_precio }; }); db.bienes = (bns || []).map(b => ({ id:b.id, clase:b.clase, tipo:b.tipo, nombre:b.nombre, moneda:b.moneda, valor:num(b.valor), nota:b.nota || "", actualizado:b.actualizado })); db.fotos = (fts || []).map(f => ({ ym:f.ym, activos:num(f.activos), deudas:num(f.deudas), cartera:num(f.cartera), neto:num(f.neto) })).sort((a,b) => a.ym.localeCompare(b.ym)); if (ajs && ajs.length){ db.ajustes.tc = num(ajs[0].tc) || db.ajustes.tc; db.ajustes.efectivoInicial = num(ajs[0].efectivo_inicial) || 0; const c = ajs[0].categorias; if (c && Array.isArray(c.gasto) && Array.isArray(c.ingreso)) db.categorias = c; } else { /* Cuenta recién estrenada: se siembra la fila de ajustes. Si no, el tipo de cambio y las categorías no viajarían a otro equipo hasta que los tocaras, y ahí el otro equipo mostraría montos distintos. */ cola.push({ t:"ajustes", fila: filaAjustes() }); anotarCola(); } /* Primera vez: la nube está en blanco y este equipo tiene historia. No se sube sola — reemplazar datos sin preguntar es como se pierden. */ const nubeVacia = !db.movimientos.length && !db.operaciones.length && !db.bienes.length && !(ajs && ajs.length); const hayLocal = local.movimientos.length || local.operaciones.length || local.bienes.length; if (nubeVacia && hayLocal){ const cuantos = local.movimientos.length + local.operaciones.length + local.bienes.length; if (confirm("Tu cuenta en la nube está vacía y este equipo tiene " + cuantos + " registros guardados.\n\n¿Subirlos ahora? Quedarán disponibles desde cualquier equipo.")){ db = local; bodega.escribir(KEY, JSON.stringify(db)); pintar(); guardar.apply(null, opsSubirTodo()); return; } } bodega.escribir(KEY, JSON.stringify(db)); if (registrarFoto()){ const f = fotoActual(); if (f){ cola.push({ t:"fotos", fila:f }); anotarCola(); } } pintar(); estadoNube(cola.length ? "mal" : "ok", cola.length ? cola.length + " sin subir" : "Todo guardado"); vaciarCola(); } /* ---------- ojito para ver la clave ---------- */ const OJO_ABIERTO = '' + ''; const OJO_TACHADO = '' + '' + '' + ''; const inpClave = document.getElementById("accClave"); const btnOjo = document.getElementById("btnOjo"); function pintarOjo(){ const visible = inpClave.type === "text"; document.getElementById("svgOjo").innerHTML = visible ? OJO_TACHADO : OJO_ABIERTO; btnOjo.title = visible ? "Ocultar la clave" : "Ver la clave"; btnOjo.setAttribute("aria-label", btnOjo.title); } btnOjo.onclick = () => { const fin = inpClave.value.length; inpClave.type = inpClave.type === "password" ? "text" : "password"; pintarOjo(); /* devolver el cursor al final: cambiar el type lo manda al principio */ inpClave.focus(); try { inpClave.setSelectionRange(fin, fin); } catch (e) {} }; pintarOjo(); document.getElementById("formAcceso").addEventListener("submit", async e => { e.preventDefault(); const btn = document.getElementById("btnEntrar"); btn.disabled = true; btn.textContent = "Entrando…"; nMsg.className = "msg"; try { await entrar(document.getElementById("accCorreo").value.trim(), document.getElementById("accClave").value); inpClave.value = ""; inpClave.type = "password"; /* no dejar la clave a la vista al volver */ pintarOjo(); ocultarAcceso(); await cargarDeLaNube(); aplicarAtajo(); /* recién ahora tiene sentido saltar al formulario */ } catch (err) { nMsg.className = "msg mal"; nMsg.textContent = err.message; } finally { btn.disabled = false; btn.textContent = "Entrar"; } }); function salir(){ if (cola.length && !confirm("Quedan " + cola.length + " cambios sin subir. Si sales ahora se pierden. ¿Salir igual?")) return; olvidarSesion(); bodega.quitar(KEY); /* la caché no se queda en un equipo ajeno */ bodega.quitar(CLAVE_COLA); location.reload(); } /* ---------- tarjeta "dónde se guardan tus datos" ---------- */ function pintarAjustesNube(){ const info = document.getElementById("infoNube"); const acc = document.getElementById("accionesNube"); if (!MODO_NUBE){ info.innerHTML = 'Modo local. Todo vive en este navegador y en este equipo, y no sale de aquí. ' + 'Para verlo también desde el teléfono u otro computador hay que conectar la base en la nube: ' + 'se completan las dos líneas de NUBE al principio del archivo y se publica la página.'; acc.innerHTML = ""; return; } const pend = cola.length; info.innerHTML = 'Modo nube. Sesión iniciada como ' + esc(sesion.correo || "—") + '. ' + 'Tus datos viven en tu base de Supabase y los ves desde cualquier equipo. ' + (pend ? 'Hay ' + pend + ' cambio' + (pend > 1 ? "s" : "") + ' sin subir, ' + 'guardados en este equipo hasta que vuelva la conexión.' : 'Todo lo que anotaste está subido.'); acc.innerHTML = '' + '
' + ''; document.getElementById("btnSalir").onclick = salir; document.getElementById("btnSync").onclick = async () => { if (cola.length) return vaciarCola(); try { await cargarDeLaNube(); } catch (e) { avisarNube(e.message); } }; } /* Cuando vuelve la conexión o vuelves a la pestaña, se intenta subir. */ window.addEventListener("online", vaciarCola); document.addEventListener("visibilitychange", () => { if (!document.hidden) vaciarCola(); }); /* Atajo directo a anotar: ?anotar=gasto o ?anotar=ingreso. Sirve para el acceso rápido del ícono en el teléfono (mantener apretado el ícono y elegir "Anotar un gasto"): abre en el formulario, con el tipo puesto y el cursor en el monto. Anotar tiene que ser dos toques, no cinco. */ function aplicarAtajo(){ /* si la pantalla de acceso está arriba, el foco es del campo de correo: el atajo espera a que entres */ if (!atajoPendiente || !document.getElementById("acceso").hidden) return; const t = atajoPendiente; atajoPendiente = null; irAAnotar(t === "ingreso" ? "ingreso" : "gasto"); } let atajoPendiente = null; function irAAnotar(tipo){ document.querySelectorAll("#nav button").forEach(b => b.classList.toggle("on", b.dataset.tab === "movimientos")); document.querySelectorAll(".tab").forEach(s => s.classList.toggle("on", s.id === "tab-movimientos")); formMov.tipo.value = tipo; llenarCats(); setTimeout(() => { inpMonto.focus(); inpMonto.select(); }, 120); } /* El service worker solo tiene sentido publicado; abierto con doble clic (file://) el navegador no lo permite, así que ni se intenta. */ if ("serviceWorker" in navigator && location.protocol.indexOf("http") === 0){ window.addEventListener("load", () => { navigator.serviceWorker.register("sw.js").catch(() => {}); }); } /* ===================== la fecha sigue al día ===================== */ /* Los campos arrancan en el día de hoy. Si dejas la pestaña abierta y pasa la medianoche, se corren solos al día nuevo — pero SOLO los que todavía tienen el valor automático: si escribiste otra fecha, o estás editando algo, no se te pisa nada. */ let HOY = hoy(); function refrescarFecha(){ const nuevo = hoy(); if (nuevo === HOY) return; const mesViejo = mesDe(HOY), mesNuevo = mesDe(nuevo); if (!formMov.id.value && formMov.fecha.value === HOY) formMov.fecha.value = nuevo; if (!formOp.id.value && formOp.fecha.value === HOY) formOp.fecha.value = nuevo; if (mesViejo !== mesNuevo){ const mr = document.getElementById("mesResumen"); if (mr.value === mesViejo) mr.value = mesNuevo; if (fMes.value === mesViejo) fMes.value = mesNuevo; } HOY = nuevo; pintar(); } /* Tres disparadores: al volver a la pestaña, al volver a la ventana, y un chequeo cada minuto por si la dejaste abierta y a la vista toda la noche. */ document.addEventListener("visibilitychange", () => { if (!document.hidden) refrescarFecha(); }); window.addEventListener("focus", refrescarFecha); setInterval(refrescarFecha, 60000); /* ===================== arranque ===================== */ formMov.fecha.value = HOY; formOp.fecha.value = HOY; document.getElementById("mesResumen").value = mesDe(HOY); fMes.value = mesDe(HOY); ajustarCamposOp(); llenarTiposBien(); const atajo = new URLSearchParams(location.search).get("anotar"); if (!MODO_NUBE){ /* la foto del mes se toma al abrir, no solo al editar: así el historial no depende de que ese mes hayas tocado algo */ if (registrarFoto()) bodega.escribir(KEY, JSON.stringify(db)); pintar(); atajoPendiente = atajo; aplicarAtajo(); } else { cargarCola(); pintar(); /* la caché aparece al tiro, sin esperar red */ atajoPendiente = atajo; /* se aplica recién cuando haya sesión */ if (cargarSesion()){ ocultarAcceso(); aplicarAtajo(); estadoNube("trabajando", "Cargando…"); cargarDeLaNube().catch(err => { /* solo se pide entrar de nuevo si de verdad murió la sesión, no por cualquier error de red que traiga la palabra "sesión" en el texto */ if (err && err.sesionMuerta){ olvidarSesion(); mostrarAcceso("Tu sesión venció. Entra de nuevo."); } else { /* sin conexión: se sigue trabajando con lo último que se descargó */ estadoNube("mal", "Sin conexión"); pintarAjustesNube(); if (window.__aviso) window.__aviso( '
No se pudo conectar con la nube.
Estás viendo la última copia guardada en ' + 'este equipo. Lo que anotes se sube solo cuando vuelva la conexión.
'); } }); } else { mostrarAcceso(); } } if (!bodega.ok && window.__aviso){ window.__aviso('
Tu navegador está bloqueando el guardado local.
' + 'Puedes usar la app igual, pero al cerrar la pestaña se pierde lo que anotes. ' + 'Revisa los permisos de datos del sitio, o exporta un respaldo desde Ajustes antes de cerrar.
'); }