Documento de diseño · PWA para Honduras · nombre de trabajo «Cucharada»

Recetas con presupuesto y comparación de súpers

Una aplicación web instalable para iOS y Android que convierte «¿qué cocino y para cuántos?» en una lista de compras escalada, con el costo total en cada supermercado de la zona y una recomendación honesta sobre si vale la pena repartir la compra entre tiendas.

Versión 0.1 · 9 sep 2026Moneda: lempira (HNL), formato «L 1,234.50»Idioma de la app: español de Honduras

Resumen de decisiones

  • «Casi real», no «tiempo real». Los precios se refrescan por demanda (lo que está en listas activas primero) con vigencia por categoría: perecederos cada 24 h, abarrotes cada 3 a 7 días. Cada precio muestra tienda, fuente y hora. Prometer tiempo real obligaría a golpear los sitios de las cadenas a un ritmo que ninguna aceptaría.
  • Tres capas de fuentes, en este orden de preferencia: convenio o API oficial con la cadena; datos públicos (SIMPAH de la FHIA y boletines semanales de la SDE) como precio de referencia; lectura respetuosa de páginas públicas de producto sólo donde robots.txt y términos lo permitan, con identificación, límite de ritmo y botón de apagado por tienda. Los precios reportados por usuarios cubren tiendas sin presencia en línea.
  • Una PWA liviana (Preact + TypeScript + Vite, Workbox, IndexedDB con Dexie) contra una API NestJS con Postgres y Redis. Los conectores corren como workers en cola (BullMQ), nunca desde el teléfono del usuario.
  • Todo el cálculo de lista corre en el cliente sobre un paquete de precios por ciudad descargado y cacheado. Escalar porciones, convertir unidades, redondear a empaques y comparar tiendas funciona sin señal.
  • Dividir entre tiendas es una recomendación con umbral, no un valor por defecto: sólo se sugiere cuando el ahorro neto supera el costo del viaje o envío extra y un mínimo configurable (L 25 o 5 %).
  • Primer cliente: Tegucigalpa y San Pedro Sula, que es donde La Colonia y Walmart entregan en línea. El resto del país arranca con precios de referencia y reportes de usuarios.

Contexto hondureño que condiciona el diseño

Dispositivo típico
Android de gama baja o media

Presupuesto de JavaScript inicial menor a 150 KB comprimidos y cero dependencias pesadas. iOS existe pero es minoría; sus limitaciones de PWA se diseñan, no se ignoran.

Red
Datos prepago por paquete

Cada megabyte cuesta. Descargas grandes sólo con Wi-Fi y consentimiento; catálogo por ciudad, no nacional; respeto al encabezado Save-Data.

Unidades de compra
Libra, cartón, manojo, bolsa

La gente compra frijoles por libra, huevos por cartón de 30 (o docena), culantro por manojo. La app habla en esas unidades y convierte a gramos sólo por dentro.

Tiendas en línea
Pocas y concentradas

La Colonia, Walmart y Paiz venden en línea en Tegucigalpa y San Pedro Sula. PriceSmart tiene catálogo público para Honduras. Maxi Despensa y Despensa Familiar no tienen tienda en línea propia; PedidosYa revende con recargo.

Referencia pública
SIMPAH y SDE

La FHIA publica precios diarios de mercados mayoristas y minoristas desde 1996. La SDE publica cada semana precios de referencia de la canasta básica. Sirven como piso de comparación y para tiendas sin dato.

Canal social
WhatsApp

La lista se comparte como texto plano por WhatsApp desde el primer día. Es más útil que cualquier función de «invitar a la familia» dentro de la app.

Alcance funcional

MóduloQué haceCorre en
RecetarioCatálogo curado de platos hondureños y de uso diario, con porciones base, tiempos, pasos e ingredientes ligados al catálogo canónico. Búsqueda y filtros por ocasión, tiempo y costo estimado.Cliente, con datos sincronizados
Escalado de porcionesAjusta cantidades de 1 a 50 personas con reglas por tipo de ingrediente y redondeo «de cocina» (½ taza, 1 cartón, 2 libras).Cliente
Conversión de unidadesMasa, volumen y unidades discretas con densidad por ingrediente. Traduce «2 tazas de frijol» a gramos y luego a «1 libra».Cliente
Lista de comprasCombina varias recetas, consolida ingredientes repetidos, descuenta la despensa del usuario, agrupa por categoría y permite ítems manuales.Cliente
Precios y disponibilidadPrecio actual, promoción, disponibilidad y última verificación por tienda, más precio de referencia público cuando no hay dato de tienda.Servidor (conectores) y cliente (caché)
SustitutosAlternativas por ingrediente con proporción y contexto («queso seco por queso duro 1:1; crema por mantequilla sólo para untar»). Se ofrecen cuando falta el producto o cuando el sustituto ahorra más de un umbral.Cliente
ComparadorCosto total de la lista por tienda, cobertura, faltantes, diferencia contra la más barata y ahorro.Cliente
Repartir compraAsignación de cada ítem a la tienda más conveniente considerando costo fijo por tienda extra, con recomendación explícita de «una sola» o «dividir».Cliente
Reporte de preciosEl usuario registra un precio visto en góndola (foto opcional). Se valida contra el histórico y votos de otros usuarios.Cliente y servidor

