Skip to content

Arquitectura del sitio

Mindset & Code edited this page Aug 18, 2026 · 5 revisions

Arquitectura del sitio

🇬🇧 English first · 🇪🇸 Español más abajo.

Layout

project-portfolio/
├── public/
│   ├── data/                    ← the six ETL JSONs, at the root
│   │   ├── churn/               ← 7 files
│   │   ├── executive/           ← 5 files
│   │   ├── hotel/               ← 6 files
│   │   └── automations.json
│   ├── dashboards/              ← 3 static HTML panels (Power BI / Tableau)
│   └── demos/revenue/           ← copy of project-revenue-management-web
├── src/
│   ├── App.jsx                  ← 11 routes + the ETL loader
│   ├── identidad.js             ← who signs this build
│   ├── contexts/LangContext.jsx ← ES/EN
│   ├── data/projects.js         ← the project catalogue (the largest data file)
│   ├── data/services.js
│   ├── components/              ← 7 ETL + churn/5 + executive/8 + hotel/6 + shared
│   └── pages/                   ← 11 pages
├── firebase.json · .firebaserc
└── vite.config.js

Note that the ETL JSONs sit at the root of public/data/, not in a data/etl/ subfolder — the other three projects have one each, the ETL does not. App.jsx names those six files explicitly, so the layout is not incidental.

Where the data comes from

None of it is generated here. Each dashboard's JSON is produced by its own project and copied in by hand; the files are committed, so the site rebuilds and redeploys without any of those repositories being present.

Route Reads Produced by
/etl 6 files at public/data/ project-sales-weather-etl (Python)
/churn public/data/churn/ generate_churn_json.mjs, which lives in the ETL repo
/executive public/data/executive/ generate_executive_json.mjs, same place
/hotel public/data/hotel/ generate_hotel_json.mjs, same place

The three .mjs generators are in project-sales-weather-etl, not in the repositories whose names they carry. Regenerating the churn data, for instance, needs the ETL repo and the churn repo checked out side by side.

The ETL loader

/etl is the only route with a wrapper, because its dashboard receives all six files at once:

Promise.all(ETL_FILES.map(f => fetch(`/data/${f}.json`).then(r => r.json()).then(d => [f, d])))
  .then(results => { setData(Object.fromEntries(results)); setLoading(false) })
  .catch(e      => { setError(e.message);                  setLoading(false) })

Promise.all is all-or-nothing: one missing file and the route shows Error loading data: rather than five working charts. The other three dashboards fetch their own files from inside their page component.

The embedded copy of the simulator

public/demos/revenue/ is a copy of project-revenue-management-web, served as-is at /revenue inside an iframe. RevenueSimulator.jsx warns that touching the original means copying the files across again.

The two are byte-identical todayscript.js and index.html both. That is worth checking rather than assuming, because for a few hours they were not: the fix below landed in the copy first and reached the original in commit e80e039.

The 7-day chart used to call Math.random() on every redraw, so the bars jumped about whenever a slider moved. Both sides now carry a fixed weekly profile:

const WEEKLY_PROFILE = [0.92, 0.94, 0.97, 1.02, 1.12, 1.15, 0.88]
const revenues = WEEKLY_PROFILE.map(factor => currentRevenue * factor)

The seven factors add up to exactly 7,00, so the average is exactly 1,0 and the week sums to seven times the daily revenue shown in the metric cards — the chart cannot drift away from the numbers beside it. The shape is an urban hotel's: quiet Monday to Thursday, full Friday and Saturday, checkout Sunday.

Before copying either way, diff them. A cp -r from the original onto the demo is the obvious way to sync, and it is exactly how a fix that only exists on one side gets thrown away.

Sizing the iframe

An iframe cannot size itself, and a fixed height is wrong at one of the two widths: measured, the simulator is 1.279 px tall in two columns and 2.363 px when its layout collapses to one. RevenueSimulator.jsx reads the real height instead of guessing it, and the detail that makes it work is shrinking first:

