Versión web de la app Arbu, para el cuidado del arbolado urbano de Cochabamba.
El sitio tiene dos mitades:
- Arbu (público): home, mapa de árboles, ranking, sección Aprende (catálogo de especies, guía de selección y guía de plantación), documentación de la API e inscripción a mapeos.
- Arbu Pro (back-office, requiere sesión y autorización): panel de admin, tabla editable de árboles mapeados, mapeo scout con exportación a Excel y gestión de proyectos.
- Node 22 (vía nvm)
- pnpm (vía corepack). No uses
npm installniyarn: el proyecto se instala conpnpm instally su lockfile espnpm-lock.yaml.
pnpm install
pnpm start # http://localhost:3000 (`pnpm dev` es lo mismo)
pnpm build # genera build/
pnpm deploy # build + firebase deploy --only hosting
pnpm deploy:rules # despliega firestore.rules (NO va en pnpm deploy)Van en un .env en la raíz (no versionado). Todas se inlinean en el bundle, así
que ninguna es secreta.
| Variable | Para qué |
|---|---|
VITE_API_KEY, VITE_AUTH_DOMAIN, VITE_DATABASE_URL, VITE_PROJECT_ID, VITE_STORAGE_BUCKET, VITE_MESSAGING_SENDER_ID, VITE_APP_ID, VITE_MEASUREMENT_ID |
Configuración del proyecto de Firebase |
(Ya no hay VITE_API_URL: era la base de una API externa de la que solo colgaba
la capa de polígonos scout, que se retiró. Ver más abajo.)
src/
pages/ # sitio público: homev, mapav (mapa nuevo), inscripcion
components/
navbar/ ranking/ catalogo/ api/ acerca/ # público
admin/ dashboard/ tabla/ mapeo-scout/ proyectos/ autenticacion/ # Arbu Pro
concurso/ # panel de campañas y concursos (Arbu Pro)
Spinner/ Skeleton/ # piezas compartidas de estado de carga
helpers/campanias/ # modelo de campaña, registros, evaluación, ranking
helpers/geo/ # municipios del área metropolitana (punto en polígono)
assets/geo/ # GeoJSON de los 7 municipios (16 KB, versionado)
actions/ reducers/ store/ # Redux (auth, mapa, catalogo, leaderboard, ...)
selectors/ # lecturas derivadas del estado (campañas, carga de árboles)
helpers/ # acceso a Firestore y utilidades compartidas
docs/ # notas técnicas (lecturas de Firestore, permisos de Arbu Pro)
scripts/ # utilidades puntuales (migración de permisos)
firebase/ # inicialización de Firebase (API modular v9)
- Firebase: solo la API modular v9 (
collection(db, ...),getDocs,updateDoc). No se usafirebase/compat. - Permisos: todo sale de
usuariosAutorizados/accesoTablas—correos(entrar al back-office),permisos(las áreas:campanas,proyectos,tabla,mapeoScout,dashboard) yroles(superadminentra a todo y reparte accesos;admines solo una etiqueta). El catálogo de áreas vive ensrc/helpers/permisos.jsy se gestiona desde/admin/accesos. Verdocs/permisos-arbu-pro.md. - Sesión: el estado vive en
state.auth = { checking, user }.checkingestruehasta que Firebase resuelve si hay sesión; las guardias (ProtectedRoute,PublicRoute) esperan a que termine antes de redirigir. - Mapas: todos los mapas usan
BASEMAP_URLyBASEMAP_ATTRIBUTIONdesrc/helpers/basemap.js. CARTO exige API key desde agosto de 2026, y ese módulo es el único sitio donde cambiarla. - Arbu Pro se carga con
React.lazydesdeApp.jsx: arrastra material-react-table, xlsx y swagger-ui, que no deben pesar en el bundle que descarga cualquier visitante. - Lecturas de Firestore: Firestore cobra por documento leído. Nada de
getDocsde una colección entera para resolver un dato suelto; se lee por id (loadUsuariosPorIds), o bajo demanda cuando la capa se enciende. El mapa público lee porleerColeccionConCache. En desarrollo, cada lectura se anota en consola con el prefijo[lecturas]. El detalle y el plan pendiente están endocs/lecturas-firestore.md. - Estados de carga: ninguna vista que dependa de Firestore se deja en
blanco. Hay dos piezas compartidas,
<Spinner>y<Skeleton>(src/components/), y el estado se lee de selectores derivados, nunca dedata.length === 0—que no distingue "todavía no llegó" de "no hay nada".
El mapa base se pinta en menos de un segundo, pero los árboles tardan entre tres
y cinco (dos getDocs de colección completa, más en red móvil). Antes ese hueco
no se señalaba de ninguna forma: el mapa vacío parecía el resultado final.
src/actions/mapaPublico.actions.js—cargarDatosMapaPublico()agrupa las cinco cargas del mapa público. EsallSettled: que falle la de usuarios (solo aporta el nombre del mapeador) no puede impedir que se pinten los árboles. Al estar en un solo sitio, el botón "Reintentar" repite exactamente la carga inicial.src/selectors/arboles.js—selectArbolesCargando,selectArbolesError,selectHayArbolesCargados. Las dos capas (plantados y mapeados) son dos peticiones, pero para quien mira son "los árboles".EstadoMapa(junto aMapWrapper) — aviso flotante sobre el mapa. Cargar manda sobre fallar: si una capa ya falló pero la otra sigue en vuelo se muestra "Cargando", porque avisar del error antes de tiempo lo haría parpadear. El aviso no bloquea: se puede arrastrar y hacer zoom mientras tanto.- El sidebar pone un
<Skeleton>en el bloque Actividades mientras llegan las campañas, para que no aparezca de golpe y empuje el resto.
Una campaña es una actividad de mapeo con nombre, ventana de fechas y reglas
de validez. Alimenta a la vez el selector del mapa público y el panel de Arbu
Pro. Viven en la colección proyectos (el nombre es histórico; el concepto se
generalizó sin migrar ningún documento) y se crean desde Proyectos.
-
Compatibilidad: los documentos sin
schemaVersionse leen con la semántica de siempre — la fecha del árbol es la de su primer monitoreo. Solo losschemaVersion: 2cuentan cualquier monitoreo dentro de la ventana. Todo pasa pornormalizarCampania(); nada se migra en Firestore. -
Qué se guarda y qué se deriva: la pertenencia de un árbol a una campaña, su municipio y su validez automática se recalculan en cliente. Solo se persiste el juicio humano, en
proyectos/{id}/revisionesyproyectos/{id}/participantes. -
La unidad de conteo es el registro, no el árbol: el par (árbol, monitoreo) que cae dentro de la ventana. Así un ejemplar mapeado por varias personas vale para todas —lo exigen las bases del concurso— y un árbol antiguo remonitoreado durante la campaña cuenta, con las fotos de ese monitoreo.
-
Una campaña mira las DOS colecciones de árboles, porque cada app registra en la suya: Android mapea en
arbolesMapeadosy iOS —que no tiene pantalla de mapeo— registra por Adopta, enarbolesPlantados. Las diferencias de esquema (mapeadoPor/plantadoPor,fotografia/fotoArbolCompleto) se normalizan enhelpers/campanias/origenArbol.js, y cada registro lleva suorigen. De una adopción cuenta solo el alta —su primer monitoreo—: los posteriores son cuidado, y contarlos metería en la campaña todo el arbolado plantado que alguien riegue durante el concurso. -
La especie no oculta nada: el filtro público es la ventana de fechas. La especie decide el icono del marcador y cuenta para la validez, pero un jacarandá mal etiquetado sigue apareciendo en el mapa y va a la cola de revisión. En los datos reales abundan
nombreComun: ""ynombreCientifico: "...", así que descartar por especie perdería aportes. -
Municipios:
src/assets/geo/municipiosMetropolitanosCbba.json(16 KB, 7 municipios con su código INE). Va ensrc/assets/y no enpublic/para que pase por Rollup, se hashee y no toque el precache de la PWA. Se regeneró así:npx -y mapshaper <fuente>.geojson \ -filter '["Sipe Sipe","Vinto","Colcapirhua","Tiquipaya","Quillacollo","Cochabamba","Sacaba"].indexOf(Municipality) > -1' \ -each 'nombre = (Municipality === "Cochabamba" ? "Cercado" : Municipality), codigoIne = ADM3_PCODE.substr(2)' \ -filter-fields nombre,codigoIne -clean \ -o precision=0.00001 format=geojson src/assets/geo/municipiosMetropolitanosCbba.json
Los bordes son una generalización: un árbol a pocos metros de un límite puede caer del lado equivocado. La válvula de escape es la revisión humana, no un buffer.
/mapa?actividad=<slug> abre el mapa ya puesto en esa actividad, con sus
dos capas encendidas:
https://arbu.app/mapa?actividad=mapa-de-la-primavera-2026
Funciona sin sesión: las campañas con publica: true se leen sin
autenticar, que es de donde sale el selector del mapa.
- El parámetro acepta el
slugde la campaña, su id de documento y el slug sin el sufijo del id. Un enlace compartido se reenvía durante meses y a veces se teclea a mano: lo caro es que deje de abrir. - La URL se mantiene sola: elegir una actividad la reescribe (con
replace, para no llenar el historial), así que compartir es copiar la barra de direcciones. En la barra lateral hay además un botón «Copiar enlace de esta actividad». - Un slug que no existe —campaña borrada, errata— no rompe nada: el mapa abre normal y el parámetro se limpia.
- Qué capas están encendidas se lee del store (
selectPlantadosActivos,selectMapeadosActivos) y no de un estado del Sidebar, porque la selección puede venir del enlace y una copia local mostraría lo contrario de lo que pinta el mapa.
Las piezas: helpers/campanias/enlaceCampania.js (el parámetro y su
resolución, sin React) y pages/mapav/hooks/useActividadEnUrl.js (la
sincronización en los dos sentidos).
El sidebar tenía un bloque "Geo Visualización" con Normal / OTBs / Scouts.
- Qué era: polígonos de triangulación por grupo scout, con un popup de los árboles dentro de cada zona.
- Por qué se retiró: el GeoJSON nunca estuvo en el repo. Se pedía a
${VITE_API_URL}/triangulacion_grupos_scouts.geojson, esa variable nunca se definió en ningún.env, y el bundle desplegado pedía literalmenteundefined/...— el hosting devolvíaindex.htmlyres.json()reventaba. La opción "OTBs" jamás llegó a implementarse: pedía el mismo archivo de scouts y el mapa solo sabía pintar la rama "scouts". - Cómo recuperarla: poner el archivo en
public/, cargarlo con unfetchrelativo (nada de API externa) y rescatar el<GeoJSON>con suonEachFeaturedel historial. Ojo: aquella versión llamaba auseMap()dentro deonEachFeature, que es un hook fuera de componente y habría reventado igualmente.
Hecho, en /admin/aportes (área aportes). Un administrador registra árboles
que no entraron por la app, corrige los existentes —incluidas coordenadas y
fotos— e importa planillas. Las fotos se arrastran a sus casillas y, si
conservan el EXIF de la cámara, la ubicación y la fecha se rellenan solas.
Los detalles, el modelo de datos y los sitios donde es fácil equivocarse están
en docs/aportes-web.md.
El antiguo TreeMappingForm se había retirado por escribir a mapeos_test con
un esquema incompatible (lugarPlantacion en vez de lugarDePlantacion,
diametro suelto, y sin latitud, longitud ni mapeadoPor): producía
documentos que el mapa no podía pintar ni atribuir a nadie. Lo de ahora escribe
en arbolesMapeados con el esquema real, envoltorio monitoreos incluido.
La app es una PWA: en producción un service worker precachea el bundle y lo sirve desde caché. Eso es lo que hace que a veces parezca que un despliegue no llegó, y conviene entender el reparto:
pnpm start(dev, :3000) — sin service worker. Además, si quedaba uno instalado de antes,src/index.jsxlo desregistra y borra sus cachés: basta con cargar la página una vez. Es donde hay que trabajar día a día.pnpm build && pnpm serve(preview, :4173) — service worker real. Sirve para validar la PWA, y es correcto usarlo, pero recuerda que cada puerto es un origen distinto con su propio service worker y su propia caché::3000,:4173yarbu.apppueden enseñarte tres versiones diferentes a la vez. Si vas a mirar preview, cierra el dev, o al menos no los compares entre sí.- Producción — al desplegar, el service worker nuevo toma el control y
src/index.jsxrecarga la pestaña una vez, así que la versión nueva entra sola. Antes hacían falta dos recargas manuales.
Si aun así ves algo viejo: DevTools → Application → Clear site data. Y para trabajar cómodo contra preview, marca "Update on reload" en Application → Service Workers.
Tres cosas se sumaban, y la primera era la gorda:
- Firebase Hosting servía
index.html,sw.jsyregisterSW.jsconCache-Control: max-age=3600, que es su valor por defecto. Durante una hora el navegador no volvía a pedirlos: daba igual lo que se hubiera desplegado, porque lo que decide qué versión se usa estaba congelado. Ahorafirebase.jsonles poneno-cache—que no es "no guardes", sino "revalida siempre": con el ETag, si no cambió nada la respuesta es un 304 vacío— y a/assets/**le pone un añoimmutable, que es seguro porque esos nombres llevan hash. - El precache pesaba 3,9 MB. El service worker nuevo no se activa hasta
haberlo descargado entero, así que cuanto más pesa, más rato sigue sirviendo
la versión anterior. Se sacaron del precache
swagger-ui(938 KB, solo la documentación de la API) yxlsx(424 KB, solo importar planillas): quedó en 2,5 MB. Siguen cacheándose, pero en runtime, cuando alguien abre esas pantallas. - Solo se comprobaba si había versión nueva al cargar la página. Una pestaña
abierta toda la tarde —lo normal en el back-office— no se enteraba nunca.
Ahora
src/index.jsxpregunta también cada hora.
Síntoma que se veía: entrar a un enlace recién desplegado y que la función nueva no estuviera, dos veces seguidas, y a la tercera sí.
Las fotos de los árboles viven en Firebase Storage y se piden sin CORS, así que
el service worker solo ve respuestas opacas. Por eso se cachean con
NetworkFirst (vite.config.mjs): con StaleWhileRevalidate una descarga
fallida se guardaba como si fuera válida y la foto quedaba rota durante 30 días
en ese dispositivo. Ante un reporte de "no se ven las fotos", el primer paso es
limpiar los datos del sitio.
El mapa público consume de inscripcionesMapeo solo {id, grupo, rama}, vía
loadInscripcionesMapeoPublic(). Es una lista blanca explícita: no añadir
campos ahí sin pensarlo. El nombre y el email de los scouts no salen del
back-office.