Arquitectura propuesta

TELÉFONO DEL USUARIO PWA · Preact + TypeScript + Vite Service Worker (Workbox) IndexedDB (Dexie): catálogo, precios, listas Motor de escalado, conversión y comparación CDN (Cloudflare) shell + paquetes de precios por ciudad API · NestJS /catalogo, /precios?desde= /listas, /reportes /paquetes/{ciudad}.json Autenticación anónima + opcional PostgreSQL catálogo, histórico, listas Redis precio actual, colas, límites WORKERS (BullMQ) Planificador Conector/tienda Normalizador Emparejador Validador crowd Empaquetador Cadenas API/convenio o páginas públicas Datos públicos SIMPAH (FHIA) Boletines SDE Usuarios reportes de góndola con foto y votos HTTPS origen
El teléfono nunca habla con los sitios de las cadenas. Sólo consume paquetes estáticos y la API propia. Los conectores viven en el servidor, donde se pueden identificar, limitar y apagar.

Stack y por qué

CapaElecciónMotivo
ClientePreact + TypeScript + Vite, enrutado por hash o preact-isoMisma ergonomía que React con un runtime de unos 4 KB. Importa en Android de gama baja.
Estado y persistenciaDexie (IndexedDB) + store liviano (Zustand o señales de Preact)Consultas indexadas sobre miles de productos sin cargar todo en memoria.
Service WorkerWorkbox con precache del shell y estrategias por rutaInstalable, offline por defecto, actualización controlada.
APINestJS + Postgres + Redis, desplegado en la VM de GCP existente o en Cloud RunCoincide con lo que ya se opera en otros proyectos; un solo binario, fácil de vigilar.
ColasBullMQ sobre RedisReintentos, prioridad por demanda, límite de ritmo por host, un worker por conector.
ConectoresHTTP simple con undici; Playwright sólo si la página exige JavaScriptUn navegador sin cabeza es pesado y más agresivo con el sitio origen; se usa como último recurso.
EstáticosCloudflare delante del origen; paquetes JSON gzip por ciudadUn precio consultado por mil usuarios se sirve desde el borde, no desde Postgres.
ObservabilidadLogs estructurados, métrica por conector (páginas, errores, cambios), alerta si un conector cae a ceroUn scraper que se rompe en silencio produce precios viejos con cara de nuevos.

Fuentes de datos y estrategia de integración ética

El principio operativo: la app compara precios para ayudar a comprar, no copia catálogos. Sólo se guarda lo que hace falta para las listas de los usuarios, siempre con enlace a la tienda y atribución de fuente. Cada conector es un módulo con una ficha de cumplimiento revisada antes de encenderse y un interruptor para apagarlo en un minuto si una cadena lo pide.

Tres niveles, en orden de preferencia

1
Convenio o API oficialCarta a cada cadena proponiendo intercambio: la app envía tráfico calificado a su tienda en línea (enlaces profundos al carrito o al producto) y acepta un feed de precios o un endpoint con clave. Es la única forma de acercarse a «tiempo real» sin abusar. Hasta que exista, el conector de esa cadena queda en el nivel 3 o apagado.
2
Datos públicos de preciosSIMPAH (administrado por la FHIA) publica precios diarios de granos, hortalizas y frutas en mercados mayoristas y minoristas; la SDE publica precios de referencia semanales de la canasta básica. Se ingieren como reference_prices y sirven para tres cosas: estimar faltantes, detectar precios reportados absurdos y mostrar «precio de mercado» en productos frescos.
3
Lectura respetuosa de páginas públicasSólo páginas de producto públicas, descubiertas por el sitemap oficial, nunca por búsqueda interna ni rutas de API prohibidas en robots.txt. Un agente de usuario identificado con correo de contacto, una petición cada 3 a 5 segundos por host, ventanas fuera de hora pico, caché de al menos 24 h, sin sesión ni cuenta, sin saltar protecciones anti-bot. Si el sitio devuelve 429 o un desafío, el conector se pausa solo.
4
Reportes de usuariosPara Maxi Despensa, Despensa Familiar, mercados y tiendas de barrio. Un reporte vale como precio «visto» con confianza baja; sube con foto, con votos y con coherencia contra el histórico y la referencia pública. Se descarta si está a más de 40 % del rango esperado sin foto.

