Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Davidmallega/Gastos-App/llms.txt

Use this file to discover all available pages before exploring further.

GastosApp es una aplicación de escritorio construida sobre Electron 42, React 19 y Vite 8. El proceso principal de Electron controla la ventana nativa y expone una API segura al renderer a través de contextBridge; el renderer ejecuta la aplicación React, que gestiona su estado globalmente mediante un store singleton sin librerías externas. Todo el almacenamiento ocurre en localStorage del proceso Electron, sin ningún servidor ni conexión a internet.

Procesos Electron

Electron opera con dos procesos separados que se comunican mediante IPC:
  • Proceso principal (electron/main.js, ESM): crea y controla la BrowserWindow, registra los handlers IPC para los controles de ventana y carga la aplicación React (Vite dev server en desarrollo, dist/index.html en producción).
  • Proceso renderer (React app): ejecuta la UI completa dentro del contexto Chromium de Electron. Se comunica con el proceso principal exclusivamente a través de window.electronAPI, el objeto expuesto por contextBridge.
El siguiente diagrama, extraído directamente del README, muestra la estructura completa:
┌─────────────────────────────────────────────────────────────────┐
│                     ELECTRON (proceso principal)                  │
│  electron/main.js (ESM) + electron/preload.cjs (CommonJS)       │
│  · BrowserWindow 1280×820 · titleBarStyle:'hidden'              │
│  · contextBridge → window.electronAPI (minimize/maximize/close) │
│  · Dev: http://localhost:5173 · Prod: dist/index.html           │
│  · setWindowOpenHandler: permite about:blank (impresión PDF)    │
└─────────────────────┬───────────────────────────────────────────┘

┌─────────────────────▼───────────────────────────────────────────┐
│                   REACT APP (renderer process)                    │
│                                                                  │
│  src/main.jsx → src/App.jsx                                     │
│        ├── <Sidebar>        navegación + tema (12 secciones)    │
│        └── <CurrentPage>    módulo activo                        │
│                                                                  │
└───────────────────────────────┬─────────────────────────────────┘
                                │ useStore()
              ┌─────────────────▼──────────────────┐
              │         useStore (singleton)         │
              │  · Estado en memoria (globalState)  │
              │  · Persiste en localStorage         │
              │  · Notifica a todos los consumidores│
              │  · Migración automática de esquema  │
              └────────────────┬───────────────────┘

              ┌────────────────▼───────────────────┐
              │       localStorage                  │
              │  'gastos_app_data' → JSON           │
              │  schemaVersion: 4                   │
              └────────────────────────────────────┘

Proceso principal (main.js)

El archivo electron/main.js está escrito en ESM (usa import/export nativos de Node.js). Al arrancar crea la ventana principal con la siguiente configuración:
win = new BrowserWindow({
  width:     1280,
  height:    820,
  minWidth:  900,
  minHeight: 600,
  title:     'GastosApp — Control Financiero',
  backgroundColor: '#f1f5f9',
  titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
  show: false,
  webPreferences: {
    nodeIntegration:  false,
    contextIsolation: true,
    preload: path.join(__dirname, 'preload.cjs'),
  },
})
Los puntos clave de esta configuración son:
  • Dimensiones: 1280 × 820 px por defecto, con un mínimo de 900 px de ancho para garantizar que la Sidebar y el contenido principal convivan correctamente.
  • titleBarStyle: 'hidden' (o 'hiddenInset' en macOS): oculta la barra de título nativa del sistema operativo. GastosApp implementa su propia barra de 36 px con los botones de minimizar, maximizar y cerrar usando la región WebkitAppRegion: 'drag'.
  • contextIsolation: true + nodeIntegration: false: configuración de seguridad recomendada por Electron. El renderer no tiene acceso directo a las APIs de Node.js; todo pasa por contextBridge.
  • show: false: la ventana se muestra recién después del evento ready-to-show, evitando el parpadeo blanco inicial.
Los handlers IPC se registran una sola vez al módulo (fuera de createWindow) para evitar registros duplicados si la ventana se vuelve a crear:
ipcMain.on('window-minimize', () => win?.minimize())
ipcMain.on('window-maximize', () => {
  if (!win) return
  if (win.isMaximized()) win.unmaximize()
  else win.maximize()
})
ipcMain.on('window-close', () => win?.close())
El handler setWindowOpenHandler permite que las ventanas about:blank se abran (necesario para la impresión PDF desde el renderer) y redirige cualquier otra URL al navegador del sistema:
win.webContents.setWindowOpenHandler(({ url }) => {
  if (url === 'about:blank') return { action: 'allow' }
  shell.openExternal(url)
  return { action: 'deny' }
})

Preload y contextBridge