el.style.height = '0px'
el.style.height = Math.max(doc.documentElement.scrollHeight, 400) + 'px'

scrollHeight never returns less than the frame's own height, so measuring without shrinking can only ever grow — coming back from a narrow window to a wide one would leave a thousand pixels of dead space. A ResizeObserver repeats the measurement when the window changes, and both the read and the observer sit inside try blocks so that a cross-origin frame degrades to the fallback height instead of throwing.

One codebase, two sites

src/identidad.js decides who signs the build:

export const DE_MARCA = !env.VITE_TITULAR

export const IDENTIDAD = {
  titular:  env.VITE_TITULAR  || 'Mindset & Code',
  cabecera: env.VITE_CABECERA || env.VITE_TITULAR || 'Mindset & Code',
  retrato:  env.VITE_RETRATO  || '/marca.png',
  urlLinkedIn: env.VITE_LINKEDIN || 'https://mindset-code.com/es',
  urlGitHub:   env.VITE_GITHUB   || 'https://github.com/mindset-code',
}

Every value falls back to the brand. There is no personal data anywhere in this repository, and there cannot be one by accident: the personal variant only appears when the build supplies VITE_TITULAR, and that comes from .env.personal, which is gitignored and exists on one machine.

The photograph follows the same rule from the opposite direction. It lives in personal/, not in public/, because Vite copies public/ wholesale into the output — a portrait there would ship with the brand build too. The personal deploy script copies it into dist-personal/ after the build.

The two audiences are the reason, and the file says so: traffic arriving from mindset-code.com lands on a site where the signature is the practice; a link sent with a job application lands on one where a recruiter can tell whose work they are looking at.


🇪🇸 Español

Distribución

project-portfolio/
├── public/
│   ├── data/                    ← los seis JSON del ETL, en la raíz
│   │   ├── churn/               ← 7 ficheros
│   │   ├── executive/           ← 5 ficheros
│   │   ├── hotel/               ← 6 ficheros
│   │   └── automations.json
│   ├── dashboards/              ← 3 paneles HTML estáticos (Power BI / Tableau)
│   └── demos/revenue/           ← copia de project-revenue-management-web
├── src/
│   ├── App.jsx                  ← 11 rutas + el cargador del ETL
│   ├── identidad.js             ← quién firma esta compilación
│   ├── contexts/LangContext.jsx ← ES/EN
│   ├── data/projects.js         ← el catálogo de proyectos
│   ├── data/services.js
│   ├── components/              ← 7 del ETL + churn/5 + executive/8 + hotel/6 + comunes
│   └── pages/                   ← 11 páginas
├── firebase.json · .firebaserc
└── vite.config.js

Conviene fijarse en que los JSON del ETL están en la raíz de public/data/ y no en una subcarpeta data/etl/: los otros tres proyectos tienen la suya, el ETL no. App.jsx nombra esos seis ficheros uno a uno, así que la distribución no es casual.

De dónde salen los datos

Ninguno se genera aquí. El JSON de cada dashboard lo produce su propio proyecto y se copia a mano; los ficheros están commiteados, así que el sitio se recompila y se vuelve a desplegar sin que ninguno de esos repositorios esté presente.

Ruta Lee Lo produce
/etl 6 ficheros en public/data/ project-sales-weather-etl (Python)
/churn public/data/churn/ generate_churn_json.mjs, que vive en el repo del ETL
/executive public/data/executive/ generate_executive_json.mjs, en el mismo sitio
/hotel public/data/hotel/ generate_hotel_json.mjs, en el mismo sitio

Los tres generadores .mjs están en project-sales-weather-etl, no en los repositorios cuyo nombre llevan. Regenerar los datos de churn, por ejemplo, exige tener el repo del ETL y el de churn clonados uno al lado del otro.

El cargador del ETL