Estado de cada fuente al 9 de septiembre de 2026

FuenteQué se verificóDecisión
La Colonia (lacolonia.com)robots.txt permite páginas de producto vía sitemap y prohíbe /api*, /busca*, /quick-view* y el subdominio móvil. Los términos prohíben violar seguridad o acceder a datos no destinados al usuario; no mencionan scraping. Entrega en Tegucigalpa y San Pedro Sula.Nivel 3 con carta previa
Sólo URLs del sitemap. Nunca la API interna ni el buscador.
Walmart / Paiz (walmart.com.hn, paiz.com.hn)robots.txt sólo bloquea cuenta, login, checkout y vistas rápidas; publica sitemaps de ambas marcas. Tienda en línea con entrega a domicilio.Nivel 3 con carta previa
Revisar términos de uso completos antes de activar.
PriceSmart (pricesmart.com/es-hn)robots.txt bloquea explícitamente bots de IA y permite buscadores; nuestro conector no es un buscador. Catálogo público, membresía para comprar.Apagado hasta tener permiso
Se muestra como tienda con precios reportados por socios.
Maxi Despensa / Despensa FamiliarSin tienda en línea propia; catálogo por volantes y redes sociales.Nivel 4 (reportes)
Más ingestión manual de volantes semanales como promociones.
PedidosYa (supermercados)Precios con recargo del canal; términos de plataforma restrictivos.No se lee
Si se integra, será como canal «a domicilio» separado y por convenio.
SIMPAH (FHIA)Reportes públicos diarios y series históricas desde 1996, por mercado y producto, en lempiras.Nivel 2, activo desde el MVP
Citar a la FHIA como fuente en pantalla.
SDE (Protección al Consumidor)Boletín semanal de precios de referencia de la canasta básica; publicado como imagen y PDF mensual, sin licencia explícita.Nivel 2, ingestión semi-manual
OCR con revisión humana; pedir formato tabular por escrito.
Regla de oro del nivel 3

Si para leer un precio hace falta iniciar sesión, resolver un desafío, cambiar de agente de usuario o llamar a una ruta que robots.txt prohíbe, ese precio no se lee. Se muestra el precio de referencia público y se invita al usuario a reportarlo. Ninguna funcionalidad de la app depende de que una cadena específica esté encendida.

Ficha de cumplimiento por conector

connectors/la-colonia/compliance.yaml
  host: www.lacolonia.com
  discovery: sitemap            # nunca búsqueda interna
  robots_checked_at: 2026-09-09
  disallowed_paths_respected: [/api, /busca, /quick-view, /account, /checkout]
  user_agent: "CucharadaBot/0.1 (+https://cucharada.hn/bot; contacto@cucharada.hn)"
  rate: { min_interval_s: 4, window: "01:00-05:00 America/Tegucigalpa" }
  max_pages_per_run: 1500
  cache_ttl_h: 24
  stores_full_catalog: false     # sólo productos ligados a ingredientes
  auto_pause_on: [429, 403, challenge_page, error_rate > 5%]
  legal_review: { by: "pendiente", date: null }
  contact_letter_sent: null
  kill_switch: env CONNECTOR_LA_COLONIA=off

Caché y actualización de precios

Cuatro capas, cada una con su vigencia. La regla es que un precio nunca aparece sin su edad, y una edad mayor a la vigencia de su categoría se marca visiblemente como «por confirmar».

CapaDóndeContenidoVigencia
L1 clienteIndexedDBCatálogo de la ciudad, precios actuales, listas y recetas del usuarioSincronización delta al abrir (?desde=) y al conectarse a Wi-Fi
L2 bordeCloudflare/paquetes/{ciudad}/{tienda}.json.gz firmado, de 60 a 200 KBRegenerado cuando cambia más del 2 % de precios o cada 6 h
L3 servidorRediscurrent_price por producto-tienda, cola de refresco, contadores de ritmoEscrito por el normalizador, leído por la API
L4 históricoPostgresToda observación con fuente, hora y confianzaPermanente; alimenta tendencias y validación

Vigencia por categoría

CategoríaRefresco objetivoSe marca «por confirmar» a lasMotivo
Frutas, verduras, carnes, lácteos frescos24 h48 hPrecio y disponibilidad cambian a diario; SIMPAH cubre el hueco.
Granos, aceites, harinas, enlatados72 h7 dCambian con promociones semanales.
Limpieza, higiene, bebidas7 d14 dBaja rotación de precio.
Promociones detectadashasta fecha final vencerSi no trae fecha, se asume domingo de esa semana.

