[{"data":1,"prerenderedAt":6066},["ShallowReactive",2],{"navigation":3,"/es/blog/jp-landing":124,"visible-blog-posts":306},[4,63],{"title":5,"path":6,"stem":7,"children":8,"page":38},"Blog","/blog","blog",[9,13,39,43,47,51,55,59],{"title":10,"path":11,"stem":12},"Using the official exchange rate in Venezuela's e-commerce","/blog/bcv-scraper","blog/bcv-scraper",{"title":14,"path":15,"stem":16,"children":17,"page":38},"Case Studies","/blog/case-studies","blog/case-studies",[18,22,26,30,34],{"title":19,"path":20,"stem":21},"Rebuilding an e-commerce platform on NestJS + Next.js + Medusa in three months","/blog/case-studies/retail-ecommerce-rebuild","blog/case-studies/retail-ecommerce-rebuild",{"title":23,"path":24,"stem":25},"Stabilizing an inherited e-commerce platform on NestJS + Strapi","/blog/case-studies/retail-ecommerce-recovery","blog/case-studies/retail-ecommerce-recovery",{"title":27,"path":28,"stem":29},"A nationwide mobile sales-quotation app, integrated with SAP","/blog/case-studies/retail-sap-quotation-app","blog/case-studies/retail-sap-quotation-app",{"title":31,"path":32,"stem":33},"Semantichelp — a knowledge base that answers with sources","/blog/case-studies/semantichelp-rag-knowledge-base","blog/case-studies/semantichelp-rag-knowledge-base",{"title":35,"path":36,"stem":37},"Shipping a high-scale PWA from an inherited e-commerce project","/blog/case-studies/supermarket-pwa-recovery","blog/case-studies/supermarket-pwa-recovery",false,{"title":40,"path":41,"stem":42},"ChronoCal: Time tracking inside Google Calendar","/blog/chronocal","blog/chronocal",{"title":44,"path":45,"stem":46},"Fixing GNOME Google Drive Mounts on NixOS 25.11","/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11","blog/fixing-gnome-google-drive-mounts-on-nixos-25-11",{"title":48,"path":49,"stem":50},"From Nuxt to Hugo and Back Again","/blog/from-nuxt-to-hugo","blog/from-nuxt-to-hugo",{"title":52,"path":53,"stem":54},"The Intern is downloading movies again","/blog/intern-movie-torrent","blog/intern-movie-torrent",{"title":56,"path":57,"stem":58},"Japanese input in openSUSE Tumbleweed's KDE","/blog/japanese-input","blog/japanese-input",{"title":60,"path":61,"stem":62},"Jhey Pi: an official landing site for an aspiring musician","/blog/jp-landing","blog/jp-landing",{"title":64,"path":65,"stem":66,"children":67,"page":38},"Es","/es","es",[68],{"title":5,"path":69,"stem":70,"children":71,"page":38},"/es/blog","es/blog",[72,76,100,104,108,112,116,120],{"title":73,"path":74,"stem":75},"Usar la tasa de cambio oficial en el e-commerce venezolano","/es/blog/bcv-scraper","es/blog/bcv-scraper",{"title":14,"path":77,"stem":78,"children":79,"page":38},"/es/blog/case-studies","es/blog/case-studies",[80,84,88,92,96],{"title":81,"path":82,"stem":83},"Reconstruir una plataforma de e-commerce sobre NestJS + Next.js + Medusa en tres meses","/es/blog/case-studies/retail-ecommerce-rebuild","es/blog/case-studies/retail-ecommerce-rebuild",{"title":85,"path":86,"stem":87},"Estabilizar una plataforma de e-commerce heredada sobre NestJS + Strapi","/es/blog/case-studies/retail-ecommerce-recovery","es/blog/case-studies/retail-ecommerce-recovery",{"title":89,"path":90,"stem":91},"Una aplicación móvil de cotizaciones para ventas en todo el país, integrada con SAP","/es/blog/case-studies/retail-sap-quotation-app","es/blog/case-studies/retail-sap-quotation-app",{"title":93,"path":94,"stem":95},"Semantichelp — una base de conocimiento que responde con fuentes","/es/blog/case-studies/semantichelp-rag-knowledge-base","es/blog/case-studies/semantichelp-rag-knowledge-base",{"title":97,"path":98,"stem":99},"Entregar una PWA de alto volumen a partir de un proyecto de e-commerce heredado","/es/blog/case-studies/supermarket-pwa-recovery","es/blog/case-studies/supermarket-pwa-recovery",{"title":101,"path":102,"stem":103},"ChronoCal: registro de tiempo dentro de Google Calendar","/es/blog/chronocal","es/blog/chronocal",{"title":105,"path":106,"stem":107},"Arreglar los montajes de Google Drive en GNOME sobre NixOS 25.11","/es/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11","es/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11",{"title":109,"path":110,"stem":111},"De Nuxt a Hugo y de vuelta","/es/blog/from-nuxt-to-hugo","es/blog/from-nuxt-to-hugo",{"title":113,"path":114,"stem":115},"El becario está descargando películas otra vez","/es/blog/intern-movie-torrent","es/blog/intern-movie-torrent",{"title":117,"path":118,"stem":119},"Entrada de japonés en KDE de openSUSE Tumbleweed","/es/blog/japanese-input","es/blog/japanese-input",{"title":121,"path":122,"stem":123},"Jhey Pi: un sitio oficial para un músico que está empezando","/es/blog/jp-landing","es/blog/jp-landing",{"id":125,"title":121,"author":126,"body":132,"category":284,"client":285,"date":286,"description":287,"extension":288,"featured":38,"hidden":38,"image":289,"locale":66,"meta":290,"minRead":291,"navigation":292,"ogImage":285,"outcome_headline":293,"path":122,"repoUrl":294,"role":295,"seo":296,"stack":297,"stem":123,"tags":301,"team_size":303,"type":284,"year":304,"__hash__":305},"blog/es/blog/jp-landing.md",{"name":127,"username":128,"to":129,"avatar":130},"Iván Álvarez","ivanovertime","https://github.com/ivanovertime",{"src":131,"alt":127},"/avatar.jpg",{"type":133,"value":134,"toc":274},"minimark",[135,140,144,148,151,155,168,172,240,244,247,250,267,271],[136,137,139],"h2",{"id":138},"tldr","TL;DR",[141,142,143],"p",{},"Jhey Pi es el sitio oficial de un músico que está empezando. Lo construí para tener la historia del artista, los lanzamientos de Spotify y los videos de YouTube en un mismo lugar, con un feed simple que se lee rápido.",[136,145,147],{"id":146},"contexto","Contexto",[141,149,150],{},"El objetivo era hacer un sitio que se sintiera como una casa de verdad para el artista, no como otra página promocional más. El sitio lleva los últimos lanzamientos de Spotify y los videos de YouTube a un solo feed, así el contenido se mantiene al día sin convertir la página en una tarea manual.",[136,152,154],{"id":153},"restricciones","Restricciones",[156,157,158,162,165],"ul",{},[159,160,161],"li",{},"Mantener la experiencia ligera y fácil de actualizar.",[159,163,164],{},"Unir los lanzamientos de Spotify, los datos del perfil del artista y los videos de YouTube en un solo feed.",[159,166,167],{},"Soportar contenido bilingüe sin que la página se sienta saturada.",[136,169,171],{"id":170},"decisiones","Decisiones",[173,174,175,191],"table",{},[176,177,178],"thead",{},[179,180,181,185,188],"tr",{},[182,183,184],"th",{},"Decisión",[182,186,187],{},"Compensación",[182,189,190],{},"Elegí",[192,193,194,205,218,228],"tbody",{},[179,195,196,200,203],{},[197,198,199],"td",{},"Construir un sitio promocional tradicional con páginas separadas para música y video",[197,201,202],{},"Más espacio para el contenido, pero más difícil de mantener al día",[197,204],{},[179,206,207,210,213],{},[197,208,209],{},"Construir un solo sitio con un feed unificado y ordenado automáticamente",[197,211,212],{},"Más fácil de leer y de mantener; el artista actualiza en un solo lugar",[197,214,215],{},[216,217],"decision-check",{},[179,219,220,223,226],{},[197,221,222],{},"Mantener el contenido en un solo idioma",[197,224,225],{},"Más simple, pero con menos alcance para un público bilingüe",[197,227],{},[179,229,230,233,236],{},[197,231,232],{},"Soportar contenido ES/EN",[197,234,235],{},"Encaja mejor con el público, con un poco más de trabajo de contenido",[197,237,238],{},[216,239],{},[136,241,243],{"id":242},"resultado","Resultado",[141,245,246],{},"El proyecto terminó como un sitio limpio para Jhey Pi, con un solo feed de contenido, los datos del perfil del artista y espacio para contenido bilingüe. También simplificó el flujo de actualización: los lanzamientos y videos nuevos pueden aparecer sin tener que reescribir toda la página.",[141,248,249],{},"En cuanto a funcionalidad, las piezas útiles fueron:",[156,251,252,255,258,261,264],{},[159,253,254],{},"Lanzamientos de Spotify y perfil del artista vía la API de Spotify.",[159,256,257],{},"Últimos videos de YouTube vía el feed del canal.",[159,259,260],{},"Feed ordenado automáticamente y paginado, con embeds.",[159,262,263],{},"Contenido bilingüe en ES/EN.",[159,265,266],{},"Caché ISR para el endpoint del feed.",[136,268,270],{"id":269},"qué-haría-distinto-hoy","Qué haría distinto hoy",[141,272,273],{},"Si esto tuviera que escalar más, mantendría el endpoint del feed con caché al estilo ISR, movería las integraciones detrás de una capa de servidor pequeña, y alojaría el front end en una plataforma de edge gestionada, con despliegues de vista previa por cada pull request.",{"title":275,"searchDepth":276,"depth":276,"links":277},"",2,[278,279,280,281,282,283],{"id":138,"depth":276,"text":139},{"id":146,"depth":276,"text":147},{"id":153,"depth":276,"text":154},{"id":170,"depth":276,"text":171},{"id":242,"depth":276,"text":243},{"id":269,"depth":276,"text":270},"side-project",null,"2026-05-18T00:00:00.000Z","Sitio oficial de Jhey Pi, construido con Nuxt 4 y Nuxt UI para combinar los lanzamientos de Spotify y los videos de YouTube en un solo feed.","md","/projects/jp-landing.png",{},3,true,"Publiqué un sitio de artista enfocado, con un solo feed para las novedades musicales y el contenido en video","https://github.com/ivanovertime/jp-landing","Ingeniero",{"title":121,"description":287},[298,299,300],"Nuxt","Vue","Tailwind CSS",[302],"nuxt",1,2026,"jwTdfFN2bwnft_NCDhtvxj3K_FM3iF-jNVMGIWvKl98",[307,1080,1764,2140,2476,2752,2994,3131,3240,3892,4491,4638,4762,4948,5122,5413,5654,5879],{"id":308,"title":31,"author":309,"body":311,"category":1059,"client":342,"date":1060,"description":1061,"extension":288,"featured":292,"hidden":38,"image":1062,"locale":1063,"meta":1064,"minRead":285,"navigation":292,"ogImage":1062,"outcome_headline":1065,"path":32,"repoUrl":285,"role":1066,"seo":1067,"stack":1068,"stem":33,"tags":1075,"team_size":303,"type":1078,"year":304,"__hash__":1079},"blog/blog/case-studies/semantichelp-rag-knowledge-base.md",{"name":127,"username":128,"to":129,"avatar":310},{"src":131,"alt":127},{"type":133,"value":312,"toc":1044},[313,315,348,352,359,362,365,380,384,454,457,461,607,611,616,619,658,665,669,672,712,734,738,745,781,792,796,803,839,846,850,853,914,921,925,933,965,972,975,979,1020,1023,1026,1040],[136,314,139],{"id":138},[156,316,317,327,335],{},[159,318,319,326],{},[320,321,325],"a",{"href":322,"rel":323},"https://semantichelp.com/",[324],"nofollow","Semantichelp"," is a multi-tenant SaaS knowledge base: an organization uploads its documents into a folder catalog, then asks questions and gets streaming answers that cite the passages they came from.",[159,328,329,330,334],{},"The differentiator is what happens ",[331,332,333],"strong",{},"when it can't answer",": the question escalates to the owner of that folder or document, and their reply is written back into the catalog as a citable Q&A document.",[159,336,337,338,343,344,347],{},"Built solo for ",[320,339,342],{"href":340,"rel":341},"https://vertexplusgroup.com/",[324],"Vertex Plus Group"," on ",[331,345,346],{},"Nuxt 4 + Cloudflare Workers"," (D1, R2, Vectorize, Queues), end to end. v1 is in production with a first real tenant.",[136,349,351],{"id":350},"context","Context",[141,353,354,355],{},"Every \"chat with your docs\" tool has the same two outcomes. Sometimes it answers. Sometimes it shrugs: ",[356,357,358],"em",{},"I couldn't find that in the documents.",[141,360,361],{},"The shrug is where these products quietly die. The person with a real question has just been told the knowledge base is not for them — and the gap they hit is one a colleague could have closed in a sentence. The catalog learns nothing, and the same question is asked again next month by someone else.",[141,363,364],{},"That shrug is the product surface here. An unanswered question is not a dead end, it's a ticket: it goes to whoever owns that area, their answer becomes a document, and the knowledge base improves by being used.",[141,366,367,368,371,372,375,376,379],{},"The users are an organization's own staff — an ",[331,369,370],{},"admin"," who sets the workspace up and configures the AI provider, an ",[331,373,374],{},"editor"," who builds the catalog, and ",[331,377,378],{},"viewers"," who read and ask. The person asking is usually not the person who wrote the document, which is why an answer without its source is not an answer.",[136,381,383],{"id":382},"constraints","Constraints",[173,385,386,396],{},[176,387,388],{},[179,389,390,393],{},[182,391,392],{},"Constraint",[182,394,395],{},"Reality",[192,397,398,406,414,422,430,438,446],{},[179,399,400,403],{},[197,401,402],{},"Team",[197,404,405],{},"One person — product decisions, UI, schema, prompts, deploy",[179,407,408,411],{},[197,409,410],{},"Runtime",[197,412,413],{},"Cloudflare Workers only: no always-on server, no long-lived process",[179,415,416,419],{},[197,417,418],{},"Storage",[197,420,421],{},"D1 (SQLite) + R2 + Vectorize; no Postgres, no pgvector",[179,423,424,427],{},[197,425,426],{},"Communication",[197,428,429],{},"No mail infrastructure, so no verification email and no reset link",[179,431,432,435],{},[197,433,434],{},"Sessions",[197,436,437],{},"A single 24-hour JWT, no refresh token",[179,439,440,443],{},[197,441,442],{},"Isolation",[197,444,445],{},"One organization's documents, questions and conversations invisible to every other — a product promise, not a technicality",[179,447,448,451],{},[197,449,450],{},"Cost",[197,452,453],{},"Per-tenant ceilings that have to hold without a billing system to enforce them",[141,455,456],{},"The non-negotiable: a team signs up, uploads a document, and gets a cited answer the same day, with no infrastructure work on their side.",[136,458,460],{"id":459},"architecture-at-a-glance","Architecture at a glance",[462,463,467],"pre",{"className":464,"code":465,"language":466,"meta":275,"style":275},"language-mermaid shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","flowchart LR\n  classDef ext fill:#e5e7eb,stroke:#6b7280,color:#282a30\n  classDef svc fill:#dbeafe,stroke:#0f4c5c,color:#282a30\n  classDef data fill:#d1fae5,stroke:#0f766e,color:#282a30\n  classDef client fill:#fef3c7,stroke:#f59e0b,color:#282a30\n\n  UP[Upload PDF / DOCX / TXT / MD] --> R2[(R2 object)]\n  UP --> D1[(D1 documents row)]\n  D1 --> Q[[Ingest queue]]\n  Q --> EX[Extract text]\n  EX --> CH[Chunk + label section]\n  CH --> EM[Embed]\n  EM --> VI[(Vectorize index)]\n  ASK[Question in chat] --> RET[Retrieve top 5, tenant-filtered]\n  VI --> RET\n  RET --> LLM[Stream answer + citations]\n  LLM -->|cannot answer| ESC[Escalate to owner]\n  ESC --> ANS[Owner answers in the Questions inbox]\n  ANS --> QA[Folder Q&A document regenerated]\n  QA --> Q\n  class UP,ASK,ESC,ANS client\n  class EX,CH,EM,RET,LLM,QA svc\n  class R2,D1,VI data\n","mermaid",[468,469,470,477,482,487,493,499,505,511,517,523,529,535,541,547,553,559,565,571,577,583,589,595,601],"code",{"__ignoreMap":275},[471,472,474],"span",{"class":473,"line":303},"line",[471,475,476],{},"flowchart LR\n",[471,478,479],{"class":473,"line":276},[471,480,481],{},"  classDef ext fill:#e5e7eb,stroke:#6b7280,color:#282a30\n",[471,483,484],{"class":473,"line":291},[471,485,486],{},"  classDef svc fill:#dbeafe,stroke:#0f4c5c,color:#282a30\n",[471,488,490],{"class":473,"line":489},4,[471,491,492],{},"  classDef data fill:#d1fae5,stroke:#0f766e,color:#282a30\n",[471,494,496],{"class":473,"line":495},5,[471,497,498],{},"  classDef client fill:#fef3c7,stroke:#f59e0b,color:#282a30\n",[471,500,502],{"class":473,"line":501},6,[471,503,504],{"emptyLinePlaceholder":292},"\n",[471,506,508],{"class":473,"line":507},7,[471,509,510],{},"  UP[Upload PDF / DOCX / TXT / MD] --> R2[(R2 object)]\n",[471,512,514],{"class":473,"line":513},8,[471,515,516],{},"  UP --> D1[(D1 documents row)]\n",[471,518,520],{"class":473,"line":519},9,[471,521,522],{},"  D1 --> Q[[Ingest queue]]\n",[471,524,526],{"class":473,"line":525},10,[471,527,528],{},"  Q --> EX[Extract text]\n",[471,530,532],{"class":473,"line":531},11,[471,533,534],{},"  EX --> CH[Chunk + label section]\n",[471,536,538],{"class":473,"line":537},12,[471,539,540],{},"  CH --> EM[Embed]\n",[471,542,544],{"class":473,"line":543},13,[471,545,546],{},"  EM --> VI[(Vectorize index)]\n",[471,548,550],{"class":473,"line":549},14,[471,551,552],{},"  ASK[Question in chat] --> RET[Retrieve top 5, tenant-filtered]\n",[471,554,556],{"class":473,"line":555},15,[471,557,558],{},"  VI --> RET\n",[471,560,562],{"class":473,"line":561},16,[471,563,564],{},"  RET --> LLM[Stream answer + citations]\n",[471,566,568],{"class":473,"line":567},17,[471,569,570],{},"  LLM -->|cannot answer| ESC[Escalate to owner]\n",[471,572,574],{"class":473,"line":573},18,[471,575,576],{},"  ESC --> ANS[Owner answers in the Questions inbox]\n",[471,578,580],{"class":473,"line":579},19,[471,581,582],{},"  ANS --> QA[Folder Q&A document regenerated]\n",[471,584,586],{"class":473,"line":585},20,[471,587,588],{},"  QA --> Q\n",[471,590,592],{"class":473,"line":591},21,[471,593,594],{},"  class UP,ASK,ESC,ANS client\n",[471,596,598],{"class":473,"line":597},22,[471,599,600],{},"  class EX,CH,EM,RET,LLM,QA svc\n",[471,602,604],{"class":473,"line":603},23,[471,605,606],{},"  class R2,D1,VI data\n",[136,608,610],{"id":609},"decisions","Decisions",[612,613,615],"h3",{"id":614},"decision-1-d1-vectorize-instead-of-postgres-pgvector","Decision 1 — D1 + Vectorize instead of Postgres + pgvector",[141,617,618],{},"Postgres with pgvector is the obvious stack for a RAG product, and a good one. It is also a database server to run, patch and pay for — the one piece an edge-only product exists to avoid.",[173,620,621,634],{},[176,622,623],{},[179,624,625,628,631],{},[182,626,627],{},"Option",[182,629,630],{},"Trade-off",[182,632,633],{},"Chose",[192,635,636,646],{},[179,637,638,641,644],{},[197,639,640],{},"Postgres + pgvector on a managed VM",[197,642,643],{},"Mature, one system for rows and vectors; a server to run and a region to pick",[197,645],{},[179,647,648,651,654],{},[197,649,650],{},"D1 (SQLite) + Vectorize on Workers",[197,652,653],{},"No server, no cold region, one deploy; SQLite's limits and a second index to keep honest",[197,655,656],{},[216,657],{},[141,659,660,661,664],{},"The price shows up three times. ",[468,662,663],{},"status: ready"," does not mean searchable, because Vectorize applies upserts asynchronously — a question asked within about a minute of an upload can return nothing while every row looks healthy. The index is 768-dimensional, so it decides which embedding models are usable: a 1024-wide model fails the whole batch, which is why the picker lists two models rather than \"any\". And because D1 bills rows read, every tenant-scoped table needed a composite index whose second column is the query's own predicate; the folder list went 6.6 ms → 0.63 ms.",[612,666,668],{"id":667},"decision-2-ingest-through-a-queue-with-an-inline-fallback","Decision 2 — Ingest through a queue, with an inline fallback",[141,670,671],{},"Extracting, chunking and embedding a 50 MB PDF does not fit in a request, or reliably in one Worker invocation. Ingestion is a job, so it is modelled as one.",[173,673,674,684],{},[176,675,676],{},[179,677,678,680,682],{},[182,679,627],{},[182,681,630],{},[182,683,633],{},[192,685,686,700],{},[179,687,688,695,698],{},[197,689,690,691,694],{},"Process the upload in the request (or behind ",[468,692,693],{},"waitUntil",")",[197,696,697],{},"No queue to provision; a big document can exceed the execution ceiling and fail with no retry",[197,699],{},[179,701,702,705,708],{},[197,703,704],{},"Send an ingest job to a Cloudflare Queue",[197,706,707],{},"Three retries 30 s apart, a dead-letter queue for what never succeeds, a cron sweeper for a job that goes silent; nothing is searchable the instant it is uploaded",[197,709,710],{},[216,711],{},[141,713,714,715,718,719,722,723,726,727,730,731,733],{},"The queue's cost is honesty about time: an upload is not an answerable source yet, and a freshly written-back Q&A document is not retrievable for roughly ",[331,716,717],{},"30–60 seconds",", the same Vectorize lag now visible in a user-facing loop. The catalog shows per-document status (",[468,720,721],{},"processing"," → ",[468,724,725],{},"ready"," / ",[468,728,729],{},"failed",") because that window exists. Without a queue binding — local dev, or a deploy whose queue is not provisioned — the job runs inline behind ",[468,732,693],{}," rather than being silently dropped.",[612,735,737],{"id":736},"decision-3-citations-are-part-of-the-answer-not-decoration","Decision 3 — Citations are part of the answer, not decoration",[141,739,740,741,744],{},"Every claim carries a numbered citation that opens the source document at the cited passage. That constrains the whole pipeline: the chunk the model reads and the chunk the citation shows must be the ",[356,742,743],{},"same"," passage, untruncated.",[173,746,747,757],{},[176,748,749],{},[179,750,751,753,755],{},[182,752,627],{},[182,754,630],{},[182,756,633],{},[192,758,759,769],{},[179,760,761,764,767],{},[197,762,763],{},"Answer first, link the document afterwards",[197,765,766],{},"Cheaper prompts, simpler UI; the reader cannot verify a claim without re-reading the source",[197,768],{},[179,770,771,774,777],{},[197,772,773],{},"Store each chunk whole, label it with document and section, cite back to it",[197,775,776],{},"Larger prompts and a stricter chunker; every claim is one click from its evidence",[197,778,779],{},[216,780],{},[141,782,783,784,787,788,791],{},"That rule has teeth. A chunk is labelled with its ",[356,785,786],{},"section",", because one file can hold several products and a chunk labelled only by document title quotes the wrong one. Dedupe is by document ",[331,789,790],{},"and"," section, because collapsing to one chunk per file hides the second product. And whether a question is covered is decided with a per-embedding-model cosine floor, because scores from two models are not comparable — a threshold calibrated on one turns a good answer into a false gap on the other.",[612,793,795],{"id":794},"decision-4-the-escalation-loop-writes-back-into-the-catalog","Decision 4 — The escalation loop writes back into the catalog",[141,797,798,799,802],{},"This is the decision the product exists for. When retrieval and the model agree the catalog cannot answer, the asker escalates to the document, folder or organization owner. That owner gets a queue in a ",[331,800,801],{},"Questions"," inbox, answers in place, and the answer is written back as a normal Q&A document in the folder — readable, downloadable, citable, re-ingested like anything else.",[173,804,805,815],{},[176,806,807],{},[179,808,809,811,813],{},[182,810,627],{},[182,812,630],{},[182,814,633],{},[192,816,817,827],{},[179,818,819,822,825],{},[197,820,821],{},"Log unanswered questions for an admin to triage later",[197,823,824],{},"Cheap; nothing improves unless someone reads the log",[197,826],{},[179,828,829,832,835],{},[197,830,831],{},"Route the question to the responsible owner and store the answer as a catalog document",[197,833,834],{},"Needs ownership, an inbox and a write-back that re-enters ingestion; the catalog grows from its own failures",[197,836,837],{},[216,838],{},[141,840,841,842,845],{},"Two rules keep it from misfiring: a ",[356,843,844],{},"failed search"," is not an uncovered question, so the escalation prompt never renders when vector search itself did not run; and the answer is stored as a document rather than as text in a thread, because knowledge that lives in a chat log is knowledge the next person has to find again.",[612,847,849],{"id":848},"decision-5-cost-discipline-before-billing","Decision 5 — Cost discipline before billing",[141,851,852],{},"There is no billing in v1, so the cost ceiling has to be engineered rather than invoiced.",[173,854,855,865],{},[176,856,857],{},[179,858,859,862],{},[182,860,861],{},"Lever",[182,863,864],{},"What it does",[192,866,867,875,886,894,906],{},[179,868,869,872],{},[197,870,871],{},"BYO provider and key",[197,873,874],{},"Inference cost sits with the tenant; Workers AI is the fallback, not the default",[179,876,877,880],{},[197,878,879],{},"Answer cache",[197,881,882,883,885],{},"Keyed on provider, model, embedding model, prompt version ",[331,884,790],{}," the retrieved document ids, so it self-heals when the index changes instead of serving a stale answer under a fresh version",[179,887,888,891],{},[197,889,890],{},"Token caps",[197,892,893],{},"Bounded history window, bounded output, bounded retrieved context",[179,895,896,899],{},[197,897,898],{},"Question quota",[197,900,901,902,905],{},"Per-plan monthly quota, counted from ",[468,903,904],{},"usage_counters",", not from the cache",[179,907,908,911],{},[197,909,910],{},"Model bake-off",[197,912,913],{},"Measured cost per question, not list price: the default stayed, follow-ups are pinned to a small model, and the picker shows cost tiers",[141,915,916,917,920],{},"The cache is the counter-intuitive one. Hashing the question and looking it up ",[356,918,919],{},"before"," retrieval would also save the embedding call — and would cache an answer produced against a stale index under a version nothing invalidates, poisoning it for days. Looking it up after retrieval costs one embedding query per ask and is self-correcting.",[136,922,924],{"id":923},"outcome","Outcome",[141,926,927,928,932],{},"v1 is complete and live at ",[320,929,931],{"href":322,"rel":930},[324],"semantichelp.com",", with a first real tenant using it:",[156,934,935,938,941,944,947,950,956,959],{},[159,936,937],{},"Multi-tenant subdomains with enforced per-tenant isolation; JWT auth with admin / editor / viewer roles.",[159,939,940],{},"A hierarchical catalog with drag-and-drop upload, per-document processing state, viewer and download.",[159,942,943],{},"Queue-based ingestion of PDF, DOCX, TXT and Markdown into a vector index.",[159,945,946],{},"Streaming chat with inline numbered citations that open the cited passage, plus persisted, re-openable sessions.",[159,948,949],{},"Escalations to owners, a Questions inbox, and answer write-back that regenerates a folder's Q&A document and re-ingests it.",[159,951,952,953,955],{},"English and Spanish across the UI ",[356,954,790],{}," the API's error messages, with a generated pseudo-locale as the canary.",[159,957,958],{},"Custom domains behind an allowlist, with the Cloudflare-for-SaaS lifecycle handled remote-first so a failed delete never leaks a billing hostname.",[159,960,961,962,964],{},"The cost guardrails above, measured in ",[468,963,904],{}," rather than estimated.",[141,966,967,968,971],{},"What it does ",[331,969,970],{},"not"," have is anything I can't show you: no paying customers, no testimonials, no usage metrics, no benchmarks. A shared read-only demo workspace is seeded with generic sample documents, and that is what the screenshot shows — a real answer, real citations, real retrieval scores.",[141,973,974],{},"For a solo build on an edge runtime, the sequencing was the interesting part: every later decision leans on ingestion and retrieval being trustworthy first.",[136,976,978],{"id":977},"whats-still-missing","What's still missing",[156,980,981,995,1001,1011],{},[159,982,983,986,987,990,991,994],{},[331,984,985],{},"Self-service password reset."," A locked-out user is currently an operator running ",[468,988,989],{},"wrangler d1 … --remote"," against production — which is what happened when the first tenant forgot his password. A change-password flow now lets a signed-in user retire a temporary one, but the real fix needs session revocation, and a stateless 24-hour JWT cannot be revoked: it takes a ",[468,992,993],{},"sessionsValidAfter"," column checked in the auth middleware, plus a delivery channel for the link.",[159,996,997,1000],{},[331,998,999],{},"Billing."," No card processor onboards a seller in this jurisdiction except PayPal, which is not a merchant of record, so the tax side stays ours. The plan and quota layer exists; the processor is a business decision, not a code one.",[159,1002,1003,1006,1007,1010],{},[331,1004,1005],{},"A platform super-admin."," There isn't one, deliberately: roles never cross a tenant, and every platform action is an out-of-band ",[468,1008,1009],{},"wrangler"," run with no identity and no audit trail. The honest fix is a role structurally separate from tenant roles, scoped to the few actions that cannot be done another way.",[159,1012,1013,1016,1017,1019],{},[331,1014,1015],{},"Very large documents."," Ingestion retries three times, parks what never succeeds in a dead-letter queue, and a sweeper gives up on a job that goes silent — but one attempt still has a wall-clock ceiling, so a pathological document ends ",[468,1018,729],{}," rather than eventually succeeding.",[141,1021,1022],{},"None of these are surprises; they are the shape of a v1 that shipped before it had a billing system. The difference from a shrug is that each one is written down where the next person will find it.",[1024,1025],"hr",{},[141,1027,1028],{},[356,1029,1030,1031,1035,1036,1039],{},"Building something in this space, or want a second pair of eyes on a RAG pipeline that has to be trustworthy? ",[320,1032,1034],{"href":1033},"/contact","Reach out here"," — the product is at ",[320,1037,931],{"href":322,"rel":1038},[324]," and the demo workspace is public.",[1041,1042,1043],"style",{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":275,"searchDepth":276,"depth":276,"links":1045},[1046,1047,1048,1049,1050,1057,1058],{"id":138,"depth":276,"text":139},{"id":350,"depth":276,"text":351},{"id":382,"depth":276,"text":383},{"id":459,"depth":276,"text":460},{"id":609,"depth":276,"text":610,"children":1051},[1052,1053,1054,1055,1056],{"id":614,"depth":291,"text":615},{"id":667,"depth":291,"text":668},{"id":736,"depth":291,"text":737},{"id":794,"depth":291,"text":795},{"id":848,"depth":291,"text":849},{"id":923,"depth":276,"text":924},{"id":977,"depth":276,"text":978},"case-study","2026-10-25T00:00:00.000Z","Semantichelp is a multi-tenant SaaS knowledge base with a RAG assistant on top: teams upload their documents, ask questions, and get streaming answers that cite the passages they came from. Built solo on Nuxt 4 and Cloudflare Workers — and the one thing \"chat with your docs\" cannot claim: a question the catalog cannot answer escalates to its owner and comes back as a citable document.\n","/blog/case-studies/semantichelp-rag-knowledge-base/featuredImage.jpg","en",{},"A multi-tenant RAG knowledge base where unanswered questions become citable answers","Product, design and build",{"title":31,"description":1061},[1069,1070,1071,1072,1073,1074],"Nuxt 4","Cloudflare Workers","D1","R2","Vectorize","Vercel AI SDK",[1076,1077,302],"ai","cloudflare","greenfield","aSMoIwPt7IMGcNZKaEU7USERndwZRMDijNMpyN1uMDs",{"id":1081,"title":93,"author":1082,"body":1084,"category":1059,"client":342,"date":1060,"description":1756,"extension":288,"featured":292,"hidden":38,"image":1062,"locale":66,"meta":1757,"minRead":285,"navigation":292,"ogImage":1062,"outcome_headline":1758,"path":94,"repoUrl":285,"role":1759,"seo":1760,"stack":1761,"stem":95,"tags":1762,"team_size":303,"type":1078,"year":304,"__hash__":1763},"blog/es/blog/case-studies/semantichelp-rag-knowledge-base.md",{"name":127,"username":128,"to":129,"avatar":1083},{"src":131,"alt":127},{"type":133,"value":1085,"toc":1741},[1086,1088,1113,1115,1121,1124,1127,1140,1142,1211,1214,1218,1327,1329,1333,1336,1373,1379,1383,1386,1424,1441,1445,1452,1488,1499,1503,1510,1546,1553,1557,1560,1620,1627,1629,1636,1668,1675,1678,1682,1720,1723,1725,1739],[136,1087,139],{"id":138},[156,1089,1090,1096,1103],{},[159,1091,1092,1095],{},[320,1093,325],{"href":322,"rel":1094},[324]," es una base de conocimiento SaaS multi-tenant: una organización sube sus documentos a un catálogo de carpetas, luego hace preguntas y recibe respuestas en streaming que citan los fragmentos de donde salieron.",[159,1097,1098,1099,1102],{},"El diferenciador es lo que pasa ",[331,1100,1101],{},"cuando no puede responder",": la pregunta escala al responsable de esa carpeta o documento, y su respuesta se escribe de vuelta en el catálogo como un documento de preguntas y respuestas citable.",[159,1104,1105,1106,1109,1110,1112],{},"Construida en solitario para ",[320,1107,342],{"href":340,"rel":1108},[324]," sobre ",[331,1111,346],{}," (D1, R2, Vectorize, Queues), de punta a punta. La v1 está en producción con un primer cliente real.",[136,1114,147],{"id":146},[141,1116,1117,1118],{},"Toda herramienta de «chatea con tus documentos» tiene los mismos dos desenlaces. A veces responde. A veces se encoge de hombros: ",[356,1119,1120],{},"no encontré eso en los documentos.",[141,1122,1123],{},"El encogimiento de hombros es donde estos productos mueren en silencio. A la persona con una pregunta real acaban de decirle que la base de conocimiento no es para ella, y el hueco con el que se topó era uno que un colega habría cerrado en una frase. El catálogo no aprende nada, y la misma pregunta la vuelve a hacer otra persona el mes siguiente.",[141,1125,1126],{},"Ese encogimiento de hombros es la superficie del producto aquí. Una pregunta sin responder no es un callejón sin salida, es un ticket: va a quien sea responsable de esa área, su respuesta se convierte en un documento, y la base de conocimiento mejora por el hecho de usarla.",[141,1128,1129,1130,1132,1133,1135,1136,1139],{},"Los usuarios son el propio personal de una organización: un ",[331,1131,370],{}," que monta el espacio de trabajo y configura el proveedor de IA, un ",[331,1134,374],{}," que construye el catálogo, y ",[331,1137,1138],{},"lectores"," que leen y preguntan. Quien pregunta normalmente no es quien escribió el documento, y por eso una respuesta sin su fuente no es una respuesta.",[136,1141,154],{"id":153},[173,1143,1144,1154],{},[176,1145,1146],{},[179,1147,1148,1151],{},[182,1149,1150],{},"Restricción",[182,1152,1153],{},"Realidad",[192,1155,1156,1164,1171,1179,1187,1195,1203],{},[179,1157,1158,1161],{},[197,1159,1160],{},"Equipo",[197,1162,1163],{},"Una persona: decisiones de producto, UI, esquema, prompts, despliegue",[179,1165,1166,1168],{},[197,1167,410],{},[197,1169,1170],{},"Solo Cloudflare Workers: sin servidor siempre encendido, sin proceso de larga vida",[179,1172,1173,1176],{},[197,1174,1175],{},"Almacenamiento",[197,1177,1178],{},"D1 (SQLite) + R2 + Vectorize; sin Postgres, sin pgvector",[179,1180,1181,1184],{},[197,1182,1183],{},"Comunicación",[197,1185,1186],{},"Sin infraestructura de correo, así que sin correo de verificación y sin enlace de restablecimiento",[179,1188,1189,1192],{},[197,1190,1191],{},"Sesiones",[197,1193,1194],{},"Un único JWT de 24 horas, sin token de refresco",[179,1196,1197,1200],{},[197,1198,1199],{},"Aislamiento",[197,1201,1202],{},"Los documentos, preguntas y conversaciones de una organización invisibles para cualquier otra: una promesa de producto, no un tecnicismo",[179,1204,1205,1208],{},[197,1206,1207],{},"Costo",[197,1209,1210],{},"Topes por tenant que tienen que sostenerse sin un sistema de facturación que los haga cumplir",[141,1212,1213],{},"Lo no negociable: un equipo se registra, sube un documento y recibe una respuesta citada el mismo día, sin trabajo de infraestructura de su lado.",[136,1215,1217],{"id":1216},"arquitectura-de-un-vistazo","Arquitectura de un vistazo",[462,1219,1221],{"className":464,"code":1220,"language":466,"meta":275,"style":275},"flowchart LR\n  classDef ext fill:#e5e7eb,stroke:#6b7280,color:#282a30\n  classDef svc fill:#dbeafe,stroke:#0f4c5c,color:#282a30\n  classDef data fill:#d1fae5,stroke:#0f766e,color:#282a30\n  classDef client fill:#fef3c7,stroke:#f59e0b,color:#282a30\n\n  UP[Subir PDF / DOCX / TXT / MD] --> R2[(Objeto en R2)]\n  UP --> D1[(Fila de documento en D1)]\n  D1 --> Q[[Cola de ingesta]]\n  Q --> EX[Extraer texto]\n  EX --> CH[Fragmentar + etiquetar sección]\n  CH --> EM[Generar embedding]\n  EM --> VI[(Índice de Vectorize)]\n  ASK[Pregunta en el chat] --> RET[Recuperar top 5, filtrado por tenant]\n  VI --> RET\n  RET --> LLM[Respuesta en streaming + citas]\n  LLM -->|no puede responder| ESC[Escalar al responsable]\n  ESC --> ANS[El responsable responde en la bandeja de Preguntas]\n  ANS --> QA[Documento de Q&A de la carpeta regenerado]\n  QA --> Q\n  class UP,ASK,ESC,ANS client\n  class EX,CH,EM,RET,LLM,QA svc\n  class R2,D1,VI data\n",[468,1222,1223,1227,1231,1235,1239,1243,1247,1252,1257,1262,1267,1272,1277,1282,1287,1291,1296,1301,1306,1311,1315,1319,1323],{"__ignoreMap":275},[471,1224,1225],{"class":473,"line":303},[471,1226,476],{},[471,1228,1229],{"class":473,"line":276},[471,1230,481],{},[471,1232,1233],{"class":473,"line":291},[471,1234,486],{},[471,1236,1237],{"class":473,"line":489},[471,1238,492],{},[471,1240,1241],{"class":473,"line":495},[471,1242,498],{},[471,1244,1245],{"class":473,"line":501},[471,1246,504],{"emptyLinePlaceholder":292},[471,1248,1249],{"class":473,"line":507},[471,1250,1251],{},"  UP[Subir PDF / DOCX / TXT / MD] --> R2[(Objeto en R2)]\n",[471,1253,1254],{"class":473,"line":513},[471,1255,1256],{},"  UP --> D1[(Fila de documento en D1)]\n",[471,1258,1259],{"class":473,"line":519},[471,1260,1261],{},"  D1 --> Q[[Cola de ingesta]]\n",[471,1263,1264],{"class":473,"line":525},[471,1265,1266],{},"  Q --> EX[Extraer texto]\n",[471,1268,1269],{"class":473,"line":531},[471,1270,1271],{},"  EX --> CH[Fragmentar + etiquetar sección]\n",[471,1273,1274],{"class":473,"line":537},[471,1275,1276],{},"  CH --> EM[Generar embedding]\n",[471,1278,1279],{"class":473,"line":543},[471,1280,1281],{},"  EM --> VI[(Índice de Vectorize)]\n",[471,1283,1284],{"class":473,"line":549},[471,1285,1286],{},"  ASK[Pregunta en el chat] --> RET[Recuperar top 5, filtrado por tenant]\n",[471,1288,1289],{"class":473,"line":555},[471,1290,558],{},[471,1292,1293],{"class":473,"line":561},[471,1294,1295],{},"  RET --> LLM[Respuesta en streaming + citas]\n",[471,1297,1298],{"class":473,"line":567},[471,1299,1300],{},"  LLM -->|no puede responder| ESC[Escalar al responsable]\n",[471,1302,1303],{"class":473,"line":573},[471,1304,1305],{},"  ESC --> ANS[El responsable responde en la bandeja de Preguntas]\n",[471,1307,1308],{"class":473,"line":579},[471,1309,1310],{},"  ANS --> QA[Documento de Q&A de la carpeta regenerado]\n",[471,1312,1313],{"class":473,"line":585},[471,1314,588],{},[471,1316,1317],{"class":473,"line":591},[471,1318,594],{},[471,1320,1321],{"class":473,"line":597},[471,1322,600],{},[471,1324,1325],{"class":473,"line":603},[471,1326,606],{},[136,1328,171],{"id":170},[612,1330,1332],{"id":1331},"decisión-1-d1-vectorize-en-vez-de-postgres-pgvector","Decisión 1 — D1 + Vectorize en vez de Postgres + pgvector",[141,1334,1335],{},"Postgres con pgvector es el stack obvio para un producto RAG, y uno bueno. También es un servidor de base de datos que hay que correr, parchear y pagar: justo la pieza que un producto solo-edge existe para evitar.",[173,1337,1338,1349],{},[176,1339,1340],{},[179,1341,1342,1345,1347],{},[182,1343,1344],{},"Opción",[182,1346,187],{},[182,1348,190],{},[192,1350,1351,1361],{},[179,1352,1353,1356,1359],{},[197,1354,1355],{},"Postgres + pgvector en una VM gestionada",[197,1357,1358],{},"Maduro, un solo sistema para filas y vectores; un servidor que correr y una región que elegir",[197,1360],{},[179,1362,1363,1366,1369],{},[197,1364,1365],{},"D1 (SQLite) + Vectorize en Workers",[197,1367,1368],{},"Sin servidor, sin región fría, un solo despliegue; los límites de SQLite y un segundo índice que mantener honesto",[197,1370,1371],{},[216,1372],{},[141,1374,1375,1376,1378],{},"El precio aparece tres veces. ",[468,1377,663],{}," no significa buscable, porque Vectorize aplica los upserts de forma asíncrona: una pregunta hecha dentro del minuto siguiente a una subida puede devolver nada mientras todas las filas se ven sanas. El índice es de 768 dimensiones, así que decide qué modelos de embedding se pueden usar: un modelo de 1024 de ancho tumba todo el lote, y por eso el selector lista dos modelos en lugar de «cualquiera». Y como D1 cobra por filas leídas, cada tabla con alcance por tenant necesitaba un índice compuesto cuya segunda columna fuera el propio predicado de la consulta; la lista de carpetas pasó de 6,6 ms a 0,63 ms.",[612,1380,1382],{"id":1381},"decisión-2-ingesta-a-través-de-una-cola-con-un-fallback-en-línea","Decisión 2 — Ingesta a través de una cola, con un fallback en línea",[141,1384,1385],{},"Extraer, fragmentar y generar embeddings de un PDF de 50 MB no cabe en una petición, ni de forma confiable en una sola invocación de Worker. La ingesta es un job, así que se modela como tal.",[173,1387,1388,1398],{},[176,1389,1390],{},[179,1391,1392,1394,1396],{},[182,1393,1344],{},[182,1395,187],{},[182,1397,190],{},[192,1399,1400,1412],{},[179,1401,1402,1407,1410],{},[197,1403,1404,1405,694],{},"Procesar la subida en la petición (o detrás de ",[468,1406,693],{},[197,1408,1409],{},"Sin cola que aprovisionar; un documento grande puede pasarse del techo de ejecución y fallar sin reintento",[197,1411],{},[179,1413,1414,1417,1420],{},[197,1415,1416],{},"Enviar un job de ingesta a una Cloudflare Queue",[197,1418,1419],{},"Tres reintentos con 30 s de separación, una cola de mensajes muertos para lo que nunca sale bien, un barrendero por cron para un job que se queda en silencio; nada es buscable en el instante en que se sube",[197,1421,1422],{},[216,1423],{},[141,1425,1426,1427,1430,1431,722,1433,726,1435,1437,1438,1440],{},"El costo de la cola es honestidad sobre el tiempo: una subida todavía no es una fuente respondible, y un documento de preguntas y respuestas recién escrito no es recuperable durante unos ",[331,1428,1429],{},"30 a 60 segundos",", ese mismo retraso de Vectorize ahora visible en un bucle que ve el usuario. El catálogo muestra el estado por documento (",[468,1432,721],{},[468,1434,725],{},[468,1436,729],{},") porque esa ventana existe. Sin un binding de cola —desarrollo local, o un despliegue cuya cola no está aprovisionada— el job corre en línea detrás de ",[468,1439,693],{}," en vez de descartarse en silencio.",[612,1442,1444],{"id":1443},"decisión-3-las-citas-son-parte-de-la-respuesta-no-decoración","Decisión 3 — Las citas son parte de la respuesta, no decoración",[141,1446,1447,1448,1451],{},"Cada afirmación lleva una cita numerada que abre el documento fuente en el fragmento citado. Eso condiciona todo el pipeline: el fragmento que lee el modelo y el fragmento que muestra la cita tienen que ser el ",[356,1449,1450],{},"mismo"," pasaje, sin truncar.",[173,1453,1454,1464],{},[176,1455,1456],{},[179,1457,1458,1460,1462],{},[182,1459,1344],{},[182,1461,187],{},[182,1463,190],{},[192,1465,1466,1476],{},[179,1467,1468,1471,1474],{},[197,1469,1470],{},"Responder primero y enlazar el documento después",[197,1472,1473],{},"Prompts más baratos, UI más simple; el lector no puede verificar una afirmación sin releer la fuente",[197,1475],{},[179,1477,1478,1481,1484],{},[197,1479,1480],{},"Guardar cada fragmento entero, etiquetarlo con documento y sección, y citarlo de vuelta",[197,1482,1483],{},"Prompts más grandes y un fragmentador más estricto; cada afirmación está a un clic de su evidencia",[197,1485,1486],{},[216,1487],{},[141,1489,1490,1491,1494,1495,1498],{},"Esa regla tiene colmillos. Un fragmento se etiqueta con su ",[356,1492,1493],{},"sección",", porque un archivo puede contener varios productos y un fragmento etiquetado solo con el título del documento cita el producto equivocado. La deduplicación es por documento ",[331,1496,1497],{},"y"," sección, porque colapsar a un fragmento por archivo esconde el segundo producto. Y si una pregunta está cubierta se decide con un piso de coseno por modelo de embedding, porque los puntajes de dos modelos no son comparables: un umbral calibrado sobre uno convierte una buena respuesta en un hueco falso sobre el otro.",[612,1500,1502],{"id":1501},"decisión-4-el-bucle-de-escalado-escribe-de-vuelta-en-el-catálogo","Decisión 4 — El bucle de escalado escribe de vuelta en el catálogo",[141,1504,1505,1506,1509],{},"Esta es la decisión por la que existe el producto. Cuando la recuperación y el modelo coinciden en que el catálogo no puede responder, quien pregunta escala al responsable del documento, la carpeta o la organización. Ese responsable recibe una cola en una bandeja de ",[331,1507,1508],{},"Preguntas",", responde ahí mismo, y la respuesta se escribe de vuelta como un documento normal de preguntas y respuestas dentro de la carpeta: legible, descargable, citable, reingresado como cualquier otro.",[173,1511,1512,1522],{},[176,1513,1514],{},[179,1515,1516,1518,1520],{},[182,1517,1344],{},[182,1519,187],{},[182,1521,190],{},[192,1523,1524,1534],{},[179,1525,1526,1529,1532],{},[197,1527,1528],{},"Registrar las preguntas sin responder para que un admin las triage después",[197,1530,1531],{},"Barato; nada mejora a menos que alguien lea el registro",[197,1533],{},[179,1535,1536,1539,1542],{},[197,1537,1538],{},"Enrutar la pregunta al responsable y guardar la respuesta como un documento del catálogo",[197,1540,1541],{},"Requiere propiedad, una bandeja y una escritura de vuelta que reingresa en la ingesta; el catálogo crece a partir de sus propios fallos",[197,1543,1544],{},[216,1545],{},[141,1547,1548,1549,1552],{},"Dos reglas evitan que esto dispare mal: una ",[356,1550,1551],{},"búsqueda fallida"," no es una pregunta sin cubrir, así que el aviso de escalado nunca se muestra cuando la búsqueda vectorial en sí no llegó a correr; y la respuesta se guarda como documento en vez de como texto en un hilo, porque el conocimiento que vive en un registro de chat es conocimiento que la próxima persona tiene que volver a encontrar.",[612,1554,1556],{"id":1555},"decisión-5-disciplina-de-costo-antes-de-facturación","Decisión 5 — Disciplina de costo antes de facturación",[141,1558,1559],{},"No hay facturación en la v1, así que el techo de costo hay que ingenierizarlo en vez de facturarlo.",[173,1561,1562,1572],{},[176,1563,1564],{},[179,1565,1566,1569],{},[182,1567,1568],{},"Palanca",[182,1570,1571],{},"Qué hace",[192,1573,1574,1582,1593,1601,1612],{},[179,1575,1576,1579],{},[197,1577,1578],{},"Proveedor y clave propios",[197,1580,1581],{},"El costo de inferencia queda del lado del tenant; Workers AI es el fallback, no el valor por defecto",[179,1583,1584,1587],{},[197,1585,1586],{},"Caché de respuestas",[197,1588,1589,1590,1592],{},"Con clave sobre proveedor, modelo, modelo de embedding, versión del prompt ",[331,1591,1497],{}," los ids de los documentos recuperados, así se autocorrige cuando cambia el índice en vez de servir una respuesta obsoleta bajo una versión nueva",[179,1594,1595,1598],{},[197,1596,1597],{},"Topes de tokens",[197,1599,1600],{},"Ventana de historial acotada, salida acotada, contexto recuperado acotado",[179,1602,1603,1606],{},[197,1604,1605],{},"Cuota de preguntas",[197,1607,1608,1609,1611],{},"Cuota mensual por plan, contada desde ",[468,1610,904],{},", no desde la caché",[179,1613,1614,1617],{},[197,1615,1616],{},"Comparativa de modelos",[197,1618,1619],{},"Costo medido por pregunta, no precio de lista: el valor por defecto se mantuvo, las preguntas de seguimiento están fijadas a un modelo pequeño, y el selector muestra los niveles de costo",[141,1621,1622,1623,1626],{},"La caché es la contraintuitiva. Hashear la pregunta y buscarla ",[356,1624,1625],{},"antes"," de la recuperación también ahorraría la llamada de embedding, y cachearía una respuesta producida contra un índice obsoleto bajo una versión que nada invalida, envenenándola durante días. Buscarla después de la recuperación cuesta una consulta de embedding por pregunta y se autocorrige.",[136,1628,243],{"id":242},[141,1630,1631,1632,1635],{},"La v1 está completa y en vivo en ",[320,1633,931],{"href":322,"rel":1634},[324],", con un primer cliente real usándola:",[156,1637,1638,1641,1644,1647,1650,1653,1659,1662],{},[159,1639,1640],{},"Subdominios multi-tenant con aislamiento por tenant aplicado; autenticación con JWT y roles de admin, editor y lector.",[159,1642,1643],{},"Un catálogo jerárquico con subida por arrastrar y soltar, estado de procesamiento por documento, visor y descarga.",[159,1645,1646],{},"Ingesta basada en cola de PDF, DOCX, TXT y Markdown hacia un índice vectorial.",[159,1648,1649],{},"Chat en streaming con citas numeradas en línea que abren el fragmento citado, más sesiones persistidas y reabribles.",[159,1651,1652],{},"Escalados a los responsables, una bandeja de Preguntas, y escritura de la respuesta que regenera el documento de preguntas y respuestas de una carpeta y lo reingresa.",[159,1654,1655,1656,1658],{},"Inglés y español en toda la UI ",[356,1657,1497],{}," en los mensajes de error de la API, con un pseudo-locale generado como canario.",[159,1660,1661],{},"Dominios personalizados detrás de una lista de permitidos, con el ciclo de Cloudflare for SaaS resuelto primero en remoto, para que un borrado fallido nunca deje escapar un hostname de facturación.",[159,1663,1664,1665,1667],{},"Las salvaguardas de costo de arriba, medidas en ",[468,1666,904],{}," en vez de estimadas.",[141,1669,1670,1671,1674],{},"Lo que ",[331,1672,1673],{},"no"," tiene es nada que no pueda mostrarte: ni clientes de pago, ni testimonios, ni métricas de uso, ni benchmarks. Un espacio de demostración compartido y de solo lectura está cargado con documentos de ejemplo genéricos, y eso es lo que muestra la captura: una respuesta real, citas reales, puntajes de recuperación reales.",[141,1676,1677],{},"Para un build en solitario sobre un runtime de edge, lo interesante fue la secuencia: cada decisión posterior se apoya en que la ingesta y la recuperación sean confiables primero.",[136,1679,1681],{"id":1680},"lo-que-todavía-falta","Lo que todavía falta",[156,1683,1684,1696,1702,1711],{},[159,1685,1686,1689,1690,1692,1693,1695],{},[331,1687,1688],{},"Restablecimiento de contraseña autoservicio."," Un usuario bloqueado hoy es un operador corriendo ",[468,1691,989],{}," contra producción, que es lo que pasó cuando el primer cliente olvidó su contraseña. Un flujo de cambio de contraseña ya permite a un usuario con sesión retirar una temporal, pero el arreglo de verdad necesita revocación de sesiones, y un JWT sin estado de 24 horas no se puede revocar: hace falta una columna ",[468,1694,993],{}," revisada en el middleware de autenticación, más un canal de entrega para el enlace.",[159,1697,1698,1701],{},[331,1699,1700],{},"Facturación."," Ningún procesador de tarjetas acepta abrir cuenta a un vendedor en esta jurisdicción salvo PayPal, que no es merchant of record, así que la parte fiscal sigue siendo nuestra. La capa de planes y cuotas existe; el procesador es una decisión de negocio, no de código.",[159,1703,1704,1707,1708,1710],{},[331,1705,1706],{},"Un super-admin de plataforma."," No hay ninguno, a propósito: los roles nunca cruzan un tenant, y cada acción de plataforma es una corrida de ",[468,1709,1009],{}," fuera de banda, sin identidad y sin rastro de auditoría. El arreglo honesto es un rol estructuralmente separado de los roles de tenant, acotado a las pocas acciones que no se pueden hacer de otra forma.",[159,1712,1713,1716,1717,1719],{},[331,1714,1715],{},"Documentos muy grandes."," La ingesta reintenta tres veces, estaciona en una cola de mensajes muertos lo que nunca sale bien, y un barrendero abandona un job que se queda en silencio; pero un intento igual tiene un techo de tiempo de reloj, así que un documento patológico termina en ",[468,1718,729],{}," en vez de tarde o temprano salir bien.",[141,1721,1722],{},"Ninguna de estas es una sorpresa; son la forma de una v1 que se entregó antes de tener un sistema de facturación. La diferencia con un encogimiento de hombros es que cada una está escrita donde la próxima persona la va a encontrar.",[1024,1724],{},[141,1726,1727],{},[356,1728,1729,1730,1734,1735,1738],{},"¿Estás construyendo algo en este espacio, o quieres un segundo par de ojos sobre un pipeline RAG que tenga que ser confiable? ",[320,1731,1733],{"href":1732},"/es/contact","Escríbeme aquí"," — el producto está en ",[320,1736,931],{"href":322,"rel":1737},[324]," y el espacio de demostración es público.",[1041,1740,1043],{},{"title":275,"searchDepth":276,"depth":276,"links":1742},[1743,1744,1745,1746,1747,1754,1755],{"id":138,"depth":276,"text":139},{"id":146,"depth":276,"text":147},{"id":153,"depth":276,"text":154},{"id":1216,"depth":276,"text":1217},{"id":170,"depth":276,"text":171,"children":1748},[1749,1750,1751,1752,1753],{"id":1331,"depth":291,"text":1332},{"id":1381,"depth":291,"text":1382},{"id":1443,"depth":291,"text":1444},{"id":1501,"depth":291,"text":1502},{"id":1555,"depth":291,"text":1556},{"id":242,"depth":276,"text":243},{"id":1680,"depth":276,"text":1681},"Semantichelp es una base de conocimiento SaaS multi-tenant con un asistente RAG encima: los equipos suben sus documentos, hacen preguntas y reciben respuestas en streaming que citan los fragmentos de donde salieron. Construida en solitario sobre Nuxt 4 y Cloudflare Workers, y con lo único que «chatea con tus documentos» no puede decir: una pregunta que el catálogo no puede responder escala a su responsable y vuelve como un documento citable.\n",{},"Una base de conocimiento RAG multi-tenant donde las preguntas sin respuesta se convierten en respuestas citables","Producto, diseño y desarrollo",{"title":93,"description":1756},[1069,1070,1071,1072,1073,1074],[1076,1077,302],"iBim2xZ6iw36crEBlcblXfJyHN8S_XIoLW6g3C-mZIw",{"id":1765,"title":40,"author":1766,"body":1768,"category":284,"client":285,"date":2125,"description":2126,"extension":288,"featured":38,"hidden":38,"image":2127,"locale":1063,"meta":2128,"minRead":501,"navigation":292,"ogImage":285,"outcome_headline":2129,"path":41,"repoUrl":2091,"role":2130,"seo":2131,"stack":2132,"stem":42,"tags":2137,"team_size":303,"type":284,"year":304,"__hash__":2139},"blog/blog/chronocal.md",{"name":127,"username":128,"to":129,"avatar":1767},{"src":131,"alt":127},{"type":133,"value":1769,"toc":2113},[1770,1774,1777,1780,1783,1797,1801,1804,1818,1822,1825,1829,1834,1845,1850,1861,1866,1877,1882,1890,1894,1897,1905,1911,1917,1923,1927,1930,1933,1950,1954,1957,1974,1978,1985,1996,2076,2079,2083,2086,2094,2096,2110],[136,1771,1773],{"id":1772},"situation","Situation",[141,1775,1776],{},"As part of an ongoing effort to simplify my development setup and leverage Google solutions as much as possible—including migrating from Outlook to Google Calendar as I transition to NixOS—I needed a time-tracking solution that lived entirely within Google Workspace.",[141,1778,1779],{},"If you live in Google Workspace (Calendar, Drive, Meet), context-switching between apps to track time on calendar events creates friction. You're already in Calendar to manage your day—why leave it to start a timer?",[141,1781,1782],{},"Most time-tracking solutions require either:",[156,1784,1785,1788,1791,1794],{},[159,1786,1787],{},"A separate app you need to remember to open",[159,1789,1790],{},"A backend service to persist state",[159,1792,1793],{},"A third-party integration that adds OAuth complexity",[159,1795,1796],{},"A browser timer that depends on the tab staying active",[136,1798,1800],{"id":1799},"problem","Problem",[141,1802,1803],{},"Calendar events are already a reliable source of truth for your time commitment. What if you could:",[156,1805,1806,1809,1812,1815],{},[159,1807,1808],{},"Start/pause/stop tracking directly from the Calendar event card",[159,1810,1811],{},"Keep state in Apps Script UserProperties (no backend needed)",[159,1813,1814],{},"Optionally write the tracked duration back to the event description",[159,1816,1817],{},"Export sessions to Google Sheets for reporting or analysis",[136,1819,1821],{"id":1820},"solution-chronocal","Solution: ChronoCal",[141,1823,1824],{},"ChronoCal is a Google Calendar add-on built entirely with Google Apps Script. It provides:",[612,1826,1828],{"id":1827},"core-features","Core Features",[141,1830,1831],{},[331,1832,1833],{},"In-Event Tracking",[156,1835,1836,1839,1842],{},[159,1837,1838],{},"Open any calendar event on desktop Calendar and a contextual card appears",[159,1840,1841],{},"Buttons to start, pause, resume, and stop tracking from the card itself",[159,1843,1844],{},"Session state persists using Apps Script UserProperties—survives browser refresh and tab close",[141,1846,1847],{},[331,1848,1849],{},"Flexible Session States",[156,1851,1852,1855,1858],{},[159,1853,1854],{},"Running: timer is actively tracking",[159,1856,1857],{},"Paused: stopped but not finalized",[159,1859,1860],{},"Stopped: finalized session ready for export",[141,1862,1863],{},[331,1864,1865],{},"Event Description Integration",[156,1867,1868,1871,1874],{},[159,1869,1870],{},"Toggle option to write the final tracked duration directly to event descriptions",[159,1872,1873],{},"Handles recurring event instances correctly",[159,1875,1876],{},"Resolves the event across all calendars in your Google Account",[141,1878,1879],{},[331,1880,1881],{},"Google Sheets Export",[156,1883,1884,1887],{},[159,1885,1886],{},"Export completed sessions to a Google Sheet for reporting, billing, or analysis",[159,1888,1889],{},"Minimal setup: the add-on can create the Sheet or append to an existing one",[612,1891,1893],{"id":1892},"technical-approach","Technical Approach",[141,1895,1896],{},"The implementation is split across six core modules:",[462,1898,1903],{"className":1899,"code":1901,"language":1902},[1900],"language-text","src/\n├── main.gs           # Entry point and onOpen handler\n├── cards.gs          # Card UI construction and button handlers\n├── session.gs        # Session state management and timing logic\n├── calendar.gs       # Calendar API interactions and event resolution\n├── sheets.gs         # Google Sheets export logic\n└── config.gs         # Configuration constants and toggles\n","text",[468,1904,1901],{"__ignoreMap":275},[141,1906,1907,1910],{},[331,1908,1909],{},"No External Backend","\nEverything runs inside Google Apps Script. UserProperties store the session state, so it survives across browser sessions and tabs.",[141,1912,1913,1916],{},[331,1914,1915],{},"OAuth Scope Minimization","\nThe manifest declares Calendar (read/write), Sheets, and Calendar add-on execution scopes only. No external API calls or third-party storage.",[141,1918,1919,1922],{},[331,1920,1921],{},"Event Resolution","\nCorrectly handles recurring events and calendar resolution, so you can track time on the same event across multiple calendars without duplication.",[136,1924,1926],{"id":1925},"why-this-approach","Why This Approach",[141,1928,1929],{},"The friction in traditional time tracking comes from context-switching. If you're already in Google Calendar (which most knowledge workers are), triggering a timer from the event card removes a step.",[141,1931,1932],{},"Apps Script is ideal for this because:",[156,1934,1935,1938,1941,1944,1947],{},[159,1936,1937],{},"Native access to Calendar and Sheets without additional APIs",[159,1939,1940],{},"UserProperties provide persistent client-side state",[159,1942,1943],{},"Contextual cards integrate directly into Calendar's UI",[159,1945,1946],{},"Zero infrastructure: no servers to maintain, no databases to back up",[159,1948,1949],{},"Tight integration with Google Workspace",[136,1951,1953],{"id":1952},"current-state","Current State",[141,1955,1956],{},"ChronoCal is a functional MVP with pause/resume and Sheets export flows. Planned evolution includes:",[156,1958,1959,1962,1965,1968,1971],{},[159,1960,1961],{},"Keyboard shortcuts for faster access",[159,1963,1964],{},"Bulk session export options",[159,1966,1967],{},"Integration with other Workspace apps (Gmail, Tasks)",[159,1969,1970],{},"Analytics dashboard within Sheets",[159,1972,1973],{},"More granular state management (e.g., session notes, billing codes)",[136,1975,1977],{"id":1976},"development-deployment","Development & Deployment",[141,1979,1980,1981,1984],{},"The repo includes a complete deployment workflow using ",[468,1982,1983],{},"clasp"," (Google Apps Script CLI). Prerequisites are minimal:",[156,1986,1987,1990,1993],{},[159,1988,1989],{},"Node.js and npm",[159,1991,1992],{},"Google account with Apps Script access",[159,1994,1995],{},"Optional: Nix + direnv (dev environment files included)",[462,1997,2001],{"className":1998,"code":1999,"language":2000,"meta":275,"style":275},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","direnv allow\nnpx @google/clasp login\nnpx @google/clasp create --type standalone --title \"ChronoCal\" --rootDir .\nnpx @google/clasp push\nnpx @google/clasp open\n","bash",[468,2002,2003,2013,2024,2058,2067],{"__ignoreMap":275},[471,2004,2005,2009],{"class":473,"line":303},[471,2006,2008],{"class":2007},"sBMFI","direnv",[471,2010,2012],{"class":2011},"sfazB"," allow\n",[471,2014,2015,2018,2021],{"class":473,"line":276},[471,2016,2017],{"class":2007},"npx",[471,2019,2020],{"class":2011}," @google/clasp",[471,2022,2023],{"class":2011}," login\n",[471,2025,2026,2028,2030,2033,2036,2039,2042,2046,2049,2052,2055],{"class":473,"line":291},[471,2027,2017],{"class":2007},[471,2029,2020],{"class":2011},[471,2031,2032],{"class":2011}," create",[471,2034,2035],{"class":2011}," --type",[471,2037,2038],{"class":2011}," standalone",[471,2040,2041],{"class":2011}," --title",[471,2043,2045],{"class":2044},"sMK4o"," \"",[471,2047,2048],{"class":2011},"ChronoCal",[471,2050,2051],{"class":2044},"\"",[471,2053,2054],{"class":2011}," --rootDir",[471,2056,2057],{"class":2011}," .\n",[471,2059,2060,2062,2064],{"class":473,"line":489},[471,2061,2017],{"class":2007},[471,2063,2020],{"class":2011},[471,2065,2066],{"class":2011}," push\n",[471,2068,2069,2071,2073],{"class":473,"line":495},[471,2070,2017],{"class":2007},[471,2072,2020],{"class":2011},[471,2074,2075],{"class":2011}," open\n",[141,2077,2078],{},"The project also includes contribution guidelines, PR templates, and issue templates for collaborators.",[136,2080,2082],{"id":2081},"key-takeaway","Key Takeaway",[141,2084,2085],{},"ChronoCal proves that powerful productivity tools don't need complex infrastructure. By leveraging Google Apps Script and UserProperties, you can build a tracking system that lives inside the tool you already use every day—Calendar—without adding operational burden or privacy concerns.",[141,2087,2088,2089],{},"If you use Google Workspace and find yourself constantly switching between Calendar and time trackers, give ChronoCal a try: ",[320,2090,2093],{"href":2091,"rel":2092},"https://github.com/ivanovertime/ChronoCal",[324],"github.com/ivanovertime/ChronoCal",[1024,2095],{},[141,2097,2098,2101,2102,343,2106],{},[331,2099,2100],{},"Image attribution:"," Featured image is from Outlook. Photo by ",[320,2103,2105],{"href":2104},"https://unsplash.com/@edhardie?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText","Ed Hardie",[320,2107,2109],{"href":2108},"https://unsplash.com/photos/a-computer-screen-with-a-calendar-on-it-4BnNnEtAGP0?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText","Unsplash",[1041,2111,2112],{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":275,"searchDepth":276,"depth":276,"links":2114},[2115,2116,2117,2121,2122,2123,2124],{"id":1772,"depth":276,"text":1773},{"id":1799,"depth":276,"text":1800},{"id":1820,"depth":276,"text":1821,"children":2118},[2119,2120],{"id":1827,"depth":291,"text":1828},{"id":1892,"depth":291,"text":1893},{"id":1925,"depth":276,"text":1926},{"id":1952,"depth":276,"text":1953},{"id":1976,"depth":276,"text":1977},{"id":2081,"depth":276,"text":2082},"2026-06-15T00:00:00.000Z","A Google Calendar add-on built with Apps Script that tracks real time spent on events directly in Google Workspace—no external backend, no third-party storage, and no browser-side timer dependency.","/blog/chronocal/featuredImage.jpg",{},"Published a functional MVP that brings real-time tracking directly into Google Calendar without external infrastructure","Engineer",{"title":40,"description":2126},[2133,2134,2135,2136],"Google Apps Script","JavaScript","Google Calendar API","Google Sheets API",[2138],"google-workspace","am0r2MnCYoYgZG2PL9PajWfh5O44OFdeLUBHGv42UOw",{"id":2141,"title":101,"author":2142,"body":2144,"category":284,"client":285,"date":2125,"description":2469,"extension":288,"featured":38,"hidden":38,"image":2127,"locale":66,"meta":2470,"minRead":501,"navigation":292,"ogImage":285,"outcome_headline":2471,"path":102,"repoUrl":2091,"role":295,"seo":2472,"stack":2473,"stem":103,"tags":2474,"team_size":303,"type":284,"year":304,"__hash__":2475},"blog/es/blog/chronocal.md",{"name":127,"username":128,"to":129,"avatar":2143},{"src":131,"alt":127},{"type":133,"value":2145,"toc":2457},[2146,2150,2153,2156,2159,2173,2177,2180,2194,2198,2201,2205,2210,2221,2226,2237,2242,2253,2258,2266,2270,2273,2278,2284,2290,2296,2300,2303,2306,2323,2327,2330,2347,2351,2357,2368,2426,2429,2433,2436,2442,2444,2455],[136,2147,2149],{"id":2148},"situación","Situación",[141,2151,2152],{},"Como parte de un esfuerzo continuo por simplificar mi entorno de desarrollo y aprovechar las soluciones de Google todo lo posible —incluida la migración de Outlook a Google Calendar mientras paso a NixOS— necesitaba una solución de registro de tiempo que viviera por completo dentro de Google Workspace.",[141,2154,2155],{},"Si vives en Google Workspace (Calendar, Drive, Meet), cambiar de aplicación para registrar tiempo sobre eventos del calendario genera fricción. Ya estás en Calendar organizando tu día, ¿por qué salir de ahí para arrancar un temporizador?",[141,2157,2158],{},"La mayoría de las soluciones de registro de tiempo exigen una de estas cosas:",[156,2160,2161,2164,2167,2170],{},[159,2162,2163],{},"Una aplicación aparte que tienes que acordarte de abrir",[159,2165,2166],{},"Un servicio de backend para persistir el estado",[159,2168,2169],{},"Una integración de terceros que añade complejidad de OAuth",[159,2171,2172],{},"Un temporizador en el navegador que depende de que la pestaña siga activa",[136,2174,2176],{"id":2175},"problema","Problema",[141,2178,2179],{},"Los eventos del calendario ya son una fuente de verdad fiable sobre a qué dedicas tu tiempo. ¿Y si pudieras...",[156,2181,2182,2185,2188,2191],{},[159,2183,2184],{},"Iniciar, pausar y detener el registro directamente desde la tarjeta del evento en Calendar",[159,2186,2187],{},"Mantener el estado en Apps Script UserProperties (sin necesidad de backend)",[159,2189,2190],{},"Escribir opcionalmente la duración registrada de vuelta en la descripción del evento",[159,2192,2193],{},"Exportar las sesiones a Google Sheets para reportes o análisis",[136,2195,2197],{"id":2196},"solución-chronocal","Solución: ChronoCal",[141,2199,2200],{},"ChronoCal es un complemento de Google Calendar construido enteramente con Google Apps Script. Ofrece:",[612,2202,2204],{"id":2203},"funcionalidad-principal","Funcionalidad principal",[141,2206,2207],{},[331,2208,2209],{},"Registro dentro del evento",[156,2211,2212,2215,2218],{},[159,2213,2214],{},"Abre cualquier evento del calendario en Calendar de escritorio y aparece una tarjeta contextual",[159,2216,2217],{},"Botones para iniciar, pausar, reanudar y detener el registro desde la propia tarjeta",[159,2219,2220],{},"El estado de la sesión persiste usando Apps Script UserProperties: sobrevive a recargar el navegador y a cerrar la pestaña",[141,2222,2223],{},[331,2224,2225],{},"Estados de sesión flexibles",[156,2227,2228,2231,2234],{},[159,2229,2230],{},"En marcha: el temporizador está registrando activamente",[159,2232,2233],{},"En pausa: detenido pero sin cerrar",[159,2235,2236],{},"Detenido: sesión cerrada, lista para exportar",[141,2238,2239],{},[331,2240,2241],{},"Integración con la descripción del evento",[156,2243,2244,2247,2250],{},[159,2245,2246],{},"Opción para escribir la duración final registrada directamente en las descripciones de los eventos",[159,2248,2249],{},"Maneja correctamente las instancias de eventos recurrentes",[159,2251,2252],{},"Resuelve el evento en todos los calendarios de tu cuenta de Google",[141,2254,2255],{},[331,2256,2257],{},"Exportación a Google Sheets",[156,2259,2260,2263],{},[159,2261,2262],{},"Exporta las sesiones completadas a una hoja de Google Sheets para reportes, facturación o análisis",[159,2264,2265],{},"Configuración mínima: el complemento puede crear la hoja o agregar filas a una existente",[612,2267,2269],{"id":2268},"enfoque-técnico","Enfoque técnico",[141,2271,2272],{},"La implementación está repartida en seis módulos principales:",[462,2274,2276],{"className":2275,"code":1901,"language":1902},[1900],[468,2277,1901],{"__ignoreMap":275},[141,2279,2280,2283],{},[331,2281,2282],{},"Sin backend externo","\nTodo corre dentro de Google Apps Script. Las UserProperties guardan el estado de la sesión, así que se mantiene entre sesiones de navegador y entre pestañas.",[141,2285,2286,2289],{},[331,2287,2288],{},"Minimización de permisos OAuth","\nEl manifiesto declara únicamente los permisos de Calendar (lectura y escritura), Sheets y ejecución del complemento de Calendar. Sin llamadas a APIs externas ni almacenamiento de terceros.",[141,2291,2292,2295],{},[331,2293,2294],{},"Resolución de eventos","\nManeja correctamente los eventos recurrentes y la resolución de calendarios, así puedes registrar tiempo sobre el mismo evento en varios calendarios sin duplicarlo.",[136,2297,2299],{"id":2298},"por-qué-este-enfoque","Por qué este enfoque",[141,2301,2302],{},"La fricción del registro de tiempo tradicional viene de cambiar de contexto. Si ya estás en Google Calendar (donde está la mayoría de la gente que trabaja con conocimiento), disparar un temporizador desde la tarjeta del evento elimina un paso.",[141,2304,2305],{},"Apps Script es ideal para esto porque:",[156,2307,2308,2311,2314,2317,2320],{},[159,2309,2310],{},"Acceso nativo a Calendar y Sheets sin APIs adicionales",[159,2312,2313],{},"Las UserProperties dan estado persistente del lado del cliente",[159,2315,2316],{},"Las tarjetas contextuales se integran directamente en la interfaz de Calendar",[159,2318,2319],{},"Cero infraestructura: no hay servidores que mantener ni bases de datos que respaldar",[159,2321,2322],{},"Integración estrecha con Google Workspace",[136,2324,2326],{"id":2325},"estado-actual","Estado actual",[141,2328,2329],{},"ChronoCal es un MVP funcional con flujos de pausa y reanudación y de exportación a Sheets. La evolución prevista incluye:",[156,2331,2332,2335,2338,2341,2344],{},[159,2333,2334],{},"Atajos de teclado para un acceso más rápido",[159,2336,2337],{},"Opciones de exportación de sesiones en bloque",[159,2339,2340],{},"Integración con otras aplicaciones de Workspace (Gmail, Tasks)",[159,2342,2343],{},"Panel de analítica dentro de Sheets",[159,2345,2346],{},"Gestión de estado más granular (por ejemplo, notas de sesión, códigos de facturación)",[136,2348,2350],{"id":2349},"desarrollo-y-despliegue","Desarrollo y despliegue",[141,2352,2353,2354,2356],{},"El repositorio incluye un flujo de despliegue completo con ",[468,2355,1983],{}," (la CLI de Google Apps Script). Los requisitos previos son mínimos:",[156,2358,2359,2362,2365],{},[159,2360,2361],{},"Node.js y npm",[159,2363,2364],{},"Una cuenta de Google con acceso a Apps Script",[159,2366,2367],{},"Opcional: Nix + direnv (los archivos del entorno de desarrollo están incluidos)",[462,2369,2370],{"className":1998,"code":1999,"language":2000,"meta":275,"style":275},[468,2371,2372,2378,2386,2410,2418],{"__ignoreMap":275},[471,2373,2374,2376],{"class":473,"line":303},[471,2375,2008],{"class":2007},[471,2377,2012],{"class":2011},[471,2379,2380,2382,2384],{"class":473,"line":276},[471,2381,2017],{"class":2007},[471,2383,2020],{"class":2011},[471,2385,2023],{"class":2011},[471,2387,2388,2390,2392,2394,2396,2398,2400,2402,2404,2406,2408],{"class":473,"line":291},[471,2389,2017],{"class":2007},[471,2391,2020],{"class":2011},[471,2393,2032],{"class":2011},[471,2395,2035],{"class":2011},[471,2397,2038],{"class":2011},[471,2399,2041],{"class":2011},[471,2401,2045],{"class":2044},[471,2403,2048],{"class":2011},[471,2405,2051],{"class":2044},[471,2407,2054],{"class":2011},[471,2409,2057],{"class":2011},[471,2411,2412,2414,2416],{"class":473,"line":489},[471,2413,2017],{"class":2007},[471,2415,2020],{"class":2011},[471,2417,2066],{"class":2011},[471,2419,2420,2422,2424],{"class":473,"line":495},[471,2421,2017],{"class":2007},[471,2423,2020],{"class":2011},[471,2425,2075],{"class":2011},[141,2427,2428],{},"El proyecto también incluye guías de contribución, plantillas de PR y plantillas de issues para colaboradores.",[136,2430,2432],{"id":2431},"conclusión-clave","Conclusión clave",[141,2434,2435],{},"ChronoCal demuestra que las herramientas de productividad potentes no necesitan infraestructura compleja. Aprovechando Google Apps Script y las UserProperties, puedes construir un sistema de registro que vive dentro de la herramienta que ya usas todos los días —Calendar— sin añadir carga operativa ni dudas sobre la privacidad.",[141,2437,2438,2439],{},"Si usas Google Workspace y te encuentras saltando constantemente entre Calendar y algún registro de tiempo, pruébalo: ",[320,2440,2093],{"href":2091,"rel":2441},[324],[1024,2443],{},[141,2445,2446,2449,2450,2452,2453],{},[331,2447,2448],{},"Atribución de imagen:"," la imagen destacada viene de Outlook. Foto de ",[320,2451,2105],{"href":2104}," en ",[320,2454,2109],{"href":2108},[1041,2456,2112],{},{"title":275,"searchDepth":276,"depth":276,"links":2458},[2459,2460,2461,2465,2466,2467,2468],{"id":2148,"depth":276,"text":2149},{"id":2175,"depth":276,"text":2176},{"id":2196,"depth":276,"text":2197,"children":2462},[2463,2464],{"id":2203,"depth":291,"text":2204},{"id":2268,"depth":291,"text":2269},{"id":2298,"depth":276,"text":2299},{"id":2325,"depth":276,"text":2326},{"id":2349,"depth":276,"text":2350},{"id":2431,"depth":276,"text":2432},"Un complemento de Google Calendar construido con Apps Script que registra el tiempo real dedicado a los eventos directamente en Google Workspace: sin backend externo, sin almacenamiento de terceros y sin depender de un temporizador en el navegador.",{},"Publiqué un MVP funcional que lleva el registro de tiempo en tiempo real directamente a Google Calendar, sin infraestructura externa",{"title":101,"description":2469},[2133,2134,2135,2136],[2138],"bS0f54dLdHTEYP6sKZkyGagVRr3Qsnn0SYFqF_e_iuM",{"id":2477,"title":44,"author":2478,"body":2480,"category":2744,"client":285,"date":2745,"description":2746,"extension":288,"featured":38,"hidden":38,"image":2497,"locale":1063,"meta":2747,"minRead":489,"navigation":292,"ogImage":285,"outcome_headline":285,"path":45,"repoUrl":285,"role":285,"seo":2748,"stack":285,"stem":46,"tags":2749,"team_size":285,"type":285,"year":285,"__hash__":2751},"blog/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11.md",{"name":127,"username":128,"to":129,"avatar":2479},{"src":131,"alt":127},{"type":133,"value":2481,"toc":2733},[2482,2485,2491,2498,2501,2505,2508,2520,2523,2527,2530,2534,2537,2564,2567,2571,2574,2604,2607,2611,2614,2634,2637,2640,2647,2650,2654,2657,2673,2676,2687,2690,2694,2701,2704,2706,2709,2727,2730],[141,2483,2484],{},"I ran into an issue on NixOS 25.11 that took me a while to understand: Google Drive would appear in GNOME Files after signing in with GNOME Online Accounts, but trying to open it failed with:",[2486,2487,2488],"blockquote",{},[141,2489,2490],{},"\"Location is not mountable\"",[141,2492,2493],{},[2494,2495],"img",{"alt":2496,"src":2497},"GNOME Files showing the \"Unable to access — Location is not mountable\" error dialog","/blog/fixing-gnome-google-drive-mounts-on-nixos/featuredImage.png",[141,2499,2500],{},"This post shares the fix that worked for me and got Drive mounts working reliably on my system.",[136,2502,2504],{"id":2503},"the-problem","The problem",[141,2506,2507],{},"At first glance, everything looked correct:",[2509,2510,2511,2514,2517],"ol",{},[159,2512,2513],{},"GNOME Online Accounts was enabled.",[159,2515,2516],{},"I could sign in to Google.",[159,2518,2519],{},"Drive showed up in the sidebar.",[141,2521,2522],{},"But the mount itself still failed. The missing piece was that the default GVFS build in this setup did not include Google backend support.",[136,2524,2526],{"id":2525},"what-i-changed","What I changed",[141,2528,2529],{},"I made three focused changes.",[612,2531,2533],{"id":2532},"_1-force-a-gvfs-build-with-google-support","1) Force a GVFS build with Google support",[141,2535,2536],{},"I overrode the GVFS package to enable Google support explicitly:",[462,2538,2542],{"className":2539,"code":2540,"language":2541,"meta":275,"style":275},"language-nix shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","services.gvfs.package = pkgs.gnome.gvfs.override {\n  gnomeSupport = true;\n  googleSupport = true;\n};\n","nix",[468,2543,2544,2549,2554,2559],{"__ignoreMap":275},[471,2545,2546],{"class":473,"line":303},[471,2547,2548],{},"services.gvfs.package = pkgs.gnome.gvfs.override {\n",[471,2550,2551],{"class":473,"line":276},[471,2552,2553],{},"  gnomeSupport = true;\n",[471,2555,2556],{"class":473,"line":291},[471,2557,2558],{},"  googleSupport = true;\n",[471,2560,2561],{"class":473,"line":489},[471,2562,2563],{},"};\n",[141,2565,2566],{},"Why: without this, GOA can authenticate, but GVFS cannot complete the Drive mount backend path.",[612,2568,2570],{"id":2569},"_2-keep-the-gnome-accountmount-stack-enabled","2) Keep the GNOME account/mount stack enabled",[141,2572,2573],{},"I ensured these services stayed enabled:",[462,2575,2577],{"className":2539,"code":2576,"language":2541,"meta":275,"style":275},"services.gnome.gnome-online-accounts.enable = true;\nservices.gvfs.enable = true;\nservices.gnome.gnome-keyring.enable = true;\nservices.accounts-daemon.enable = true;\nprograms.dconf.enable = true;\n",[468,2578,2579,2584,2589,2594,2599],{"__ignoreMap":275},[471,2580,2581],{"class":473,"line":303},[471,2582,2583],{},"services.gnome.gnome-online-accounts.enable = true;\n",[471,2585,2586],{"class":473,"line":276},[471,2587,2588],{},"services.gvfs.enable = true;\n",[471,2590,2591],{"class":473,"line":291},[471,2592,2593],{},"services.gnome.gnome-keyring.enable = true;\n",[471,2595,2596],{"class":473,"line":489},[471,2597,2598],{},"services.accounts-daemon.enable = true;\n",[471,2600,2601],{"class":473,"line":495},[471,2602,2603],{},"programs.dconf.enable = true;\n",[141,2605,2606],{},"Why: these are all part of the GNOME identity, credential, and desktop integration chain used for online volumes.",[612,2608,2610],{"id":2609},"_3-allow-the-insecure-libsoup2-package-required-by-this-backend-path","3) Allow the insecure libsoup2 package required by this backend path",[141,2612,2613],{},"I added:",[462,2615,2617],{"className":2539,"code":2616,"language":2541,"meta":275,"style":275},"nixpkgs.config.permittedInsecurePackages = [\n  \"libsoup-2.74.3\"\n];\n",[468,2618,2619,2624,2629],{"__ignoreMap":275},[471,2620,2621],{"class":473,"line":303},[471,2622,2623],{},"nixpkgs.config.permittedInsecurePackages = [\n",[471,2625,2626],{"class":473,"line":276},[471,2627,2628],{},"  \"libsoup-2.74.3\"\n",[471,2630,2631],{"class":473,"line":291},[471,2632,2633],{},"];\n",[141,2635,2636],{},"Why: in this channel/version combination, enabling the GVFS Google backend pulls libsoup2, which is flagged insecure. This exception allows rebuild and evaluation to proceed.",[141,2638,2639],{},"Important note: this is a pragmatic compatibility workaround, not a long-term ideal from a security perspective.",[141,2641,2642,2643,2646],{},"From what nixpkgs currently documents, this is not about a single isolated CVE. The ",[468,2644,2645],{},"libsoup 2"," line is end-of-life and marked with many known unfixed CVEs, so Nix surfaces it as insecure by default.",[141,2648,2649],{},"I am genuinely grateful this warning exists. It is easy to feel blocked when a rebuild stops, but this policy is doing an important job: reminding us where a compatibility fix trades off against long-term security hygiene.",[136,2651,2653],{"id":2652},"apply-and-verify","Apply and verify",[141,2655,2656],{},"After updating your configuration:",[462,2658,2660],{"className":1998,"code":2659,"language":2000,"meta":275,"style":275},"sudo nixos-rebuild switch\n",[468,2661,2662],{"__ignoreMap":275},[471,2663,2664,2667,2670],{"class":473,"line":303},[471,2665,2666],{"class":2007},"sudo",[471,2668,2669],{"class":2011}," nixos-rebuild",[471,2671,2672],{"class":2011}," switch\n",[141,2674,2675],{},"Then:",[2509,2677,2678,2681,2684],{},[159,2679,2680],{},"Open GNOME Online Accounts and confirm your Google account is connected.",[159,2682,2683],{},"Open Files.",[159,2685,2686],{},"Select Google Drive from the sidebar.",[141,2688,2689],{},"If everything is wired correctly, Drive should mount instead of failing with \"Location is not mountable.\"",[136,2691,2693],{"id":2692},"tradeoffs-and-future-direction","Tradeoffs and future direction",[141,2695,2696,2697,2700],{},"This fix restores native GNOME Drive mounting, but it currently depends on a package exception (",[468,2698,2699],{},"libsoup-2.74.3","). A cleaner long-term path is to move to a backend stack that avoids insecure dependencies, or use an alternative mounting approach such as rclone.",[141,2702,2703],{},"In other words, this got me unblocked quickly, and I appreciate that. At the same time, I treat it as temporary until the Drive backend path can run without the older EOL library line.",[136,2705,139],{"id":138},[141,2707,2708],{},"If Google Drive appears in GNOME Files but will not mount on NixOS 25.11, this is the short version of what helped me:",[2509,2710,2711,2718,2721],{},[159,2712,2713,2714,2717],{},"Override GVFS with ",[468,2715,2716],{},"googleSupport = true",".",[159,2719,2720],{},"Keep GOA, GVFS, keyring, accounts-daemon, and dconf services enabled.",[159,2722,2723,2724,2726],{},"Permit ",[468,2725,2699],{}," so the build works in current channel constraints.",[141,2728,2729],{},"I am thankful to the Nix maintainers and GNOME ecosystem here: even when this path is inconvenient, the defaults push us toward safer choices.",[1041,2731,2732],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}",{"title":275,"searchDepth":276,"depth":276,"links":2734},[2735,2736,2741,2742,2743],{"id":2503,"depth":276,"text":2504},{"id":2525,"depth":276,"text":2526,"children":2737},[2738,2739,2740],{"id":2532,"depth":291,"text":2533},{"id":2569,"depth":291,"text":2570},{"id":2609,"depth":291,"text":2610},{"id":2652,"depth":276,"text":2653},{"id":2692,"depth":276,"text":2693},{"id":138,"depth":276,"text":139},"article","2026-05-31T00:00:00.000Z","How I fixed the 'Location is not mountable' error by enabling GVFS Google support and related GNOME services on NixOS 25.11.",{},{"title":44,"description":2746},[2750],"linux","X7nXQxpb0rddUbAfxOTR9XPRPWh3IBqPCMvPU5VJJlE",{"id":2753,"title":105,"author":2754,"body":2756,"category":2744,"client":285,"date":2745,"description":2989,"extension":288,"featured":38,"hidden":38,"image":2497,"locale":66,"meta":2990,"minRead":489,"navigation":292,"ogImage":285,"outcome_headline":285,"path":106,"repoUrl":285,"role":285,"seo":2991,"stack":285,"stem":107,"tags":2992,"team_size":285,"type":285,"year":285,"__hash__":2993},"blog/es/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11.md",{"name":127,"username":128,"to":129,"avatar":2755},{"src":131,"alt":127},{"type":133,"value":2757,"toc":2978},[2758,2761,2765,2770,2773,2777,2780,2791,2794,2798,2801,2805,2808,2828,2831,2835,2838,2862,2865,2869,2872,2888,2891,2894,2900,2903,2907,2910,2922,2925,2936,2939,2943,2949,2952,2954,2957,2973,2976],[141,2759,2760],{},"Me topé con un problema en NixOS 25.11 que tardé un rato en entender: Google Drive aparecía en Archivos de GNOME después de iniciar sesión con Cuentas en línea de GNOME, pero al intentar abrirlo fallaba con:",[2486,2762,2763],{},[141,2764,2490],{},[141,2766,2767],{},[2494,2768],{"alt":2769,"src":2497},"Archivos de GNOME mostrando el diálogo de error «Unable to access — Location is not mountable»",[141,2771,2772],{},"Este artículo comparte el arreglo que me funcionó y que dejó los montajes de Drive funcionando de forma confiable en mi sistema.",[136,2774,2776],{"id":2775},"el-problema","El problema",[141,2778,2779],{},"A primera vista, todo se veía correcto:",[2509,2781,2782,2785,2788],{},[159,2783,2784],{},"Cuentas en línea de GNOME estaba habilitado.",[159,2786,2787],{},"Podía iniciar sesión en Google.",[159,2789,2790],{},"Drive aparecía en la barra lateral.",[141,2792,2793],{},"Pero el montaje seguía fallando. La pieza que faltaba era que el build por defecto de GVFS en esta configuración no incluía el soporte del backend de Google.",[136,2795,2797],{"id":2796},"qué-cambié","Qué cambié",[141,2799,2800],{},"Hice tres cambios puntuales.",[612,2802,2804],{"id":2803},"_1-forzar-un-build-de-gvfs-con-soporte-de-google","1) Forzar un build de GVFS con soporte de Google",[141,2806,2807],{},"Sobrescribí el paquete GVFS para habilitar el soporte de Google de forma explícita:",[462,2809,2810],{"className":2539,"code":2540,"language":2541,"meta":275,"style":275},[468,2811,2812,2816,2820,2824],{"__ignoreMap":275},[471,2813,2814],{"class":473,"line":303},[471,2815,2548],{},[471,2817,2818],{"class":473,"line":276},[471,2819,2553],{},[471,2821,2822],{"class":473,"line":291},[471,2823,2558],{},[471,2825,2826],{"class":473,"line":489},[471,2827,2563],{},[141,2829,2830],{},"Por qué: sin esto, GOA puede autenticar, pero GVFS no puede completar la ruta del backend de montaje de Drive.",[612,2832,2834],{"id":2833},"_2-mantener-activo-el-stack-de-cuenta-y-montaje-de-gnome","2) Mantener activo el stack de cuenta y montaje de GNOME",[141,2836,2837],{},"Me aseguré de que estos servicios siguieran habilitados:",[462,2839,2840],{"className":2539,"code":2576,"language":2541,"meta":275,"style":275},[468,2841,2842,2846,2850,2854,2858],{"__ignoreMap":275},[471,2843,2844],{"class":473,"line":303},[471,2845,2583],{},[471,2847,2848],{"class":473,"line":276},[471,2849,2588],{},[471,2851,2852],{"class":473,"line":291},[471,2853,2593],{},[471,2855,2856],{"class":473,"line":489},[471,2857,2598],{},[471,2859,2860],{"class":473,"line":495},[471,2861,2603],{},[141,2863,2864],{},"Por qué: todos forman parte de la cadena de identidad, credenciales e integración de escritorio de GNOME que usan los volúmenes en línea.",[612,2866,2868],{"id":2867},"_3-permitir-el-paquete-libsoup2-marcado-como-inseguro-que-exige-esta-ruta-de-backend","3) Permitir el paquete libsoup2 marcado como inseguro que exige esta ruta de backend",[141,2870,2871],{},"Agregué:",[462,2873,2874],{"className":2539,"code":2616,"language":2541,"meta":275,"style":275},[468,2875,2876,2880,2884],{"__ignoreMap":275},[471,2877,2878],{"class":473,"line":303},[471,2879,2623],{},[471,2881,2882],{"class":473,"line":276},[471,2883,2628],{},[471,2885,2886],{"class":473,"line":291},[471,2887,2633],{},[141,2889,2890],{},"Por qué: en esta combinación de canal y versión, habilitar el backend de Google de GVFS arrastra libsoup2, que está marcado como inseguro. Esta excepción permite que el rebuild y la evaluación continúen.",[141,2892,2893],{},"Nota importante: esto es un parche de compatibilidad pragmático, no un ideal a largo plazo desde el punto de vista de la seguridad.",[141,2895,2896,2897,2899],{},"Por lo que nixpkgs documenta actualmente, no se trata de un solo CVE aislado. La línea ",[468,2898,2645],{}," llegó al final de su vida útil y está marcada con muchos CVE conocidos sin arreglar, así que Nix la expone como insegura por defecto.",[141,2901,2902],{},"Agradezco de verdad que exista esa advertencia. Es fácil sentirse bloqueado cuando un rebuild se detiene, pero esta política está haciendo un trabajo importante: recordarnos dónde un arreglo de compatibilidad se paga con higiene de seguridad a largo plazo.",[136,2904,2906],{"id":2905},"aplicar-y-verificar","Aplicar y verificar",[141,2908,2909],{},"Después de actualizar tu configuración:",[462,2911,2912],{"className":1998,"code":2659,"language":2000,"meta":275,"style":275},[468,2913,2914],{"__ignoreMap":275},[471,2915,2916,2918,2920],{"class":473,"line":303},[471,2917,2666],{"class":2007},[471,2919,2669],{"class":2011},[471,2921,2672],{"class":2011},[141,2923,2924],{},"Luego:",[2509,2926,2927,2930,2933],{},[159,2928,2929],{},"Abre Cuentas en línea de GNOME y confirma que tu cuenta de Google está conectada.",[159,2931,2932],{},"Abre Archivos.",[159,2934,2935],{},"Selecciona Google Drive en la barra lateral.",[141,2937,2938],{},"Si todo está bien conectado, Drive debería montarse en vez de fallar con \"Location is not mountable\".",[136,2940,2942],{"id":2941},"compensaciones-y-dirección-futura","Compensaciones y dirección futura",[141,2944,2945,2946,2948],{},"Este arreglo restaura el montaje nativo de Drive en GNOME, pero por ahora depende de una excepción de paquete (",[468,2947,2699],{},"). Un camino más limpio a largo plazo es pasar a un stack de backend que evite dependencias inseguras, o usar un enfoque de montaje alternativo como rclone.",[141,2950,2951],{},"En otras palabras, esto me desbloqueó rápido y lo agradezco. A la vez, lo trato como temporal hasta que la ruta del backend de Drive pueda funcionar sin la línea de librerías antiguas en fin de vida.",[136,2953,139],{"id":138},[141,2955,2956],{},"Si Google Drive aparece en Archivos de GNOME pero no se monta en NixOS 25.11, esta es la versión corta de lo que me ayudó:",[2509,2958,2959,2964,2967],{},[159,2960,2961,2962,2717],{},"Sobrescribir GVFS con ",[468,2963,2716],{},[159,2965,2966],{},"Mantener habilitados los servicios GOA, GVFS, keyring, accounts-daemon y dconf.",[159,2968,2969,2970,2972],{},"Permitir ",[468,2971,2699],{}," para que el build funcione con las restricciones del canal actual.",[141,2974,2975],{},"Agradezco a los mantenedores de Nix y al ecosistema de GNOME: incluso cuando este camino es incómodo, los valores por defecto nos empujan hacia decisiones más seguras.",[1041,2977,2732],{},{"title":275,"searchDepth":276,"depth":276,"links":2979},[2980,2981,2986,2987,2988],{"id":2775,"depth":276,"text":2776},{"id":2796,"depth":276,"text":2797,"children":2982},[2983,2984,2985],{"id":2803,"depth":291,"text":2804},{"id":2833,"depth":291,"text":2834},{"id":2867,"depth":291,"text":2868},{"id":2905,"depth":276,"text":2906},{"id":2941,"depth":276,"text":2942},{"id":138,"depth":276,"text":139},"Cómo arreglé el error «Location is not mountable» habilitando el soporte de Google en GVFS y los servicios de GNOME relacionados en NixOS 25.11.",{},{"title":105,"description":2989},[2750],"mRIqrzIBCBtrsNnJCubcIXgNCCnLeuWDxejOmE58owU",{"id":2995,"title":60,"author":2996,"body":2998,"category":284,"client":285,"date":286,"description":3124,"extension":288,"featured":38,"hidden":38,"image":289,"locale":1063,"meta":3125,"minRead":291,"navigation":292,"ogImage":285,"outcome_headline":3126,"path":61,"repoUrl":294,"role":2130,"seo":3127,"stack":3128,"stem":62,"tags":3129,"team_size":303,"type":284,"year":304,"__hash__":3130},"blog/blog/jp-landing.md",{"name":127,"username":128,"to":129,"avatar":2997},{"src":131,"alt":127},{"type":133,"value":2999,"toc":3116},[3000,3002,3005,3007,3010,3012,3023,3025,3084,3086,3089,3092,3109,3113],[136,3001,139],{"id":138},[141,3003,3004],{},"Jhey Pi is the official landing site for an aspiring musician. I built it to keep the artist story, Spotify releases, and YouTube videos in one place, with a simple feed that stays easy to scan.",[136,3006,351],{"id":350},[141,3008,3009],{},"The goal was to make a landing site that felt like a proper home for the artist, not just another promo page. The site pulls the latest Spotify releases and YouTube videos into one feed, so the content stays current without turning the page into a manual update burden.",[136,3011,383],{"id":382},[156,3013,3014,3017,3020],{},[159,3015,3016],{},"Keep the experience lightweight and easy to update.",[159,3018,3019],{},"Merge Spotify releases, artist profile data, and YouTube videos into one feed.",[159,3021,3022],{},"Support bilingual copy without making the page feel crowded.",[136,3024,610],{"id":609},[173,3026,3027,3038],{},[176,3028,3029],{},[179,3030,3031,3034,3036],{},[182,3032,3033],{},"Decision",[182,3035,630],{},[182,3037,633],{},[192,3039,3040,3050,3062,3072],{},[179,3041,3042,3045,3048],{},[197,3043,3044],{},"Build a traditional promo site with separate pages for music and video",[197,3046,3047],{},"More room for content, but harder to keep current",[197,3049],{},[179,3051,3052,3055,3058],{},[197,3053,3054],{},"Build a single landing site with a merged, auto-sorted feed",[197,3056,3057],{},"Easier to scan and maintain; the artist updates in one place",[197,3059,3060],{},[216,3061],{},[179,3063,3064,3067,3070],{},[197,3065,3066],{},"Keep the copy monolingual",[197,3068,3069],{},"Simpler, but weaker reach for bilingual audiences",[197,3071],{},[179,3073,3074,3077,3080],{},[197,3075,3076],{},"Support ES/EN copy",[197,3078,3079],{},"Better fit for the audience, with a little more content work",[197,3081,3082],{},[216,3083],{},[136,3085,924],{"id":923},[141,3087,3088],{},"The project shipped as a clean landing site for Jhey Pi with a single content feed, artist profile data, and room for bilingual copy. It also made the update flow simpler: new releases and videos can surface without needing the whole page to be rewritten.",[141,3090,3091],{},"Feature-wise, the useful pieces were:",[156,3093,3094,3097,3100,3103,3106],{},[159,3095,3096],{},"Spotify releases and artist profile via Spotify API.",[159,3098,3099],{},"YouTube latest videos via the channel feed.",[159,3101,3102],{},"Auto-sorted, paginated feed with embeds.",[159,3104,3105],{},"Bilingual copy in ES/EN.",[159,3107,3108],{},"ISR caching for the feed endpoint.",[136,3110,3112],{"id":3111},"what-id-do-differently-today","What I'd do differently today",[141,3114,3115],{},"If this needed to scale further, I would keep the feed endpoint cached with ISR-style behavior, move the integrations behind a small server layer, and host the front end on a managed edge platform with preview deployments per pull request.",{"title":275,"searchDepth":276,"depth":276,"links":3117},[3118,3119,3120,3121,3122,3123],{"id":138,"depth":276,"text":139},{"id":350,"depth":276,"text":351},{"id":382,"depth":276,"text":383},{"id":609,"depth":276,"text":610},{"id":923,"depth":276,"text":924},{"id":3111,"depth":276,"text":3112},"Official landing site for Jhey Pi, built with Nuxt 4 and Nuxt UI to combine Spotify releases and YouTube videos into a single feed.",{},"Shipped a focused artist landing site with a single feed for music updates and video content",{"title":60,"description":3124},[298,299,300],[302],"agEjP2J1jTj_mxiSicPdeNP_PGKmJlUB4RqCGcBwpdk",{"id":125,"title":121,"author":3132,"body":3134,"category":284,"client":285,"date":286,"description":287,"extension":288,"featured":38,"hidden":38,"image":289,"locale":66,"meta":3236,"minRead":291,"navigation":292,"ogImage":285,"outcome_headline":293,"path":122,"repoUrl":294,"role":295,"seo":3237,"stack":3238,"stem":123,"tags":3239,"team_size":303,"type":284,"year":304,"__hash__":305},{"name":127,"username":128,"to":129,"avatar":3133},{"src":131,"alt":127},{"type":133,"value":3135,"toc":3228},[3136,3138,3140,3142,3144,3146,3154,3156,3206,3208,3210,3212,3224,3226],[136,3137,139],{"id":138},[141,3139,143],{},[136,3141,147],{"id":146},[141,3143,150],{},[136,3145,154],{"id":153},[156,3147,3148,3150,3152],{},[159,3149,161],{},[159,3151,164],{},[159,3153,167],{},[136,3155,171],{"id":170},[173,3157,3158,3168],{},[176,3159,3160],{},[179,3161,3162,3164,3166],{},[182,3163,184],{},[182,3165,187],{},[182,3167,190],{},[192,3169,3170,3178,3188,3196],{},[179,3171,3172,3174,3176],{},[197,3173,199],{},[197,3175,202],{},[197,3177],{},[179,3179,3180,3182,3184],{},[197,3181,209],{},[197,3183,212],{},[197,3185,3186],{},[216,3187],{},[179,3189,3190,3192,3194],{},[197,3191,222],{},[197,3193,225],{},[197,3195],{},[179,3197,3198,3200,3202],{},[197,3199,232],{},[197,3201,235],{},[197,3203,3204],{},[216,3205],{},[136,3207,243],{"id":242},[141,3209,246],{},[141,3211,249],{},[156,3213,3214,3216,3218,3220,3222],{},[159,3215,254],{},[159,3217,257],{},[159,3219,260],{},[159,3221,263],{},[159,3223,266],{},[136,3225,270],{"id":269},[141,3227,273],{},{"title":275,"searchDepth":276,"depth":276,"links":3229},[3230,3231,3232,3233,3234,3235],{"id":138,"depth":276,"text":139},{"id":146,"depth":276,"text":147},{"id":153,"depth":276,"text":154},{"id":170,"depth":276,"text":171},{"id":242,"depth":276,"text":243},{"id":269,"depth":276,"text":270},{},{"title":121,"description":287},[298,299,300],[302],{"id":3241,"title":35,"author":3242,"body":3244,"category":1059,"client":3871,"date":3872,"description":3873,"extension":288,"featured":292,"hidden":38,"image":3874,"locale":1063,"meta":3875,"minRead":285,"navigation":292,"ogImage":3874,"outcome_headline":3876,"path":36,"repoUrl":285,"role":3877,"seo":3878,"stack":3879,"stem":37,"tags":3885,"team_size":276,"type":3889,"year":3890,"__hash__":3891},"blog/blog/case-studies/supermarket-pwa-recovery.md",{"name":127,"username":128,"to":129,"avatar":3243},{"src":131,"alt":127},{"type":133,"value":3245,"toc":3857},[3246,3248,3266,3268,3271,3274,3285,3291,3293,3345,3348,3350,3353,3568,3570,3574,3577,3613,3616,3620,3623,3659,3662,3666,3673,3709,3712,3716,3719,3755,3758,3760,3763,3792,3795,3797,3800,3842,3845,3847,3855],[136,3247,139],{"id":138},[156,3249,3250,3253,3259],{},[159,3251,3252],{},"A Node.js + Angular e-commerce project at a regional supermarket chain had reached pre-launch with no reliable deploy path.",[159,3254,3255,3256,2717],{},"I joined as the second engineer on a two-person team, finished the platform, and shipped it as a ",[331,3257,3258],{},"PWA + mobile apps to a high-volume customer base",[159,3260,3261,3262,3265],{},"Along the way we built the ",[331,3263,3264],{},"Docker image registry and CI/CD pipeline"," that made continuous delivery possible — for this project and the ones that followed.",[136,3267,351],{"id":350},[141,3269,3270],{},"A regional supermarket chain in Venezuela had an e-commerce initiative in flight when I joined: there was a Node.js backend, an Angular frontend, and a customer base waiting for it. What was missing was the path from \"code in a repo\" to \"an app customers actually use.\"",[141,3272,3273],{},"The shape of the work is one any engineer who has joined a pre-launch greenfield will recognise:",[156,3275,3276,3279,3282],{},[159,3277,3278],{},"A codebase that ran in development but didn't yet have a repeatable build or deploy path.",[159,3280,3281],{},"An open question about the customer surface — PWA, native apps, or both.",[159,3283,3284],{},"Adjacent business needs (customer segmentation, exchange-rate ingestion) waiting on someone with the bandwidth to pick them up.",[141,3286,3287,3288],{},"The brief was simple in one sentence and hard in practice: ",[356,3289,3290],{},"finish it and ship it.",[136,3292,383],{"id":382},[173,3294,3295,3303],{},[176,3296,3297],{},[179,3298,3299,3301],{},[182,3300,392],{},[182,3302,395],{},[192,3304,3305,3313,3321,3329,3337],{},[179,3306,3307,3310],{},[197,3308,3309],{},"Team size",[197,3311,3312],{},"2 engineers total, including me, embedded with the business",[179,3314,3315,3318],{},[197,3316,3317],{},"Calendar",[197,3319,3320],{},"Months, not quarters — the chain had already waited too long",[179,3322,3323,3326],{},[197,3324,3325],{},"Stack inherited",[197,3327,3328],{},"Node.js backend, Angular frontend, no deploy story",[179,3330,3331,3334],{},[197,3332,3333],{},"Customer surface",[197,3335,3336],{},"Needed to reach customers on phones, fast, without an app-store gauntlet",[179,3338,3339,3342],{},[197,3340,3341],{},"Adjacent work",[197,3343,3344],{},"Customer segmentation and exchange-rate ingestion were also on the table",[141,3346,3347],{},"The non-negotiable: ship something customers could use, then build the platform underneath that made the next thing easy.",[136,3349,460],{"id":459},[141,3351,3352],{},"Based on the original delivery architecture (simplified from the internal diagram), this is the flow we shipped:",[462,3354,3356],{"className":464,"code":3355,"language":466,"meta":275,"style":275},"flowchart LR\n  classDef ext fill:#e5e7eb,stroke:#6b7280,color:#282a30\n  classDef svc fill:#dbeafe,stroke:#0f4c5c,color:#282a30\n  classDef data fill:#d1fae5,stroke:#0f766e,color:#282a30\n  classDef client fill:#fef3c7,stroke:#f59e0b,color:#282a30\n\n  ERP[(ERP / iDempiere)]\n  M2[(Magento 2)]\n\n  subgraph Integration[Integration Layer]\n    INT[Integrator]\n    MS[Catalog Sync Microservice]\n  end\n  class INT,MS svc\n\n  DB[(MongoDB)]\n\n  subgraph APIs[Platform APIs]\n    PROC[Process API\\nCatalog, prices, tags, stock]\n    APP[App API\\nOrders, profile, addresses]\n  end\n  class PROC,APP svc\n\n  subgraph Clients[Customer Surface]\n    PWA[PWA Storefront]\n    MOB[Mobile App]\n  end\n  class PWA,MOB client\n  class ERP,M2 ext\n  class DB data\n\n  ERP --> INT\n  M2 --> INT\n  INT --> MS\n  MS --> DB\n  DB --> PROC\n  PROC --> PWA\n  PROC --> MOB\n  PWA --> APP\n  MOB --> APP\n  APP --> M2\n",[468,3357,3358,3362,3366,3370,3374,3378,3382,3387,3392,3396,3401,3406,3411,3416,3421,3425,3430,3434,3439,3444,3449,3453,3458,3462,3468,3474,3480,3485,3491,3497,3503,3508,3514,3520,3526,3532,3538,3544,3550,3556,3562],{"__ignoreMap":275},[471,3359,3360],{"class":473,"line":303},[471,3361,476],{},[471,3363,3364],{"class":473,"line":276},[471,3365,481],{},[471,3367,3368],{"class":473,"line":291},[471,3369,486],{},[471,3371,3372],{"class":473,"line":489},[471,3373,492],{},[471,3375,3376],{"class":473,"line":495},[471,3377,498],{},[471,3379,3380],{"class":473,"line":501},[471,3381,504],{"emptyLinePlaceholder":292},[471,3383,3384],{"class":473,"line":507},[471,3385,3386],{},"  ERP[(ERP / iDempiere)]\n",[471,3388,3389],{"class":473,"line":513},[471,3390,3391],{},"  M2[(Magento 2)]\n",[471,3393,3394],{"class":473,"line":519},[471,3395,504],{"emptyLinePlaceholder":292},[471,3397,3398],{"class":473,"line":525},[471,3399,3400],{},"  subgraph Integration[Integration Layer]\n",[471,3402,3403],{"class":473,"line":531},[471,3404,3405],{},"    INT[Integrator]\n",[471,3407,3408],{"class":473,"line":537},[471,3409,3410],{},"    MS[Catalog Sync Microservice]\n",[471,3412,3413],{"class":473,"line":543},[471,3414,3415],{},"  end\n",[471,3417,3418],{"class":473,"line":549},[471,3419,3420],{},"  class INT,MS svc\n",[471,3422,3423],{"class":473,"line":555},[471,3424,504],{"emptyLinePlaceholder":292},[471,3426,3427],{"class":473,"line":561},[471,3428,3429],{},"  DB[(MongoDB)]\n",[471,3431,3432],{"class":473,"line":567},[471,3433,504],{"emptyLinePlaceholder":292},[471,3435,3436],{"class":473,"line":573},[471,3437,3438],{},"  subgraph APIs[Platform APIs]\n",[471,3440,3441],{"class":473,"line":579},[471,3442,3443],{},"    PROC[Process API\\nCatalog, prices, tags, stock]\n",[471,3445,3446],{"class":473,"line":585},[471,3447,3448],{},"    APP[App API\\nOrders, profile, addresses]\n",[471,3450,3451],{"class":473,"line":591},[471,3452,3415],{},[471,3454,3455],{"class":473,"line":597},[471,3456,3457],{},"  class PROC,APP svc\n",[471,3459,3460],{"class":473,"line":603},[471,3461,504],{"emptyLinePlaceholder":292},[471,3463,3465],{"class":473,"line":3464},24,[471,3466,3467],{},"  subgraph Clients[Customer Surface]\n",[471,3469,3471],{"class":473,"line":3470},25,[471,3472,3473],{},"    PWA[PWA Storefront]\n",[471,3475,3477],{"class":473,"line":3476},26,[471,3478,3479],{},"    MOB[Mobile App]\n",[471,3481,3483],{"class":473,"line":3482},27,[471,3484,3415],{},[471,3486,3488],{"class":473,"line":3487},28,[471,3489,3490],{},"  class PWA,MOB client\n",[471,3492,3494],{"class":473,"line":3493},29,[471,3495,3496],{},"  class ERP,M2 ext\n",[471,3498,3500],{"class":473,"line":3499},30,[471,3501,3502],{},"  class DB data\n",[471,3504,3506],{"class":473,"line":3505},31,[471,3507,504],{"emptyLinePlaceholder":292},[471,3509,3511],{"class":473,"line":3510},32,[471,3512,3513],{},"  ERP --> INT\n",[471,3515,3517],{"class":473,"line":3516},33,[471,3518,3519],{},"  M2 --> INT\n",[471,3521,3523],{"class":473,"line":3522},34,[471,3524,3525],{},"  INT --> MS\n",[471,3527,3529],{"class":473,"line":3528},35,[471,3530,3531],{},"  MS --> DB\n",[471,3533,3535],{"class":473,"line":3534},36,[471,3536,3537],{},"  DB --> PROC\n",[471,3539,3541],{"class":473,"line":3540},37,[471,3542,3543],{},"  PROC --> PWA\n",[471,3545,3547],{"class":473,"line":3546},38,[471,3548,3549],{},"  PROC --> MOB\n",[471,3551,3553],{"class":473,"line":3552},39,[471,3554,3555],{},"  PWA --> APP\n",[471,3557,3559],{"class":473,"line":3558},40,[471,3560,3561],{},"  MOB --> APP\n",[471,3563,3565],{"class":473,"line":3564},41,[471,3566,3567],{},"  APP --> M2\n",[136,3569,610],{"id":609},[612,3571,3573],{"id":3572},"decision-1-ship-a-pwa-first-native-apps-second","Decision 1 — Ship a PWA first, native apps second",[141,3575,3576],{},"The fastest path to a phone screen wasn't an iOS/Android binary — it was a Progressive Web App that customers could install from a link.",[173,3578,3579,3589],{},[176,3580,3581],{},[179,3582,3583,3585,3587],{},[182,3584,627],{},[182,3586,630],{},[182,3588,633],{},[192,3590,3591,3601],{},[179,3592,3593,3596,3599],{},[197,3594,3595],{},"Build native iOS + Android apps from day one",[197,3597,3598],{},"Months of platform-specific work; app-store review on every change",[197,3600],{},[179,3602,3603,3606,3609],{},[197,3604,3605],{},"Ship a PWA from the existing Angular code, wrap as mobile apps later",[197,3607,3608],{},"Web-first surface available in days, not months; one codebase to maintain",[197,3610,3611],{},[216,3612],{},[141,3614,3615],{},"The mobile apps came after, on the foundation the PWA had already proven.",[612,3617,3619],{"id":3618},"decision-2-build-the-deploy-path-before-finishing-the-features","Decision 2 — Build the deploy path before finishing the features",[141,3621,3622],{},"Tempting as it was to chase the feature backlog, the bottleneck wasn't features — it was that nothing could be deployed reliably.",[173,3624,3625,3635],{},[176,3626,3627],{},[179,3628,3629,3631,3633],{},[182,3630,627],{},[182,3632,630],{},[182,3634,633],{},[192,3636,3637,3647],{},[179,3638,3639,3642,3645],{},[197,3640,3641],{},"Hand-deploy until launch, automate later",[197,3643,3644],{},"Faster on day one; every deploy after that costs the same hour",[197,3646],{},[179,3648,3649,3652,3655],{},[197,3650,3651],{},"Stand up a private Docker registry + a thin CI/CD pipeline first",[197,3653,3654],{},"Two weeks of platform work before any new feature shipped; every deploy after that was free",[197,3656,3657],{},[216,3658],{},[141,3660,3661],{},"Same principle as similar rescue work: stabilize the runtime before chasing the roadmap. The pipeline outlived the project — the team after me kept using it for the Node.js and Angular services that came next.",[612,3663,3665],{"id":3664},"decision-3-treat-customer-segmentation-as-a-small-useful-side-quest","Decision 3 — Treat customer segmentation as a small, useful side-quest",[141,3667,3668,3669,3672],{},"Marketing wanted to know which customers to talk to and when. I ran an ",[331,3670,3671],{},"RFM analysis"," (Recency, Frequency, Monetary) on the order data the new platform was already collecting.",[173,3674,3675,3685],{},[176,3676,3677],{},[179,3678,3679,3681,3683],{},[182,3680,627],{},[182,3682,630],{},[182,3684,633],{},[192,3686,3687,3697],{},[179,3688,3689,3692,3695],{},[197,3690,3691],{},"Wait until a dedicated data team exists",[197,3693,3694],{},"Months of waiting on insight the business needed now",[197,3696],{},[179,3698,3699,3702,3705],{},[197,3700,3701],{},"Run RFM on the existing data, hand the segments to marketing",[197,3703,3704],{},"Lightweight, immediately useful, and proved the new platform's data was worth something",[197,3706,3707],{},[216,3708],{},[141,3710,3711],{},"It wasn't a \"data platform.\" It was a one-engineer analysis that paid for itself the first week marketing used it.",[612,3713,3715],{"id":3714},"decision-4-automate-exchange-rate-ingestion-instead-of-typing-it-in","Decision 4 — Automate exchange-rate ingestion instead of typing it in",[141,3717,3718],{},"Pricing in Venezuela depends on the official exchange rate, which someone on the team was copy-pasting daily. Manual rate entry was both error-prone and beneath the pay grade of every person doing it.",[173,3720,3721,3731],{},[176,3722,3723],{},[179,3724,3725,3727,3729],{},[182,3726,627],{},[182,3728,630],{},[182,3730,633],{},[192,3732,3733,3743],{},[179,3734,3735,3738,3741],{},[197,3736,3737],{},"Keep doing it manually",[197,3739,3740],{},"Free until it isn't — a single typo moves prices on a whole catalog",[197,3742],{},[179,3744,3745,3748,3751],{},[197,3746,3747],{},"Scrape the official rate on a schedule and push it into the platform",[197,3749,3750],{},"A small ingestion job; removes a daily chore and a class of pricing bugs",[197,3752,3753],{},[216,3754],{},[141,3756,3757],{},"This is the seed of the BCV-scraper work I later wrote about publicly. Small automations like this one tend to pay back quickly, mostly because they remove a recurring human failure mode rather than because they save time directly.",[136,3759,924],{"id":923},[141,3761,3762],{},"By the end of the engagement:",[156,3764,3765,3771,3778,3785],{},[159,3766,3767,3768,2717],{},"The platform shipped as a ",[331,3769,3770],{},"PWA + mobile apps used by a high-volume customer base",[159,3772,3773,3774,3777],{},"The company had a ",[331,3775,3776],{},"private Docker registry and a working CI/CD pipeline"," for Node.js and Angular services — the foundation for everything that came after.",[159,3779,3780,3781,3784],{},"Marketing had ",[331,3782,3783],{},"RFM-based customer segments"," they could act on, drawn from the platform's own data.",[159,3786,3787,3788,3791],{},"Pricing ran on an ",[331,3789,3790],{},"automated exchange-rate feed"," instead of a daily copy-paste.",[141,3793,3794],{},"In hindsight, the sequencing mattered more than the headcount: getting the deploy path in place before chasing the feature backlog is what made the rest of it shippable by a small team.",[136,3796,3112],{"id":3111},[141,3798,3799],{},"Same brief today, same team size, same constraints. The instincts wouldn't change; the targets would.",[156,3801,3802,3812,3818,3824,3830,3836],{},[159,3803,3804,3807,3808,3811],{},[331,3805,3806],{},"Frontend:"," ship the PWA the same way, but host it on ",[331,3809,3810],{},"Cloudflare Pages"," (or any managed static host with a CDN edge). Lighthouse perf as a CI gate, not an afterthought.",[159,3813,3814,3817],{},[331,3815,3816],{},"Backend:"," the same Node.js services on a managed container runtime — no private registry to run and patch.",[159,3819,3820,3823],{},[331,3821,3822],{},"CI/CD:"," a preview environment per PR wired through CI. The pipeline I hand-built in 2022 is now ten lines of YAML, give or take.",[159,3825,3826,3829],{},[331,3827,3828],{},"RFM and segmentation:"," land the orders in a hosted analytics store via a scheduled job, run the RFM query as a scheduled view, and surface the segments to marketing through a dashboard instead of a CSV. Same analysis, no engineer in the loop after week one.",[159,3831,3832,3835],{},[331,3833,3834],{},"Exchange-rate ingestion:"," scheduled job → managed queue → analytics store + the storefront cache. The scraper still runs; everything around it stops being a cron on a single VM.",[159,3837,3838,3841],{},[331,3839,3840],{},"Mobile apps:"," Capacitor or PWABuilder wrapping the same PWA, signed and shipped with staged rollouts.",[141,3843,3844],{},"The shape of the work stays the same — deploy path first, customer surface next, the smaller automations after that. A managed platform just removes a lot of the plumbing that used to be part of the job.",[1024,3846],{},[141,3848,3849],{},[356,3850,3851,3852,2717],{},"If you're working through something similar and want a second pair of eyes, I'm happy to talk it through — ",[320,3853,3854],{"href":1033},"reach out here",[1041,3856,1043],{},{"title":275,"searchDepth":276,"depth":276,"links":3858},[3859,3860,3861,3862,3863,3869,3870],{"id":138,"depth":276,"text":139},{"id":350,"depth":276,"text":351},{"id":382,"depth":276,"text":383},{"id":459,"depth":276,"text":460},{"id":609,"depth":276,"text":610,"children":3864},[3865,3866,3867,3868],{"id":3572,"depth":291,"text":3573},{"id":3618,"depth":291,"text":3619},{"id":3664,"depth":291,"text":3665},{"id":3714,"depth":291,"text":3715},{"id":923,"depth":276,"text":924},{"id":3111,"depth":276,"text":3112},"Supermarket Client (LatAm)","2026-05-15T00:00:00.000Z","How a stalled Node.js + Angular e-commerce project was diagnosed, recovered, and shipped end-to-end as a PWA and mobile apps for a high-volume customer base — with a Docker registry and CI/CD pipeline that made continuous delivery possible for the team that came after.\n","/blog/case-studies/supermarket-pwa-recovery/featuredImage.jpg",{},"Recovered an inherited project and shipped to a high-volume customer base","Programmer",{"title":35,"description":3873},[3880,3881,3882,3883,3884],"Node.js","Angular","PWA","Docker","CI/CD",[3886,3887,3888],"e-commerce","platform-recovery","devops","rescue",2022,"BFjJzRRPvvzR2hIuInYTEic9fkQ-egxxYPRmSOwRSnY",{"id":3893,"title":97,"author":3894,"body":3896,"category":1059,"client":4482,"date":3872,"description":4483,"extension":288,"featured":292,"hidden":38,"image":3874,"locale":66,"meta":4484,"minRead":285,"navigation":292,"ogImage":3874,"outcome_headline":4485,"path":98,"repoUrl":285,"role":4486,"seo":4487,"stack":4488,"stem":99,"tags":4489,"team_size":276,"type":3889,"year":3890,"__hash__":4490},"blog/es/blog/case-studies/supermarket-pwa-recovery.md",{"name":127,"username":128,"to":129,"avatar":3895},{"src":131,"alt":127},{"type":133,"value":3897,"toc":4468},[3898,3900,3918,3920,3923,3926,3937,3943,3945,3997,4000,4002,4005,4183,4185,4189,4192,4228,4231,4235,4238,4274,4277,4281,4288,4324,4327,4331,4334,4370,4373,4375,4378,4407,4410,4412,4415,4453,4456,4458,4466],[136,3899,139],{"id":138},[156,3901,3902,3905,3911],{},[159,3903,3904],{},"Un proyecto de e-commerce en Node.js + Angular en una cadena regional de supermercados había llegado a pre-lanzamiento sin una ruta de despliegue confiable.",[159,3906,3907,3908,2717],{},"Me sumé como segundo ingeniero de un equipo de dos, terminé la plataforma y la entregué como ",[331,3909,3910],{},"PWA + aplicaciones móviles a una base de clientes de alto volumen",[159,3912,3913,3914,3917],{},"En el camino construimos el ",[331,3915,3916],{},"registro de imágenes Docker y el pipeline de CI/CD"," que hicieron posible la entrega continua, para este proyecto y para los que vinieron después.",[136,3919,147],{"id":146},[141,3921,3922],{},"Una cadena regional de supermercados en Venezuela tenía una iniciativa de e-commerce en marcha cuando me sumé: había un backend en Node.js, un frontend en Angular y una base de clientes esperando. Lo que faltaba era el camino desde \"código en un repositorio\" hasta \"una aplicación que los clientes realmente usan\".",[141,3924,3925],{},"La forma del trabajo la reconoce cualquier ingeniero que se haya sumado a un greenfield en pre-lanzamiento:",[156,3927,3928,3931,3934],{},[159,3929,3930],{},"Un código que corría en desarrollo pero que todavía no tenía una ruta de build o despliegue repetible.",[159,3932,3933],{},"Una pregunta abierta sobre la superficie de cliente: PWA, aplicaciones nativas o ambas.",[159,3935,3936],{},"Necesidades de negocio adyacentes (segmentación de clientes, ingesta de la tasa de cambio) esperando a alguien con el ancho de banda para tomarlas.",[141,3938,3939,3940],{},"El encargo era simple en una frase y difícil en la práctica: ",[356,3941,3942],{},"termínalo y entrégalo.",[136,3944,154],{"id":153},[173,3946,3947,3955],{},[176,3948,3949],{},[179,3950,3951,3953],{},[182,3952,1150],{},[182,3954,1153],{},[192,3956,3957,3965,3973,3981,3989],{},[179,3958,3959,3962],{},[197,3960,3961],{},"Tamaño del equipo",[197,3963,3964],{},"2 ingenieros en total, incluyéndome, embebidos con el negocio",[179,3966,3967,3970],{},[197,3968,3969],{},"Calendario",[197,3971,3972],{},"Meses, no trimestres: la cadena ya había esperado demasiado",[179,3974,3975,3978],{},[197,3976,3977],{},"Stack heredado",[197,3979,3980],{},"Backend en Node.js, frontend en Angular, sin historia de despliegue",[179,3982,3983,3986],{},[197,3984,3985],{},"Superficie de cliente",[197,3987,3988],{},"Había que llegar a los clientes en el teléfono, rápido y sin el calvario de las tiendas de aplicaciones",[179,3990,3991,3994],{},[197,3992,3993],{},"Trabajo adyacente",[197,3995,3996],{},"La segmentación de clientes y la ingesta de la tasa de cambio también estaban sobre la mesa",[141,3998,3999],{},"Lo no negociable: entregar algo que los clientes pudieran usar y después construir la plataforma debajo que hiciera fácil lo siguiente.",[136,4001,1217],{"id":1216},[141,4003,4004],{},"Basado en la arquitectura original de entrega (simplificada del diagrama interno), este es el flujo que entregamos:",[462,4006,4008],{"className":464,"code":4007,"language":466,"meta":275,"style":275},"flowchart LR\n  classDef ext fill:#e5e7eb,stroke:#6b7280,color:#282a30\n  classDef svc fill:#dbeafe,stroke:#0f4c5c,color:#282a30\n  classDef data fill:#d1fae5,stroke:#0f766e,color:#282a30\n  classDef client fill:#fef3c7,stroke:#f59e0b,color:#282a30\n\n  ERP[(ERP / iDempiere)]\n  M2[(Magento 2)]\n\n  subgraph Integration[Capa de Integración]\n    INT[Integrador]\n    MS[Microservicio de Sincronización de Catálogo]\n  end\n  class INT,MS svc\n\n  DB[(MongoDB)]\n\n  subgraph APIs[APIs de Plataforma]\n    PROC[Process API\\nCatálogo, precios, etiquetas, stock]\n    APP[App API\\nPedidos, perfil, direcciones]\n  end\n  class PROC,APP svc\n\n  subgraph Clients[Superficie del Cliente]\n    PWA[Tienda PWA]\n    MOB[App móvil]\n  end\n  class PWA,MOB client\n  class ERP,M2 ext\n  class DB data\n\n  ERP --> INT\n  M2 --> INT\n  INT --> MS\n  MS --> DB\n  DB --> PROC\n  PROC --> PWA\n  PROC --> MOB\n  PWA --> APP\n  MOB --> APP\n  APP --> M2\n",[468,4009,4010,4014,4018,4022,4026,4030,4034,4038,4042,4046,4051,4056,4061,4065,4069,4073,4077,4081,4086,4091,4096,4100,4104,4108,4113,4118,4123,4127,4131,4135,4139,4143,4147,4151,4155,4159,4163,4167,4171,4175,4179],{"__ignoreMap":275},[471,4011,4012],{"class":473,"line":303},[471,4013,476],{},[471,4015,4016],{"class":473,"line":276},[471,4017,481],{},[471,4019,4020],{"class":473,"line":291},[471,4021,486],{},[471,4023,4024],{"class":473,"line":489},[471,4025,492],{},[471,4027,4028],{"class":473,"line":495},[471,4029,498],{},[471,4031,4032],{"class":473,"line":501},[471,4033,504],{"emptyLinePlaceholder":292},[471,4035,4036],{"class":473,"line":507},[471,4037,3386],{},[471,4039,4040],{"class":473,"line":513},[471,4041,3391],{},[471,4043,4044],{"class":473,"line":519},[471,4045,504],{"emptyLinePlaceholder":292},[471,4047,4048],{"class":473,"line":525},[471,4049,4050],{},"  subgraph Integration[Capa de Integración]\n",[471,4052,4053],{"class":473,"line":531},[471,4054,4055],{},"    INT[Integrador]\n",[471,4057,4058],{"class":473,"line":537},[471,4059,4060],{},"    MS[Microservicio de Sincronización de Catálogo]\n",[471,4062,4063],{"class":473,"line":543},[471,4064,3415],{},[471,4066,4067],{"class":473,"line":549},[471,4068,3420],{},[471,4070,4071],{"class":473,"line":555},[471,4072,504],{"emptyLinePlaceholder":292},[471,4074,4075],{"class":473,"line":561},[471,4076,3429],{},[471,4078,4079],{"class":473,"line":567},[471,4080,504],{"emptyLinePlaceholder":292},[471,4082,4083],{"class":473,"line":573},[471,4084,4085],{},"  subgraph APIs[APIs de Plataforma]\n",[471,4087,4088],{"class":473,"line":579},[471,4089,4090],{},"    PROC[Process API\\nCatálogo, precios, etiquetas, stock]\n",[471,4092,4093],{"class":473,"line":585},[471,4094,4095],{},"    APP[App API\\nPedidos, perfil, direcciones]\n",[471,4097,4098],{"class":473,"line":591},[471,4099,3415],{},[471,4101,4102],{"class":473,"line":597},[471,4103,3457],{},[471,4105,4106],{"class":473,"line":603},[471,4107,504],{"emptyLinePlaceholder":292},[471,4109,4110],{"class":473,"line":3464},[471,4111,4112],{},"  subgraph Clients[Superficie del Cliente]\n",[471,4114,4115],{"class":473,"line":3470},[471,4116,4117],{},"    PWA[Tienda PWA]\n",[471,4119,4120],{"class":473,"line":3476},[471,4121,4122],{},"    MOB[App móvil]\n",[471,4124,4125],{"class":473,"line":3482},[471,4126,3415],{},[471,4128,4129],{"class":473,"line":3487},[471,4130,3490],{},[471,4132,4133],{"class":473,"line":3493},[471,4134,3496],{},[471,4136,4137],{"class":473,"line":3499},[471,4138,3502],{},[471,4140,4141],{"class":473,"line":3505},[471,4142,504],{"emptyLinePlaceholder":292},[471,4144,4145],{"class":473,"line":3510},[471,4146,3513],{},[471,4148,4149],{"class":473,"line":3516},[471,4150,3519],{},[471,4152,4153],{"class":473,"line":3522},[471,4154,3525],{},[471,4156,4157],{"class":473,"line":3528},[471,4158,3531],{},[471,4160,4161],{"class":473,"line":3534},[471,4162,3537],{},[471,4164,4165],{"class":473,"line":3540},[471,4166,3543],{},[471,4168,4169],{"class":473,"line":3546},[471,4170,3549],{},[471,4172,4173],{"class":473,"line":3552},[471,4174,3555],{},[471,4176,4177],{"class":473,"line":3558},[471,4178,3561],{},[471,4180,4181],{"class":473,"line":3564},[471,4182,3567],{},[136,4184,171],{"id":170},[612,4186,4188],{"id":4187},"decisión-1-entregar-una-pwa-primero-las-aplicaciones-nativas-después","Decisión 1 — Entregar una PWA primero, las aplicaciones nativas después",[141,4190,4191],{},"El camino más rápido a una pantalla de teléfono no era un binario de iOS o Android, sino una aplicación web progresiva que los clientes pudieran instalar desde un enlace.",[173,4193,4194,4204],{},[176,4195,4196],{},[179,4197,4198,4200,4202],{},[182,4199,1344],{},[182,4201,187],{},[182,4203,190],{},[192,4205,4206,4216],{},[179,4207,4208,4211,4214],{},[197,4209,4210],{},"Construir aplicaciones nativas de iOS + Android desde el primer día",[197,4212,4213],{},"Meses de trabajo específico por plataforma; revisión de la tienda de aplicaciones en cada cambio",[197,4215],{},[179,4217,4218,4221,4224],{},[197,4219,4220],{},"Entregar una PWA a partir del código Angular existente, envolverla como aplicaciones móviles después",[197,4222,4223],{},"Superficie web disponible en días, no meses; un solo código que mantener",[197,4225,4226],{},[216,4227],{},[141,4229,4230],{},"Las aplicaciones móviles vinieron después, sobre la base que la PWA ya había probado.",[612,4232,4234],{"id":4233},"decisión-2-construir-la-ruta-de-despliegue-antes-de-terminar-la-funcionalidad","Decisión 2 — Construir la ruta de despliegue antes de terminar la funcionalidad",[141,4236,4237],{},"Por tentador que fuera perseguir el backlog de funcionalidad, el cuello de botella no eran las funcionalidades: era que nada se podía desplegar de forma confiable.",[173,4239,4240,4250],{},[176,4241,4242],{},[179,4243,4244,4246,4248],{},[182,4245,1344],{},[182,4247,187],{},[182,4249,190],{},[192,4251,4252,4262],{},[179,4253,4254,4257,4260],{},[197,4255,4256],{},"Desplegar a mano hasta el lanzamiento y automatizar después",[197,4258,4259],{},"Más rápido el primer día; cada despliegue posterior cuesta la misma hora",[197,4261],{},[179,4263,4264,4267,4270],{},[197,4265,4266],{},"Levantar primero un registro Docker privado y un pipeline de CI/CD ligero",[197,4268,4269],{},"Dos semanas de trabajo de plataforma antes de entregar cualquier funcionalidad; cada despliegue posterior salía gratis",[197,4271,4272],{},[216,4273],{},[141,4275,4276],{},"El mismo principio que en otros trabajos de rescate: estabiliza el runtime antes de perseguir la hoja de ruta. El pipeline sobrevivió al proyecto: el equipo que vino después lo siguió usando para los servicios en Node.js y Angular que llegaron luego.",[612,4278,4280],{"id":4279},"decisión-3-tratar-la-segmentación-de-clientes-como-una-misión-secundaria-pequeña-y-útil","Decisión 3 — Tratar la segmentación de clientes como una misión secundaria pequeña y útil",[141,4282,4283,4284,4287],{},"Marketing quería saber a qué clientes hablar y cuándo. Hice un ",[331,4285,4286],{},"análisis RFM"," (Recencia, Frecuencia, Monto) sobre los datos de pedidos que la nueva plataforma ya estaba recogiendo.",[173,4289,4290,4300],{},[176,4291,4292],{},[179,4293,4294,4296,4298],{},[182,4295,1344],{},[182,4297,187],{},[182,4299,190],{},[192,4301,4302,4312],{},[179,4303,4304,4307,4310],{},[197,4305,4306],{},"Esperar a que exista un equipo de datos dedicado",[197,4308,4309],{},"Meses esperando un insight que el negocio necesitaba ya",[197,4311],{},[179,4313,4314,4317,4320],{},[197,4315,4316],{},"Correr RFM sobre los datos existentes y entregar los segmentos a marketing",[197,4318,4319],{},"Ligero, útil de inmediato, y demostró que los datos de la nueva plataforma valían algo",[197,4321,4322],{},[216,4323],{},[141,4325,4326],{},"No era una \"plataforma de datos\". Era un análisis de un solo ingeniero que se pagó solo la primera semana en que marketing lo usó.",[612,4328,4330],{"id":4329},"decisión-4-automatizar-la-ingesta-de-la-tasa-de-cambio-en-vez-de-teclearla","Decisión 4 — Automatizar la ingesta de la tasa de cambio en vez de teclearla",[141,4332,4333],{},"Fijar precios en Venezuela depende de la tasa de cambio oficial, que alguien del equipo copiaba y pegaba a diario. La carga manual de la tasa era propensa a errores y estaba por debajo del nivel de cualquiera que la hiciera.",[173,4335,4336,4346],{},[176,4337,4338],{},[179,4339,4340,4342,4344],{},[182,4341,1344],{},[182,4343,187],{},[182,4345,190],{},[192,4347,4348,4358],{},[179,4349,4350,4353,4356],{},[197,4351,4352],{},"Seguir haciéndolo a mano",[197,4354,4355],{},"Gratis hasta que deja de serlo: un solo error de tecleo mueve los precios de todo un catálogo",[197,4357],{},[179,4359,4360,4363,4366],{},[197,4361,4362],{},"Extraer la tasa oficial de forma programada e inyectarla en la plataforma",[197,4364,4365],{},"Un pequeño job de ingesta; elimina una tarea diaria y una clase de bugs de precios",[197,4367,4368],{},[216,4369],{},[141,4371,4372],{},"Esta es la semilla del trabajo del scraper del BCV sobre el que después escribí públicamente. Las automatizaciones pequeñas como esta suelen pagarse rápido, sobre todo porque eliminan un modo de fallo humano recurrente y no tanto porque ahorren tiempo directamente.",[136,4374,243],{"id":242},[141,4376,4377],{},"Al final del trabajo:",[156,4379,4380,4386,4393,4400],{},[159,4381,4382,4383,2717],{},"La plataforma se entregó como ",[331,4384,4385],{},"PWA + aplicaciones móviles usadas por una base de clientes de alto volumen",[159,4387,4388,4389,4392],{},"La empresa quedó con un ",[331,4390,4391],{},"registro Docker privado y un pipeline de CI/CD funcionando"," para servicios en Node.js y Angular: la base de todo lo que vino después.",[159,4394,4395,4396,4399],{},"Marketing tenía ",[331,4397,4398],{},"segmentos de clientes basados en RFM"," sobre los que podía actuar, sacados de los propios datos de la plataforma.",[159,4401,4402,4403,4406],{},"Los precios corrían sobre un ",[331,4404,4405],{},"feed automático de la tasa de cambio"," en vez de un copiar y pegar diario.",[141,4408,4409],{},"Mirándolo en retrospectiva, la secuencia importó más que el número de personas: tener la ruta de despliegue en su sitio antes de perseguir el backlog de funcionalidad es lo que hizo que el resto fuera entregable por un equipo pequeño.",[136,4411,270],{"id":269},[141,4413,4414],{},"El mismo encargo hoy, el mismo tamaño de equipo, las mismas restricciones. Los instintos no cambiarían; los objetivos sí.",[156,4416,4417,4425,4430,4435,4441,4447],{},[159,4418,4419,4421,4422,4424],{},[331,4420,3806],{}," entregar la PWA igual, pero alojarla en ",[331,4423,3810],{}," (o cualquier host estático gestionado con CDN en el edge). Rendimiento de Lighthouse como puerta en el CI, no como ocurrencia tardía.",[159,4426,4427,4429],{},[331,4428,3816],{}," los mismos servicios en Node.js sobre un runtime de contenedores gestionado, sin registry privado que correr y parchear.",[159,4431,4432,4434],{},[331,4433,3822],{}," un entorno de vista previa por cada PR conectado al CI. El pipeline que armé a mano en 2022 hoy son unas diez líneas de YAML, más o menos.",[159,4436,4437,4440],{},[331,4438,4439],{},"RFM y segmentación:"," aterrizar los pedidos en un almacén de analítica gestionado vía un job programado, correr la consulta de RFM como una vista programada y mostrarle los segmentos a marketing en un tablero en vez de un CSV. El mismo análisis, sin un ingeniero en el circuito después de la primera semana.",[159,4442,4443,4446],{},[331,4444,4445],{},"Ingesta de la tasa de cambio:"," job programado → cola gestionada → almacén de analítica + la caché del storefront. El scraper sigue corriendo; todo lo que lo rodea deja de ser un cron en una sola VM.",[159,4448,4449,4452],{},[331,4450,4451],{},"Aplicaciones móviles:"," Capacitor o PWABuilder envolviendo la misma PWA, firmadas y publicadas con despliegues por etapas.",[141,4454,4455],{},"La forma del trabajo se mantiene —ruta de despliegue primero, superficie de cliente después, y las automatizaciones más pequeñas luego—. Una plataforma gestionada simplemente elimina buena parte de la plomería que antes era parte del trabajo.",[1024,4457],{},[141,4459,4460],{},[356,4461,4462,4463,2717],{},"Si estás pasando por algo parecido y quieres un segundo par de ojos, con gusto lo conversamos — ",[320,4464,4465],{"href":1732},"escríbeme aquí",[1041,4467,1043],{},{"title":275,"searchDepth":276,"depth":276,"links":4469},[4470,4471,4472,4473,4474,4480,4481],{"id":138,"depth":276,"text":139},{"id":146,"depth":276,"text":147},{"id":153,"depth":276,"text":154},{"id":1216,"depth":276,"text":1217},{"id":170,"depth":276,"text":171,"children":4475},[4476,4477,4478,4479],{"id":4187,"depth":291,"text":4188},{"id":4233,"depth":291,"text":4234},{"id":4279,"depth":291,"text":4280},{"id":4329,"depth":291,"text":4330},{"id":242,"depth":276,"text":243},{"id":269,"depth":276,"text":270},"Cadena de supermercados (LatAm)","Cómo un proyecto de e-commerce en Node.js + Angular que estaba estancado se diagnosticó, se recuperó y se entregó de punta a punta como PWA y aplicaciones móviles para una base de clientes de alto volumen, con un registro Docker y un pipeline de CI/CD que hicieron posible la entrega continua para el equipo que vino después.\n",{},"Recuperé un proyecto heredado y lo entregué a una base de clientes de alto volumen","Programador",{"title":97,"description":4483},[3880,3881,3882,3883,3884],[3886,3887,3888],"YQ4hM4ipebaDLi7WUH6-Un3aae6hHfeYZlnxXXi0AmI",{"id":4492,"title":52,"author":4493,"body":4495,"category":2744,"client":285,"date":4630,"description":4631,"extension":288,"featured":38,"hidden":38,"image":4632,"locale":1063,"meta":4633,"minRead":489,"navigation":292,"ogImage":285,"outcome_headline":285,"path":53,"repoUrl":285,"role":285,"seo":4634,"stack":285,"stem":54,"tags":4635,"team_size":285,"type":285,"year":285,"__hash__":4637},"blog/blog/intern-movie-torrent.md",{"name":127,"username":128,"to":129,"avatar":4494},{"src":131,"alt":127},{"type":133,"value":4496,"toc":4626},[4497,4503,4506,4509,4516,4520,4523,4543,4547,4556,4560,4567,4596,4605,4609,4619],[141,4498,4499],{},[2494,4500],{"alt":4501,"src":4502},"Pie chart of types of torrents","/blog/intern-movie-torrent/venezuela_torrent.png",[4504,4505,1773],"h1",{"id":1772},[141,4507,4508],{},"Venezuela has a bandwidth problem, and plenty of people will stretch whatever connection is available—especially the internet at work.",[141,4510,4511,4512,4515],{},"What happens when you let tech-savvy people stay online for eight hours a day? I call it ",[331,4513,4514],{},"The Intern is downloading movies"," problem.",[4504,4517,4519],{"id":4518},"solution","Solution",[141,4521,4522],{},"I Know What You Download is a monitoring tool that allows you to check if someone is downloading torrents. Here’s how to use it:",[2509,4524,4525,4533,4536],{},[159,4526,4527,4528,2717],{},"Visit ",[320,4529,4532],{"href":4530,"rel":4531},"https://iknowwhatyoudownload.com/en/peer/",[324],"iknowwhatyoudownload.com",[159,4534,4535],{},"On the homepage, you’ll see your current external IP address.",[159,4537,4538,4539],{},"For an organization-wide check, review the results page to see whether any torrents are being downloaded from that IP (you can test other addresses if you’re unsure).\n",[2494,4540],{"alt":4541,"src":4542},"Home page of the tool","/blog/intern-movie-torrent/venezuela_torrent2.png",[136,4544,4546],{"id":4545},"for-tracking-other-people","For tracking other people",[141,4548,4549,4550,4555],{},"You can ",[320,4551,4554],{"href":4552,"rel":4553},"https://iknowwhatyoudownload.com/en/link/",[324],"create a link"," and send it; the tracker logs activity as soon as someone clicks.",[136,4557,4559],{"id":4558},"for-tracking-peers","For tracking peers",[141,4561,4562,4563,4566],{},"If you have the ",[468,4564,4565],{},".torrent"," file you can extract peers and seeds from your torrent client (e.g., qBittorrent).",[2509,4568,4569,4572,4587],{},[159,4570,4571],{},"Copy the selected peer or host (or everything if you have time).",[159,4573,4574,4575,4580,4581,4586],{},"Convert the host address using an IP lookup tool like ",[320,4576,4579],{"href":4577,"rel":4578},"https://www.ip-tracker.org/",[324],"ip-tracker.org"," or ",[320,4582,4585],{"href":4583,"rel":4584},"https://whois.domaintools.com/",[324],"whois.domaintools.com"," if it’s not listed.",[159,4588,4589,4590,4595],{},"Use ",[320,4591,4594],{"href":4592,"rel":4593},"https://iphub.info/",[324],"iphub.info"," to determine whether the IP is a proxy or a residential address.",[141,4597,4598,4599,4604],{},"Note that VPNs and proxies may give noisy results, as explained in the ",[320,4600,4603],{"href":4601,"rel":4602},"https://iknowwhatyoudownload.com/en/contacts/",[324],"FAQ",". Still, it’s rare to see premium torrent VPNs inside organizations in Venezuela because perimeter firewalls like Fortinet often block that traffic.",[4504,4606,4608],{"id":4607},"references","References",[141,4610,4611,4612,343,4616],{},"Photo by ",[320,4613,4615],{"href":4614},"https://unsplash.com/@jsshotz?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash","Jorge Salvador",[320,4617,2109],{"href":4618},"https://unsplash.com/photos/black-and-white-satellite-dish-wjMMVxy8C0g?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[141,4620,4621],{},[320,4622,4625],{"href":4623,"rel":4624},"https://torrentfreak.com/i-know-what-you-download-overwhelmed-by-bogus-dmca-notices-221023/",[324],"Torrent Freak",{"title":275,"searchDepth":276,"depth":276,"links":4627},[4628,4629],{"id":4545,"depth":276,"text":4546},{"id":4558,"depth":276,"text":4559},"2024-05-16T00:00:00.000Z","What happens when you give tech-savvy people eight hours of internet access a day? Welcome to the The Intern is downloading movies again problem.","/blog/intern-movie-torrent/featuredImage.jpg",{},{"title":52,"description":4631},[4636],"venezuela","8UWPqUBCQRgqySh8NQNtPZZise6ROjZ91B6MmDcJ8yA",{"id":4639,"title":113,"author":4640,"body":4642,"category":2744,"client":285,"date":4630,"description":4757,"extension":288,"featured":38,"hidden":38,"image":4632,"locale":66,"meta":4758,"minRead":489,"navigation":292,"ogImage":285,"outcome_headline":285,"path":114,"repoUrl":285,"role":285,"seo":4759,"stack":285,"stem":115,"tags":4760,"team_size":285,"type":285,"year":285,"__hash__":4761},"blog/es/blog/intern-movie-torrent.md",{"name":127,"username":128,"to":129,"avatar":4641},{"src":131,"alt":127},{"type":133,"value":4643,"toc":4753},[4644,4649,4651,4654,4660,4664,4667,4684,4688,4696,4700,4706,4729,4737,4741,4748],[141,4645,4646],{},[2494,4647],{"alt":4648,"src":4502},"Gráfico circular de tipos de torrents",[4504,4650,2149],{"id":2148},[141,4652,4653],{},"Venezuela tiene un problema de ancho de banda, y mucha gente va a estirar cualquier conexión que tenga a mano, sobre todo el internet del trabajo.",[141,4655,4656,4657,2717],{},"¿Qué pasa cuando dejas que gente con conocimientos técnicos se quede en línea ocho horas al día? Yo lo llamo el problema de ",[331,4658,4659],{},"El becario está descargando películas",[4504,4661,4663],{"id":4662},"solución","Solución",[141,4665,4666],{},"I Know What You Download es una herramienta de monitoreo que te permite revisar si alguien está descargando torrents. Así se usa:",[2509,4668,4669,4675,4678],{},[159,4670,4671,4672,2717],{},"Visita ",[320,4673,4532],{"href":4530,"rel":4674},[324],[159,4676,4677],{},"En la página principal verás tu dirección IP externa actual.",[159,4679,4680,4681],{},"Para revisar toda la organización, mira la página de resultados y comprueba si se están descargando torrents desde esa IP (puedes probar otras direcciones si tienes dudas).\n",[2494,4682],{"alt":4683,"src":4542},"Página principal de la herramienta",[136,4685,4687],{"id":4686},"para-rastrear-a-otras-personas","Para rastrear a otras personas",[141,4689,4690,4691,4695],{},"Puedes ",[320,4692,4694],{"href":4552,"rel":4693},[324],"crear un enlace"," y enviarlo; el rastreador registra la actividad en cuanto alguien hace clic.",[136,4697,4699],{"id":4698},"para-rastrear-pares","Para rastrear pares",[141,4701,4702,4703,4705],{},"Si tienes el archivo ",[468,4704,4565],{}," puedes extraer los pares y las semillas desde tu cliente de torrents (por ejemplo, qBittorrent).",[2509,4707,4708,4711,4722],{},[159,4709,4710],{},"Copia el par o host seleccionado (o todo, si tienes tiempo).",[159,4712,4713,4714,4717,4718,4721],{},"Convierte la dirección del host con una herramienta de búsqueda de IP como ",[320,4715,4579],{"href":4577,"rel":4716},[324]," o ",[320,4719,4585],{"href":4583,"rel":4720},[324]," si no aparece listada.",[159,4723,4724,4725,4728],{},"Usa ",[320,4726,4594],{"href":4592,"rel":4727},[324]," para determinar si la IP es un proxy o una dirección residencial.",[141,4730,4731,4732,4736],{},"Ten en cuenta que las VPN y los proxies pueden dar resultados ruidosos, como se explica en las ",[320,4733,4735],{"href":4601,"rel":4734},[324],"preguntas frecuentes",". Aun así, es raro ver VPN premium para torrents dentro de organizaciones en Venezuela, porque los cortafuegos perimetrales como Fortinet suelen bloquear ese tráfico.",[4504,4738,4740],{"id":4739},"referencias","Referencias",[141,4742,4743,4744,2452,4746],{},"Foto de ",[320,4745,4615],{"href":4614},[320,4747,2109],{"href":4618},[141,4749,4750],{},[320,4751,4625],{"href":4623,"rel":4752},[324],{"title":275,"searchDepth":276,"depth":276,"links":4754},[4755,4756],{"id":4686,"depth":276,"text":4687},{"id":4698,"depth":276,"text":4699},"¿Qué pasa cuando le das a gente con conocimientos técnicos ocho horas de internet al día? Bienvenido al problema de El becario está descargando películas.",{},{"title":113,"description":4757},[4636],"sPfToG45XZi2UqcF0YgX_psTOlL2QvTJ2Yx1FDP0BC0",{"id":4763,"title":48,"author":4764,"body":4766,"category":284,"client":285,"date":4937,"description":4938,"extension":288,"featured":38,"hidden":38,"image":4939,"locale":1063,"meta":4940,"minRead":489,"navigation":292,"ogImage":285,"outcome_headline":4941,"path":49,"repoUrl":4784,"role":2130,"seo":4942,"stack":4943,"stem":50,"tags":4945,"team_size":303,"type":284,"year":4946,"__hash__":4947},"blog/blog/from-nuxt-to-hugo.md",{"name":127,"username":128,"to":129,"avatar":4765},{"src":131,"alt":127},{"type":133,"value":4767,"toc":4930},[4768,4770,4772,4775,4777,4780,4787,4790,4794,4798,4835,4839,4871,4875,4907,4919,4922,4924,4927],[4504,4769,1773],{"id":1772},[136,4771,139],{"id":138},[141,4773,4774],{},"I migrated this site from Nuxt 2 to Hugo to reduce complexity and improve build speed (static generation). Later I realized I missed component reuse and content flexibility, so I moved back to Nuxt 4. The lesson: understand the trade-offs between static generation and component systems, and don't over-engineer until the problem is real.",[136,4776,351],{"id":350},[141,4778,4779],{},"I originally migrated this site from Nuxt 2 to Hugo to simplify a content‑first build and reduce runtime complexity. Later, I moved back to Nuxt to regain a richer component system, content tooling, and a more flexible UI layer.",[141,4781,4782],{},[320,4783,4786],{"href":4784,"rel":4785},"https://github.com/ivanovertime/alvarezivan.net",[324],"Check the repo",[141,4788,4789],{},"This post documents that round‑trip and highlights an additional goal: exploring Cloudflare Pages as the hosting target, including build limits, cache behavior, and deployment ergonomics.",[4504,4791,4793],{"id":4792},"what-changed","What changed",[136,4795,4797],{"id":4796},"decisions-made","Decisions Made",[173,4799,4800,4810],{},[176,4801,4802],{},[179,4803,4804,4806,4808],{},[182,4805,3033],{},[182,4807,630],{},[182,4809,633],{},[192,4811,4812,4823],{},[179,4813,4814,4817,4820],{},[197,4815,4816],{},"Use Hugo for this site",[197,4818,4819],{},"Fast builds, simple deployment; limited component reuse and markdown composition",[197,4821,4822],{},"(Temporary)",[179,4824,4825,4828,4831],{},[197,4826,4827],{},"Return to Nuxt 4 + Nuxt Content",[197,4829,4830],{},"More flexible, better for rich components; slightly heavier build; component-first instead of static-first",[197,4832,4833],{},[216,4834],{},[136,4836,4838],{"id":4837},"what-changed-1","What Changed",[2509,4840,4841,4851,4861],{},[159,4842,4843,4846],{},[331,4844,4845],{},"Content model",[156,4847,4848],{},[159,4849,4850],{},"Hugo proved fast and lean for static content, but the authoring workflow and UI composition were more constrained than I wanted.",[159,4852,4853,4856],{},[331,4854,4855],{},"Rendering strategy",[156,4857,4858],{},[159,4859,4860],{},"Moving back to Nuxt restored dynamic layouts, component reuse, and flexible data sourcing without sacrificing static performance.",[159,4862,4863,4866],{},[331,4864,4865],{},"Deploy & hosting",[156,4867,4868],{},[159,4869,4870],{},"Cloudflare Pages made preview deployments frictionless, with simple caching rules and edge delivery that fit a static‑first site.",[4504,4872,4874],{"id":4873},"tech-used","Tech used",[156,4876,4877,4883,4889,4895,4901],{},[159,4878,4879,4882],{},[331,4880,4881],{},"Frameworks:"," Hugo, Nuxt (Vue)",[159,4884,4885,4888],{},[331,4886,4887],{},"Content:"," Markdown with front matter",[159,4890,4891,4894],{},[331,4892,4893],{},"Styling:"," Tailwind CSS",[159,4896,4897,4900],{},[331,4898,4899],{},"Hosting:"," Cloudflare Pages",[159,4902,4903,4906],{},[331,4904,4905],{},"Tooling:"," pnpm, GitHub Actions",[2486,4908,4909],{},[141,4910,4911,4912,4915,4916,4918],{},"It's not DNS ",[4913,4914],"br",{},"\nIt cannot be DNS ",[4913,4917],{},"\nIt was DNS",[141,4920,4921],{},"Good luck with your migration! If you hit a snag, it's probably DNS—check it anyway.",[136,4923,924],{"id":923},[141,4925,4926],{},"The lesson wasn't about Hugo vs. Nuxt — it was about understanding the trade-offs. Hugo was the right tool for a content-first static site when the authoring workflow and deployment needed to be simple. Later, when I wanted richer components and more flexible content sourcing, Nuxt's component system and Nuxt Content justified the added complexity.",[141,4928,4929],{},"The hosted outcome: faster lighthouse scores, simpler preview deployments on Cloudflare Pages, and the freedom to embed rich Vue components directly in markdown when it makes sense. No over-engineering, just the right tool at the right moment.",{"title":275,"searchDepth":276,"depth":276,"links":4931},[4932,4933,4934,4935,4936],{"id":138,"depth":276,"text":139},{"id":350,"depth":276,"text":351},{"id":4796,"depth":276,"text":4797},{"id":4837,"depth":276,"text":4838},{"id":923,"depth":276,"text":924},"2024-05-12T00:00:00.000Z","Why I migrated this site from Nuxt 2 to Hugo and later returned to Nuxt—what changed, what stayed, and the decisions that kept the rebuild manageable.","/blog/from-nuxt-to-hugo/featuredImage.png",{},"Learned the right moment to reach for static, and the value of component systems when they justify their weight",{"title":48,"description":4938},[4944,298,299,300,3810],"Hugo",[302],2024,"u6oxGv7YkeO4q1V21oATyN58T9WA92EFAFDl7-WVcIY",{"id":4949,"title":109,"author":4950,"body":4952,"category":284,"client":285,"date":4937,"description":5115,"extension":288,"featured":38,"hidden":38,"image":4939,"locale":66,"meta":5116,"minRead":489,"navigation":292,"ogImage":285,"outcome_headline":5117,"path":110,"repoUrl":4784,"role":295,"seo":5118,"stack":5119,"stem":111,"tags":5120,"team_size":303,"type":284,"year":4946,"__hash__":5121},"blog/es/blog/from-nuxt-to-hugo.md",{"name":127,"username":128,"to":129,"avatar":4951},{"src":131,"alt":127},{"type":133,"value":4953,"toc":5108},[4954,4956,4958,4961,4963,4966,4972,4975,4979,4983,5020,5024,5056,5060,5086,5097,5100,5102,5105],[4504,4955,2149],{"id":2148},[136,4957,139],{"id":138},[141,4959,4960],{},"Migré este sitio de Nuxt 2 a Hugo para reducir complejidad y mejorar la velocidad de build (generación estática). Después me di cuenta de que echaba en falta reutilizar componentes y la flexibilidad del contenido, así que volví a Nuxt 4. La lección: entiende las compensaciones entre generación estática y sistemas de componentes, y no sobre-ingenierices hasta que el problema sea real.",[136,4962,147],{"id":146},[141,4964,4965],{},"Originalmente migré este sitio de Nuxt 2 a Hugo para simplificar un build centrado en contenido y reducir la complejidad en tiempo de ejecución. Después volví a Nuxt para recuperar un sistema de componentes más rico, mejores herramientas de contenido y una capa de UI más flexible.",[141,4967,4968],{},[320,4969,4971],{"href":4784,"rel":4970},[324],"Mira el repositorio",[141,4973,4974],{},"Este artículo documenta ese ida y vuelta y resalta un objetivo adicional: explorar Cloudflare Pages como destino de hosting, incluyendo los límites de build, el comportamiento de la caché y la ergonomía del despliegue.",[4504,4976,4978],{"id":4977},"qué-cambió","Qué cambió",[136,4980,4982],{"id":4981},"decisiones-tomadas","Decisiones tomadas",[173,4984,4985,4995],{},[176,4986,4987],{},[179,4988,4989,4991,4993],{},[182,4990,184],{},[182,4992,187],{},[182,4994,190],{},[192,4996,4997,5008],{},[179,4998,4999,5002,5005],{},[197,5000,5001],{},"Usar Hugo para este sitio",[197,5003,5004],{},"Builds rápidos, despliegue simple; poca reutilización de componentes y poca composición de markdown",[197,5006,5007],{},"(Temporal)",[179,5009,5010,5013,5016],{},[197,5011,5012],{},"Volver a Nuxt 4 + Nuxt Content",[197,5014,5015],{},"Más flexible, mejor para componentes ricos; build algo más pesado; primero componentes en vez de primero estático",[197,5017,5018],{},[216,5019],{},[136,5021,5023],{"id":5022},"los-cambios","Los cambios",[2509,5025,5026,5036,5046],{},[159,5027,5028,5031],{},[331,5029,5030],{},"Modelo de contenido",[156,5032,5033],{},[159,5034,5035],{},"Hugo resultó rápido y ligero para contenido estático, pero el flujo de escritura y la composición de UI eran más limitados de lo que quería.",[159,5037,5038,5041],{},[331,5039,5040],{},"Estrategia de renderizado",[156,5042,5043],{},[159,5044,5045],{},"Volver a Nuxt recuperó los layouts dinámicos, la reutilización de componentes y un origen de datos flexible, sin sacrificar el rendimiento estático.",[159,5047,5048,5051],{},[331,5049,5050],{},"Despliegue y hosting",[156,5052,5053],{},[159,5054,5055],{},"Cloudflare Pages hizo que los despliegues de vista previa no dieran fricción, con reglas de caché simples y entrega desde el edge que le sentaban bien a un sitio primero estático.",[4504,5057,5059],{"id":5058},"tecnologías-usadas","Tecnologías usadas",[156,5061,5062,5066,5072,5077,5081],{},[159,5063,5064,4882],{},[331,5065,4881],{},[159,5067,5068,5071],{},[331,5069,5070],{},"Contenido:"," Markdown con front matter",[159,5073,5074,4894],{},[331,5075,5076],{},"Estilos:",[159,5078,5079,4900],{},[331,5080,4899],{},[159,5082,5083,4906],{},[331,5084,5085],{},"Herramientas:",[2486,5087,5088],{},[141,5089,5090,5091,5093,5094,5096],{},"No es DNS ",[4913,5092],{},"\nNo puede ser DNS ",[4913,5095],{},"\nEra DNS",[141,5098,5099],{},"¡Suerte con tu migración! Si te topas con un obstáculo, probablemente sea DNS — revísalo igual.",[136,5101,243],{"id":242},[141,5103,5104],{},"La lección no era Hugo contra Nuxt, sino entender las compensaciones. Hugo era la herramienta correcta para un sitio estático centrado en contenido, cuando el flujo de escritura y el despliegue tenían que ser simples. Después, cuando quise componentes más ricos y un origen de contenido más flexible, el sistema de componentes de Nuxt y Nuxt Content justificaron la complejidad añadida.",[141,5106,5107],{},"El resultado en producción: mejores puntajes de Lighthouse, despliegues de vista previa más simples en Cloudflare Pages, y la libertad de incrustar componentes Vue ricos directamente en markdown cuando tiene sentido. Sin sobre-ingeniería, solo la herramienta correcta en el momento correcto.",{"title":275,"searchDepth":276,"depth":276,"links":5109},[5110,5111,5112,5113,5114],{"id":138,"depth":276,"text":139},{"id":146,"depth":276,"text":147},{"id":4981,"depth":276,"text":4982},{"id":5022,"depth":276,"text":5023},{"id":242,"depth":276,"text":243},"Por qué migré este sitio de Nuxt 2 a Hugo y después volví a Nuxt: qué cambió, qué se mantuvo y las decisiones que hicieron manejable la reconstrucción.",{},"Aprendí cuándo conviene ir a por lo estático, y el valor de los sistemas de componentes cuando justifican su peso",{"title":109,"description":5115},[4944,298,299,300,3810],[302],"hu9pRhuloemXWkoyCw-y7m_Y57_pdU3y2l1cFjUZ7qo",{"id":5123,"title":10,"author":5124,"body":5126,"category":284,"client":285,"date":5399,"description":5400,"extension":288,"featured":38,"hidden":38,"image":5401,"locale":1063,"meta":5402,"minRead":507,"navigation":292,"ogImage":285,"outcome_headline":5403,"path":11,"repoUrl":5305,"role":2130,"seo":5404,"stack":5405,"stem":12,"tags":5410,"team_size":303,"type":284,"year":4946,"__hash__":5412},"blog/blog/bcv-scraper.md",{"name":127,"username":128,"to":129,"avatar":5125},{"src":131,"alt":127},{"type":133,"value":5127,"toc":5385},[5128,5130,5132,5135,5137,5151,5165,5174,5188,5191,5202,5211,5213,5217,5220,5256,5259,5263,5267,5276,5280,5283,5286,5300,5308,5311,5313,5315,5318,5321,5332],[136,5129,1773],{"id":1772},[136,5131,139],{"id":138},[141,5133,5134],{},"Venezuelan e-commerce platforms are required by law to invoice in bolívares while most catalog pricing stays in USD. Manual daily exchange-rate entry is error-prone and not scalable. This post walks through the scraping approach, a simple FastAPI microservice that can be deployed anywhere, and when you'd actually use it vs. a third-party rate API.",[136,5136,351],{"id":350},[141,5138,5139,5140,5150],{},"Venezuela's e-commerce sector is expected to grow thanks to increasing internet penetration, an evolving legal framework, and banks that now provide more secure transactions ",[5141,5142,5143],"sup",{},[320,5144,5149],{"href":5145,"ariaDescribedBy":5146,"dataFootnoteRef":275,"id":5148},"#user-content-fn-1",[5147],"footnote-label","user-content-fnref-1","1",". After the pandemic, business boomed; the law requires invoices in the national currency (Bs.), but most products and transactions are advertised in USD. Let's look at two examples:",[141,5152,5153,5156,5157,2717],{},[331,5154,5155],{},"Librería La Alegría"," is a bookstore and stationery store that offers over 15,000 products related to office supplies, school items, art supplies, technical and school books, games, and more. The store also provides a cultural center for the city with workshops for children and events ",[5141,5158,5159],{},[320,5160,5164],{"href":5161,"ariaDescribedBy":5162,"dataFootnoteRef":275,"id":5163},"#user-content-fn-2",[5147],"user-content-fnref-2","2",[141,5166,5167,5168,5173],{},"Its tech stack is an Odoo instance. There's no mobile app and billing happens directly in-store. A ",[320,5169,5172],{"href":5170,"rel":5171},"https://apps.odoo.com/apps/modules/browse?search=BCV",[324],"quick search"," indicates there are three extensions and one of them is free, but it might not be open source—I couldn't find a repo.",[141,5175,5176,5179,5180,2717],{},[331,5177,5178],{},"Tu Zona Market"," is an online marketplace that connects local vendors with customers. The platform offers a range of products, including groceries, electronics, and home appliances, and provides a seamless shopping experience in two states ",[5141,5181,5182],{},[320,5183,5187],{"href":5184,"ariaDescribedBy":5185,"dataFootnoteRef":275,"id":5186},"#user-content-fn-3",[5147],"user-content-fnref-3","3",[141,5189,5190],{},"They appear to use Angular with what looks like an Express backend, although I can't be certain. To stay compliant they need a few things:",[2509,5192,5193,5196,5199],{},[159,5194,5195],{},"A web scraper: a program that extracts data from a website by parsing its HTML code.",[159,5197,5198],{},"RESTful APIs with minimal code and high performance.",[159,5200,5201],{},"Database storage for historical data (for returns, refunds, and the like).",[141,5203,5204,5205,5210],{},"If you use a service like ",[320,5206,5209],{"href":5207,"rel":5208},"https://www.exchangerate-api.com/",[324],"Exchange Rate API"," you can get the daily exchange rate, but you could get rate-limited quickly.",[136,5212,4519],{"id":4518},[136,5214,5216],{"id":5215},"decision-api-service-or-scheduled-job","Decision: API service or scheduled job?",[141,5218,5219],{},"You have two paths here:",[173,5221,5222,5232],{},[176,5223,5224],{},[179,5225,5226,5228,5230],{},[182,5227,627],{},[182,5229,630],{},[182,5231,633],{},[192,5233,5234,5244],{},[179,5235,5236,5239,5242],{},[197,5237,5238],{},"Third-party exchange rate API (e.g., exchangerate-api.com)",[197,5240,5241],{},"You pay per request or per month; rate-limited on free tier; adds a dependency",[197,5243],{},[179,5245,5246,5249,5252],{},[197,5247,5248],{},"Build your own scraper + microservice",[197,5250,5251],{},"Runs on your infrastructure; handles bursts free; full control of the rate feed",[197,5253,5254],{},[216,5255],{},[141,5257,5258],{},"For Venezuelan platforms, the scraper wins: the Banco Central publishes daily, regulation requires the official rate, and you avoid external rate limits. Deploy it on the infrastructure you already have.",[136,5260,5262],{"id":5261},"implementation","Implementation",[612,5264,5266],{"id":5265},"if-you-have-hardware","If you have hardware",[141,5268,5269,5270,5275],{},"Creating a web scraper and using it with ",[320,5271,5274],{"href":5272,"rel":5273},"https://www.crawlab.cn/en",[324],"crawlab"," as storage in MongoDB is a great solution.",[612,5277,5279],{"id":5278},"if-you-dont","If you don't",[141,5281,5282],{},"A simple web scraper built in Python with FastAPI can be useful for a software developer who wants to access and analyze the financial data from the official website of Banco Central de Venezuela.",[141,5284,5285],{},"By using a web scraper built in Python with FastAPI, you can:",[156,5287,5288,5291,5294,5297],{},[159,5289,5290],{},"Automate the process of fetching the latest exchange rates from the Banco Central de Venezuela website.",[159,5292,5293],{},"Convert the scraped data into JSON format, which can be stored, transmitted, and processed by various applications and tools.",[159,5295,5296],{},"Create a custom API that exposes the scraped data as endpoints, which can be accessed by other developers or users who need the data for their own purposes.",[159,5298,5299],{},"Apply data analysis, visualization, or machine learning techniques to the scraped data, using the rich set of libraries and packages available in Python.",[141,5301,5302,5303,2717],{},"I published a starter implementation in this ",[320,5304,5307],{"href":5305,"rel":5306},"https://github.com/ivanovertime/bcv_scraper",[324],"repo",[141,5309,5310],{},"Let me know if you have any questions.",[4504,5312,4608],{"id":4607},[136,5314,924],{"id":923},[141,5316,5317],{},"The BCV scraper is now published as an open-source starter repo. It eliminates a recurring manual task (daily copy-paste of exchange rates) and becomes a building block for any Venezuelan e-commerce or SaaS platform that needs rate compliance. Developers can fork it, customize it for their needs, and deploy it on the infrastructure they already have.",[136,5319,4608],{"id":5320},"references-1",[141,5322,4611,5323,343,5328],{},[320,5324,5327],{"href":5325,"rel":5326},"https://unsplash.com/@frederickjmedina?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[324],"Frederick Medina",[320,5329,2109],{"href":5330,"rel":5331},"https://unsplash.com/photos/person-holding-two-brown-doughnuts-LxyT2CgQSj8?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[324],[786,5333,5336,5341],{"className":5334,"dataFootnotes":275},[5335],"footnotes",[136,5337,5340],{"className":5338,"id":5147},[5339],"sr-only","Footnotes",[2509,5342,5343,5359,5372],{},[159,5344,5346,5347,5351,5352],{"id":5345},"user-content-fn-1","E-commerce in Venezuela. ",[320,5348,5349],{"href":5349,"rel":5350},"https://www.lloydsbanktrade.com/en/market-potential/venezuela/ecommerce",[324],". ",[320,5353,5358],{"href":5354,"ariaLabel":5355,"className":5356,"dataFootnoteBackref":275},"#user-content-fnref-1","Back to reference 1",[5357],"data-footnote-backref","↩",[159,5360,5362,5363,5351,5367],{"id":5361},"user-content-fn-2","Librería La Alegría. ",[320,5364,5365],{"href":5365,"rel":5366},"https://www.librerialaalegria.com.ve/",[324],[320,5368,5358],{"href":5369,"ariaLabel":5370,"className":5371,"dataFootnoteBackref":275},"#user-content-fnref-2","Back to reference 2",[5357],[159,5373,5375,5376,5351,5380],{"id":5374},"user-content-fn-3","Tu Zona Market. ",[320,5377,5378],{"href":5378,"rel":5379},"https://tuzonamarket.com/",[324],[320,5381,5358],{"href":5382,"ariaLabel":5383,"className":5384,"dataFootnoteBackref":275},"#user-content-fnref-3","Back to reference 3",[5357],{"title":275,"searchDepth":276,"depth":276,"links":5386},[5387,5388,5389,5390,5391,5392,5396,5397,5398],{"id":1772,"depth":276,"text":1773},{"id":138,"depth":276,"text":139},{"id":350,"depth":276,"text":351},{"id":4518,"depth":276,"text":4519},{"id":5215,"depth":276,"text":5216},{"id":5261,"depth":276,"text":5262,"children":5393},[5394,5395],{"id":5265,"depth":291,"text":5266},{"id":5278,"depth":291,"text":5279},{"id":923,"depth":276,"text":924},{"id":5320,"depth":276,"text":4608},{"id":5147,"depth":276,"text":5340},"2024-01-19T00:00:00.000Z","Venezuela has unique challenges for e-commerce. Local laws and tax regulations require invoices in bolívares while prices are quoted in USD. A simple Python and FastAPI scraper helps developers fetch the Banco Central de Venezuela exchange rate—here's when it makes sense and how to build it.","/blog/bcv-scraper/featuredImage.jpg",{},"Published an open-source scraper that solves daily manual exchange-rate entry for Venezuelan e-commerce teams",{"title":10,"description":5400},[5406,5407,5408,5409],"Python","FastAPI","Web Scraping","REST API",[4636,5411,3886],"integrations","nAsIppWOL_gx5iEFL-br03wVypnhbwX9jfwcFADVz5I",{"id":5414,"title":73,"author":5415,"body":5417,"category":284,"client":285,"date":5399,"description":5647,"extension":288,"featured":38,"hidden":38,"image":5401,"locale":66,"meta":5648,"minRead":507,"navigation":292,"ogImage":285,"outcome_headline":5649,"path":74,"repoUrl":5305,"role":295,"seo":5650,"stack":5651,"stem":75,"tags":5652,"team_size":303,"type":284,"year":4946,"__hash__":5653},"blog/es/blog/bcv-scraper.md",{"name":127,"username":128,"to":129,"avatar":5416},{"src":131,"alt":127},{"type":133,"value":5418,"toc":5633},[5419,5421,5423,5426,5428,5437,5447,5455,5465,5468,5479,5486,5488,5492,5495,5531,5534,5538,5542,5549,5553,5556,5559,5573,5580,5583,5585,5587,5590,5593,5601],[136,5420,2149],{"id":2148},[136,5422,139],{"id":138},[141,5424,5425],{},"Las plataformas de e-commerce venezolanas están obligadas por ley a facturar en bolívares, mientras la mayor parte del catálogo se cotiza en USD. Cargar la tasa de cambio a mano todos los días es propenso a errores y no escala. Este artículo recorre el enfoque de scraping, un microservicio simple en FastAPI que se puede desplegar en cualquier parte, y cuándo lo usarías de verdad frente a una API de tasas de terceros.",[136,5427,147],{"id":146},[141,5429,5430,5431,5436],{},"Se espera que el sector de e-commerce venezolano crezca gracias al aumento de la penetración de internet, un marco legal en evolución y bancos que ahora ofrecen transacciones más seguras ",[5141,5432,5433],{},[320,5434,5149],{"href":5145,"ariaDescribedBy":5435,"dataFootnoteRef":275,"id":5148},[5147],". Después de la pandemia, el negocio explotó; la ley exige facturar en la moneda nacional (Bs.), pero la mayoría de los productos y transacciones se anuncian en USD. Veamos dos ejemplos:",[141,5438,5439,5441,5442,2717],{},[331,5440,5155],{}," es una librería y papelería que ofrece más de 15.000 productos relacionados con artículos de oficina, útiles escolares, materiales de arte, libros técnicos y escolares, y más. La tienda también ofrece a la ciudad un centro cultural con talleres para niños y eventos ",[5141,5443,5444],{},[320,5445,5164],{"href":5161,"ariaDescribedBy":5446,"dataFootnoteRef":275,"id":5163},[5147],[141,5448,5449,5450,5454],{},"Su stack tecnológico es una instancia de Odoo. No hay aplicación móvil y la facturación ocurre directamente en la tienda. Una ",[320,5451,5453],{"href":5170,"rel":5452},[324],"búsqueda rápida"," indica que hay tres extensiones y una de ellas es gratuita, pero puede que no sea open source: no encontré un repositorio.",[141,5456,5457,5459,5460,2717],{},[331,5458,5178],{}," es un marketplace en línea que conecta a vendedores locales con clientes. La plataforma ofrece una variedad de productos, incluyendo víveres, electrónica y electrodomésticos, y brinda una experiencia de compra fluida en dos estados ",[5141,5461,5462],{},[320,5463,5187],{"href":5184,"ariaDescribedBy":5464,"dataFootnoteRef":275,"id":5186},[5147],[141,5466,5467],{},"Al parecer usan Angular con lo que parece un backend en Express, aunque no puedo asegurarlo. Para mantenerse en regla necesitan algunas cosas:",[2509,5469,5470,5473,5476],{},[159,5471,5472],{},"Un web scraper: un programa que extrae datos de un sitio web analizando su código HTML.",[159,5474,5475],{},"APIs RESTful con poco código y alto rendimiento.",[159,5477,5478],{},"Almacenamiento en base de datos para datos históricos (para devoluciones, reembolsos y similares).",[141,5480,5481,5482,5485],{},"Si usas un servicio como ",[320,5483,5209],{"href":5207,"rel":5484},[324]," puedes obtener la tasa diaria, pero podrías toparte rápido con el límite de peticiones.",[136,5487,4663],{"id":4662},[136,5489,5491],{"id":5490},"decisión-servicio-de-api-o-tarea-programada","Decisión: ¿servicio de API o tarea programada?",[141,5493,5494],{},"Aquí tienes dos caminos:",[173,5496,5497,5507],{},[176,5498,5499],{},[179,5500,5501,5503,5505],{},[182,5502,1344],{},[182,5504,187],{},[182,5506,190],{},[192,5508,5509,5519],{},[179,5510,5511,5514,5517],{},[197,5512,5513],{},"API de tasas de terceros (por ejemplo, exchangerate-api.com)",[197,5515,5516],{},"Pagas por petición o por mes; hay límite en el plan gratuito; añade una dependencia",[197,5518],{},[179,5520,5521,5524,5527],{},[197,5522,5523],{},"Construir tu propio scraper + microservicio",[197,5525,5526],{},"Corre en tu infraestructura; maneja picos sin costo; control total del feed de la tasa",[197,5528,5529],{},[216,5530],{},[141,5532,5533],{},"Para las plataformas venezolanas, gana el scraper: el Banco Central publica a diario, la normativa exige la tasa oficial y te evitas los límites de terceros. Despliégalo en la infraestructura que ya tienes.",[136,5535,5537],{"id":5536},"implementación","Implementación",[612,5539,5541],{"id":5540},"si-tienes-hardware","Si tienes hardware",[141,5543,5544,5545,5548],{},"Crear un web scraper y usarlo con ",[320,5546,5274],{"href":5272,"rel":5547},[324]," como almacenamiento en MongoDB es una gran solución.",[612,5550,5552],{"id":5551},"si-no-lo-tienes","Si no lo tienes",[141,5554,5555],{},"Un scraper simple hecho en Python con FastAPI puede ser útil para un desarrollador de software que quiera acceder y analizar los datos financieros del sitio oficial del Banco Central de Venezuela.",[141,5557,5558],{},"Usando un web scraper hecho en Python con FastAPI, puedes:",[156,5560,5561,5564,5567,5570],{},[159,5562,5563],{},"Automatizar el proceso de obtener las últimas tasas de cambio del sitio del Banco Central de Venezuela.",[159,5565,5566],{},"Convertir los datos extraídos a formato JSON, que se puede almacenar, transmitir y procesar por distintas aplicaciones y herramientas.",[159,5568,5569],{},"Crear una API propia que exponga los datos extraídos como endpoints, accesibles por otros desarrolladores o usuarios que necesiten esos datos para sus propios fines.",[159,5571,5572],{},"Aplicar técnicas de análisis de datos, visualización o machine learning a los datos extraídos, usando el amplio conjunto de librerías y paquetes disponibles en Python.",[141,5574,5575,5576,2717],{},"Publiqué una implementación inicial en este ",[320,5577,5579],{"href":5305,"rel":5578},[324],"repositorio",[141,5581,5582],{},"Avísame si tienes alguna pregunta.",[4504,5584,4740],{"id":4739},[136,5586,243],{"id":242},[141,5588,5589],{},"El scraper del BCV está publicado como repositorio inicial open source. Elimina una tarea manual recurrente (el copiar y pegar la tasa de cambio a diario) y se convierte en una pieza base para cualquier plataforma venezolana de e-commerce o SaaS que necesite cumplir con la tasa. Los desarrolladores pueden hacer fork, adaptarlo a sus necesidades y desplegarlo en la infraestructura que ya tienen.",[136,5591,4740],{"id":5592},"referencias-1",[141,5594,4743,5595,2452,5598],{},[320,5596,5327],{"href":5325,"rel":5597},[324],[320,5599,2109],{"href":5330,"rel":5600},[324],[786,5602,5604,5607],{"className":5603,"dataFootnotes":275},[5335],[136,5605,5340],{"className":5606,"id":5147},[5339],[2509,5608,5609,5617,5625],{},[159,5610,5346,5611,5351,5614],{"id":5345},[320,5612,5349],{"href":5349,"rel":5613},[324],[320,5615,5358],{"href":5354,"ariaLabel":5355,"className":5616,"dataFootnoteBackref":275},[5357],[159,5618,5362,5619,5351,5622],{"id":5361},[320,5620,5365],{"href":5365,"rel":5621},[324],[320,5623,5358],{"href":5369,"ariaLabel":5370,"className":5624,"dataFootnoteBackref":275},[5357],[159,5626,5375,5627,5351,5630],{"id":5374},[320,5628,5378],{"href":5378,"rel":5629},[324],[320,5631,5358],{"href":5382,"ariaLabel":5383,"className":5632,"dataFootnoteBackref":275},[5357],{"title":275,"searchDepth":276,"depth":276,"links":5634},[5635,5636,5637,5638,5639,5640,5644,5645,5646],{"id":2148,"depth":276,"text":2149},{"id":138,"depth":276,"text":139},{"id":146,"depth":276,"text":147},{"id":4662,"depth":276,"text":4663},{"id":5490,"depth":276,"text":5491},{"id":5536,"depth":276,"text":5537,"children":5641},[5642,5643],{"id":5540,"depth":291,"text":5541},{"id":5551,"depth":291,"text":5552},{"id":242,"depth":276,"text":243},{"id":5592,"depth":276,"text":4740},{"id":5147,"depth":276,"text":5340},"Venezuela tiene retos particulares para el e-commerce. Las leyes locales y la normativa fiscal exigen facturar en bolívares mientras los precios se cotizan en USD. Un scraper simple en Python y FastAPI ayuda a los desarrolladores a obtener la tasa del Banco Central de Venezuela: aquí cuándo tiene sentido y cómo construirlo.",{},"Publiqué un scraper open source que elimina la carga manual diaria de la tasa de cambio para equipos de e-commerce venezolanos",{"title":73,"description":5647},[5406,5407,5408,5409],[4636,5411,3886],"LO9GQjWpuqNIK5TvmPL8IGFa9WFpZwCcVcXWYIEUTUI",{"id":5655,"title":56,"author":5656,"body":5658,"category":2744,"client":285,"date":5872,"description":5873,"extension":288,"featured":38,"hidden":38,"image":5874,"locale":1063,"meta":5875,"minRead":495,"navigation":292,"ogImage":285,"outcome_headline":285,"path":57,"repoUrl":285,"role":285,"seo":5876,"stack":285,"stem":58,"tags":5877,"team_size":285,"type":285,"year":285,"__hash__":5878},"blog/blog/japanese-input.md",{"name":127,"username":128,"to":129,"avatar":5657},{"src":131,"alt":127},{"type":133,"value":5659,"toc":5869},[5660,5662,5665,5680,5682,5694,5714,5727,5775,5778,5785,5794,5805,5816,5826,5828,5830,5837,5866],[4504,5661,1773],{"id":1772},[141,5663,5664],{},"Last year, after a pretty bad blackout, I decided to test the native Btrfs snapshots in openSUSE Tumbleweed and switch from Ubuntu 22.10 on my personal laptop. Change always brings learning with it, but most forum answers leaned toward GNOME.",[141,5666,5667,5668,5671,5672,5675,5676,5679],{},"Here are the steps to install ",[331,5669,5670],{},"fcitx5"," and ",[331,5673,5674],{},"fcitx5-mozc"," input method for Japanese in ",[331,5677,5678],{},"openSUSE Tumbleweed",":",[4504,5681,4519],{"id":4518},[141,5683,5684,5685,5671,5687,5689,5679],{},"Open the terminal and enter the following command to install ",[331,5686,5670],{},[331,5688,5674],{},[5141,5690,5691],{},[320,5692,5149],{"href":5145,"ariaDescribedBy":5693,"dataFootnoteRef":275,"id":5148},[5147],[462,5695,5699],{"className":5696,"code":5697,"language":5698,"meta":275,"style":275},"language-shell shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","sudo zypper install fcitx5-mozc\n","shell",[468,5700,5701],{"__ignoreMap":275},[471,5702,5703,5705,5708,5711],{"class":473,"line":303},[471,5704,2666],{"class":2007},[471,5706,5707],{"class":2011}," zypper",[471,5709,5710],{"class":2011}," install",[471,5712,5713],{"class":2011}," fcitx5-mozc\n",[141,5715,5716,5717,5722,5723,5726],{},"Next, if you aren't using a desktop environment ",[5141,5718,5719],{},[320,5720,5164],{"href":5161,"ariaDescribedBy":5721,"dataFootnoteRef":275,"id":5163},[5147]," you may need to configure the input method. To do this, create or open ",[468,5724,5725],{},"~/.profile"," and add the following lines:",[462,5728,5730],{"className":1998,"code":5729,"language":2000,"meta":275,"style":275},"export GTK_IM_MODULE=fcitx\nexport QT_IM_MODULE=fcitx\nexport XMODIFIERS=@im=fcitx\n",[468,5731,5732,5748,5759],{"__ignoreMap":275},[471,5733,5734,5738,5742,5745],{"class":473,"line":303},[471,5735,5737],{"class":5736},"spNyl","export",[471,5739,5741],{"class":5740},"sTEyZ"," GTK_IM_MODULE",[471,5743,5744],{"class":2044},"=",[471,5746,5747],{"class":5740},"fcitx\n",[471,5749,5750,5752,5755,5757],{"class":473,"line":276},[471,5751,5737],{"class":5736},[471,5753,5754],{"class":5740}," QT_IM_MODULE",[471,5756,5744],{"class":2044},[471,5758,5747],{"class":5740},[471,5760,5761,5763,5766,5768,5771,5773],{"class":473,"line":291},[471,5762,5737],{"class":5736},[471,5764,5765],{"class":5740}," XMODIFIERS",[471,5767,5744],{"class":2044},[471,5769,5770],{"class":5740},"@im",[471,5772,5744],{"class":2044},[471,5774,5747],{"class":5740},[141,5776,5777],{},"Save the file and restart your system.",[141,5779,5780,5781,5784],{},"Once you've restarted, open the ",[331,5782,5783],{},"fcitx5 configuration tool"," by running:",[462,5786,5788],{"className":1998,"code":5787,"language":2000,"meta":275,"style":275},"fcitx5-configtool\n",[468,5789,5790],{"__ignoreMap":275},[471,5791,5792],{"class":473,"line":303},[471,5793,5787],{"class":2007},[141,5795,5796,5797,5800,5801,5804],{},"In the configuration tool, click the ",[331,5798,5799],{},"Input Method"," tab and select the ",[331,5802,5803],{},"+"," button to add a new input method.",[141,5806,5807,5808,5811,5812,5815],{},"Select ",[331,5809,5810],{},"Mozc"," from the list of input methods and click on the ",[331,5813,5814],{},"Add"," button.",[141,5817,5818,5819,5821,5822,5825],{},"You can now switch to the ",[331,5820,5810],{}," input method by pressing the ",[331,5823,5824],{},"Ctrl + Space"," keys.",[141,5827,5310],{},[4504,5829,4608],{"id":4607},[141,5831,4611,5832,343,5834],{},[320,5833,5327],{"href":5325},[320,5835,2109],{"href":5836},"https://unsplash.com/photos/person-holding-brown-bread-with-yellow-sliced-fruit-6SWHWcLyD-8?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[786,5838,5840,5843],{"className":5839,"dataFootnotes":275},[5335],[136,5841,5340],{"className":5842,"id":5147},[5339],[2509,5844,5845,5856],{},[159,5846,5847,5848,5852,5853],{"id":5345},"Japanese input in plasma 5.8 kde ",[320,5849,5850],{"href":5850,"rel":5851},"https://forums.opensuse.org/t/japanese-input-in-plasma-5-8-kde/125211",[324]," ",[320,5854,5358],{"href":5354,"ariaLabel":5355,"className":5855,"dataFootnoteBackref":275},[5357],[159,5857,5858,5859,5351,5863],{"id":5361},"Localization/Japanese - ArchWiki. ",[320,5860,5861],{"href":5861,"rel":5862},"https://wiki.archlinux.org/title/Localization/Japanese",[324],[320,5864,5358],{"href":5369,"ariaLabel":5370,"className":5865,"dataFootnoteBackref":275},[5357],[1041,5867,5868],{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}",{"title":275,"searchDepth":276,"depth":276,"links":5870},[5871],{"id":5147,"depth":276,"text":5340},"2024-01-15T00:00:00.000Z","Here are the steps to install fcitx5 and fcitx5-mozc input method for Japanese in openSUSE Tumbleweed.","/blog/japanese-input/featuredImage.jpg",{},{"title":56,"description":5873},[2750],"Tf2xZx1awjN8OhIj3AY1SjJFLwMhDAxUUk7rgjr5BDc",{"id":5880,"title":117,"author":5881,"body":5883,"category":2744,"client":285,"date":5872,"description":6061,"extension":288,"featured":38,"hidden":38,"image":5874,"locale":66,"meta":6062,"minRead":495,"navigation":292,"ogImage":285,"outcome_headline":285,"path":118,"repoUrl":285,"role":285,"seo":6063,"stack":285,"stem":119,"tags":6064,"team_size":285,"type":285,"year":285,"__hash__":6065},"blog/es/blog/japanese-input.md",{"name":127,"username":128,"to":129,"avatar":5882},{"src":131,"alt":127},{"type":133,"value":5884,"toc":6058},[5885,5887,5890,5901,5903,5915,5929,5941,5979,5982,5989,5997,6006,6014,6022,6024,6026,6032,6056],[4504,5886,2149],{"id":2148},[141,5888,5889],{},"El año pasado, después de un apagón bastante malo, decidí probar los snapshots nativos de Btrfs en openSUSE Tumbleweed y cambiar Ubuntu 22.10 en mi laptop personal. Un cambio siempre trae aprendizaje, pero la mayoría de las respuestas en los foros apuntaban a GNOME.",[141,5891,5892,5893,5895,5896,5898,5899,5679],{},"Estos son los pasos para instalar el método de entrada ",[331,5894,5670],{}," y ",[331,5897,5674],{}," para japonés en ",[331,5900,5678],{},[4504,5902,4663],{"id":4662},[141,5904,5905,5906,5895,5908,5910,5679],{},"Abre la terminal e introduce el siguiente comando para instalar ",[331,5907,5670],{},[331,5909,5674],{},[5141,5911,5912],{},[320,5913,5149],{"href":5145,"ariaDescribedBy":5914,"dataFootnoteRef":275,"id":5148},[5147],[462,5916,5917],{"className":5696,"code":5697,"language":5698,"meta":275,"style":275},[468,5918,5919],{"__ignoreMap":275},[471,5920,5921,5923,5925,5927],{"class":473,"line":303},[471,5922,2666],{"class":2007},[471,5924,5707],{"class":2011},[471,5926,5710],{"class":2011},[471,5928,5713],{"class":2011},[141,5930,5931,5932,5937,5938,5940],{},"Luego, si no estás usando un entorno de escritorio ",[5141,5933,5934],{},[320,5935,5164],{"href":5161,"ariaDescribedBy":5936,"dataFootnoteRef":275,"id":5163},[5147]," puede que necesites configurar el método de entrada. Para eso, crea o abre ",[468,5939,5725],{}," y agrega las siguientes líneas:",[462,5942,5943],{"className":1998,"code":5729,"language":2000,"meta":275,"style":275},[468,5944,5945,5955,5965],{"__ignoreMap":275},[471,5946,5947,5949,5951,5953],{"class":473,"line":303},[471,5948,5737],{"class":5736},[471,5950,5741],{"class":5740},[471,5952,5744],{"class":2044},[471,5954,5747],{"class":5740},[471,5956,5957,5959,5961,5963],{"class":473,"line":276},[471,5958,5737],{"class":5736},[471,5960,5754],{"class":5740},[471,5962,5744],{"class":2044},[471,5964,5747],{"class":5740},[471,5966,5967,5969,5971,5973,5975,5977],{"class":473,"line":291},[471,5968,5737],{"class":5736},[471,5970,5765],{"class":5740},[471,5972,5744],{"class":2044},[471,5974,5770],{"class":5740},[471,5976,5744],{"class":2044},[471,5978,5747],{"class":5740},[141,5980,5981],{},"Guarda el archivo y reinicia el sistema.",[141,5983,5984,5985,5988],{},"Una vez reiniciado, abre la ",[331,5986,5987],{},"herramienta de configuración de fcitx5"," ejecutando:",[462,5990,5991],{"className":1998,"code":5787,"language":2000,"meta":275,"style":275},[468,5992,5993],{"__ignoreMap":275},[471,5994,5995],{"class":473,"line":303},[471,5996,5787],{"class":2007},[141,5998,5999,6000,6002,6003,6005],{},"En la herramienta de configuración, haz clic en la pestaña ",[331,6001,5799],{}," y selecciona el botón ",[331,6004,5803],{}," para agregar un método de entrada nuevo.",[141,6007,6008,6009,6011,6012,2717],{},"Elige ",[331,6010,5810],{}," de la lista de métodos de entrada y haz clic en el botón ",[331,6013,5814],{},[141,6015,6016,6017,6019,6020,2717],{},"Ya puedes cambiar al método de entrada ",[331,6018,5810],{}," presionando las teclas ",[331,6021,5824],{},[141,6023,5582],{},[4504,6025,4740],{"id":4739},[141,6027,4743,6028,2452,6030],{},[320,6029,5327],{"href":5325},[320,6031,2109],{"href":5836},[786,6033,6035,6038],{"className":6034,"dataFootnotes":275},[5335],[136,6036,5340],{"className":6037,"id":5147},[5339],[2509,6039,6040,6048],{},[159,6041,5847,6042,5852,6045],{"id":5345},[320,6043,5850],{"href":5850,"rel":6044},[324],[320,6046,5358],{"href":5354,"ariaLabel":5355,"className":6047,"dataFootnoteBackref":275},[5357],[159,6049,5858,6050,5351,6053],{"id":5361},[320,6051,5861],{"href":5861,"rel":6052},[324],[320,6054,5358],{"href":5369,"ariaLabel":5370,"className":6055,"dataFootnoteBackref":275},[5357],[1041,6057,5868],{},{"title":275,"searchDepth":276,"depth":276,"links":6059},[6060],{"id":5147,"depth":276,"text":5340},"Estos son los pasos para instalar el método de entrada fcitx5 y fcitx5-mozc para japonés en openSUSE Tumbleweed.",{},{"title":117,"description":6061},[2750],"NpEa-xJsxo2zf6j3YMUnh0PA76xNvuXQksDd9eQHwbw",1793036713963]