[{"data":1,"prerenderedAt":3688},["ShallowReactive",2],{"navigation":3,"/blog/case-studies/semantichelp-rag-knowledge-base":63,"visible-blog-posts":862},[4],{"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",{"id":64,"title":31,"author":65,"body":71,"category":838,"client":108,"date":839,"description":840,"extension":841,"featured":281,"hidden":38,"image":842,"meta":843,"minRead":844,"navigation":281,"ogImage":842,"outcome_headline":845,"path":32,"repoUrl":844,"role":846,"seo":847,"stack":848,"stem":33,"tags":855,"team_size":248,"type":859,"year":860,"__hash__":861},"blog/blog/case-studies/semantichelp-rag-knowledge-base.md",{"name":66,"username":67,"to":68,"avatar":69},"Iván Álvarez","ivanovertime","https://github.com/ivanovertime",{"src":70,"alt":66},"/avatar.jpg",{"type":72,"value":73,"toc":823},"minimark",[74,79,114,118,126,129,132,147,151,227,230,234,385,389,394,397,437,444,448,451,491,513,517,524,560,571,575,582,618,625,629,632,693,700,704,712,744,751,754,758,799,802,805,819],[75,76,78],"h2",{"id":77},"tldr","TL;DR",[80,81,82,93,101],"ul",{},[83,84,85,92],"li",{},[86,87,91],"a",{"href":88,"rel":89},"https://semantichelp.com/",[90],"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.",[83,94,95,96,100],{},"The differentiator is what happens ",[97,98,99],"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.",[83,102,103,104,109,110,113],{},"Built solo for ",[86,105,108],{"href":106,"rel":107},"https://vertexplusgroup.com/",[90],"Vertex Plus Group"," on ",[97,111,112],{},"Nuxt 4 + Cloudflare Workers"," (D1, R2, Vectorize, Queues), end to end. v1 is in production with a first real tenant.",[75,115,117],{"id":116},"context","Context",[119,120,121,122],"p",{},"Every \"chat with your docs\" tool has the same two outcomes. Sometimes it answers. Sometimes it shrugs: ",[123,124,125],"em",{},"I couldn't find that in the documents.",[119,127,128],{},"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.",[119,130,131],{},"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.",[119,133,134,135,138,139,142,143,146],{},"The users are an organization's own staff — an ",[97,136,137],{},"admin"," who sets the workspace up and configures the AI provider, an ",[97,140,141],{},"editor"," who builds the catalog, and ",[97,144,145],{},"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.",[75,148,150],{"id":149},"constraints","Constraints",[152,153,154,167],"table",{},[155,156,157],"thead",{},[158,159,160,164],"tr",{},[161,162,163],"th",{},"Constraint",[161,165,166],{},"Reality",[168,169,170,179,187,195,203,211,219],"tbody",{},[158,171,172,176],{},[173,174,175],"td",{},"Team",[173,177,178],{},"One person — product decisions, UI, schema, prompts, deploy",[158,180,181,184],{},[173,182,183],{},"Runtime",[173,185,186],{},"Cloudflare Workers only: no always-on server, no long-lived process",[158,188,189,192],{},[173,190,191],{},"Storage",[173,193,194],{},"D1 (SQLite) + R2 + Vectorize; no Postgres, no pgvector",[158,196,197,200],{},[173,198,199],{},"Communication",[173,201,202],{},"No mail infrastructure, so no verification email and no reset link",[158,204,205,208],{},[173,206,207],{},"Sessions",[173,209,210],{},"A single 24-hour JWT, no refresh token",[158,212,213,216],{},[173,214,215],{},"Isolation",[173,217,218],{},"One organization's documents, questions and conversations invisible to every other — a product promise, not a technicality",[158,220,221,224],{},[173,222,223],{},"Cost",[173,225,226],{},"Per-tenant ceilings that have to hold without a billing system to enforce them",[119,228,229],{},"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.",[75,231,233],{"id":232},"architecture-at-a-glance","Architecture at a glance",[235,236,241],"pre",{"className":237,"code":238,"language":239,"meta":240,"style":240},"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","",[242,243,244,252,258,264,270,276,283,289,295,301,307,313,319,325,331,337,343,349,355,361,367,373,379],"code",{"__ignoreMap":240},[245,246,249],"span",{"class":247,"line":248},"line",1,[245,250,251],{},"flowchart LR\n",[245,253,255],{"class":247,"line":254},2,[245,256,257],{},"  classDef ext fill:#e5e7eb,stroke:#6b7280,color:#282a30\n",[245,259,261],{"class":247,"line":260},3,[245,262,263],{},"  classDef svc fill:#dbeafe,stroke:#0f4c5c,color:#282a30\n",[245,265,267],{"class":247,"line":266},4,[245,268,269],{},"  classDef data fill:#d1fae5,stroke:#0f766e,color:#282a30\n",[245,271,273],{"class":247,"line":272},5,[245,274,275],{},"  classDef client fill:#fef3c7,stroke:#f59e0b,color:#282a30\n",[245,277,279],{"class":247,"line":278},6,[245,280,282],{"emptyLinePlaceholder":281},true,"\n",[245,284,286],{"class":247,"line":285},7,[245,287,288],{},"  UP[Upload PDF / DOCX / TXT / MD] --> R2[(R2 object)]\n",[245,290,292],{"class":247,"line":291},8,[245,293,294],{},"  UP --> D1[(D1 documents row)]\n",[245,296,298],{"class":247,"line":297},9,[245,299,300],{},"  D1 --> Q[[Ingest queue]]\n",[245,302,304],{"class":247,"line":303},10,[245,305,306],{},"  Q --> EX[Extract text]\n",[245,308,310],{"class":247,"line":309},11,[245,311,312],{},"  EX --> CH[Chunk + label section]\n",[245,314,316],{"class":247,"line":315},12,[245,317,318],{},"  CH --> EM[Embed]\n",[245,320,322],{"class":247,"line":321},13,[245,323,324],{},"  EM --> VI[(Vectorize index)]\n",[245,326,328],{"class":247,"line":327},14,[245,329,330],{},"  ASK[Question in chat] --> RET[Retrieve top 5, tenant-filtered]\n",[245,332,334],{"class":247,"line":333},15,[245,335,336],{},"  VI --> RET\n",[245,338,340],{"class":247,"line":339},16,[245,341,342],{},"  RET --> LLM[Stream answer + citations]\n",[245,344,346],{"class":247,"line":345},17,[245,347,348],{},"  LLM -->|cannot answer| ESC[Escalate to owner]\n",[245,350,352],{"class":247,"line":351},18,[245,353,354],{},"  ESC --> ANS[Owner answers in the Questions inbox]\n",[245,356,358],{"class":247,"line":357},19,[245,359,360],{},"  ANS --> QA[Folder Q&A document regenerated]\n",[245,362,364],{"class":247,"line":363},20,[245,365,366],{},"  QA --> Q\n",[245,368,370],{"class":247,"line":369},21,[245,371,372],{},"  class UP,ASK,ESC,ANS client\n",[245,374,376],{"class":247,"line":375},22,[245,377,378],{},"  class EX,CH,EM,RET,LLM,QA svc\n",[245,380,382],{"class":247,"line":381},23,[245,383,384],{},"  class R2,D1,VI data\n",[75,386,388],{"id":387},"decisions","Decisions",[390,391,393],"h3",{"id":392},"decision-1-d1-vectorize-instead-of-postgres-pgvector","Decision 1 — D1 + Vectorize instead of Postgres + pgvector",[119,395,396],{},"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.",[152,398,399,412],{},[155,400,401],{},[158,402,403,406,409],{},[161,404,405],{},"Option",[161,407,408],{},"Trade-off",[161,410,411],{},"Chose",[168,413,414,424],{},[158,415,416,419,422],{},[173,417,418],{},"Postgres + pgvector on a managed VM",[173,420,421],{},"Mature, one system for rows and vectors; a server to run and a region to pick",[173,423],{},[158,425,426,429,432],{},[173,427,428],{},"D1 (SQLite) + Vectorize on Workers",[173,430,431],{},"No server, no cold region, one deploy; SQLite's limits and a second index to keep honest",[173,433,434],{},[435,436],"decision-check",{},[119,438,439,440,443],{},"The price shows up three times. ",[242,441,442],{},"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.",[390,445,447],{"id":446},"decision-2-ingest-through-a-queue-with-an-inline-fallback","Decision 2 — Ingest through a queue, with an inline fallback",[119,449,450],{},"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.",[152,452,453,463],{},[155,454,455],{},[158,456,457,459,461],{},[161,458,405],{},[161,460,408],{},[161,462,411],{},[168,464,465,479],{},[158,466,467,474,477],{},[173,468,469,470,473],{},"Process the upload in the request (or behind ",[242,471,472],{},"waitUntil",")",[173,475,476],{},"No queue to provision; a big document can exceed the execution ceiling and fail with no retry",[173,478],{},[158,480,481,484,487],{},[173,482,483],{},"Send an ingest job to a Cloudflare Queue",[173,485,486],{},"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",[173,488,489],{},[435,490],{},[119,492,493,494,497,498,501,502,505,506,509,510,512],{},"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 ",[97,495,496],{},"30–60 seconds",", the same Vectorize lag now visible in a user-facing loop. The catalog shows per-document status (",[242,499,500],{},"processing"," → ",[242,503,504],{},"ready"," / ",[242,507,508],{},"failed",") because that window exists. Without a queue binding — local dev, or a deploy whose queue is not provisioned — the job runs inline behind ",[242,511,472],{}," rather than being silently dropped.",[390,514,516],{"id":515},"decision-3-citations-are-part-of-the-answer-not-decoration","Decision 3 — Citations are part of the answer, not decoration",[119,518,519,520,523],{},"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 ",[123,521,522],{},"same"," passage, untruncated.",[152,525,526,536],{},[155,527,528],{},[158,529,530,532,534],{},[161,531,405],{},[161,533,408],{},[161,535,411],{},[168,537,538,548],{},[158,539,540,543,546],{},[173,541,542],{},"Answer first, link the document afterwards",[173,544,545],{},"Cheaper prompts, simpler UI; the reader cannot verify a claim without re-reading the source",[173,547],{},[158,549,550,553,556],{},[173,551,552],{},"Store each chunk whole, label it with document and section, cite back to it",[173,554,555],{},"Larger prompts and a stricter chunker; every claim is one click from its evidence",[173,557,558],{},[435,559],{},[119,561,562,563,566,567,570],{},"That rule has teeth. A chunk is labelled with its ",[123,564,565],{},"section",", because one file can hold several products and a chunk labelled only by document title quotes the wrong one. Dedupe is by document ",[97,568,569],{},"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.",[390,572,574],{"id":573},"decision-4-the-escalation-loop-writes-back-into-the-catalog","Decision 4 — The escalation loop writes back into the catalog",[119,576,577,578,581],{},"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 ",[97,579,580],{},"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.",[152,583,584,594],{},[155,585,586],{},[158,587,588,590,592],{},[161,589,405],{},[161,591,408],{},[161,593,411],{},[168,595,596,606],{},[158,597,598,601,604],{},[173,599,600],{},"Log unanswered questions for an admin to triage later",[173,602,603],{},"Cheap; nothing improves unless someone reads the log",[173,605],{},[158,607,608,611,614],{},[173,609,610],{},"Route the question to the responsible owner and store the answer as a catalog document",[173,612,613],{},"Needs ownership, an inbox and a write-back that re-enters ingestion; the catalog grows from its own failures",[173,615,616],{},[435,617],{},[119,619,620,621,624],{},"Two rules keep it from misfiring: a ",[123,622,623],{},"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.",[390,626,628],{"id":627},"decision-5-cost-discipline-before-billing","Decision 5 — Cost discipline before billing",[119,630,631],{},"There is no billing in v1, so the cost ceiling has to be engineered rather than invoiced.",[152,633,634,644],{},[155,635,636],{},[158,637,638,641],{},[161,639,640],{},"Lever",[161,642,643],{},"What it does",[168,645,646,654,665,673,685],{},[158,647,648,651],{},[173,649,650],{},"BYO provider and key",[173,652,653],{},"Inference cost sits with the tenant; Workers AI is the fallback, not the default",[158,655,656,659],{},[173,657,658],{},"Answer cache",[173,660,661,662,664],{},"Keyed on provider, model, embedding model, prompt version ",[97,663,569],{}," the retrieved document ids, so it self-heals when the index changes instead of serving a stale answer under a fresh version",[158,666,667,670],{},[173,668,669],{},"Token caps",[173,671,672],{},"Bounded history window, bounded output, bounded retrieved context",[158,674,675,678],{},[173,676,677],{},"Question quota",[173,679,680,681,684],{},"Per-plan monthly quota, counted from ",[242,682,683],{},"usage_counters",", not from the cache",[158,686,687,690],{},[173,688,689],{},"Model bake-off",[173,691,692],{},"Measured cost per question, not list price: the default stayed, follow-ups are pinned to a small model, and the picker shows cost tiers",[119,694,695,696,699],{},"The cache is the counter-intuitive one. Hashing the question and looking it up ",[123,697,698],{},"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.",[75,701,703],{"id":702},"outcome","Outcome",[119,705,706,707,711],{},"v1 is complete and live at ",[86,708,710],{"href":88,"rel":709},[90],"semantichelp.com",", with a first real tenant using it:",[80,713,714,717,720,723,726,729,735,738],{},[83,715,716],{},"Multi-tenant subdomains with enforced per-tenant isolation; JWT auth with admin / editor / viewer roles.",[83,718,719],{},"A hierarchical catalog with drag-and-drop upload, per-document processing state, viewer and download.",[83,721,722],{},"Queue-based ingestion of PDF, DOCX, TXT and Markdown into a vector index.",[83,724,725],{},"Streaming chat with inline numbered citations that open the cited passage, plus persisted, re-openable sessions.",[83,727,728],{},"Escalations to owners, a Questions inbox, and answer write-back that regenerates a folder's Q&A document and re-ingests it.",[83,730,731,732,734],{},"English and Spanish across the UI ",[123,733,569],{}," the API's error messages, with a generated pseudo-locale as the canary.",[83,736,737],{},"Custom domains behind an allowlist, with the Cloudflare-for-SaaS lifecycle handled remote-first so a failed delete never leaks a billing hostname.",[83,739,740,741,743],{},"The cost guardrails above, measured in ",[242,742,683],{}," rather than estimated.",[119,745,746,747,750],{},"What it does ",[97,748,749],{},"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.",[119,752,753],{},"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.",[75,755,757],{"id":756},"whats-still-missing","What's still missing",[80,759,760,774,780,790],{},[83,761,762,765,766,769,770,773],{},[97,763,764],{},"Self-service password reset."," A locked-out user is currently an operator running ",[242,767,768],{},"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 ",[242,771,772],{},"sessionsValidAfter"," column checked in the auth middleware, plus a delivery channel for the link.",[83,775,776,779],{},[97,777,778],{},"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.",[83,781,782,785,786,789],{},[97,783,784],{},"A platform super-admin."," There isn't one, deliberately: roles never cross a tenant, and every platform action is an out-of-band ",[242,787,788],{},"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.",[83,791,792,795,796,798],{},[97,793,794],{},"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 ",[242,797,508],{}," rather than eventually succeeding.",[119,800,801],{},"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.",[803,804],"hr",{},[119,806,807],{},[123,808,809,810,814,815,818],{},"Building something in this space, or want a second pair of eyes on a RAG pipeline that has to be trustworthy? ",[86,811,813],{"href":812},"/contact","Reach out here"," — the product is at ",[86,816,710],{"href":88,"rel":817},[90]," and the demo workspace is public.",[820,821,822],"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":240,"searchDepth":254,"depth":254,"links":824},[825,826,827,828,829,836,837],{"id":77,"depth":254,"text":78},{"id":116,"depth":254,"text":117},{"id":149,"depth":254,"text":150},{"id":232,"depth":254,"text":233},{"id":387,"depth":254,"text":388,"children":830},[831,832,833,834,835],{"id":392,"depth":260,"text":393},{"id":446,"depth":260,"text":447},{"id":515,"depth":260,"text":516},{"id":573,"depth":260,"text":574},{"id":627,"depth":260,"text":628},{"id":702,"depth":254,"text":703},{"id":756,"depth":254,"text":757},"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","md","/blog/case-studies/semantichelp-rag-knowledge-base/featuredImage.jpg",{},null,"A multi-tenant RAG knowledge base where unanswered questions become citable answers","Product, design and build",{"title":31,"description":840},[849,850,851,852,853,854],"Nuxt 4","Cloudflare Workers","D1","R2","Vectorize","Vercel AI SDK",[856,857,858],"ai","cloudflare","nuxt","greenfield",2026,"SmCFzNbT05KDO5Tc3ZTtA32eQIqRb6nBLiBRxh4-0yE",[863,1391,1768,2044,2187,2839,2986,3172,3463],{"id":64,"title":31,"author":864,"body":866,"category":838,"client":108,"date":839,"description":840,"extension":841,"featured":281,"hidden":38,"image":842,"meta":1387,"minRead":844,"navigation":281,"ogImage":842,"outcome_headline":845,"path":32,"repoUrl":844,"role":846,"seo":1388,"stack":1389,"stem":33,"tags":1390,"team_size":248,"type":859,"year":860,"__hash__":861},{"name":66,"username":67,"to":68,"avatar":865},{"src":70,"alt":66},{"type":72,"value":867,"toc":1372},[868,870,888,890,894,896,898,906,908,962,964,966,1062,1064,1066,1068,1100,1104,1106,1108,1142,1154,1156,1160,1192,1198,1200,1204,1236,1240,1242,1244,1290,1294,1296,1301,1323,1327,1329,1331,1357,1359,1361,1370],[75,869,78],{"id":77},[80,871,872,877,881],{},[83,873,874,92],{},[86,875,91],{"href":88,"rel":876},[90],[83,878,95,879,100],{},[97,880,99],{},[83,882,103,883,109,886,113],{},[86,884,108],{"href":106,"rel":885},[90],[97,887,112],{},[75,889,117],{"id":116},[119,891,121,892],{},[123,893,125],{},[119,895,128],{},[119,897,131],{},[119,899,134,900,138,902,142,904,146],{},[97,901,137],{},[97,903,141],{},[97,905,145],{},[75,907,150],{"id":149},[152,909,910,918],{},[155,911,912],{},[158,913,914,916],{},[161,915,163],{},[161,917,166],{},[168,919,920,926,932,938,944,950,956],{},[158,921,922,924],{},[173,923,175],{},[173,925,178],{},[158,927,928,930],{},[173,929,183],{},[173,931,186],{},[158,933,934,936],{},[173,935,191],{},[173,937,194],{},[158,939,940,942],{},[173,941,199],{},[173,943,202],{},[158,945,946,948],{},[173,947,207],{},[173,949,210],{},[158,951,952,954],{},[173,953,215],{},[173,955,218],{},[158,957,958,960],{},[173,959,223],{},[173,961,226],{},[119,963,229],{},[75,965,233],{"id":232},[235,967,968],{"className":237,"code":238,"language":239,"meta":240,"style":240},[242,969,970,974,978,982,986,990,994,998,1002,1006,1010,1014,1018,1022,1026,1030,1034,1038,1042,1046,1050,1054,1058],{"__ignoreMap":240},[245,971,972],{"class":247,"line":248},[245,973,251],{},[245,975,976],{"class":247,"line":254},[245,977,257],{},[245,979,980],{"class":247,"line":260},[245,981,263],{},[245,983,984],{"class":247,"line":266},[245,985,269],{},[245,987,988],{"class":247,"line":272},[245,989,275],{},[245,991,992],{"class":247,"line":278},[245,993,282],{"emptyLinePlaceholder":281},[245,995,996],{"class":247,"line":285},[245,997,288],{},[245,999,1000],{"class":247,"line":291},[245,1001,294],{},[245,1003,1004],{"class":247,"line":297},[245,1005,300],{},[245,1007,1008],{"class":247,"line":303},[245,1009,306],{},[245,1011,1012],{"class":247,"line":309},[245,1013,312],{},[245,1015,1016],{"class":247,"line":315},[245,1017,318],{},[245,1019,1020],{"class":247,"line":321},[245,1021,324],{},[245,1023,1024],{"class":247,"line":327},[245,1025,330],{},[245,1027,1028],{"class":247,"line":333},[245,1029,336],{},[245,1031,1032],{"class":247,"line":339},[245,1033,342],{},[245,1035,1036],{"class":247,"line":345},[245,1037,348],{},[245,1039,1040],{"class":247,"line":351},[245,1041,354],{},[245,1043,1044],{"class":247,"line":357},[245,1045,360],{},[245,1047,1048],{"class":247,"line":363},[245,1049,366],{},[245,1051,1052],{"class":247,"line":369},[245,1053,372],{},[245,1055,1056],{"class":247,"line":375},[245,1057,378],{},[245,1059,1060],{"class":247,"line":381},[245,1061,384],{},[75,1063,388],{"id":387},[390,1065,393],{"id":392},[119,1067,396],{},[152,1069,1070,1080],{},[155,1071,1072],{},[158,1073,1074,1076,1078],{},[161,1075,405],{},[161,1077,408],{},[161,1079,411],{},[168,1081,1082,1090],{},[158,1083,1084,1086,1088],{},[173,1085,418],{},[173,1087,421],{},[173,1089],{},[158,1091,1092,1094,1096],{},[173,1093,428],{},[173,1095,431],{},[173,1097,1098],{},[435,1099],{},[119,1101,439,1102,443],{},[242,1103,442],{},[390,1105,447],{"id":446},[119,1107,450],{},[152,1109,1110,1120],{},[155,1111,1112],{},[158,1113,1114,1116,1118],{},[161,1115,405],{},[161,1117,408],{},[161,1119,411],{},[168,1121,1122,1132],{},[158,1123,1124,1128,1130],{},[173,1125,469,1126,473],{},[242,1127,472],{},[173,1129,476],{},[173,1131],{},[158,1133,1134,1136,1138],{},[173,1135,483],{},[173,1137,486],{},[173,1139,1140],{},[435,1141],{},[119,1143,493,1144,497,1146,501,1148,505,1150,509,1152,512],{},[97,1145,496],{},[242,1147,500],{},[242,1149,504],{},[242,1151,508],{},[242,1153,472],{},[390,1155,516],{"id":515},[119,1157,519,1158,523],{},[123,1159,522],{},[152,1161,1162,1172],{},[155,1163,1164],{},[158,1165,1166,1168,1170],{},[161,1167,405],{},[161,1169,408],{},[161,1171,411],{},[168,1173,1174,1182],{},[158,1175,1176,1178,1180],{},[173,1177,542],{},[173,1179,545],{},[173,1181],{},[158,1183,1184,1186,1188],{},[173,1185,552],{},[173,1187,555],{},[173,1189,1190],{},[435,1191],{},[119,1193,562,1194,566,1196,570],{},[123,1195,565],{},[97,1197,569],{},[390,1199,574],{"id":573},[119,1201,577,1202,581],{},[97,1203,580],{},[152,1205,1206,1216],{},[155,1207,1208],{},[158,1209,1210,1212,1214],{},[161,1211,405],{},[161,1213,408],{},[161,1215,411],{},[168,1217,1218,1226],{},[158,1219,1220,1222,1224],{},[173,1221,600],{},[173,1223,603],{},[173,1225],{},[158,1227,1228,1230,1232],{},[173,1229,610],{},[173,1231,613],{},[173,1233,1234],{},[435,1235],{},[119,1237,620,1238,624],{},[123,1239,623],{},[390,1241,628],{"id":627},[119,1243,631],{},[152,1245,1246,1254],{},[155,1247,1248],{},[158,1249,1250,1252],{},[161,1251,640],{},[161,1253,643],{},[168,1255,1256,1262,1270,1276,1284],{},[158,1257,1258,1260],{},[173,1259,650],{},[173,1261,653],{},[158,1263,1264,1266],{},[173,1265,658],{},[173,1267,661,1268,664],{},[97,1269,569],{},[158,1271,1272,1274],{},[173,1273,669],{},[173,1275,672],{},[158,1277,1278,1280],{},[173,1279,677],{},[173,1281,680,1282,684],{},[242,1283,683],{},[158,1285,1286,1288],{},[173,1287,689],{},[173,1289,692],{},[119,1291,695,1292,699],{},[123,1293,698],{},[75,1295,703],{"id":702},[119,1297,706,1298,711],{},[86,1299,710],{"href":88,"rel":1300},[90],[80,1302,1303,1305,1307,1309,1311,1313,1317,1319],{},[83,1304,716],{},[83,1306,719],{},[83,1308,722],{},[83,1310,725],{},[83,1312,728],{},[83,1314,731,1315,734],{},[123,1316,569],{},[83,1318,737],{},[83,1320,740,1321,743],{},[242,1322,683],{},[119,1324,746,1325,750],{},[97,1326,749],{},[119,1328,753],{},[75,1330,757],{"id":756},[80,1332,1333,1341,1345,1351],{},[83,1334,1335,765,1337,769,1339,773],{},[97,1336,764],{},[242,1338,768],{},[242,1340,772],{},[83,1342,1343,779],{},[97,1344,778],{},[83,1346,1347,785,1349,789],{},[97,1348,784],{},[242,1350,788],{},[83,1352,1353,795,1355,798],{},[97,1354,794],{},[242,1356,508],{},[119,1358,801],{},[803,1360],{},[119,1362,1363],{},[123,1364,809,1365,814,1367,818],{},[86,1366,813],{"href":812},[86,1368,710],{"href":88,"rel":1369},[90],[820,1371,822],{},{"title":240,"searchDepth":254,"depth":254,"links":1373},[1374,1375,1376,1377,1378,1385,1386],{"id":77,"depth":254,"text":78},{"id":116,"depth":254,"text":117},{"id":149,"depth":254,"text":150},{"id":232,"depth":254,"text":233},{"id":387,"depth":254,"text":388,"children":1379},[1380,1381,1382,1383,1384],{"id":392,"depth":260,"text":393},{"id":446,"depth":260,"text":447},{"id":515,"depth":260,"text":516},{"id":573,"depth":260,"text":574},{"id":627,"depth":260,"text":628},{"id":702,"depth":254,"text":703},{"id":756,"depth":254,"text":757},{},{"title":31,"description":840},[849,850,851,852,853,854],[856,857,858],{"id":1392,"title":40,"author":1393,"body":1395,"category":1752,"client":844,"date":1753,"description":1754,"extension":841,"featured":38,"hidden":38,"image":1755,"meta":1756,"minRead":278,"navigation":281,"ogImage":844,"outcome_headline":1757,"path":41,"repoUrl":1718,"role":1758,"seo":1759,"stack":1760,"stem":42,"tags":1765,"team_size":248,"type":1752,"year":860,"__hash__":1767},"blog/blog/chronocal.md",{"name":66,"username":67,"to":68,"avatar":1394},{"src":70,"alt":66},{"type":72,"value":1396,"toc":1740},[1397,1401,1404,1407,1410,1424,1428,1431,1445,1449,1452,1456,1461,1472,1477,1488,1493,1504,1509,1517,1521,1524,1532,1538,1544,1550,1554,1557,1560,1577,1581,1584,1601,1605,1612,1623,1703,1706,1710,1713,1721,1723,1737],[75,1398,1400],{"id":1399},"situation","Situation",[119,1402,1403],{},"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.",[119,1405,1406],{},"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?",[119,1408,1409],{},"Most time-tracking solutions require either:",[80,1411,1412,1415,1418,1421],{},[83,1413,1414],{},"A separate app you need to remember to open",[83,1416,1417],{},"A backend service to persist state",[83,1419,1420],{},"A third-party integration that adds OAuth complexity",[83,1422,1423],{},"A browser timer that depends on the tab staying active",[75,1425,1427],{"id":1426},"problem","Problem",[119,1429,1430],{},"Calendar events are already a reliable source of truth for your time commitment. What if you could:",[80,1432,1433,1436,1439,1442],{},[83,1434,1435],{},"Start/pause/stop tracking directly from the Calendar event card",[83,1437,1438],{},"Keep state in Apps Script UserProperties (no backend needed)",[83,1440,1441],{},"Optionally write the tracked duration back to the event description",[83,1443,1444],{},"Export sessions to Google Sheets for reporting or analysis",[75,1446,1448],{"id":1447},"solution-chronocal","Solution: ChronoCal",[119,1450,1451],{},"ChronoCal is a Google Calendar add-on built entirely with Google Apps Script. It provides:",[390,1453,1455],{"id":1454},"core-features","Core Features",[119,1457,1458],{},[97,1459,1460],{},"In-Event Tracking",[80,1462,1463,1466,1469],{},[83,1464,1465],{},"Open any calendar event on desktop Calendar and a contextual card appears",[83,1467,1468],{},"Buttons to start, pause, resume, and stop tracking from the card itself",[83,1470,1471],{},"Session state persists using Apps Script UserProperties—survives browser refresh and tab close",[119,1473,1474],{},[97,1475,1476],{},"Flexible Session States",[80,1478,1479,1482,1485],{},[83,1480,1481],{},"Running: timer is actively tracking",[83,1483,1484],{},"Paused: stopped but not finalized",[83,1486,1487],{},"Stopped: finalized session ready for export",[119,1489,1490],{},[97,1491,1492],{},"Event Description Integration",[80,1494,1495,1498,1501],{},[83,1496,1497],{},"Toggle option to write the final tracked duration directly to event descriptions",[83,1499,1500],{},"Handles recurring event instances correctly",[83,1502,1503],{},"Resolves the event across all calendars in your Google Account",[119,1505,1506],{},[97,1507,1508],{},"Google Sheets Export",[80,1510,1511,1514],{},[83,1512,1513],{},"Export completed sessions to a Google Sheet for reporting, billing, or analysis",[83,1515,1516],{},"Minimal setup: the add-on can create the Sheet or append to an existing one",[390,1518,1520],{"id":1519},"technical-approach","Technical Approach",[119,1522,1523],{},"The implementation is split across six core modules:",[235,1525,1530],{"className":1526,"code":1528,"language":1529},[1527],"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",[242,1531,1528],{"__ignoreMap":240},[119,1533,1534,1537],{},[97,1535,1536],{},"No External Backend","\nEverything runs inside Google Apps Script. UserProperties store the session state, so it survives across browser sessions and tabs.",[119,1539,1540,1543],{},[97,1541,1542],{},"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.",[119,1545,1546,1549],{},[97,1547,1548],{},"Event Resolution","\nCorrectly handles recurring events and calendar resolution, so you can track time on the same event across multiple calendars without duplication.",[75,1551,1553],{"id":1552},"why-this-approach","Why This Approach",[119,1555,1556],{},"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.",[119,1558,1559],{},"Apps Script is ideal for this because:",[80,1561,1562,1565,1568,1571,1574],{},[83,1563,1564],{},"Native access to Calendar and Sheets without additional APIs",[83,1566,1567],{},"UserProperties provide persistent client-side state",[83,1569,1570],{},"Contextual cards integrate directly into Calendar's UI",[83,1572,1573],{},"Zero infrastructure: no servers to maintain, no databases to back up",[83,1575,1576],{},"Tight integration with Google Workspace",[75,1578,1580],{"id":1579},"current-state","Current State",[119,1582,1583],{},"ChronoCal is a functional MVP with pause/resume and Sheets export flows. Planned evolution includes:",[80,1585,1586,1589,1592,1595,1598],{},[83,1587,1588],{},"Keyboard shortcuts for faster access",[83,1590,1591],{},"Bulk session export options",[83,1593,1594],{},"Integration with other Workspace apps (Gmail, Tasks)",[83,1596,1597],{},"Analytics dashboard within Sheets",[83,1599,1600],{},"More granular state management (e.g., session notes, billing codes)",[75,1602,1604],{"id":1603},"development-deployment","Development & Deployment",[119,1606,1607,1608,1611],{},"The repo includes a complete deployment workflow using ",[242,1609,1610],{},"clasp"," (Google Apps Script CLI). Prerequisites are minimal:",[80,1613,1614,1617,1620],{},[83,1615,1616],{},"Node.js and npm",[83,1618,1619],{},"Google account with Apps Script access",[83,1621,1622],{},"Optional: Nix + direnv (dev environment files included)",[235,1624,1628],{"className":1625,"code":1626,"language":1627,"meta":240,"style":240},"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",[242,1629,1630,1640,1651,1685,1694],{"__ignoreMap":240},[245,1631,1632,1636],{"class":247,"line":248},[245,1633,1635],{"class":1634},"sBMFI","direnv",[245,1637,1639],{"class":1638},"sfazB"," allow\n",[245,1641,1642,1645,1648],{"class":247,"line":254},[245,1643,1644],{"class":1634},"npx",[245,1646,1647],{"class":1638}," @google/clasp",[245,1649,1650],{"class":1638}," login\n",[245,1652,1653,1655,1657,1660,1663,1666,1669,1673,1676,1679,1682],{"class":247,"line":260},[245,1654,1644],{"class":1634},[245,1656,1647],{"class":1638},[245,1658,1659],{"class":1638}," create",[245,1661,1662],{"class":1638}," --type",[245,1664,1665],{"class":1638}," standalone",[245,1667,1668],{"class":1638}," --title",[245,1670,1672],{"class":1671},"sMK4o"," \"",[245,1674,1675],{"class":1638},"ChronoCal",[245,1677,1678],{"class":1671},"\"",[245,1680,1681],{"class":1638}," --rootDir",[245,1683,1684],{"class":1638}," .\n",[245,1686,1687,1689,1691],{"class":247,"line":266},[245,1688,1644],{"class":1634},[245,1690,1647],{"class":1638},[245,1692,1693],{"class":1638}," push\n",[245,1695,1696,1698,1700],{"class":247,"line":272},[245,1697,1644],{"class":1634},[245,1699,1647],{"class":1638},[245,1701,1702],{"class":1638}," open\n",[119,1704,1705],{},"The project also includes contribution guidelines, PR templates, and issue templates for collaborators.",[75,1707,1709],{"id":1708},"key-takeaway","Key Takeaway",[119,1711,1712],{},"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.",[119,1714,1715,1716],{},"If you use Google Workspace and find yourself constantly switching between Calendar and time trackers, give ChronoCal a try: ",[86,1717,1720],{"href":1718,"rel":1719},"https://github.com/ivanovertime/ChronoCal",[90],"github.com/ivanovertime/ChronoCal",[803,1722],{},[119,1724,1725,1728,1729,109,1733],{},[97,1726,1727],{},"Image attribution:"," Featured image is from Outlook. Photo by ",[86,1730,1732],{"href":1731},"https://unsplash.com/@edhardie?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText","Ed Hardie",[86,1734,1736],{"href":1735},"https://unsplash.com/photos/a-computer-screen-with-a-calendar-on-it-4BnNnEtAGP0?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText","Unsplash",[820,1738,1739],{},"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":240,"searchDepth":254,"depth":254,"links":1741},[1742,1743,1744,1748,1749,1750,1751],{"id":1399,"depth":254,"text":1400},{"id":1426,"depth":254,"text":1427},{"id":1447,"depth":254,"text":1448,"children":1745},[1746,1747],{"id":1454,"depth":260,"text":1455},{"id":1519,"depth":260,"text":1520},{"id":1552,"depth":254,"text":1553},{"id":1579,"depth":254,"text":1580},{"id":1603,"depth":254,"text":1604},{"id":1708,"depth":254,"text":1709},"side-project","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":1754},[1761,1762,1763,1764],"Google Apps Script","JavaScript","Google Calendar API","Google Sheets API",[1766],"google-workspace","x7b-Z2Ky0bKogae8SrwvaD57Goiy5-yvrdXt9W9Ol9Y",{"id":1769,"title":44,"author":1770,"body":1772,"category":2036,"client":844,"date":2037,"description":2038,"extension":841,"featured":38,"hidden":38,"image":1789,"meta":2039,"minRead":266,"navigation":281,"ogImage":844,"outcome_headline":844,"path":45,"repoUrl":844,"role":844,"seo":2040,"stack":844,"stem":46,"tags":2041,"team_size":844,"type":844,"year":844,"__hash__":2043},"blog/blog/fixing-gnome-google-drive-mounts-on-nixos-25-11.md",{"name":66,"username":67,"to":68,"avatar":1771},{"src":70,"alt":66},{"type":72,"value":1773,"toc":2025},[1774,1777,1783,1790,1793,1797,1800,1812,1815,1819,1822,1826,1829,1856,1859,1863,1866,1896,1899,1903,1906,1926,1929,1932,1939,1942,1946,1949,1965,1968,1979,1982,1986,1993,1996,1998,2001,2019,2022],[119,1775,1776],{},"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:",[1778,1779,1780],"blockquote",{},[119,1781,1782],{},"\"Location is not mountable\"",[119,1784,1785],{},[1786,1787],"img",{"alt":1788,"src":1789},"GNOME Files showing the \"Unable to access — Location is not mountable\" error dialog","/blog/fixing-gnome-google-drive-mounts-on-nixos/featuredImage.png",[119,1791,1792],{},"This post shares the fix that worked for me and got Drive mounts working reliably on my system.",[75,1794,1796],{"id":1795},"the-problem","The problem",[119,1798,1799],{},"At first glance, everything looked correct:",[1801,1802,1803,1806,1809],"ol",{},[83,1804,1805],{},"GNOME Online Accounts was enabled.",[83,1807,1808],{},"I could sign in to Google.",[83,1810,1811],{},"Drive showed up in the sidebar.",[119,1813,1814],{},"But the mount itself still failed. The missing piece was that the default GVFS build in this setup did not include Google backend support.",[75,1816,1818],{"id":1817},"what-i-changed","What I changed",[119,1820,1821],{},"I made three focused changes.",[390,1823,1825],{"id":1824},"_1-force-a-gvfs-build-with-google-support","1) Force a GVFS build with Google support",[119,1827,1828],{},"I overrode the GVFS package to enable Google support explicitly:",[235,1830,1834],{"className":1831,"code":1832,"language":1833,"meta":240,"style":240},"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",[242,1835,1836,1841,1846,1851],{"__ignoreMap":240},[245,1837,1838],{"class":247,"line":248},[245,1839,1840],{},"services.gvfs.package = pkgs.gnome.gvfs.override {\n",[245,1842,1843],{"class":247,"line":254},[245,1844,1845],{},"  gnomeSupport = true;\n",[245,1847,1848],{"class":247,"line":260},[245,1849,1850],{},"  googleSupport = true;\n",[245,1852,1853],{"class":247,"line":266},[245,1854,1855],{},"};\n",[119,1857,1858],{},"Why: without this, GOA can authenticate, but GVFS cannot complete the Drive mount backend path.",[390,1860,1862],{"id":1861},"_2-keep-the-gnome-accountmount-stack-enabled","2) Keep the GNOME account/mount stack enabled",[119,1864,1865],{},"I ensured these services stayed enabled:",[235,1867,1869],{"className":1831,"code":1868,"language":1833,"meta":240,"style":240},"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",[242,1870,1871,1876,1881,1886,1891],{"__ignoreMap":240},[245,1872,1873],{"class":247,"line":248},[245,1874,1875],{},"services.gnome.gnome-online-accounts.enable = true;\n",[245,1877,1878],{"class":247,"line":254},[245,1879,1880],{},"services.gvfs.enable = true;\n",[245,1882,1883],{"class":247,"line":260},[245,1884,1885],{},"services.gnome.gnome-keyring.enable = true;\n",[245,1887,1888],{"class":247,"line":266},[245,1889,1890],{},"services.accounts-daemon.enable = true;\n",[245,1892,1893],{"class":247,"line":272},[245,1894,1895],{},"programs.dconf.enable = true;\n",[119,1897,1898],{},"Why: these are all part of the GNOME identity, credential, and desktop integration chain used for online volumes.",[390,1900,1902],{"id":1901},"_3-allow-the-insecure-libsoup2-package-required-by-this-backend-path","3) Allow the insecure libsoup2 package required by this backend path",[119,1904,1905],{},"I added:",[235,1907,1909],{"className":1831,"code":1908,"language":1833,"meta":240,"style":240},"nixpkgs.config.permittedInsecurePackages = [\n  \"libsoup-2.74.3\"\n];\n",[242,1910,1911,1916,1921],{"__ignoreMap":240},[245,1912,1913],{"class":247,"line":248},[245,1914,1915],{},"nixpkgs.config.permittedInsecurePackages = [\n",[245,1917,1918],{"class":247,"line":254},[245,1919,1920],{},"  \"libsoup-2.74.3\"\n",[245,1922,1923],{"class":247,"line":260},[245,1924,1925],{},"];\n",[119,1927,1928],{},"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.",[119,1930,1931],{},"Important note: this is a pragmatic compatibility workaround, not a long-term ideal from a security perspective.",[119,1933,1934,1935,1938],{},"From what nixpkgs currently documents, this is not about a single isolated CVE. The ",[242,1936,1937],{},"libsoup 2"," line is end-of-life and marked with many known unfixed CVEs, so Nix surfaces it as insecure by default.",[119,1940,1941],{},"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.",[75,1943,1945],{"id":1944},"apply-and-verify","Apply and verify",[119,1947,1948],{},"After updating your configuration:",[235,1950,1952],{"className":1625,"code":1951,"language":1627,"meta":240,"style":240},"sudo nixos-rebuild switch\n",[242,1953,1954],{"__ignoreMap":240},[245,1955,1956,1959,1962],{"class":247,"line":248},[245,1957,1958],{"class":1634},"sudo",[245,1960,1961],{"class":1638}," nixos-rebuild",[245,1963,1964],{"class":1638}," switch\n",[119,1966,1967],{},"Then:",[1801,1969,1970,1973,1976],{},[83,1971,1972],{},"Open GNOME Online Accounts and confirm your Google account is connected.",[83,1974,1975],{},"Open Files.",[83,1977,1978],{},"Select Google Drive from the sidebar.",[119,1980,1981],{},"If everything is wired correctly, Drive should mount instead of failing with \"Location is not mountable.\"",[75,1983,1985],{"id":1984},"tradeoffs-and-future-direction","Tradeoffs and future direction",[119,1987,1988,1989,1992],{},"This fix restores native GNOME Drive mounting, but it currently depends on a package exception (",[242,1990,1991],{},"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.",[119,1994,1995],{},"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.",[75,1997,78],{"id":77},[119,1999,2000],{},"If Google Drive appears in GNOME Files but will not mount on NixOS 25.11, this is the short version of what helped me:",[1801,2002,2003,2010,2013],{},[83,2004,2005,2006,2009],{},"Override GVFS with ",[242,2007,2008],{},"googleSupport = true",".",[83,2011,2012],{},"Keep GOA, GVFS, keyring, accounts-daemon, and dconf services enabled.",[83,2014,2015,2016,2018],{},"Permit ",[242,2017,1991],{}," so the build works in current channel constraints.",[119,2020,2021],{},"I am thankful to the Nix maintainers and GNOME ecosystem here: even when this path is inconvenient, the defaults push us toward safer choices.",[820,2023,2024],{},"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":240,"searchDepth":254,"depth":254,"links":2026},[2027,2028,2033,2034,2035],{"id":1795,"depth":254,"text":1796},{"id":1817,"depth":254,"text":1818,"children":2029},[2030,2031,2032],{"id":1824,"depth":260,"text":1825},{"id":1861,"depth":260,"text":1862},{"id":1901,"depth":260,"text":1902},{"id":1944,"depth":254,"text":1945},{"id":1984,"depth":254,"text":1985},{"id":77,"depth":254,"text":78},"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":2038},[2042],"linux","f8LIeSQrR_lyECiQn1LUsuQZQLOLXzA5MudJb--3aiw",{"id":2045,"title":60,"author":2046,"body":2048,"category":1752,"client":844,"date":2174,"description":2175,"extension":841,"featured":38,"hidden":38,"image":2176,"meta":2177,"minRead":260,"navigation":281,"ogImage":844,"outcome_headline":2178,"path":61,"repoUrl":2179,"role":1758,"seo":2180,"stack":2181,"stem":62,"tags":2185,"team_size":248,"type":1752,"year":860,"__hash__":2186},"blog/blog/jp-landing.md",{"name":66,"username":67,"to":68,"avatar":2047},{"src":70,"alt":66},{"type":72,"value":2049,"toc":2166},[2050,2052,2055,2057,2060,2062,2073,2075,2134,2136,2139,2142,2159,2163],[75,2051,78],{"id":77},[119,2053,2054],{},"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.",[75,2056,117],{"id":116},[119,2058,2059],{},"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.",[75,2061,150],{"id":149},[80,2063,2064,2067,2070],{},[83,2065,2066],{},"Keep the experience lightweight and easy to update.",[83,2068,2069],{},"Merge Spotify releases, artist profile data, and YouTube videos into one feed.",[83,2071,2072],{},"Support bilingual copy without making the page feel crowded.",[75,2074,388],{"id":387},[152,2076,2077,2088],{},[155,2078,2079],{},[158,2080,2081,2084,2086],{},[161,2082,2083],{},"Decision",[161,2085,408],{},[161,2087,411],{},[168,2089,2090,2100,2112,2122],{},[158,2091,2092,2095,2098],{},[173,2093,2094],{},"Build a traditional promo site with separate pages for music and video",[173,2096,2097],{},"More room for content, but harder to keep current",[173,2099],{},[158,2101,2102,2105,2108],{},[173,2103,2104],{},"Build a single landing site with a merged, auto-sorted feed",[173,2106,2107],{},"Easier to scan and maintain; the artist updates in one place",[173,2109,2110],{},[435,2111],{},[158,2113,2114,2117,2120],{},[173,2115,2116],{},"Keep the copy monolingual",[173,2118,2119],{},"Simpler, but weaker reach for bilingual audiences",[173,2121],{},[158,2123,2124,2127,2130],{},[173,2125,2126],{},"Support ES/EN copy",[173,2128,2129],{},"Better fit for the audience, with a little more content work",[173,2131,2132],{},[435,2133],{},[75,2135,703],{"id":702},[119,2137,2138],{},"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.",[119,2140,2141],{},"Feature-wise, the useful pieces were:",[80,2143,2144,2147,2150,2153,2156],{},[83,2145,2146],{},"Spotify releases and artist profile via Spotify API.",[83,2148,2149],{},"YouTube latest videos via the channel feed.",[83,2151,2152],{},"Auto-sorted, paginated feed with embeds.",[83,2154,2155],{},"Bilingual copy in ES/EN.",[83,2157,2158],{},"ISR caching for the feed endpoint.",[75,2160,2162],{"id":2161},"what-id-do-differently-today","What I'd do differently today",[119,2164,2165],{},"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":240,"searchDepth":254,"depth":254,"links":2167},[2168,2169,2170,2171,2172,2173],{"id":77,"depth":254,"text":78},{"id":116,"depth":254,"text":117},{"id":149,"depth":254,"text":150},{"id":387,"depth":254,"text":388},{"id":702,"depth":254,"text":703},{"id":2161,"depth":254,"text":2162},"2026-05-18T00:00:00.000Z","Official landing site for Jhey Pi, built with Nuxt 4 and Nuxt UI to combine Spotify releases and YouTube videos into a single feed.","/projects/jp-landing.png",{},"Shipped a focused artist landing site with a single feed for music updates and video content","https://github.com/ivanovertime/jp-landing",{"title":60,"description":2175},[2182,2183,2184],"Nuxt","Vue","Tailwind CSS",[858],"mICavYpQBydc4owJkH7qczNHLgFWSk9gh4tikxJe2KU",{"id":2188,"title":35,"author":2189,"body":2191,"category":838,"client":2818,"date":2819,"description":2820,"extension":841,"featured":281,"hidden":38,"image":2821,"meta":2822,"minRead":844,"navigation":281,"ogImage":2821,"outcome_headline":2823,"path":36,"repoUrl":844,"role":2824,"seo":2825,"stack":2826,"stem":37,"tags":2832,"team_size":254,"type":2836,"year":2837,"__hash__":2838},"blog/blog/case-studies/supermarket-pwa-recovery.md",{"name":66,"username":67,"to":68,"avatar":2190},{"src":70,"alt":66},{"type":72,"value":2192,"toc":2804},[2193,2195,2213,2215,2218,2221,2232,2238,2240,2292,2295,2297,2300,2515,2517,2521,2524,2560,2563,2567,2570,2606,2609,2613,2620,2656,2659,2663,2666,2702,2705,2707,2710,2739,2742,2744,2747,2789,2792,2794,2802],[75,2194,78],{"id":77},[80,2196,2197,2200,2206],{},[83,2198,2199],{},"A Node.js + Angular e-commerce project at a regional supermarket chain had reached pre-launch with no reliable deploy path.",[83,2201,2202,2203,2009],{},"I joined as the second engineer on a two-person team, finished the platform, and shipped it as a ",[97,2204,2205],{},"PWA + mobile apps to a high-volume customer base",[83,2207,2208,2209,2212],{},"Along the way we built the ",[97,2210,2211],{},"Docker image registry and CI/CD pipeline"," that made continuous delivery possible — for this project and the ones that followed.",[75,2214,117],{"id":116},[119,2216,2217],{},"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.\"",[119,2219,2220],{},"The shape of the work is one any engineer who has joined a pre-launch greenfield will recognise:",[80,2222,2223,2226,2229],{},[83,2224,2225],{},"A codebase that ran in development but didn't yet have a repeatable build or deploy path.",[83,2227,2228],{},"An open question about the customer surface — PWA, native apps, or both.",[83,2230,2231],{},"Adjacent business needs (customer segmentation, exchange-rate ingestion) waiting on someone with the bandwidth to pick them up.",[119,2233,2234,2235],{},"The brief was simple in one sentence and hard in practice: ",[123,2236,2237],{},"finish it and ship it.",[75,2239,150],{"id":149},[152,2241,2242,2250],{},[155,2243,2244],{},[158,2245,2246,2248],{},[161,2247,163],{},[161,2249,166],{},[168,2251,2252,2260,2268,2276,2284],{},[158,2253,2254,2257],{},[173,2255,2256],{},"Team size",[173,2258,2259],{},"2 engineers total, including me, embedded with the business",[158,2261,2262,2265],{},[173,2263,2264],{},"Calendar",[173,2266,2267],{},"Months, not quarters — the chain had already waited too long",[158,2269,2270,2273],{},[173,2271,2272],{},"Stack inherited",[173,2274,2275],{},"Node.js backend, Angular frontend, no deploy story",[158,2277,2278,2281],{},[173,2279,2280],{},"Customer surface",[173,2282,2283],{},"Needed to reach customers on phones, fast, without an app-store gauntlet",[158,2285,2286,2289],{},[173,2287,2288],{},"Adjacent work",[173,2290,2291],{},"Customer segmentation and exchange-rate ingestion were also on the table",[119,2293,2294],{},"The non-negotiable: ship something customers could use, then build the platform underneath that made the next thing easy.",[75,2296,233],{"id":232},[119,2298,2299],{},"Based on the original delivery architecture (simplified from the internal diagram), this is the flow we shipped:",[235,2301,2303],{"className":237,"code":2302,"language":239,"meta":240,"style":240},"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",[242,2304,2305,2309,2313,2317,2321,2325,2329,2334,2339,2343,2348,2353,2358,2363,2368,2372,2377,2381,2386,2391,2396,2400,2405,2409,2415,2421,2427,2432,2438,2444,2450,2455,2461,2467,2473,2479,2485,2491,2497,2503,2509],{"__ignoreMap":240},[245,2306,2307],{"class":247,"line":248},[245,2308,251],{},[245,2310,2311],{"class":247,"line":254},[245,2312,257],{},[245,2314,2315],{"class":247,"line":260},[245,2316,263],{},[245,2318,2319],{"class":247,"line":266},[245,2320,269],{},[245,2322,2323],{"class":247,"line":272},[245,2324,275],{},[245,2326,2327],{"class":247,"line":278},[245,2328,282],{"emptyLinePlaceholder":281},[245,2330,2331],{"class":247,"line":285},[245,2332,2333],{},"  ERP[(ERP / iDempiere)]\n",[245,2335,2336],{"class":247,"line":291},[245,2337,2338],{},"  M2[(Magento 2)]\n",[245,2340,2341],{"class":247,"line":297},[245,2342,282],{"emptyLinePlaceholder":281},[245,2344,2345],{"class":247,"line":303},[245,2346,2347],{},"  subgraph Integration[Integration Layer]\n",[245,2349,2350],{"class":247,"line":309},[245,2351,2352],{},"    INT[Integrator]\n",[245,2354,2355],{"class":247,"line":315},[245,2356,2357],{},"    MS[Catalog Sync Microservice]\n",[245,2359,2360],{"class":247,"line":321},[245,2361,2362],{},"  end\n",[245,2364,2365],{"class":247,"line":327},[245,2366,2367],{},"  class INT,MS svc\n",[245,2369,2370],{"class":247,"line":333},[245,2371,282],{"emptyLinePlaceholder":281},[245,2373,2374],{"class":247,"line":339},[245,2375,2376],{},"  DB[(MongoDB)]\n",[245,2378,2379],{"class":247,"line":345},[245,2380,282],{"emptyLinePlaceholder":281},[245,2382,2383],{"class":247,"line":351},[245,2384,2385],{},"  subgraph APIs[Platform APIs]\n",[245,2387,2388],{"class":247,"line":357},[245,2389,2390],{},"    PROC[Process API\\nCatalog, prices, tags, stock]\n",[245,2392,2393],{"class":247,"line":363},[245,2394,2395],{},"    APP[App API\\nOrders, profile, addresses]\n",[245,2397,2398],{"class":247,"line":369},[245,2399,2362],{},[245,2401,2402],{"class":247,"line":375},[245,2403,2404],{},"  class PROC,APP svc\n",[245,2406,2407],{"class":247,"line":381},[245,2408,282],{"emptyLinePlaceholder":281},[245,2410,2412],{"class":247,"line":2411},24,[245,2413,2414],{},"  subgraph Clients[Customer Surface]\n",[245,2416,2418],{"class":247,"line":2417},25,[245,2419,2420],{},"    PWA[PWA Storefront]\n",[245,2422,2424],{"class":247,"line":2423},26,[245,2425,2426],{},"    MOB[Mobile App]\n",[245,2428,2430],{"class":247,"line":2429},27,[245,2431,2362],{},[245,2433,2435],{"class":247,"line":2434},28,[245,2436,2437],{},"  class PWA,MOB client\n",[245,2439,2441],{"class":247,"line":2440},29,[245,2442,2443],{},"  class ERP,M2 ext\n",[245,2445,2447],{"class":247,"line":2446},30,[245,2448,2449],{},"  class DB data\n",[245,2451,2453],{"class":247,"line":2452},31,[245,2454,282],{"emptyLinePlaceholder":281},[245,2456,2458],{"class":247,"line":2457},32,[245,2459,2460],{},"  ERP --> INT\n",[245,2462,2464],{"class":247,"line":2463},33,[245,2465,2466],{},"  M2 --> INT\n",[245,2468,2470],{"class":247,"line":2469},34,[245,2471,2472],{},"  INT --> MS\n",[245,2474,2476],{"class":247,"line":2475},35,[245,2477,2478],{},"  MS --> DB\n",[245,2480,2482],{"class":247,"line":2481},36,[245,2483,2484],{},"  DB --> PROC\n",[245,2486,2488],{"class":247,"line":2487},37,[245,2489,2490],{},"  PROC --> PWA\n",[245,2492,2494],{"class":247,"line":2493},38,[245,2495,2496],{},"  PROC --> MOB\n",[245,2498,2500],{"class":247,"line":2499},39,[245,2501,2502],{},"  PWA --> APP\n",[245,2504,2506],{"class":247,"line":2505},40,[245,2507,2508],{},"  MOB --> APP\n",[245,2510,2512],{"class":247,"line":2511},41,[245,2513,2514],{},"  APP --> M2\n",[75,2516,388],{"id":387},[390,2518,2520],{"id":2519},"decision-1-ship-a-pwa-first-native-apps-second","Decision 1 — Ship a PWA first, native apps second",[119,2522,2523],{},"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.",[152,2525,2526,2536],{},[155,2527,2528],{},[158,2529,2530,2532,2534],{},[161,2531,405],{},[161,2533,408],{},[161,2535,411],{},[168,2537,2538,2548],{},[158,2539,2540,2543,2546],{},[173,2541,2542],{},"Build native iOS + Android apps from day one",[173,2544,2545],{},"Months of platform-specific work; app-store review on every change",[173,2547],{},[158,2549,2550,2553,2556],{},[173,2551,2552],{},"Ship a PWA from the existing Angular code, wrap as mobile apps later",[173,2554,2555],{},"Web-first surface available in days, not months; one codebase to maintain",[173,2557,2558],{},[435,2559],{},[119,2561,2562],{},"The mobile apps came after, on the foundation the PWA had already proven.",[390,2564,2566],{"id":2565},"decision-2-build-the-deploy-path-before-finishing-the-features","Decision 2 — Build the deploy path before finishing the features",[119,2568,2569],{},"Tempting as it was to chase the feature backlog, the bottleneck wasn't features — it was that nothing could be deployed reliably.",[152,2571,2572,2582],{},[155,2573,2574],{},[158,2575,2576,2578,2580],{},[161,2577,405],{},[161,2579,408],{},[161,2581,411],{},[168,2583,2584,2594],{},[158,2585,2586,2589,2592],{},[173,2587,2588],{},"Hand-deploy until launch, automate later",[173,2590,2591],{},"Faster on day one; every deploy after that costs the same hour",[173,2593],{},[158,2595,2596,2599,2602],{},[173,2597,2598],{},"Stand up a private Docker registry + a thin CI/CD pipeline first",[173,2600,2601],{},"Two weeks of platform work before any new feature shipped; every deploy after that was free",[173,2603,2604],{},[435,2605],{},[119,2607,2608],{},"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.",[390,2610,2612],{"id":2611},"decision-3-treat-customer-segmentation-as-a-small-useful-side-quest","Decision 3 — Treat customer segmentation as a small, useful side-quest",[119,2614,2615,2616,2619],{},"Marketing wanted to know which customers to talk to and when. I ran an ",[97,2617,2618],{},"RFM analysis"," (Recency, Frequency, Monetary) on the order data the new platform was already collecting.",[152,2621,2622,2632],{},[155,2623,2624],{},[158,2625,2626,2628,2630],{},[161,2627,405],{},[161,2629,408],{},[161,2631,411],{},[168,2633,2634,2644],{},[158,2635,2636,2639,2642],{},[173,2637,2638],{},"Wait until a dedicated data team exists",[173,2640,2641],{},"Months of waiting on insight the business needed now",[173,2643],{},[158,2645,2646,2649,2652],{},[173,2647,2648],{},"Run RFM on the existing data, hand the segments to marketing",[173,2650,2651],{},"Lightweight, immediately useful, and proved the new platform's data was worth something",[173,2653,2654],{},[435,2655],{},[119,2657,2658],{},"It wasn't a \"data platform.\" It was a one-engineer analysis that paid for itself the first week marketing used it.",[390,2660,2662],{"id":2661},"decision-4-automate-exchange-rate-ingestion-instead-of-typing-it-in","Decision 4 — Automate exchange-rate ingestion instead of typing it in",[119,2664,2665],{},"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.",[152,2667,2668,2678],{},[155,2669,2670],{},[158,2671,2672,2674,2676],{},[161,2673,405],{},[161,2675,408],{},[161,2677,411],{},[168,2679,2680,2690],{},[158,2681,2682,2685,2688],{},[173,2683,2684],{},"Keep doing it manually",[173,2686,2687],{},"Free until it isn't — a single typo moves prices on a whole catalog",[173,2689],{},[158,2691,2692,2695,2698],{},[173,2693,2694],{},"Scrape the official rate on a schedule and push it into the platform",[173,2696,2697],{},"A small ingestion job; removes a daily chore and a class of pricing bugs",[173,2699,2700],{},[435,2701],{},[119,2703,2704],{},"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.",[75,2706,703],{"id":702},[119,2708,2709],{},"By the end of the engagement:",[80,2711,2712,2718,2725,2732],{},[83,2713,2714,2715,2009],{},"The platform shipped as a ",[97,2716,2717],{},"PWA + mobile apps used by a high-volume customer base",[83,2719,2720,2721,2724],{},"The company had a ",[97,2722,2723],{},"private Docker registry and a working CI/CD pipeline"," for Node.js and Angular services — the foundation for everything that came after.",[83,2726,2727,2728,2731],{},"Marketing had ",[97,2729,2730],{},"RFM-based customer segments"," they could act on, drawn from the platform's own data.",[83,2733,2734,2735,2738],{},"Pricing ran on an ",[97,2736,2737],{},"automated exchange-rate feed"," instead of a daily copy-paste.",[119,2740,2741],{},"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.",[75,2743,2162],{"id":2161},[119,2745,2746],{},"Same brief today, same team size, same constraints. The instincts wouldn't change; the targets would.",[80,2748,2749,2759,2765,2771,2777,2783],{},[83,2750,2751,2754,2755,2758],{},[97,2752,2753],{},"Frontend:"," ship the PWA the same way, but host it on ",[97,2756,2757],{},"Cloudflare Pages"," (or any managed static host with a CDN edge). Lighthouse perf as a CI gate, not an afterthought.",[83,2760,2761,2764],{},[97,2762,2763],{},"Backend:"," the same Node.js services on a managed container runtime — no private registry to run and patch.",[83,2766,2767,2770],{},[97,2768,2769],{},"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.",[83,2772,2773,2776],{},[97,2774,2775],{},"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.",[83,2778,2779,2782],{},[97,2780,2781],{},"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.",[83,2784,2785,2788],{},[97,2786,2787],{},"Mobile apps:"," Capacitor or PWABuilder wrapping the same PWA, signed and shipped with staged rollouts.",[119,2790,2791],{},"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.",[803,2793],{},[119,2795,2796],{},[123,2797,2798,2799,2009],{},"If you're working through something similar and want a second pair of eyes, I'm happy to talk it through — ",[86,2800,2801],{"href":812},"reach out here",[820,2803,822],{},{"title":240,"searchDepth":254,"depth":254,"links":2805},[2806,2807,2808,2809,2810,2816,2817],{"id":77,"depth":254,"text":78},{"id":116,"depth":254,"text":117},{"id":149,"depth":254,"text":150},{"id":232,"depth":254,"text":233},{"id":387,"depth":254,"text":388,"children":2811},[2812,2813,2814,2815],{"id":2519,"depth":260,"text":2520},{"id":2565,"depth":260,"text":2566},{"id":2611,"depth":260,"text":2612},{"id":2661,"depth":260,"text":2662},{"id":702,"depth":254,"text":703},{"id":2161,"depth":254,"text":2162},"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":2820},[2827,2828,2829,2830,2831],"Node.js","Angular","PWA","Docker","CI/CD",[2833,2834,2835],"e-commerce","platform-recovery","devops","rescue",2022,"SkqWcC_-Haur4H90b5rnqsZm1DhQqbHTQ2yjozwgJ6I",{"id":2840,"title":52,"author":2841,"body":2843,"category":2036,"client":844,"date":2978,"description":2979,"extension":841,"featured":38,"hidden":38,"image":2980,"meta":2981,"minRead":266,"navigation":281,"ogImage":844,"outcome_headline":844,"path":53,"repoUrl":844,"role":844,"seo":2982,"stack":844,"stem":54,"tags":2983,"team_size":844,"type":844,"year":844,"__hash__":2985},"blog/blog/intern-movie-torrent.md",{"name":66,"username":67,"to":68,"avatar":2842},{"src":70,"alt":66},{"type":72,"value":2844,"toc":2974},[2845,2851,2854,2857,2864,2868,2871,2891,2895,2904,2908,2915,2944,2953,2957,2967],[119,2846,2847],{},[1786,2848],{"alt":2849,"src":2850},"Pie chart of types of torrents","/blog/intern-movie-torrent/venezuela_torrent.png",[2852,2853,1400],"h1",{"id":1399},[119,2855,2856],{},"Venezuela has a bandwidth problem, and plenty of people will stretch whatever connection is available—especially the internet at work.",[119,2858,2859,2860,2863],{},"What happens when you let tech-savvy people stay online for eight hours a day? I call it ",[97,2861,2862],{},"The Intern is downloading movies"," problem.",[2852,2865,2867],{"id":2866},"solution","Solution",[119,2869,2870],{},"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:",[1801,2872,2873,2881,2884],{},[83,2874,2875,2876,2009],{},"Visit ",[86,2877,2880],{"href":2878,"rel":2879},"https://iknowwhatyoudownload.com/en/peer/",[90],"iknowwhatyoudownload.com",[83,2882,2883],{},"On the homepage, you’ll see your current external IP address.",[83,2885,2886,2887],{},"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",[1786,2888],{"alt":2889,"src":2890},"Home page of the tool","/blog/intern-movie-torrent/venezuela_torrent2.png",[75,2892,2894],{"id":2893},"for-tracking-other-people","For tracking other people",[119,2896,2897,2898,2903],{},"You can ",[86,2899,2902],{"href":2900,"rel":2901},"https://iknowwhatyoudownload.com/en/link/",[90],"create a link"," and send it; the tracker logs activity as soon as someone clicks.",[75,2905,2907],{"id":2906},"for-tracking-peers","For tracking peers",[119,2909,2910,2911,2914],{},"If you have the ",[242,2912,2913],{},".torrent"," file you can extract peers and seeds from your torrent client (e.g., qBittorrent).",[1801,2916,2917,2920,2935],{},[83,2918,2919],{},"Copy the selected peer or host (or everything if you have time).",[83,2921,2922,2923,2928,2929,2934],{},"Convert the host address using an IP lookup tool like ",[86,2924,2927],{"href":2925,"rel":2926},"https://www.ip-tracker.org/",[90],"ip-tracker.org"," or ",[86,2930,2933],{"href":2931,"rel":2932},"https://whois.domaintools.com/",[90],"whois.domaintools.com"," if it’s not listed.",[83,2936,2937,2938,2943],{},"Use ",[86,2939,2942],{"href":2940,"rel":2941},"https://iphub.info/",[90],"iphub.info"," to determine whether the IP is a proxy or a residential address.",[119,2945,2946,2947,2952],{},"Note that VPNs and proxies may give noisy results, as explained in the ",[86,2948,2951],{"href":2949,"rel":2950},"https://iknowwhatyoudownload.com/en/contacts/",[90],"FAQ",". Still, it’s rare to see premium torrent VPNs inside organizations in Venezuela because perimeter firewalls like Fortinet often block that traffic.",[2852,2954,2956],{"id":2955},"references","References",[119,2958,2959,2960,109,2964],{},"Photo by ",[86,2961,2963],{"href":2962},"https://unsplash.com/@jsshotz?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash","Jorge Salvador",[86,2965,1736],{"href":2966},"https://unsplash.com/photos/black-and-white-satellite-dish-wjMMVxy8C0g?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[119,2968,2969],{},[86,2970,2973],{"href":2971,"rel":2972},"https://torrentfreak.com/i-know-what-you-download-overwhelmed-by-bogus-dmca-notices-221023/",[90],"Torrent Freak",{"title":240,"searchDepth":254,"depth":254,"links":2975},[2976,2977],{"id":2893,"depth":254,"text":2894},{"id":2906,"depth":254,"text":2907},"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":2979},[2984],"venezuela","MMJLNTnWg5XgfIoE4qkpsnIuxC794I1Uw16abClpxAk",{"id":2987,"title":48,"author":2988,"body":2990,"category":1752,"client":844,"date":3161,"description":3162,"extension":841,"featured":38,"hidden":38,"image":3163,"meta":3164,"minRead":266,"navigation":281,"ogImage":844,"outcome_headline":3165,"path":49,"repoUrl":3008,"role":1758,"seo":3166,"stack":3167,"stem":50,"tags":3169,"team_size":248,"type":1752,"year":3170,"__hash__":3171},"blog/blog/from-nuxt-to-hugo.md",{"name":66,"username":67,"to":68,"avatar":2989},{"src":70,"alt":66},{"type":72,"value":2991,"toc":3154},[2992,2994,2996,2999,3001,3004,3011,3014,3018,3022,3059,3063,3095,3099,3131,3143,3146,3148,3151],[2852,2993,1400],{"id":1399},[75,2995,78],{"id":77},[119,2997,2998],{},"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.",[75,3000,117],{"id":116},[119,3002,3003],{},"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.",[119,3005,3006],{},[86,3007,3010],{"href":3008,"rel":3009},"https://github.com/ivanovertime/alvarezivan.net",[90],"Check the repo",[119,3012,3013],{},"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.",[2852,3015,3017],{"id":3016},"what-changed","What changed",[75,3019,3021],{"id":3020},"decisions-made","Decisions Made",[152,3023,3024,3034],{},[155,3025,3026],{},[158,3027,3028,3030,3032],{},[161,3029,2083],{},[161,3031,408],{},[161,3033,411],{},[168,3035,3036,3047],{},[158,3037,3038,3041,3044],{},[173,3039,3040],{},"Use Hugo for this site",[173,3042,3043],{},"Fast builds, simple deployment; limited component reuse and markdown composition",[173,3045,3046],{},"(Temporary)",[158,3048,3049,3052,3055],{},[173,3050,3051],{},"Return to Nuxt 4 + Nuxt Content",[173,3053,3054],{},"More flexible, better for rich components; slightly heavier build; component-first instead of static-first",[173,3056,3057],{},[435,3058],{},[75,3060,3062],{"id":3061},"what-changed-1","What Changed",[1801,3064,3065,3075,3085],{},[83,3066,3067,3070],{},[97,3068,3069],{},"Content model",[80,3071,3072],{},[83,3073,3074],{},"Hugo proved fast and lean for static content, but the authoring workflow and UI composition were more constrained than I wanted.",[83,3076,3077,3080],{},[97,3078,3079],{},"Rendering strategy",[80,3081,3082],{},[83,3083,3084],{},"Moving back to Nuxt restored dynamic layouts, component reuse, and flexible data sourcing without sacrificing static performance.",[83,3086,3087,3090],{},[97,3088,3089],{},"Deploy & hosting",[80,3091,3092],{},[83,3093,3094],{},"Cloudflare Pages made preview deployments frictionless, with simple caching rules and edge delivery that fit a static‑first site.",[2852,3096,3098],{"id":3097},"tech-used","Tech used",[80,3100,3101,3107,3113,3119,3125],{},[83,3102,3103,3106],{},[97,3104,3105],{},"Frameworks:"," Hugo, Nuxt (Vue)",[83,3108,3109,3112],{},[97,3110,3111],{},"Content:"," Markdown with front matter",[83,3114,3115,3118],{},[97,3116,3117],{},"Styling:"," Tailwind CSS",[83,3120,3121,3124],{},[97,3122,3123],{},"Hosting:"," Cloudflare Pages",[83,3126,3127,3130],{},[97,3128,3129],{},"Tooling:"," pnpm, GitHub Actions",[1778,3132,3133],{},[119,3134,3135,3136,3139,3140,3142],{},"It's not DNS ",[3137,3138],"br",{},"\nIt cannot be DNS ",[3137,3141],{},"\nIt was DNS",[119,3144,3145],{},"Good luck with your migration! If you hit a snag, it's probably DNS—check it anyway.",[75,3147,703],{"id":702},[119,3149,3150],{},"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.",[119,3152,3153],{},"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":240,"searchDepth":254,"depth":254,"links":3155},[3156,3157,3158,3159,3160],{"id":77,"depth":254,"text":78},{"id":116,"depth":254,"text":117},{"id":3020,"depth":254,"text":3021},{"id":3061,"depth":254,"text":3062},{"id":702,"depth":254,"text":703},"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":3162},[3168,2182,2183,2184,2757],"Hugo",[858],2024,"IYZp_tRRnqiJ-uPfyT_tBxHQWQbVL00Vf7vstk-jXrM",{"id":3173,"title":10,"author":3174,"body":3176,"category":1752,"client":844,"date":3449,"description":3450,"extension":841,"featured":38,"hidden":38,"image":3451,"meta":3452,"minRead":285,"navigation":281,"ogImage":844,"outcome_headline":3453,"path":11,"repoUrl":3355,"role":1758,"seo":3454,"stack":3455,"stem":12,"tags":3460,"team_size":248,"type":1752,"year":3170,"__hash__":3462},"blog/blog/bcv-scraper.md",{"name":66,"username":67,"to":68,"avatar":3175},{"src":70,"alt":66},{"type":72,"value":3177,"toc":3435},[3178,3180,3182,3185,3187,3201,3215,3224,3238,3241,3252,3261,3263,3267,3270,3306,3309,3313,3317,3326,3330,3333,3336,3350,3358,3361,3363,3365,3368,3371,3382],[75,3179,1400],{"id":1399},[75,3181,78],{"id":77},[119,3183,3184],{},"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.",[75,3186,117],{"id":116},[119,3188,3189,3190,3200],{},"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 ",[3191,3192,3193],"sup",{},[86,3194,3199],{"href":3195,"ariaDescribedBy":3196,"dataFootnoteRef":240,"id":3198},"#user-content-fn-1",[3197],"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:",[119,3202,3203,3206,3207,2009],{},[97,3204,3205],{},"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 ",[3191,3208,3209],{},[86,3210,3214],{"href":3211,"ariaDescribedBy":3212,"dataFootnoteRef":240,"id":3213},"#user-content-fn-2",[3197],"user-content-fnref-2","2",[119,3216,3217,3218,3223],{},"Its tech stack is an Odoo instance. There's no mobile app and billing happens directly in-store. A ",[86,3219,3222],{"href":3220,"rel":3221},"https://apps.odoo.com/apps/modules/browse?search=BCV",[90],"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.",[119,3225,3226,3229,3230,2009],{},[97,3227,3228],{},"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 ",[3191,3231,3232],{},[86,3233,3237],{"href":3234,"ariaDescribedBy":3235,"dataFootnoteRef":240,"id":3236},"#user-content-fn-3",[3197],"user-content-fnref-3","3",[119,3239,3240],{},"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:",[1801,3242,3243,3246,3249],{},[83,3244,3245],{},"A web scraper: a program that extracts data from a website by parsing its HTML code.",[83,3247,3248],{},"RESTful APIs with minimal code and high performance.",[83,3250,3251],{},"Database storage for historical data (for returns, refunds, and the like).",[119,3253,3254,3255,3260],{},"If you use a service like ",[86,3256,3259],{"href":3257,"rel":3258},"https://www.exchangerate-api.com/",[90],"Exchange Rate API"," you can get the daily exchange rate, but you could get rate-limited quickly.",[75,3262,2867],{"id":2866},[75,3264,3266],{"id":3265},"decision-api-service-or-scheduled-job","Decision: API service or scheduled job?",[119,3268,3269],{},"You have two paths here:",[152,3271,3272,3282],{},[155,3273,3274],{},[158,3275,3276,3278,3280],{},[161,3277,405],{},[161,3279,408],{},[161,3281,411],{},[168,3283,3284,3294],{},[158,3285,3286,3289,3292],{},[173,3287,3288],{},"Third-party exchange rate API (e.g., exchangerate-api.com)",[173,3290,3291],{},"You pay per request or per month; rate-limited on free tier; adds a dependency",[173,3293],{},[158,3295,3296,3299,3302],{},[173,3297,3298],{},"Build your own scraper + microservice",[173,3300,3301],{},"Runs on your infrastructure; handles bursts free; full control of the rate feed",[173,3303,3304],{},[435,3305],{},[119,3307,3308],{},"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.",[75,3310,3312],{"id":3311},"implementation","Implementation",[390,3314,3316],{"id":3315},"if-you-have-hardware","If you have hardware",[119,3318,3319,3320,3325],{},"Creating a web scraper and using it with ",[86,3321,3324],{"href":3322,"rel":3323},"https://www.crawlab.cn/en",[90],"crawlab"," as storage in MongoDB is a great solution.",[390,3327,3329],{"id":3328},"if-you-dont","If you don't",[119,3331,3332],{},"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.",[119,3334,3335],{},"By using a web scraper built in Python with FastAPI, you can:",[80,3337,3338,3341,3344,3347],{},[83,3339,3340],{},"Automate the process of fetching the latest exchange rates from the Banco Central de Venezuela website.",[83,3342,3343],{},"Convert the scraped data into JSON format, which can be stored, transmitted, and processed by various applications and tools.",[83,3345,3346],{},"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.",[83,3348,3349],{},"Apply data analysis, visualization, or machine learning techniques to the scraped data, using the rich set of libraries and packages available in Python.",[119,3351,3352,3353,2009],{},"I published a starter implementation in this ",[86,3354,3357],{"href":3355,"rel":3356},"https://github.com/ivanovertime/bcv_scraper",[90],"repo",[119,3359,3360],{},"Let me know if you have any questions.",[2852,3362,2956],{"id":2955},[75,3364,703],{"id":702},[119,3366,3367],{},"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.",[75,3369,2956],{"id":3370},"references-1",[119,3372,2959,3373,109,3378],{},[86,3374,3377],{"href":3375,"rel":3376},"https://unsplash.com/@frederickjmedina?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[90],"Frederick Medina",[86,3379,1736],{"href":3380,"rel":3381},"https://unsplash.com/photos/person-holding-two-brown-doughnuts-LxyT2CgQSj8?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[90],[565,3383,3386,3391],{"className":3384,"dataFootnotes":240},[3385],"footnotes",[75,3387,3390],{"className":3388,"id":3197},[3389],"sr-only","Footnotes",[1801,3392,3393,3409,3422],{},[83,3394,3396,3397,3401,3402],{"id":3395},"user-content-fn-1","E-commerce in Venezuela. ",[86,3398,3399],{"href":3399,"rel":3400},"https://www.lloydsbanktrade.com/en/market-potential/venezuela/ecommerce",[90],". ",[86,3403,3408],{"href":3404,"ariaLabel":3405,"className":3406,"dataFootnoteBackref":240},"#user-content-fnref-1","Back to reference 1",[3407],"data-footnote-backref","↩",[83,3410,3412,3413,3401,3417],{"id":3411},"user-content-fn-2","Librería La Alegría. ",[86,3414,3415],{"href":3415,"rel":3416},"https://www.librerialaalegria.com.ve/",[90],[86,3418,3408],{"href":3419,"ariaLabel":3420,"className":3421,"dataFootnoteBackref":240},"#user-content-fnref-2","Back to reference 2",[3407],[83,3423,3425,3426,3401,3430],{"id":3424},"user-content-fn-3","Tu Zona Market. ",[86,3427,3428],{"href":3428,"rel":3429},"https://tuzonamarket.com/",[90],[86,3431,3408],{"href":3432,"ariaLabel":3433,"className":3434,"dataFootnoteBackref":240},"#user-content-fnref-3","Back to reference 3",[3407],{"title":240,"searchDepth":254,"depth":254,"links":3436},[3437,3438,3439,3440,3441,3442,3446,3447,3448],{"id":1399,"depth":254,"text":1400},{"id":77,"depth":254,"text":78},{"id":116,"depth":254,"text":117},{"id":2866,"depth":254,"text":2867},{"id":3265,"depth":254,"text":3266},{"id":3311,"depth":254,"text":3312,"children":3443},[3444,3445],{"id":3315,"depth":260,"text":3316},{"id":3328,"depth":260,"text":3329},{"id":702,"depth":254,"text":703},{"id":3370,"depth":254,"text":2956},{"id":3197,"depth":254,"text":3390},"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":3450},[3456,3457,3458,3459],"Python","FastAPI","Web Scraping","REST API",[2984,3461,2833],"integrations","4BYxSLsy-E6DHlbaUzAhoSxQljKXWN8WeihqeGitxHk",{"id":3464,"title":56,"author":3465,"body":3467,"category":2036,"client":844,"date":3681,"description":3682,"extension":841,"featured":38,"hidden":38,"image":3683,"meta":3684,"minRead":272,"navigation":281,"ogImage":844,"outcome_headline":844,"path":57,"repoUrl":844,"role":844,"seo":3685,"stack":844,"stem":58,"tags":3686,"team_size":844,"type":844,"year":844,"__hash__":3687},"blog/blog/japanese-input.md",{"name":66,"username":67,"to":68,"avatar":3466},{"src":70,"alt":66},{"type":72,"value":3468,"toc":3678},[3469,3471,3474,3489,3491,3503,3523,3536,3584,3587,3594,3603,3614,3625,3635,3637,3639,3646,3675],[2852,3470,1400],{"id":1399},[119,3472,3473],{},"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.",[119,3475,3476,3477,3480,3481,3484,3485,3488],{},"Here are the steps to install ",[97,3478,3479],{},"fcitx5"," and ",[97,3482,3483],{},"fcitx5-mozc"," input method for Japanese in ",[97,3486,3487],{},"openSUSE Tumbleweed",":",[2852,3490,2867],{"id":2866},[119,3492,3493,3494,3480,3496,3498,3488],{},"Open the terminal and enter the following command to install ",[97,3495,3479],{},[97,3497,3483],{},[3191,3499,3500],{},[86,3501,3199],{"href":3195,"ariaDescribedBy":3502,"dataFootnoteRef":240,"id":3198},[3197],[235,3504,3508],{"className":3505,"code":3506,"language":3507,"meta":240,"style":240},"language-shell shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","sudo zypper install fcitx5-mozc\n","shell",[242,3509,3510],{"__ignoreMap":240},[245,3511,3512,3514,3517,3520],{"class":247,"line":248},[245,3513,1958],{"class":1634},[245,3515,3516],{"class":1638}," zypper",[245,3518,3519],{"class":1638}," install",[245,3521,3522],{"class":1638}," fcitx5-mozc\n",[119,3524,3525,3526,3531,3532,3535],{},"Next, if you aren't using a desktop environment ",[3191,3527,3528],{},[86,3529,3214],{"href":3211,"ariaDescribedBy":3530,"dataFootnoteRef":240,"id":3213},[3197]," you may need to configure the input method. To do this, create or open ",[242,3533,3534],{},"~/.profile"," and add the following lines:",[235,3537,3539],{"className":1625,"code":3538,"language":1627,"meta":240,"style":240},"export GTK_IM_MODULE=fcitx\nexport QT_IM_MODULE=fcitx\nexport XMODIFIERS=@im=fcitx\n",[242,3540,3541,3557,3568],{"__ignoreMap":240},[245,3542,3543,3547,3551,3554],{"class":247,"line":248},[245,3544,3546],{"class":3545},"spNyl","export",[245,3548,3550],{"class":3549},"sTEyZ"," GTK_IM_MODULE",[245,3552,3553],{"class":1671},"=",[245,3555,3556],{"class":3549},"fcitx\n",[245,3558,3559,3561,3564,3566],{"class":247,"line":254},[245,3560,3546],{"class":3545},[245,3562,3563],{"class":3549}," QT_IM_MODULE",[245,3565,3553],{"class":1671},[245,3567,3556],{"class":3549},[245,3569,3570,3572,3575,3577,3580,3582],{"class":247,"line":260},[245,3571,3546],{"class":3545},[245,3573,3574],{"class":3549}," XMODIFIERS",[245,3576,3553],{"class":1671},[245,3578,3579],{"class":3549},"@im",[245,3581,3553],{"class":1671},[245,3583,3556],{"class":3549},[119,3585,3586],{},"Save the file and restart your system.",[119,3588,3589,3590,3593],{},"Once you've restarted, open the ",[97,3591,3592],{},"fcitx5 configuration tool"," by running:",[235,3595,3597],{"className":1625,"code":3596,"language":1627,"meta":240,"style":240},"fcitx5-configtool\n",[242,3598,3599],{"__ignoreMap":240},[245,3600,3601],{"class":247,"line":248},[245,3602,3596],{"class":1634},[119,3604,3605,3606,3609,3610,3613],{},"In the configuration tool, click the ",[97,3607,3608],{},"Input Method"," tab and select the ",[97,3611,3612],{},"+"," button to add a new input method.",[119,3615,3616,3617,3620,3621,3624],{},"Select ",[97,3618,3619],{},"Mozc"," from the list of input methods and click on the ",[97,3622,3623],{},"Add"," button.",[119,3626,3627,3628,3630,3631,3634],{},"You can now switch to the ",[97,3629,3619],{}," input method by pressing the ",[97,3632,3633],{},"Ctrl + Space"," keys.",[119,3636,3360],{},[2852,3638,2956],{"id":2955},[119,3640,2959,3641,109,3643],{},[86,3642,3377],{"href":3375},[86,3644,1736],{"href":3645},"https://unsplash.com/photos/person-holding-brown-bread-with-yellow-sliced-fruit-6SWHWcLyD-8?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash",[565,3647,3649,3652],{"className":3648,"dataFootnotes":240},[3385],[75,3650,3390],{"className":3651,"id":3197},[3389],[1801,3653,3654,3665],{},[83,3655,3656,3657,3661,3662],{"id":3395},"Japanese input in plasma 5.8 kde ",[86,3658,3659],{"href":3659,"rel":3660},"https://forums.opensuse.org/t/japanese-input-in-plasma-5-8-kde/125211",[90]," ",[86,3663,3408],{"href":3404,"ariaLabel":3405,"className":3664,"dataFootnoteBackref":240},[3407],[83,3666,3667,3668,3401,3672],{"id":3411},"Localization/Japanese - ArchWiki. ",[86,3669,3670],{"href":3670,"rel":3671},"https://wiki.archlinux.org/title/Localization/Japanese",[90],[86,3673,3408],{"href":3419,"ariaLabel":3420,"className":3674,"dataFootnoteBackref":240},[3407],[820,3676,3677],{},"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":240,"searchDepth":254,"depth":254,"links":3679},[3680],{"id":3197,"depth":254,"text":3390},"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":3682},[2042],"gu9sDPrzwuAfMcPRK8-gQvJ6VOjuLgsDt5T9k8WP5s4",1792943895175]