Refresco por demanda

  • Prioridad alta: productos presentes en listas activas (modificadas en los últimos 7 días). Se refrescan primero dentro de la ventana permitida.
  • Prioridad media: productos ligados a recetas populares de la semana.
  • Prioridad baja: el resto del catálogo ligado a ingredientes, en rotación.
  • Detección de cambio: se guarda un hash del bloque de precio de cada página; si no cambió, no se crea observación nueva, sólo se actualiza last_seen_at. Reduce escritura y ruido.
  • Presupuesto de ritmo: con 4 s entre peticiones y una ventana de 4 h, un conector lee unas 3,600 páginas por noche. Alcanza para un catálogo de ingredientes de 1,500 a 2,500 productos por cadena. Si no alcanza, se reduce alcance, nunca se acelera.

Sincronización con el cliente

  1. Al abrir, el cliente pide /precios?ciudad=tgu&desde=<marca>. La respuesta trae sólo cambios y suele pesar menos de 10 KB.
  2. Si el paquete completo de la ciudad tiene una versión nueva y el usuario está en Wi-Fi (o aceptó usar datos), se descarga entero y reemplaza la tabla local en una transacción.
  3. Android con soporte usa Periodic Background Sync para hacer esto en segundo plano; iOS no lo permite, así que se hace al abrir y se muestra un aviso discreto de «precios de hace 2 días» mientras llega.
  4. Los reportes de precio del usuario se encolan localmente y se envían con Background Sync donde exista, o en la siguiente apertura.

Modelo de datos

La separación clave es entre ingrediente (lo que pide una receta: «frijol rojo»), producto (algo con marca y contenido neto: «Frijol rojo Natura, bolsa 1 lb») y producto en tienda (ese producto en una cadena, con su SKU, URL y precio). Las recetas se ligan a ingredientes; los precios, a productos en tienda. El emparejador conecta ambos mundos.

TablaCampos principalesNotas
storesid, chain, name, city, lat, lng, channel (tienda | en_linea | domicilio), delivery_fee, min_order, pickup, connector_idUna cadena puede tener varias sucursales; el precio en línea aplica a la ciudad.
categoriesid, parent_id, name, slug, ttl_hours, sortTaxonomía propia de dos niveles; la vigencia de caché cuelga de aquí.
ingredientsid, name, aliases[], category_id, base_unit (g | ml | unidad), density_g_ml, typical_packs[], is_pantry_stapleIngrediente canónico. «Culantro», «cilantro» y «culantro ancho» son alias o ingredientes distintos según la cocina.
productsid, ingredient_id, brand, net_qty, net_unit, barcode, image_url, pack_kind (bolsa | lata | cartón | botella | unidad)Un producto puede servir a un ingrediente; el contenido neto es lo que permite el precio por unidad base.
store_productsid, store_id, product_id, external_sku, url, availability (disponible | agotado | desconocido), availability_at, last_seen_at, page_hashDisponibilidad se separa de precio: un producto puede tener precio y estar agotado.
price_observationsid, store_product_id, price, promo_price, promo_until, observed_at, source (conector | crowd | oficial), confidence, unit_price_baseSerie temporal. unit_price_base es L por gramo, mililitro o unidad, calculado al insertar.
current_pricesvista materializada: store_product_id, price, promo_price, observed_at, source, confidence, is_staleLo que se empaqueta para el cliente.
reference_pricesingredient_id, source (SIMPAH | SDE), market, city, price, unit, observed_onPrecio público de referencia; nunca se presenta como precio de una tienda.
recipesid, slug, title, region, base_servings, prep_min, cook_min, tags[], steps[], cost_band, publishedcost_band se recalcula cada noche con precios medios de la ciudad principal.
recipe_ingredientsrecipe_id, ingredient_id, qty, unit, prep_note, optional, group, scaling (lineal | sublineal | fijo), sortscaling gobierna el escalado: sal y especias son sublineales; «1 hoja de laurel» es fijo.
substitutesingredient_id, substitute_id, ratio, context, quality (1 a 3), noteDirigido: A puede sustituir a B sin que B sustituya a A.
unit_defscode, dimension (masa | volumen | unidad), to_base, names_hn[], pluralIncluye libra, onza, taza, cucharada, cucharadita, cartón, docena, manojo.
usersid, device_key, city_id, household_size, prefs (tiendas preferidas, radio, usa_datos_moviles), created_atCuenta anónima por dispositivo; correo opcional sólo para respaldar listas.
pantry_itemsuser_id, ingredient_id, qty, unit, updated_atLo que el usuario ya tiene; se descuenta de la lista.
shopping_listsid, user_id, name, recipes[{recipe_id, servings}], mode (una_tienda | dividida), max_stores, statusGuarda la decisión del usuario, no sólo el cálculo.
list_itemslist_id, ingredient_id, qty_needed_base, display_qty, display_unit, from_recipes[], chosen_product_id, manual, checkedConsolidado por ingrediente; el producto elegido puede cambiar por tienda.
list_allocationslist_id, item_id, store_id, store_product_id, packs, unit_price, line_cost, computed_atResultado del reparto. Se recalcula al cambiar precios o modo.
price_reportsid, user_id, store_id, product_id, price, photo_url, created_at, votes_up, votes_down, statusEntra a price_observations con source = crowd cuando pasa validación.
connector_runsconnector_id, started_at, finished_at, pages, changed, errors, status, paused_reasonAuditoría de cada corrida; alimenta alertas.