/etl es la única ruta con envoltorio, porque su dashboard recibe los seis ficheros de golpe con un Promise.all. Es todo o nada: basta con que falte uno para que la ruta muestre Error loading data: en vez de cinco gráficos que funcionan. Los otros tres dashboards piden sus propios ficheros desde dentro de su componente de página.

La copia embebida del simulador

public/demos/revenue/ es una copia de project-revenue-management-web, servida tal cual en /revenue dentro de un iframe. RevenueSimulator.jsx avisa de que tocar el original obliga a volver a copiar los ficheros.

Hoy son idénticos byte a bytescript.js e index.html, los dos. Merece la pena comprobarlo en vez de darlo por hecho, porque durante unas horas no lo fueron: el arreglo de abajo entró primero en la copia y llegó al original en el commit e80e039.

El gráfico de 7 días llamaba a Math.random() en cada repintado, así que las barras saltaban cada vez que se movía un control. Los dos lados llevan ahora un perfil semanal fijo:

const WEEKLY_PROFILE = [0.92, 0.94, 0.97, 1.02, 1.12, 1.15, 0.88]
const revenues = WEEKLY_PROFILE.map(factor => currentRevenue * factor)

Los siete factores suman exactamente 7,00, así que la media es exactamente 1,0 y la semana suma siete veces el ingreso diario que muestran las tarjetas — el gráfico no puede irse de los números que tiene al lado. La forma es la de un hotel urbano: flojo de lunes a jueves, lleno viernes y sábado, salida el domingo.

Antes de copiar en cualquiera de los dos sentidos, hacer un diff. Un cp -r del original encima de la demo es la forma obvia de sincronizar, y es exactamente como se tira a la basura un arreglo que solo existe en un lado.

Dar altura al iframe

Un iframe no se dimensiona solo, y una altura fija está mal en una de las dos anchuras: medido, el simulador ocupa 1.279 px a dos columnas y 2.363 px cuando su maqueta cae a una. RevenueSimulator.jsx lee la altura real en vez de adivinarla, y el detalle que lo hace funcionar es encoger antes de medir:

el.style.height = '0px'
el.style.height = Math.max(doc.documentElement.scrollHeight, 400) + 'px'

scrollHeight nunca devuelve menos que el alto del propio marco, así que medir sin encoger solo puede crecer: al volver de una ventana estrecha a una ancha quedaría un hueco muerto de mil píxeles. Un ResizeObserver repite la medición cuando cambia la ventana, y tanto la lectura como el observador van dentro de try, de modo que un marco de otro origen degrada a la altura de respaldo en vez de lanzar una excepción.

Un código, dos sitios

src/identidad.js decide quién firma la compilación:

export const DE_MARCA = !env.VITE_TITULAR

export const IDENTIDAD = {
  titular:  env.VITE_TITULAR  || 'Mindset & Code',
  cabecera: env.VITE_CABECERA || env.VITE_TITULAR || 'Mindset & Code',
  retrato:  env.VITE_RETRATO  || '/marca.png',
  urlLinkedIn: env.VITE_LINKEDIN || 'https://mindset-code.com/es',
  urlGitHub:   env.VITE_GITHUB   || 'https://github.com/mindset-code',
}

Todos los valores caen del lado de la marca. En este repositorio no hay ni un dato personal, y no puede haberlo por accidente: la variante personal solo aparece si la compilación aporta VITE_TITULAR, y eso sale de .env.personal, que está en .gitignore y existe en una sola máquina.

La fotografía sigue la misma regla por el lado contrario. Vive en personal/ y no en public/, porque Vite copia public/ entera a la salida: un retrato ahí se publicaría también en la compilación de marca. El script de despliegue personal la copia a dist-personal/ después de compilar.

La razón son los dos públicos, y el fichero lo dice: el tráfico que llega desde mindset-code.com aterriza en un sitio donde quien firma es el despacho; el enlace que se manda en una candidatura aterriza en uno donde un reclutador puede saber de quién es el trabajo que está mirando.