El archivo electron/preload.cjs está en formato CommonJS (requerido por Electron para los scripts de preload) y utiliza contextBridge para exponer de forma segura un subconjunto de la API IPC al renderer:
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  minimize: () => ipcRenderer.send('window-minimize'),
  maximize: () => ipcRenderer.send('window-maximize'),
  close:    () => ipcRenderer.send('window-close'),
})
contextBridge.exposeInMainWorld inyecta el objeto electronAPI en window del renderer de forma aislada, respetando el límite de contexto que impone contextIsolation: true. Desde cualquier componente React se puede llamar, por ejemplo, window.electronAPI?.minimize(). El operador opcional ?. es intencional: en modo web (browser, sin Electron) window.electronAPI no existe y la llamada simplemente no ocurre. window.electronAPI expone exactamente tres métodos:
MétodoCanal IPCAcción
minimize()window-minimizeMinimiza la ventana
maximize()window-maximizeMaximiza o restaura la ventana
close()window-closeCierra la ventana

Renderer (React)

El punto de entrada del renderer es src/main.jsx, que monta la aplicación en el nodo #root:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
src/App.jsx es el componente raíz. Gestiona:
  • El estado de navegación (currentPage), inicializado en 'dashboard'.
  • El tema claro/oscuro, persistido en localStorage bajo la clave 'theme'.
  • La barra de título personalizada con los botones de control de ventana.
  • La composición <Sidebar> + <CurrentPage>.
<Sidebar> contiene los 12 ítems de navegación definidos en navItems:
IDEtiqueta
dashboardDashboard
gastos-cajaGastos por Caja
facturasFacturas SII
pendientesPagos Pendientes
pagos-realizadosPagos Realizados
compromisosCompromisos
informesInformes
categoriasCategorías
proveedoresProveedores
importarImportar CSV SII
backupRespaldo
papeleraPapelera
<CurrentPage> es simplemente const Page = pages[currentPage] || Dashboard, donde pages es un objeto que mapea cada ID a su componente de página correspondiente. GastosApp implementa navegación mediante un Custom Event del DOM en lugar de React Router. Cualquier componente, sin importar su profundidad en el árbol, puede navegar a otra página con una sola línea:
window.dispatchEvent(new CustomEvent('navigate', { detail: 'gastos-caja' }))
App.jsx escucha este evento en un useEffect y actualiza el estado currentPage:
useEffect(() => {
  const handler = (e) => setCurrentPage(e.detail)
  window.addEventListener('navigate', handler)
  return () => window.removeEventListener('navigate', handler)
}, [])
Esta decisión tiene tres justificaciones concretas:
  1. Sin prop drilling: un botón dentro de un modal anidado en tres niveles puede navegar sin que ninguno de sus componentes padre reciba ni propague un callback onNavigate.
  2. Sin dependencias extra: no se instala react-router-dom ni ningún otro paquete de routing.
  3. Consistencia con el modelo desktop: la app tiene un único nivel de “rutas” (12 páginas planas), por lo que las capacidades avanzadas de React Router (rutas anidadas, parámetros de URL, history API) no aportan valor en este contexto.
La app usa un custom event para navegación, no React Router. Esto permite que componentes anidados naveguen sin prop drilling.
Los componentes Modal y Dialog de src/components/ui/index.jsx renderizan su contenido directamente en document.body usando createPortal:
return createPortal(
  <>
    <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 1000 }} />
    <div style={{ position: 'fixed', inset: 0, zIndex: 1001, ... }}>
      {/* contenido del modal */}
    </div>
  </>,
  document.body
)
El motivo es un comportamiento específico de Chromium (y por tanto de Electron): cuando un elemento contenedor tiene overflow: hidden — como el <aside> del Sidebar — cualquier descendiente con position: fixed queda recortado dentro de ese contexto de apilamiento. Al teleportar el modal a document.body con createPortal, el overlay y el panel flotante escapan completamente del árbol del Sidebar y se renderizan sobre la totalidad de la ventana, con el z-index correcto.

Fechas como string YYYY-MM-DD

Todos los campos de fecha de los modelos de datos (fecha, fechaPago) se almacenan como strings en formato 'YYYY-MM-DD', no como objetos Date ni timestamps Unix. Esta decisión evita un problema real en Electron: al hacer new Date('2025-06-15').toISOString() en una zona horaria UTC-4 o UTC-5, el resultado es '2025-06-14T...' — la fecha se desfasa un día al cruzar la medianoche UTC. Almacenando la fecha directamente como string, la comparación y visualización son siempre exactas:
// ✅ Correcto — usa el helper localStr(d) que construye 'YYYY-MM-DD' desde año/mes/día locales
function localStr(d) {
  const y = d.getFullYear()
  const m = String(d.getMonth() + 1).padStart(2, '0')
  const day = String(d.getDate()).padStart(2, '0')
  return `${y}-${m}-${day}`
}

// ❌ Incorrecto en UTC-4/UTC-5 — puede retroceder un día
new Date('2025-06-15').toISOString().slice(0, 10)
Las únicas fechas que se guardan como ISO strings completos (createdAt, deletedAt, ultimoRegistro, creadoEn) son marcas de tiempo internas que nunca se muestran como fechas de calendario al usuario.

Build docs developers (and LLMs) love