En el cliente, Dexie replica ingredients, products, store_products, current_prices, reference_prices, unit_defs, substitutes y las recetas publicadas para la ciudad elegida, más las tablas propias del usuario. Cada tabla lleva updated_at para la sincronización delta.

Lógica de negocio

Conversión de unidades

Toda cantidad se normaliza a una unidad base por dimensión: gramos para masa, mililitros para volumen, piezas para unidades discretas. Cruzar de volumen a masa requiere la densidad del ingrediente; cruzar de piezas a masa requiere un peso típico por pieza (un huevo mediano, 50 g; un tomate, 120 g). Si falta el dato, la conversión se rechaza y el ítem se muestra en su unidad original con una nota, nunca con una cifra inventada.

Unidad (como se dice en Honduras)DimensiónEquivalenciaUso
libra (lb)masa453.6 gUnidad de compra dominante en carne, granos, queso, verduras.
onza (oz)masa28.35 gEmpaques importados, quesos.
kilogramomasa1,000 gHarinas y azúcar en empaques grandes.
tazavolumen240 mlRecetas caseras.
cucharada / cucharaditavolumen15 ml / 5 mlCondimentos y grasas.
litro, botella de 750 mlvolumen1,000 ml / 750 mlAceite, leche, bebidas.
docena / cartónunidad12 / 30 huevos«Cartón» sin más significa 30; se confirma en la pantalla de producto.
manojounidad → masa≈ 30 g (culantro)Peso típico por ingrediente; editable.
unidad / piezaunidadpeso típico por ingredientePlátano, tomate, cebolla, aguacate.

Escalado de porciones

Factor f = porciones deseadas / porciones base. Se aplica por ingrediente según su modo:

  • Lineal (la mayoría): qty × f.
  • Sublineal (sal, especias, ajo, consomé, levadura): qty × f^0.85 cuando f > 1, lineal cuando f < 1. Al duplicar la receta la sal sube 80 %, no 100 %; es lo que hacen las cocineras con experiencia.
  • Fijo (una hoja de laurel, una tapa de olla): no cambia, salvo que f supere 3, donde se duplica.

Después del cálculo entra el redondeo de cocina: volúmenes a fracciones de taza y cucharada (¼, ⅓, ½, ⅔, ¾), masas a múltiplos de 5 g bajo 100 g y de 25 g arriba, piezas al entero superior. La cantidad exacta queda guardada; sólo la presentación se redondea. Tiempos de cocción no escalan; se muestra una nota cuando f ≥ 2.5 («usá dos ollas o alargá el tiempo»).

Redondeo a empaques

Una lista pide 900 g de harina, pero la tienda vende bolsas de 1 lb y de 2 kg. El motor elige, por tienda, la combinación de empaques que cubre la necesidad al menor costo, con desempate por menor sobrante. La pantalla muestra las tres cifras: necesitás, comprás, te sobra. El sobrante alimenta la despensa si el usuario lo acepta.

Sustitutos

Se ofrecen en dos momentos: cuando el ingrediente no está disponible en la tienda evaluada y cuando el sustituto de calidad 1 o 2 cuesta al menos 15 % menos. El sustituto entra con su ratio (1.0 para queso duro por queso seco; 1.25 para margarina por mantequilla en repostería) y su contexto, para que nunca se proponga crema en lugar de mantequilla al hornear.

Comparación por tienda

