-
Notifications
You must be signed in to change notification settings - Fork 0
Arquitectura del sitio
🇬🇧 English first · 🇪🇸 Español más abajo.
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.
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.
/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.
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 today — script.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.
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.
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.
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.
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.
/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.
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 byte — script.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.
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.
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.