Para cada tienda de la ciudad se calcula:

  • Total de los ítems con precio y disponibilidad conocidas, ya redondeados a empaque.
  • Cobertura: ítems con dato entre el total de ítems.
  • Faltantes: lista de lo que no tiene o no se conoce, con sustituto si existe.
  • Total comparable: total más los faltantes valorados a precio de referencia público, marcado como estimado. Sólo así se pueden ordenar tiendas con cobertura distinta sin favorecer a la que tiene menos datos.
  • Diferencia contra la tienda más barata, en lempiras y porcentaje, y frescura mínima de los precios usados.

Repartir entre tiendas

Con hasta cinco tiendas por ciudad, la búsqueda exhaustiva es trivial: se evalúan los 31 subconjuntos no vacíos, en cada uno cada ítem va a la tienda más barata del subconjunto, y al total se suma un costo fijo por tienda extra (viaje, o envío mínimo si es en línea) que el usuario configura y por defecto vale L 15 por tienda adicional. Gana el subconjunto de menor costo total. La recomendación de dividir sólo aparece si el ahorro neto contra la mejor tienda única supera L 25 o 5 % del total, lo que sea mayor. Cuando la lista supera 60 ítems, o el usuario permite más de cinco tiendas, se cambia a asignación voraz por ítem con el mismo costo fijo, que en la práctica da el mismo resultado.

Baleadas sencillas, 8 personasNecesita → compraTienda 1Tienda 2Tienda 3Mínimo
Harina de trigo900 g → 2 × 1 lb44.0041.00sin dato41.00 (T2)
Frijol rojo1 lb → 1 lb32.0034.0029.0029.00 (T3)
Queso seco½ lb → 1 lb105.0098.00112.0098.00 (T2)
Mantequilla (crema)½ lb → 1 lb62.0065.0058.0058.00 (T3)
Huevos8 → docena58.0055.0060.0055.00 (T2)
Aceite120 ml → 750 ml48.0052.0046.0046.00 (T3)
Margarina150 g → 400 g38.0036.0041.0036.00 (T2)
Total387.00381.00346.00 (6 de 7)363.00
Total comparablefaltante a referencia387.00381.00389.00 (est.)
Precios ilustrativos en lempiras, no reales. Mejor tienda única: Tienda 2 con L 381.00. Dividir entre Tienda 2 y Tienda 3 da L 363.00, ahorro bruto L 18.00 (4.7 %); con L 15.00 de viaje extra el ahorro neto es L 3.00 y no supera el umbral. La app recomienda «todo en Tienda 2» y muestra el detalle para quien quiera dividir de todos modos.
// Núcleo del reparto (TypeScript, corre en el cliente)
function repartir(items: Item[], tiendas: Tienda[], costoExtra: number, maxTiendas = 5) {
  let mejor = { costo: Infinity, tiendas: [] as Tienda[], asign: new Map() };
  for (const sub of subconjuntos(tiendas, maxTiendas)) {
    const asign = new Map();
    let costo = costoExtra * (sub.length - 1);
    for (const it of items) {
      const opciones = sub.map(t => ({ t, c: costoEnTienda(it, t) })).filter(o => o.c !== null);
      if (opciones.length === 0) { costo += precioReferencia(it); continue; } // faltante estimado
      const min = opciones.reduce((a, b) => (b.c! < a.c! ? b : a));
      asign.set(it, min.t); costo += min.c!;
    }
    if (costo < mejor.costo) mejor = { costo, tiendas: sub, asign };
  }
  return mejor;
}

Flujos de UI

Cinco pantallas cargan con el trabajo real; el resto son detalles. Lenguaje directo en segundo persona del voseo hondureño («elegí», «agregá»), cifras siempre en lempiras con dos decimales, y ninguna acción de dinero escondida detrás de un ícono. Tema claro en blanco y tema oscuro en negro, con el acento sólo en botones primarios, ahorros y estado de frescura.

Inicio
¿Qué cocinamos hoy?Buscar
Sopa de frijoles≈ L 95
Pollo con tajadas≈ L 240
Baleadas sencillas≈ L 180
Mi lista de la semana12 ítems
Armar lista
Receta
Baleadas sencillas4 → 8 personas
Harina de trigo2 lb
Frijol rojo cocido1 lb
Queso seco · ver sustitutos½ lb
Huevos8
Agregar a la lista · ≈ L 381
Lista
Granos y harinas
Harina · 2 bolsas de 1 lbsobra 7 g
Frijol rojo · 1 lbtenés ½ lb
Lácteos y huevos
Huevos · 1 docenasobran 4
Comparar súpers
Comparar
Tienda 2L 381.00
7 de 7 · precios de hoymás barata
Tienda 1L 387.00
7 de 7 · +L 6.00 (1.6 %)
Tienda 3≈ L 389.00
6 de 7 · falta harinaestimado
Ver reparto entre tiendas
Reparto
Una sola tiendaL 381.00
Dividir en 2L 363.00
+ viaje extraL 15.00
Ahorro netoL 3.00
Recomendacióntodo en Tienda 2
Compartir por WhatsApp

Recorridos principales

A
Primera vezElegir ciudad (Tegucigalpa, San Pedro Sula u «otra»), tamaño del hogar, tiendas que le quedan cerca. Se descarga el paquete de la ciudad. Sin registro. En «otra» ciudad la app funciona con precios de referencia y reportes, y lo dice.
B
De receta a listaAbrir receta, mover el contador de personas, ver cantidades cambiar en el momento, tocar un ingrediente para ver sustitutos o marcar «ya tengo», agregar a la lista. Se pueden sumar varias recetas; la lista consolida (dos recetas con cebolla dan una sola línea).
C
Comparar y decidirTarjetas por tienda ordenadas por total comparable. Tocar una tienda muestra el detalle línea por línea, con faltantes y sustitutos propuestos. El botón de reparto muestra la comparación «una sola» contra «dividida» con el ahorro neto y la recomendación.
D
Ir a comprarModo compra: lista agrupada por pasillo, checkboxes grandes, pantalla que no se apaga, funciona sin señal. Si el precio en góndola difiere, un toque abre «reportar precio» con el valor prellenado.
E
CompartirTexto plano por WhatsApp con la lista, la tienda recomendada y el total. Enlace de vuelta a la app opcional.

Offline y rendimiento para Honduras

Presupuestos

JavaScript inicial
≤ 150 KB gz

Preact, enrutador, Dexie y la pantalla de inicio. El motor de comparación y el recetario completo se cargan en segundo plano.

Primera carga en 3G rápido
LCP ≤ 2.5 s

Medido con Lighthouse en perfil Moto G4 y red «Slow 4G». Se rompe el build si se pasa.

Paquete de ciudad
60 a 200 KB gz

Sólo productos ligados a ingredientes. Descarga completa sólo en Wi-Fi o con permiso.

Uso típico diario
< 50 KB

Sincronización delta más una o dos imágenes de receta en WebP a 480 px.

Estrategias del Service Worker

RecursoEstrategiaNota
Shell (HTML, JS, CSS, fuentes)Precache con versiónActualización silenciosa; se aplica al siguiente arranque con aviso «hay versión nueva».
Paquete de precios por ciudadStale-while-revalidateSe usa el local de inmediato y se reemplaza al llegar el nuevo.
API de listas y reportesNetwork-first con cola offlineEscrituras se guardan en IndexedDB y se reenvían.
Imágenes de recetas y productosCache-first con tope de 60 MB y expiraciónWebP con fallback; nunca se descargan imágenes de producto en modo ahorro de datos.
Fuentes tipográficasPrecache, subconjunto latinoCon font-display: swap para no bloquear texto.

iOS y Android, distinto por diseño

iOS (Safari)
  • Sin Background Sync ni Periodic Sync: todo refresco ocurre al abrir la app. La interfaz muestra la edad de los precios sin drama.
  • Almacenamiento puede evacuarse tras días sin uso si la app no está instalada. Se pide navigator.storage.persist() y se insiste en «agregar a inicio» con instrucciones visuales, porque la app instalada conserva datos.
  • Notificaciones push sólo con la app instalada y fuera de la Unión Europea; para Honduras aplica. Se usan con moderación: «bajó el precio de algo en tu lista».
  • Cuota de caché más ajustada: tope de imágenes en 60 MB y limpieza por antigüedad.
  • Modo compra usa Screen Wake Lock, disponible en apps instaladas desde Safari 18.4.
Android (Chrome y derivados)
  • Background Sync para reenviar reportes y Periodic Background Sync para el paquete de ciudad cuando la app se usa con frecuencia.
  • Instalación con beforeinstallprompt tras la segunda lista creada, no en la primera visita.
  • Web Share Target opcional: recibir una foto de volante desde la galería y proponer un reporte de precio.
  • Se respeta navigator.connection.saveData y el tipo de red para decidir si descargar el paquete completo.

Rendimiento en el dispositivo

  • Consultas a IndexedDB por índice compuesto [ingredient_id+store_id]; la comparación de una lista de 30 ítems contra 5 tiendas se resuelve en menos de 50 ms en un Android de gama baja.
  • Listas largas con virtualización sólo cuando superan 100 filas; antes no vale el peso.
  • Nada de mapas embebidos: la ubicación de tiendas se muestra como lista con distancia y un enlace externo a Google Maps.
  • Fuentes del sistema como respaldo real: si Google Fonts no responde en 3 s, el texto ya está en pantalla con la fuente local.

Seguridad y privacidad

  • Mínimo dato personal. La cuenta es una clave de dispositivo; correo sólo si el usuario quiere respaldar listas. No se pide teléfono ni ubicación precisa; la ciudad se elige a mano.
  • Honduras no tiene aún una ley general de protección de datos vigente, así que se diseña como si la tuviera: propósito declarado, borrado a pedido desde la app, retención de reportes de precio anonimizada a los 90 días.
  • Las fotos de reportes se recortan del lado del cliente al bloque de precio y se borran del servidor una vez validado el reporte.
  • API con límite de ritmo por dispositivo, paquetes firmados para que un CDN o un proxy no puedan alterar precios, y sin secretos en el cliente.
  • Marcas de las cadenas: se usan sus nombres como texto, no sus logotipos, hasta tener permiso escrito.
  • Los conectores no guardan cookies, no usan credenciales y registran cada corrida, para poder demostrar ante cualquier cadena exactamente qué se leyó y cuándo.

Fases y riesgos

FaseEntregaCriterio de salida
0 · Permisos (semanas 1 a 2, en paralelo)Cartas a La Colonia, Walmart Centroamérica y PriceSmart; solicitud de formato tabular a la SDE; alta como usuario de SIMPAH.Al menos una respuesta o un silencio de 15 días documentado antes de encender cualquier conector de nivel 3.
1 · Motor sin precios (semanas 1 a 4)PWA con 40 recetas hondureñas, escalado, conversión, sustitutos, lista consolidada y despensa. Sin tiendas.Usable offline en iPhone y Android de gama baja; 10 hogares reales lo usan una semana.
2 · Referencia pública (semanas 4 a 6)Ingestión de SIMPAH y SDE; costo estimado por receta; reportes de usuarios con validación.Toda receta muestra un costo estimado con fuente y fecha.
3 · Primer conector (semanas 6 a 9)Un conector de nivel 3 o API oficial, emparejador ingrediente–producto revisado a mano para los 400 productos más usados, comparador y reparto.Cobertura mayor a 85 % de los ingredientes de las 40 recetas en Tegucigalpa y San Pedro Sula.
4 · Segunda y tercera tienda (semanas 9 a 12)Más conectores según permisos; alertas de baja de precio; compartir por WhatsApp; modo compra.Un usuario puede decidir dónde comprar sin abrir ningún otro sitio.

Riesgos abiertos

  • Que ninguna cadena responda. La app sigue siendo útil con referencia pública y reportes, pero la promesa de «comparar súpers» se cumple a medias. Mitigación: fase 1 y 2 no dependen de ellas.
  • Emparejamiento ingrediente–producto. Es el trabajo más manual y el que más errores visibles produce («queso seco» emparejado con queso crema). Mitigación: revisión humana de los 400 productos más frecuentes y botón «esto no es lo que pedí» en cada línea.
  • Precios en línea distintos a los de sucursal. Algunas cadenas cobran distinto en línea. Mitigación: el canal se muestra siempre («precio en línea») y los reportes de góndola se guardan como canal aparte.
  • Desgaste de conectores. Los sitios cambian su HTML sin aviso. Mitigación: pruebas de contrato por conector, alerta cuando una corrida produce cero cambios o más de 5 % de errores, y precios marcados «por confirmar» automáticamente al vencer su vigencia.
  • Evacuación de datos en iOS. Un usuario que no instala la app puede perder su lista. Mitigación: respaldo opcional por correo y aviso claro de instalar.

Fuentes consultadas

  1. Términos y condiciones de Supermercados La Colonia y su robots.txt, revisados el 9 de septiembre de 2026.
  2. Walmart Honduras, tienda en línea y su robots.txt, con sitemaps de Walmart y Paiz.
  3. PriceSmart Honduras y su robots.txt, que bloquea bots de IA y permite buscadores.
  4. SIMPAH, Sistema de Información de Mercados de Productos Agrícolas de Honduras, administrado por la FHIA; ejemplo de reporte diario de hortalizas.
  5. Boletines de la canasta básica alimentaria, Secretaría de Desarrollo Económico; nota de la TNH sobre los precios de referencia semanales.
  6. OBSAN de la UNAH, monitoreo de precios de granos básicos.
  7. PedidosYa Honduras, supermercados.
  8. Maxi Despensa y Despensa Familiar, ubicaciones.
  9. Prizio, guía de ofertas de supermercados en Honduras 2026 (app comparadora de volantes ya existente en el mercado).
  10. Limitaciones de PWA en iOS y soporte de Safari, 2026 y PWA en 2026: qué funciona en iOS y Android.